Skip to content

源码拆解 05 · dsh-llm:适配器注册与流式词汇表

本篇拆解 packages/llm/llm(接缝本体)、llm-deepseek(真实适配器范例)、llm-retry(重试策略)。读完你会知道一个生产级适配器到底要处理多少"协议之外"的事。

5.1 文件地图

text
packages/llm/
├── llm/                  # 接缝本体(@deepseek-ai/dsh-llm)
│   └── src/
│       ├── types.ts      # 词汇表:Message / StreamChunk / GenerateOptions
│       ├── index.ts      # LlmRuntime 服务 + LlmAdapter 抽象类
│       ├── assembler.ts  # BlockAssembler:增量 → 组装消息
│       ├── message.ts    # 消息构造与投影
│       ├── call-config.ts# LlmCallConfig + 深冻结 + 比较
│       ├── retry-policy.ts
│       └── error.ts      # LlmError(带 code)
├── llm-deepseek/         # DeepSeek 官方适配器(最佳学习范本)
│   └── src/
│       ├── adapter.ts    # ★ 适配器主体(346 行)
│       ├── serialize.ts  # 请求序列化(统一词汇表 → wire 请求)
│       ├── sse.ts        # SSE 分帧解析
│       ├── translate.ts  # ★ wire 响应 → StreamChunk 翻译
│       └── index.ts      # 插件胶水(Config、凭据门、registerAdapter)
├── llm-retry/            # agent/request-error 的默认重试监听器
└── token-meter/          # token 计量

5.2 LlmRuntime:注册表的三件事

index.ts 里的 LlmRuntime 服务做了三件事(原理篇 4.4讲过接口,这里看实现意图):

  1. 注册表registerAdapter(providers, adapter)——多路由一次性校验、冲突即抛 DUPLICATE_ADAPTER,返回的 handle 可原子 replace()。实现里注册与换路由都走"先整体校验、后单段同步提交",保证任何读者都观察不到半注册状态(HMR 换适配器的瞬间,请求不会撞上路由空洞)。
  2. 调用入口stream(options)——按 options.provider 选适配器,把适配器抛出的任何异常归一化为终止 finish { kind: 'error' | 'aborted' } 再暴露给消费方。消费方(agent-loop)永远只看到合法流。
  3. 能力查询listModels / resolveModelInfo / resolveCallConfig——设置页、上下文窗口判断、默认值物化都走这里,全部只读、全部不校验路由(目录成员身份是建议性的,官方注释强调 "catalog membership is advisory, not request validation")。

还有一个关键抽象 PreparedLlmCallprepareCall(config) 把"配置解析"与"流式派发"绑在同一次适配器注册上——HMR 换适配器的瞬间,不会出现"能力查自旧适配器、请求发到新适配器"的错配

5.3 BlockAssembler:容错的组装算法

assembler.ts 是"整个 harness 唯一的组装算法"(文件头注释原文:the single canonical assembly algorithm used by the agent loop)。它把增量 chunk 组装成消息:

ts
class BlockAssembler {
  private partials = new Map<number, PartialBlock>()  // index → 半成品块
  push(chunk: StreamChunk): void {
    switch (chunk.type) {
      case 'block-start': /* 建 partial */ break
      case 'text-delta': /* 追加增量 */ break
      case 'tool-call-delta': /* 追加参数增量 */ break
      case 'block-end': /* 冻结:块完成,后续增量忽略 */ break
      // …
    }
  }
}

两个工程细节值得学:

  • 容错协议:允许只有 delta 没有 start/end 的流(有些提供方就这德行);block-end 之后到达的 delta 被忽略——一个行为异常的适配器不能通过无限追加增量把内存撑爆,也不能污染已完成的块;
  • 单一算法:agent-loop 边流式落盘边喂给 assembler,UI 回放、消息派生都用同一份组装结果——组装逻辑只有一份,不会出现"实时渲染和回放渲染长得不一样"的经典 bug。

5.4 真实适配器:DeepSeekAdapter 逐段读

llm-deepseek/src/adapter.ts 是适配器作者最好的范本。stream() 的开头(第 214–227 行)值得整段琢磨:

