LLM 抽象与 DeepSeek

模型访问被收敛成一条服务 seam:消费者只依赖 ILlmClient,供应商把自己注册到这个键上。 对应实现:src/Tether.Llm/(定义)与 src/Tether.Llm.DeepSeek/(适配器)。

服务 seam

public interface ILlmClient : IChatClient
{
}

它是一个空接口,只作为标记类型存在——Microsoft.Extensions.AI 的 IChatClient 提供全部能力。这样做的收益是:Tether.Llm 这个程序集零项目引用,消费者只依赖它,换供应商时预设里换实现而引用不动。详见架构总览里的能力 seam 规则。

不可变请求快照

一次派发被快照成 LlmStreamRequest,穿过 llm/stream waterfall:

var request = new LlmStreamRequest(messages, config, toolDeclarations, cancellation);

// 物化总是返回新的可变 MEAI 对象
IReadOnlyList<ChatMessage> msgs = request.MaterializeMessages();
ChatOptions options = request.MaterializeOptions();

消息与工具声明在构造时就被序列化成 JSON 快照。中间层拿到的永远是脱离副本,改它不会影响别的监听器看到的内容——这是让多个插件安全观察同一次派发的前提。

调用配置

LlmCallConfig 是不可变的,构造即校验:

参数约束
provider、model必填非空白
temperature必须有限
topP必须有限且落在 [0, 1]
maxOutputTokens必须为正
stopSequences每一项都不能为空
reasoningEffort与供应商无关的推理强度

CreateChatOptions(tools) 产出一个调用方独占的可变 ChatOptions,把 reasoningEffort 映射成 MEAI 的 ReasoningOptions。

一次逻辑请求只有一个 prepared authority

agent/request waterfall 可以改写 provider、model 与调用参数。它结束后,Agent 不再从可变配置、 route table 或 Cordis root 分别拼装请求,而是向这次派发使用的 exact ILlmClient 请求一个 LlmPreparedRequest:

public interface ILlmRequestPreparation
{
    ValueTask<LlmPreparedRequest> PrepareAsync(
        LlmCallConfig config,
        CancellationToken cancellation = default);
}

这个 process-local authority 一次冻结 effective provider/model、profile generation、credential、 图片 capability 与 retry policy。附件读取、transport 和 logical-request retry 都复用同一份 prepared request;route table 即使在读取附件时热更新,也只能影响下一次逻辑请求。ChatOptions 同时携带 tether:provider 与不可序列化的 prepared token,owning adapter 会核对 token 来自自己这个 exact client generation。

Prepared request 不是 durable DTO

LlmPreparedRequest 显式拒绝 JSON 序列化,不进入 SessionEvent、snapshot、 CLI/Headless 输出或 browser payload。日志里的权威事实仍是 provider-neutral 的 request/header;prepared object 只在当前进程的一次逻辑请求内证明“这些参数来自同一代 provider”。

派发 waterfall

事件名常量是 LlmEvents.Stream,值为 llm/stream,形态是环绕式 waterfall。插件在这里可以做重试、缓存、录制回放、注入额外消息等等,规则与事件系统里的 waterfall 一致:不调用续延就短路整条链路。

用派发方提供的类型化注册 API

直接按字符串订阅 llm/stream 是可行的,但推荐用派发这次请求的 Agent 包提供的 类型化注册方法,避免手写参数位置。

稳定失败码

失败以 LlmException 抛出,其中机器可读的 Code 才是权威,消息文本只用于诊断。

常量含义
UNKNOWN适配器未能分类。
ABORTED调用方取消。
TIMEOUT请求或流超出配置的时间预算。
TRANSPORT拿到有效响应之前传输就失败了。
MALFORMED_RESPONSE响应不符合所请求的协议。
STREAM_CLOSED响应流在终止标记之前就结束。
EMPTY_RESPONSE供应商返回成功但没有内容。
AUTH凭据被拒。
RATE_LIMIT触及速率限制。
INVALID_REQUEST请求被判为非法。
CONTEXT_WINDOW_EXCEEDED超出上下文窗口。
QUOTA账户额度或余额耗尽。
SERVER供应商服务端故障。
UNSUPPORTED适配器无法表达调用方要求的特性。

