前面几篇,我们沿插件框架与会话日志向内走,看到了事件如何折叠、请求如何记录。本篇回答一个更具体的问题:当 dsh 需要同时对接 DeepSeek 官方 API、OpenAI 兼容网关和回放测试时,它怎样用一套类型词汇"和模型说话"?失败之后,谁来决定重试?
答案集中在 packages/llm 一组包里。它们构成 LLM 适配层:核心词汇表、三个内置适配器、一个重试插件,以及只服务官方路由的协议扩展。本文基于 2026 年 10 月的 master 分支(0.2.1-alpha.1 前后);项目处于 developer preview 阶段,API 可能继续变动。
词汇表:消息、内容块与流式协议
打开 packages/llm/llm/src/types.ts,先看到持久消息的定义。Message 是按 role 判别的联合:user、assistant、system、developer、tool-result。消息体是 ContentBlock[],块的联合从 ContentBlockMap 派生。
ContentBlockMap 是一个可合并扩展的映射,默认七项:text、reasoning、image、file、tool-call、tool-addition、tool-removal。插件可以用 TypeScript 的 declaration merge 往映射里增补自己的块类型——源码注释写明了配套条件:新块类型必须连同适配器、UI 与压缩支持一起落地。工具调用不是独立角色,而是 assistant 消息里的一个块;工具结果则是独立的一等 ToolResultMessage(message.ts),携带 toolCallId、content 与可选 isError。把"工具返回了什么"建模为消息而非内容块,压缩、回放、UI 才能以同样方式对待它。消息来源(MessageSourceMap)是同构的设计:user、model、tool、system-prompt 各自声明,没有兜底的 plugin kind;kind 回答"谁产生",可选的 form 回答"这是什么信息",两个轴刻意独立,消费方对未声明的值按不透明内容呈现。
流式输出走另一套类型:StreamChunk。它是封闭的原始协议,只有七个变体:
type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
| { type: 'text-delta'; index: number; text: string }
| { type: 'reasoning-delta'; index: number; text: string }
| { type: 'tool-call-delta'; index: number; id: ToolCallId; argumentsDelta: string }
| { type: 'block-end'; index: number; block: ContentBlock }
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: FinishReason; replayState?: ReplayEnvelope }一次响应里,文本、推理、多个工具调用交错到达。index 把每个 delta 关联到所属块;block-end 直接携带组装好的完整块,消费方不必自己拼 delta。协议还要求适配器先吐 usage 再吐 finish,之后不再有任何分片。
封闭联合的价值在消费方。BlockAssembler(assembler.ts:94)对 chunk.type 做 switch,以 assertNever 结尾:将来给协议新增变体,每一个必须处理它的消费方都会在编译期报错,而不是悄悄走进 default 分支。"可合并扩展 + assertNever 闭包"这套组合,在消息来源、内容块、结束原因三处词汇表里反复出现。
sequenceDiagram participant AD as 适配器 participant AS as BlockAssembler AD->>AS: block-start index=0 type=text AD->>AS: block-start index=1 type=tool-call AD->>AS: text-delta index=0 我们 AD->>AS: tool-call-delta index=1 name=bash AD->>AS: text-delta index=0 继续看 AD->>AS: block-end index=0 完整文本块 AD->>AS: usage 输入输出 token 计数 AD->>AS: block-end index=1 完整工具调用块 AD->>AS: finish reason=tool-calls
失败也有词汇。LlmFailure(types.ts)是提供方无关、可序列化的负载:code、status、providerRetryAfterMs、requestId。注意 providerRetryAfterMs 只是提供方请求的正数延迟,不是重试决策——重试与否由策略层裁定,适配器只负责把事实带回来。消费方按 code 路由,绝不依赖提供方文本:两个适配器都把上下文溢出归一成 CONTEXT_WINDOW_EXCEEDED,把空 completion 归一成可重试的 EMPTY_RESPONSE(llm-deepseek/src/adapter.ts:146),fail-loud 而不是静默成功。
请求与结果的其余词汇同样克制。GenerateOptions 是完全组装的请求:路由、派生历史、可选的 system/tools/toolHistory/signal/sessionId/purpose。面向模型的 ToolSchema 特意声明在 dsh-llm 而非 tools 包——它是请求组装的一部分,定义与消费在同一处。TokenUsage 的各计数互不重叠:缓存读写单独报告,DeepSeek 把缓存命中折进 prompt_tokens 时,适配器负责把它扣出来。FinishReasonMap 与内容块一样可合并扩展;成功响应的 replayState 是一个 ReplayEnvelope——响应级元数据加逐块条目,组装丢弃某个块时,同位置的条目一并裁剪,内容与元数据同生共死。
适配器接口:注册即绑定一代
词汇表之上是适配器约定。LlmAdapter(llm/src/index.ts:208)是抽象类,唯一必须实现的方法是 stream(options):
abstract class LlmAdapter {
providerInfo(provider: string): LlmProviderInfo
providerRetryPolicy(p): ResolvedRetryPolicy | undefined
imageRequestPricing(p, model): LlmImageRequestPricing | undefined
listModels(p): Promise<readonly LlmModelInfo[]>
resolveModel(p, model, signal?): Promise<LlmResolvedModelInfo>
prepareCall(p, model, signal?): Promise<PreparedAdapterCall>
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}其余方法都有保守默认:模型目录、按路由的重试策略、按模型的图片定价、精确模型解析。prepareCall 返回的 PreparedLlmCall 是一次性句柄:config 已分离并冻结,adapterDefaults 记下由适配器补默认值的字段,stream() 只接受与 config 匹配的请求,复用或错配直接 INVALID_PREPARED_CALL(index.ts:959)。它把能力解析、header 落盘、分派锁进同一次注册——会话日志讲的可重建性,物理上就靠这个句柄兜底。
注册入口是 LlmRuntime.registerAdapter(providers, adapter)(index.ts:389),语义全有或全无:候选路由集先整体校验,任何一条重复(DUPLICATE_ADAPTER)、非法或元数据有错,直接抛错、一条路由也不注册。返回的句柄既是 disposer,又带 replace(providers)——先全量校验,再在一个同步段内原子替换路由集合,进行中的请求不会观察到"路由消失的一瞬间"。对路由由用户配置决定的插件,这正是热更新端点时需要的操作。注册表之外还有 registerConfigurableProviders()(休眠路由目录,让配置界面在任何路由上线前就能陈列可选提供方)和 registerModelDiscovery()(向尚未存储的草稿端点询问模型列表)。
内置适配器只有三个:
llm-deepseek:DeepSeekAdapter(llm-deepseek/src/adapter.ts:20)直连官方 API,用fetch手写请求,自行序列化 Anthropic Messages 正文(含anthropic-version: 2023-06-01头,adapter.ts:126),并自带 idle watchdog(streamIdleTimeoutMs默认 5 分钟,只在next()未完成时计时,adapter.ts:54)。llm-pi-ai:基于@earendil-works/pi-ai库的通用适配器,用 profile 机制覆盖 OpenAI 兼容及 deepseek/openrouter/together/zai/qwen 等多种 reasoning wire format(llm-pi-ai/src/catalog.ts:100)。llm-replay(packages/test-support/llm-replay):回放适配器,把录制的响应喂回循环。
注意一个不存在:仓库里没有独立的 anthropic 或 openai 适配器包。通用路由全走 pi-ai 的 profile,官方路由走专用适配器——新增一家提供方,多数时候只是加一个 profile,而不是写新包。这正是词汇表收敛的红利。
适配器约定还有几条硬规矩,每条都有协议级测试守着:每个提供方 HTTP 请求都带应用归属头(attribution.ts:40 的 APP_IDENTITY,版本取自包 manifest,不含任何秘密);usage 在 finish 之前;工具调用的 arguments 全程保持原始 JSON 字符串,部分片段经 argumentsDelta 流式传输,提供方给解析后对象时适配器在 block-end 重新序列化;回放状态归适配器私有,共享的只有切分方式——其他适配器只会收到提供方无关的内容和 provider/model 字段,绝收不到别人的私有状态。
请求生命周期:四个阶段与三种结局
词汇表和适配器最终要在 agent loop 里跑起来。主循环在 packages/core/agent-loop/src/agent.ts 的 step()(agent.ts:398),一次模型请求穿过四个阶段。
阶段一,准备调用配置(prepareRequest,agent.ts:547)。循环从会话日志折叠出最新的 request/header(记录 provider、模型、推理强度、采样标量与工具顺序),经 agent/request waterfall 允许中间件替换路由:waterfall 开始前,循环先移除标记为适配器默认值的字段,让所选路由的当前值填入;结束后,显式指定但不受支持的推理强度会被拒绝——不自动调整,也不起别名。然后调 ctx.llm.prepareCall(config, signal)(agent.ts:587)。prepareCall(llm/src/index.ts:929)一次性捕获三件事:适配器注册项、精确模型元数据、按路由的重试策略,并把 config deepFreeze(structuredClone(...))。注释说得很直白:HMR 不能把 A 代适配器解析出的能力和 B 代注册的端点拼接在一起。此后整个步骤持有的都是同一代注册。
阶段二,准入与提交。prepareCall 返回后先 signal.throwIfAborted(),随后才提交系统提示词(system/message)与首轮用户消息(user/message)。此前取消,两者都不落日志——日志里不会出现从未发生的对话。
阶段三,冻结请求(buildRequest,agent.ts:599)。循环记录新的 request/header(与上一条做 headerEquals 比较,变了才带 reason:'change' 落盘),deriveMessages() 派生出的历史逐条 deepFreeze 进可复用的 frozenMessages 集合,最后组装出 Object.freeze 的请求并打上 markAgentLoopRequest 进程本地标记(agent.ts:678)。
阶段四,流消费。AssistantStreamAttempt(assistant-stream.ts:18)把每个 chunk 同时喂给三处:紧凑持久化累加器、BlockAssembler、进程内的 agent/assistant-stream frame。一份分片,三本账。frame 词汇只有三帧:start 带 attemptId 与 revision,chunk 带 index、时间与 chunk,end 要么 committed(带 eventType 与 seq)要么 abandoned——实时呈现读 frame,持久回放读紧凑流,遥测直接扫紧凑记录,互不拖累。
深度冻结不是实现细节,而是协议:循环构建的请求内容是会话日志的纯函数,监听者改写它直接抛异常。llm/stream waterfall 的监听器因此只剩两种合法动作——读取,或整体短路返回自己的流。可重建性由此成为可执行的不变量:给定同一份日志,任何进程都能重建逐字节一致的请求。
四个阶段走完,结局只有三种。正常结束提交 assistant/message:组装好的块、usage、紧凑流、回放状态。error 或 aborted 结束只提交 assistant/attempt,不提交 surface 消息,也不产生工具副作用。流中途被信号打断但已有可见内容则提交 interrupted: true 的 assistant/message——用户已经看到一半回答,不应凭空消失。max-tokens 截断时,assembler 丢弃没写完的 tool-call,因为截断的调用不能安全执行;同一决定按相同位置裁剪回放条目,内容保留到哪,元数据就保留到哪。
失败与重试:策略层的 waterfall 与退避插件
适配器可以抛异常,也可以带内发 finish{kind:'error'} chunk。两条路径在 LlmRuntime.adapterStream()(llm/src/index.ts:1026)汇合:适配器查找、分派、迭代中的异常全部归一化成终态 finish{kind:'error'|'aborted'} chunk;中间件与消费方的异常仍向外抛出。失败通道是双轨的,边界是唯一的。
重试决策不在适配器里。适配器约定写死了一条:一次适配器调用 = 一次提供方尝试,适配器自身禁用库内重试。失败发生后,循环先提交 attempt,再发 agent/request-error waterfall,携带 LlmFailure、不可变的重试策略和已重试事实;监听器在 await 完自己的修复后返回 {kind:'retry'},循环 continue 开新一轮尝试。返回 retry 是一种承诺:监听器必须先把故障修好——比如轮换凭据——再许下重试。
默认的修复监听器来自插件 packages/llm/llm-retry:指数退避加抖动——initialDelayMs * 2^n 夹在 maxDelayMs 内,再乘 [1-jitterRatio, 1+jitterRatio] 的随机系数(llm-retry/src/index.ts:61-63),取消信号会中止等待。它按 provider 加 policyKey 记 llmRetry 投影状态,policyKey 就是策略字段的 JSON 序列化(index.ts:68),同一路由的同一条策略跨轮次共享退避进度;EMPTY_RESPONSE 这类规范 code 在默认 retryableCodes 里,空响应因此会被重试而不是吞掉。默认 normal 模式最多重试五次,之后结构化失败才成为轮次错误。
flowchart TD
E["适配器抛错或带内 finish error"] --> N["adapterStream 归一为终态 finish chunk"]
N --> A["提交 assistant/attempt"]
A --> W["agent/request-error waterfall"]
W --> D{"监听器返回 retry?"}
D -->|"否"| F["结构化失败成为轮次错误"]
D -->|"是"| R["llm-retry 指数退避加抖动"]
R --> C{"等待期间被取消?"}
C -->|"是"| F
C -->|"否"| V["continue 开新一轮尝试"]
V --> EDeepSeek 官方协议的私有扩展
官方路由还有一层私有协议,定义在 docs/deepseek-llm-api-wire-extensions.zh.md。扩展全部位于 messages、tools、system 提示词之外——不占模型输入 token,也不改变模型可见前缀。载体是 4 个 HTTP 头(user-agent 应用归属、x-deepseek-harness-user-id 匿名 UUID、x-deepseek-harness-session-id、x-deepseek-harness-compact: 1)和 2 个 dsh_ 前缀的正文字段。
机制由另一个 seam ctx.deepseekLlmApiExtensions(packages/llm/deepseek-llm-api-extensions)支撑:每个顶层字段只有唯一提供方。适配器序列化完基础正文后调 prepare(request),各提供方产出冻结的字段值并返回幂等的 accept() 事务,HTTP 2xx 之后才执行 accept()(llm-deepseek/src/adapter.ts:106、adapter.ts:145)。传输失败或提供方拒绝不会被误记为"已接受";合并正文无法序列化时,请求干脆不带扩展字段发出,让贡献方下次重发。
命名也有规矩:HTTP 头用 kebab-case,正文扩展用保留的 dsh_ 前缀 snake case,DSH 持有的嵌套成员用 camelCase;每个字段独立持有自己的 version,字段版本之间没有兼容或排序关系,接收方按字段名定位、按各字段自己的 version 分派。
两个内置贡献者:dsh_plugin_packages 是存活插件包的名称与版本清单,每次请求重新读取存活 Loader 配置项,同名不同版本保留为独立条目;dsh_session_log 是权威会话日志的连续后缀,以 afterSeq/throughSeq 水位推进,默认按 8MiB 分块、at-least-once 交付——2xx 之后向日志追加 delivery-accepted 事件抬高水位。水位按会话 id 加格式 generation 折叠最大的匹配 throughSeq:并发接受不会使游标倒退,其他 generation 的水位不能授权当前后缀,fork 忽略指向父会话的继承水位、从序号零重新上传;恢复后的进程从持久日志重建游标,崩溃重发只产生重复,绝不产生序号缺口。pi-ai 路径不实现这些扩展,它们只服务官方路由。对 self-host 网关同样生效:扩展发送到解析后的 baseURL,网关收到的值与官方端点完全一致。
小结
LLM 适配层的设计可以归纳为三句话。词汇表收敛:七类内容块、七个流式变体、一种失败负载,可扩展但封闭,新增变体在编译期暴露所有遗漏。请求可重建:四阶段生命周期把注册、日志提交、冻结、消费串成一条线,深度冻结让"请求是日志的纯函数"成为可执行协议。失败归边界:适配器只报事实,重试归策略层 waterfall,一次适配器调用永远等于一次提供方尝试。词表之外还有值得追的支线——TokenUsage 计量如何驱动压缩选段、图片请求如何按路由计价——这些留到压缩一篇再展开。
本篇的 ctx.llm 本身就是一个 seam——定义在 dsh-llm,提供方是三个适配器包,消费方是 agent loop 与压缩。下一篇我们把视角放大:文件系统、shell、沙箱、子进程、subagent 都是同样的三角色结构,而替换一组提供方,就能把整个产品的执行世界从本地搬到远程。