深色模式
源码拆解 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讲过接口,这里看实现意图):
- 注册表:
registerAdapter(providers, adapter)——多路由一次性校验、冲突即抛DUPLICATE_ADAPTER,返回的 handle 可原子replace()。实现里注册与换路由都走"先整体校验、后单段同步提交",保证任何读者都观察不到半注册状态(HMR 换适配器的瞬间,请求不会撞上路由空洞)。 - 调用入口:
stream(options)——按options.provider选适配器,把适配器抛出的任何异常归一化为终止finish { kind: 'error' | 'aborted' }再暴露给消费方。消费方(agent-loop)永远只看到合法流。 - 能力查询:
listModels/resolveModelInfo/resolveCallConfig——设置页、上下文窗口判断、默认值物化都走这里,全部只读、全部不校验路由(目录成员身份是建议性的,官方注释强调 "catalog membership is advisory, not request validation")。
还有一个关键抽象 PreparedLlmCall:prepareCall(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, …) // 空闲看门狗
// …
}三个生产级考量:
- 密钥与端点同代绑定:配置可以热更新,但一个请求的端点与发给它的密钥必须来自同一次解析——注释里说得很直白:"a request can never pair one generation's URL with another generation's secret"。这是个真实的安全边界,不是洁癖。
- 双取消源:调用方的
options.signal(用户点了停止)与适配器内部的consumer(自身超时)通过AbortSignal.any合并。 - 空闲看门狗: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 状态翻译
}注意所有失败都被翻译成带稳定 code 的 LlmError——第 4 章说的"结构化错误落盘"依赖的就是这个纪律。
translate.ts 做 SSE wire 响应 → StreamChunk 的翻译:usage 在 finish 前发出、工具参数保持原始 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,做完你就真的能读懂任何一家适配器了。)