未归类的 HTTP 状态用 LlmErrorCodes.Http(status) 生成形如 HTTP_503 的码。

LlmException 还带一组结构化事实:StatusCode、RequestId、ProviderRetryAfterMs(以及便捷的 RetryAfter,TimeSpan 形式)、IsCallerCancellation。这些字段构造时都会校验——重试延迟必须是有限正数且能被 TimeSpan 表示。

为什么区分 RATE_LIMIT 与 QUOTA

前者是暂时的,等一会儿重试有意义;后者是终局的,重试只会继续失败。 把它们分成两个码,重试策略才能写对。

DeepSeek 适配器

plugins:
  - id: llm
    name: deepseek
    config:
      apiKey: ${DEEPSEEK_API_KEY}
      model: deepseek-flash
配置项默认值约束
baseUrlhttps://api.deepseek.com/anthropic必须是无凭据/查询/片段的 HTTP(S) 根;客户端归一后追加 /v1/messages
apiKey无必填,作为 x-api-key 请求头
modeldeepseek-flash必填,也可用 deepseek-v4-pro
timeoutSeconds3001–3600,等首个 HTTP 响应头的上限
streamIdleTimeoutMilliseconds3000001–3600000,等下一行 SSE 的上限

API key 的设计意图是经组合文档的环境变量替换注入,不要硬写进 YAML。

宿主需要先把名字注册进目录:

catalog.AddDeepSeek();   // 注册 "deepseek" 这个名字

插件拥有 HttpClient

HttpClient 在 apply 时创建,并通过 ctx.OnDispose 挂到插件作用域上, 因此预设里换掉供应商会干净地拆掉旧传输层。这是[注册即回收](/tether/plugin-context.html)纪律在产品层的一次具体应用。

流式与 message_stop

DeepSeek 走官方 /anthropic 面,流式响应是 Anthropic Messages 形状的 SSE:message_start 开帧,内容块依次经 content_block_start/content_block_delta/content_block_stop 到达,message_delta 携带 stop_reason,message_stop 收尾。message_start 之前到达的事件按 MALFORMED 拒绝。两条硬性行为:

  • 流在 message_stop 之前结束 → STREAM_CLOSED;
  • stop_reason 已落定但没有任何内容块 → EMPTY_RESPONSE。

工具调用以 tool_use 块传递:id 与名字在 content_block_start 原子落定,参数经 input_json_delta 逐段累计,只在 content_block_stop 才物化为完整调用。这避免把半截参数 JSON 交给下游。

SSE 事件 text_delta tool_use 起帧 input_json_delta input_json_delta 块停帧 msg_stop 文本增量直接流下 参数逐段累积,块停帧才物化 IAsyncEnumerable 增量 完整 ToolCall(一次)
下游永远看不到半截参数 JSON;message_stop 之前流结束是 STREAM_CLOSED 失败,不是正常终止。

原样参数被保留下来,键在 LlmContentProperties.RawToolArguments(值为 tether.llm.raw-tool-arguments),挂在 MEAI 内容的附加属性上。保留它的理由是供应商发出的确切 JSON 文本对复现与审计有价值,而反序列化再序列化会丢掉格式差异。

HTTP 失败分类顺序

分类是有先后的,顺序本身就是语义:

  1. 401 或 403 → AUTH
  2. 错误详情匹配额度耗尽特征 → QUOTA
  3. 429 → RATE_LIMIT
  4. 400 → 详情匹配上下文溢出特征则 CONTEXT_WINDOW_EXCEEDED,否则 INVALID_REQUEST
  5. >= 500 → SERVER
  6. 其余 → HTTP_nnn

额度检查排在 429 之前

这不是笔误。有些供应商用 429 同时表达"太快了"和"余额没了"。 先按错误详情识别额度耗尽,才能把终局失败与可重试的限流区分开—— 否则重试策略会对一个永远不会成功的请求一直退避重试。