ts
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  // 每次调用解析一次连接事实:本次请求的端点与密钥被冻结,
  // 飞行中的流永远不会看到配置变更;下一次调用重新解析。
  const connection = this.config.options()
  const apiKey = await this.config.resolveApiKey(connection)
  const upstream = options.signal === undefined
    ? consumer.signal
    : AbortSignal.any([options.signal, consumer.signal])  // 内外双取消源合并
  using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, …)  // 空闲看门狗
  // …
}

三个生产级考量:

  1. 密钥与端点同代绑定:配置可以热更新,但一个请求的端点与发给它的密钥必须来自同一次解析——注释里说得很直白:"a request can never pair one generation's URL with another generation's secret"。这是个真实的安全边界,不是洁癖。
  2. 双取消源:调用方的 options.signal(用户点了停止)与适配器内部的 consumer(自身超时)通过 AbortSignal.any 合并。
  3. 空闲看门狗:SSE 流可能"半死"——连接在但不再吐数据。idleWatchdog 在每次收到数据时 pulse(),超时未收到就中止流并抛 LlmError('TIMEOUT')

错误路径(第 246–257 行):

ts
} catch (error) {
  if (timeoutOf(watchdog.signal, …)) throw new LlmError('…idle timeout…', 'TIMEOUT', …)
  if (options.signal?.aborted) throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', …)
  if (error instanceof LlmError) throw error      // 已结构化的错误原样抛
  // …其余拍平为 UNKNOWN 或按 HTTP 状态翻译
}

注意所有失败都被翻译成带稳定 codeLlmError——第 4 章说的"结构化错误落盘"依赖的就是这个纪律。

translate.ts 做 SSE wire 响应 → StreamChunk 的翻译:usagefinish 前发出、工具参数保持原始 JSON 字符串、思考内容与正文分流——原理篇 4.3的协议义务在这里逐条兑现。

5.5 插件胶水:凭据门与注册

index.ts(276 行)是适配器的插件面。它做的事:

  • 用 schemastery Config 声明配置(baseURL、models、密钥环境变量名、超时……),带环境变量回退;
  • 凭据门:解析 API key,缺失时报出那条你在 Demo 5 会亲眼看到的错误——MISSING_CREDENTIAL: no API key for provider route "deepseek-official"
  • DeepSeekAdapter 注册到 ctx.llm

密钥处理有一条硬规矩(官方 cookbook):密钥永远通过 Cordis 配置注入(!!js process.env.XXX),绝不在适配器代码里自己读密钥文件——凭据策略是凭据插件的职责,适配器只认解析结果。

5.6 llm-retry:重试也是一个插件

llm-retry 包监听 agent/request-error 瀑布,按适配器注册时声明的 ResolvedRetryPolicy 决定是否返回 { kind: 'retry' }

ts
interface ResolvedRetryPolicy {
  normal: { maxRetries: number; retryableCodes: string[]; backoff: … }  // 常规重试
  always: { … }   // 无条件重试(如幂等查询)
}

策略数据挂在适配器注册上(providerRetryPolicy()),策略执行挂在事件上——数据与逻辑分离,两者都能被替换。第 4 章说的"恢复决策交给事件"在这里有了具体实现。

5.7 本章小结

LLM 层的工程哲学一句话:接缝极薄,纪律极严。 接口就一个 stream(),但围绕它堆满了协议义务(usage 先于 finish、参数保持原始字符串)、容错(assembler 忽略坏增量)、安全(凭据同代绑定)、可观察(结构化错误)。写一个能用的适配器只要 20 行,写一个生产级适配器要 300 行——差别全在这些纪律里。

小练习:读你的第一个生产适配器

打开 llm-deepseek/src/translate.ts,找到处理 SSE tool_calls 增量与处理 reasoning_content 的分支,回答:1)工具参数增量如何拼接?block-end 时为什么要重新 stringify?2)思考内容与正文如何分流到不同的块 index?3)usage 在哪个分支发出?为什么它必须在 finish 之前?

(答案都在原理篇 4.3与本章 5.4,做完你就真的能读懂任何一家适配器了。)

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写