分类用的详情是错误对象的 code、type、message 拼接后的文本,识别靠一组正则;上下文溢出与额度耗尽的判定函数 LlmErrorCodes.IsContextWindowExceeded 与 IsQuotaExceeded 是公开的,新适配器应当复用它们,以保证跨供应商的码一致。HTTP 413(payload 过大)归入 INVALID_REQUEST——这是 rc.8 语义:请求本身太大是请求方的问题,不是限流。

rc.8 多模态与 reasoning 回传

对齐 dsh rc.8 之后,DeepSeek 适配器多了三条硬语义:

  • 图片进 wire。 图片块序列化为 Messages image 块(inline base64 或 Files file_id 引用)随请求发出;模型的 inputModalities catalog 声明它是否收图(DeepSeek 默认 ['text'])。
  • 请求图片预算。 MaxRequestImageBytes 默认 20 MiB;整批图片超预算时淘汰最旧的图片(替换为占位文本),durable 消息本身不动——预算约束的是这一次请求,不是会话事实。
  • thinking 逐轮回传。 模型返回的 thinking/thinking_delta 块映射为 TextReasoningContent,且每一个 reasoned turn 都回传(不再仅限 tool-call turn):网关重编码依赖思维链签名(signature 随 replay envelope 往返),丢一轮会导致后续空 content 报 400。

Multi-provider 适配器

Tether.Llm.Providers 提供通用 MultiProviderClient:一张 route table 把逻辑路由名映射到 provider profile,按 ChatOptions 上的 tether:provider 附加属性(或默认 route)选路。

支持的 wire 族:DeepSeek(Anthropic Messages + thinking + output_config.effort)、OpenAi、Anthropic(messages API)、OpenAiCompatible(chat-completions;手写网关默认 catalog-free,下文的供应商模板 route 带模板目录)。带 catalog 的 route 声明各模型的上下文窗口、输出上限、输入模态与推理强度,未知模型直接拒绝;catalog-free route 放行任意模型。

标准 OpenAi route 的派发(#470)。 准入谓词是 MeaiOpenAiBridge.CanServe:kind: openai 且未声明显式 baseUrl、自定义 headers 或 route/model 级 compat 覆盖的 profile 默认经官方 Microsoft.Extensions.AI.OpenAI adapter(供应商 SDK + IChatClient 桥)执行——复用借用的 HttpClient 传输与 Tether 凭据注入,但把消息/工具/用量投影交给官方适配器。版本钉死在 CPM:Microsoft.Extensions.AI.OpenAI 10.9.0 + OpenAI 2.12.0 + System.ClientModel 1.14.0。SDK 自带的 429/5xx/断网重试已显式关闭(pass-through NoRetryPolicy),一次 adapter 调用恰好一次网络尝试,外层 ProviderRetryPlugin 是唯一可见的重试权威。显式 baseUrl(兼容网关)、自定义 headers 或任何 compat 覆盖的 OpenAi profile 静默回落到 ChatCompletionsWire(兼容逃生通道);OpenAiCompatible 与 Anthropic/DeepSeek 恒走各自 wire,不经 adapter。adapter 失败按稳定错误码上抛,不会在运行期自动回落旧 wire。详见 官方 adapter 设计稿。

通用 wire 的 400 同样只在错误信封(code、type、message;无信封时是原始 body)经 LlmErrorCodes.IsContextWindowExceeded 判定为上下文溢出时才是 CONTEXT_WINDOW_EXCEEDED,其余 400 是 INVALID_REQUEST。溢出码会触发 上下文压缩 的溢出恢复,误判会白白压缩历史。

其余机制:

  • 凭据间接寻址。 profile 只持 CredentialId,秘密只经由 凭据 seam 逐次解析,配置里永远没有明文 key。
  • ModelMap 逻辑替换。 逻辑模型名先经映射再到 wire。
  • 原子换表。 SwapTable(RouteTable) 原子替换整张 route table——热更新配置不会出现半张新表。
  • 严格 last-good 配置。 profile、compat、retry/backoff、图片预算与 credential key 采用 allow-list 和 present-sensitive 类型校验;非法 overlay 在换表前失败,旧表继续服务。
  • exact generation dispatch。 preparation 快照 route table 后解析 credential、capability 与 retry;transport 不再二次读取 current table,retry 插件也只读取 prepared policy。
  • 明确的 HttpClient owner。 插件只关闭自己创建的 client;宿主注入的 client/factory 仍由宿主拥有。
  • 录制回放。 ReplayState 按适配器 id tether.multi-provider 捕获/恢复请求-响应对,支撑无密钥测试与快照。

Direct DeepSeek 与 multi-provider 都从 exact client 声明图片 capability;foreign provider 不能借用 DeepSeek 的 vision catalog,未知模型也不会靠名字猜 modality。

CLI bundle 里默认挂的就是它(llm-multi),direct DeepSeek 单例作为独立 provider 保留。标准 OpenAI 流量自 #470 起走官方 adapter,其余 wire 保持原实现。

响应头里的请求 id 依次尝试 x-request-id、x-deepseek-request-id、request-id;Retry-After 被解析进 ProviderRetryAfterMs。

供应商模板

Tether.Llm.Providers 内嵌一份供应商模板清单 src/Tether.Llm.Providers/Templates/provider-templates.json,由 scripts/sync-cindy-provider-templates.mjs 从 cindy 仓库的模型供应商模板投影生成(文件的 source 字段记录所取的 cindy 提交)。每个模板是一条休眠的 catalog route:

  • 激活方式。 模板以 declared: false 出现在 ListConfigurableProviders,排在内建 deepseek/openai/anthropic 之后。在设置页「添加」下拉里选中模板、填入 API key,就会写入 providers.<模板 id>(key 按页面约定存为 <ROUTE>_API_KEY)。没写进设置的模板不注册 route,也不进模型选择。
  • 默认值与覆盖。 route 激活后,展示名、baseURL、默认请求头、模型目录与模型级 compat 取自模板,用户字段总是优先:请求头按 HTTP 语义大小写不敏感地替换同名模板头;models 一旦声明就替换整份目录,但按 id 继承模板里的模型元数据;modelCompat 在模板值上按字段叠加。显式写了与模板协议不同的 kind/api 时,该 route 按手写 route 处理,模板默认值全部不生效。
  • 协议。 模板只用 tether 能直接服务的两种 wire:openai-completions 模板落到 OpenAiCompatible,anthropic-messages 模板落到 Anthropic。模板模型的推理档位只保留 low/medium/high,因为通用 wire 表达不了 off、minimal、xhigh、max。
  • 需要填端点的模板。 少数模板(如 Cloudflare)的 baseURL 带 {CLOUDFLARE_ACCOUNT_ID} 这类占位符。有效 base URL 仍含占位符时,profile 解析与 model discovery 都会在发请求前拒绝,并提示在 baseURL 填入真实端点。
  • 无需 key 的模板。 LiteLLM、LM Studio、llama.cpp、vLLM 这类本机或自托管模板(authMethod: none)不需要凭据,写入空对象 {} 即可。它们没有内置目录:模型先经 model discovery 从本地端点列出再选,未配置时的探测只携带本次请求填写的 key。
  • 未收录的模板。 同步脚本把落选的 cindy 模板连同原因记在 JSON 的 skipped 里:只提供 tether 尚不支持的 wire(OpenAI Responses、Google Generative AI、Bedrock Converse、Mistral Conversations 等);与内建 route 重复(DeepSeek、Anthropic 官方端点);或者声明的模型在所选端点上都无法服务。
  • 重新同步。 node scripts/sync-cindy-provider-templates.mjs 默认读取本地 cindy 仓库的 origin/main(只做 git archive 读取,不改 cindy 工作区与 refs)并重写 JSON;--check 只比对不写入,数据漂移时以退出码 1 报告。cindy 路径可用 --cindy 或 CINDY_REPO 指定,默认是 tether 主仓库的同级目录 cindy。

虚拟 route

为了支持多模型熔断池、降级切换与自定义路由策略,Tether.Llm 提供了虚拟 route 扩展 seam:ILlmVirtualRouteRegistry 与 ILlmVirtualRoute。树外扩展包或自定义插件可以动态注册一条虚拟 route,在模型目录和请求路由中作为独立的 provider 暴露给会话使用。

注册与生命周期

虚拟 route 由 multi-provider 宿主暴露的 ILlmVirtualRouteRegistry 进行注册:

  • 注册即回收:Register(ILlmVirtualRoute) 返回 IDisposable 句柄,调用方必须按 Cordis 纪律将句柄挂载到自身的 EffectScope 上。句柄释放是幂等的,且按精确对象引用认领撤回,不影响后续同名注册。
  • ID 约束与唯一性:虚拟 route ID 必须非空、无空白且不包含 /;重复注册相同 ID 的虚拟 route 会立即以 INVALID_REQUEST 失败。
  • 真实 route 遮蔽:允许虚拟 route 与真实 route 同名,但真实 route 具有绝对优先级——被真实 route 遮蔽的虚拟 route 不出现在目录中,且不可准备与派发。

目录与能力可见性

虚拟 route 与真实 route 在不同目录面上的可见性遵循严格分层:

接口 / 能力面虚拟 route 表现
ILlmProviderDirectory.ListProviders()真实 route 保持原有顺序,其后按注册顺序追加未被遮蔽且描述合法的虚拟 route。
ILlmProviderDirectory.DescribeProvider(id)真实 route 优先;不存在时回落至未被遮蔽的虚拟 route 描述。
ILlmModelCatalog.ContextWindowOf(route, model)真实 route 优先;不存在时读取虚拟 route 目录对应模型的 ContextTokens。
ILlmInputCapabilities.AdmitsImages(provider, model)真实 route 优先;不存在时读取虚拟模型的 AdmitsImages 属性;未知模型返回 null。
ILlmRouteDirectory / ILlmModelDirectory / ILlmModelDiscovery / ListConfigurableProviders仅含真实 route。虚拟 route 不包含底层 endpoint、物理凭据与 discovery,绝不出现在这些物理路由接口中。

如果虚拟 route 的 Describe() 抛出异常或返回的 Id 与注册 route 不符,目录查询会自动跳过该 route 而不向外抛出异常,保证宿主目录面不受插件故障影响。

准备与派发委托

对虚拟 route 的请求同样遵循 exact-client prepared request 纪律:

  1. PrepareAsync:宿主调用虚拟 route 的 PrepareAsync(config) 获得原始的 inner prepared request,校验其 Config.Provider == config.Provider,并构建宿主包装的 LlmPreparedRequest。包装包含宿主生成的 ChatClientMetadata(确保 terminal provider 一致性校验通过),并将 LlmRetryPolicy 等服务透传自 inner。
  2. StreamAsync:流式派发时,宿主将原始 inner prepared 实例传入虚拟 route 的 StreamAsync,同时将带有宿主 route 键与宿主包装请求的 options 传给实现。虚拟 route 可根据内部策略(如 failover 优先级队列)以目标真实 route 的名义重入调用同一客户端。
  3. 陈旧注册快速失败:如果请求在准备完成后发生插件重载、虚拟 route 注销、或同名真实 route 挂载,派发时会立即抛出 INVALID_REQUEST(virtual route "x" changed or is no longer available)。这与真实 route 的换表快速失败语义同义,不做静默重新准备。

多模型故障转移与熔断(Failover 扩展包)

基于虚拟 route 契约,树外扩展包 extensions/tether-plugin-llm-failover(跟踪 #367)提供了多模型自动故障转移与独立熔断能力:

  • 组路由聚合:将多个提供相同模型能力的真实 route(如备用 API Key、不同的中转网关)聚合成一条虚拟 route(如 primary-pool/deepseek-chat),在模型目录中作为正常模型供会话选择。
  • 转移链调度策略:
    • 优先级策略(Priority):默认策略。每次请求从优先级最高的可用 route 开始尝试;当高优先级 route 从故障中恢复后,自动回切到高优先级节点。
    • 粘性策略(Sticky):一旦发生故障转移至目标节点,后续请求继续保持在该目标,直到当前节点也发生故障才再次转移。
  • 精准故障分类与快速熔断:
    • 对于额度耗尽(QUOTA、HTTP 402、AUTH)或服务端持续不可用(5xx),立即熔断该节点并转移至下一目标,绝不进行无意义的退避重试耗费时间;
    • 临时限流(RATE_LIMIT、HTTP 429)则遵循服务端下发的 Retry-After,在熔断期间对探测请求执行有界的指数退避(Bounded Exponential Backoff)。
  • 与单 route 重试的正交分层:
    • 单 route 内部的网络抖动由 ProviderRetryPlugin 负责(同一 attempt 内);
    • 虚拟 route 负责跨 route 级别的故障降级,组内单次逻辑派发耗尽所有备用节点后,以最后一次失败码通知外层 waterfall。

LLM 可观测性管道(Llm.Observability)

src/Tether.Llm.Observability 提供了结构化的模型调用遥测与追踪管道(LlmObservabilityPlugin):

  • 分布式追踪集成:通过 LlmTelemetryActivityBridge 与 .NET System.Diagnostics.Activity 标准对接,将模型请求的生命周期注入全链路 Trace,精准记录从请求构建、准备(Prepare)、首 Token 到达(TTFT)、到完整响应结束的每一段耗时。
  • 受控调用门(ObservedCallGate):在流式派发外层提供无开销的计量拦截,精确统计真实输入/输出 Token、流中断原因、重试次数及异常堆栈。
  • 纯装饰器设计:实现 ILlmClientDecorator,保持 ILlmClient seam 契约零污染,即插即拔。

DeepSeek 请求扩展与日志分享

默认组合挂载 deepseek-llm-api-extensions 注册表。贡献者只能增加独占的请求顶层字段,不能覆盖已经存在的基础字段;OpenAI、Anthropic 等其他 provider 不调用它。每次 HTTP 尝试重新准备扩展,收到 2xx 后才执行接受回调,随后读取响应正文。准备或接受失败使用 REQUEST_EXTENSION 错误码。

plugin-package-inventory-deepseek 默认开启。DeepSeek 请求中的 dsh_plugin_packages 是版本 1 的包名与版本清单:包括当前生效的组合行,以及请求会话实际绑定的 preset 行。它不包含插件配置、文件路径或依赖程序集;匿名插件不会被猜测为某个包。包身份来自构建时声明的 PackageId / PackageVersion,并在挂载时捕获,修改 catalog 或 preset 文件不会改写旧挂载的身份。可用同名组合 patch 设置 enabled: false 关闭此字段。

清单覆盖普通、压缩和标题请求;没有会话时仍有宿主清单。Boot 完成组合发布前发起 DeepSeek 请求会明确失败,不等待启动事件,以免插件 apply 相互等待。热替换期间读取的是已发布的组合,而不是尚未提交的候选树。

session-log-deepseek 默认关闭——这是与上游 dsh v0.1.6-alpha.2 的有意分歧(上游自 db942b3530 起默认开启),已在 #267 登记为 privacy deviation:完整轨迹上传必须显式选择加入。需要将完整会话日志随 DeepSeek 请求发送到已配置的模型 endpoint 时,可在组合 patch 中显式启用:

patches:
  - id: session-log-deepseek
    name: session-log-deepseek
    config:
      enabled: true

启用后,dsh_session_log 字段包含会话 header,以及尚未记录为已接受的完整事件后缀,包括用户输入、模型输出和工具事件;这不是仅包含用量的遥测。普通请求、压缩请求和标题请求采用相同规则。没有当前会话时不附加日志。关闭此项后仍能读取此前保存的投递标记。

接受标记写入原会话日志,不维护第二份游标文件,也不额外强制刷盘。如果进程在标记持久化前退出,恢复后可能重复发送;收到非 2xx 不推进水位。上传保留 Tether 自身的事件格式版本和原始 JSON,不自动转换成 dsh 的内部事件词汇,也不截断后缀。

与 Agent 的衔接

模型请求失败后不是直接冒泡到调用方,而是先过 agent/request-error 恢复 waterfall,插件有机会把它转成重试或降级。重试只重开 transport stream,不会重跑 request waterfall、prompt、prepared generation 或附件读取。真正失败的 turn 会以 turn/end 记录,reason.kind 为 error,并携带一个 LlmFailureSnapshot——里面正是这套与供应商无关的稳定码。细节见 Agent 生命周期。

下一步

在 GitHub 上编辑此页