第三部分 · 能力接缝
第 9 章:LLM 接缝与流式
一张封闭的 chunk 协议、一个可扩展的 block 词表,和一座「零决策」的 DeepSeek 传输层
词汇表即架构
LLM 接缝(packages/llm/llm,ctx.llm)是全书最「类型驱动」的一章——它先把三张判别联合钉死在 types.ts 里,再让 adapter、loop、UI 三方围着它们转:
| 词汇 | 性质 | 内容 |
|---|---|---|
ContentBlockMap | merge-extensible(插件可加) | text / reasoning / image / tool-call / tool-result 五种 block |
StreamChunk | 封闭判别联合(assertNever 收尾) | block-start / text-delta / reasoning-delta / tool-call-delta / block-end / usage / finish |
FinishReasonMap | merge-extensible | completed / max-tokens / error / aborted(后两者携带 LlmFailure) |
核心严格、插件可扩——两条互补而非互斥的演进策略:新 block 类型要落地必须同时有 adapter、UI、compaction 支持(ContentBlockMap 的 JSDoc 明说);而新 chunk 变体在编译期炸遍所有消费者(switch 的 default 是 assertNever)。StreamChunk 的原始协议长这样:
export 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: CallId; name?: string; argumentsDelta: string }
| { type: 'block-end'; index: number; block: ContentBlock }
| { type: 'usage'; usage: TokenUsage }
| {
type: 'finish'
reason: FinishReason
/** Adapter-private lossless-JSON state for replaying a successful response. */
replayState?: unknown
}
值得注意的两个设计:tool-call-delta 的 argumentsDelta 是字符串增量——模型在流式吐 JSON 参数,adapter 不解析它,原样累积;finish.replayState 是 adapter 私有的重放状态,随 assistant/message 持久化(第 4 章),让「重放一次成功响应」成为可能。
三件套:从 GenerateOptions 到 LlmAdapter
GenerateOptions(types.ts)是「完全组装好的单次请求」:provider+model 选路由、messages/system/tools/采样标量/超时/取消。它的 epoch 级子集 LlmCallConfig(call-config.ts)与日志 header 对应(第 4 章的 request/header)。Service Provider 是抽象类 LlmAdapter——只强制实现 stream(options): AsyncIterable<StreamChunk>,可选 providerInfo/retryPolicy/listModels/resolveModel。注册:
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
const owned = new Set<string>()
let released = false
const dispose = this.ctx.effect(function* (this: LlmRuntime) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => { released = true; for (const provider of owned) this.adapters.delete(provider) ... }
}.bind(this), 'llm.registerAdapter()')
// ...
一个 adapter 实例可挂多个 provider 路由,重复路由抛 DUPLICATE_ADAPTER(all-or-nothing),返回的 handle 带原子 replace()——HMR 时代换 adapter 不中断在飞请求的注册一致性。Consumer(agent-loop 的 step())一侧:
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
一边落日志(chunk 级事件,第 4 章),一边喂 assembler——「模型可见 ⟺ 已记录」在这里就是同一行代码的两个动作。
prepareCall:把能力解析与 dispatch 绑在同一注册上
LlmRuntime.prepareCall(config, signal) 是 dsh 的一个精妙机制。它先锁住 adapter 注册,resolveModel 拿 exact-model 元数据(校验 contextWindow/defaultMaxTokens/reasoning efforts),物化默认值(缺省 maxTokens 且模型有默认时补上;reasoningEffort 用模型 defaultEffort;显式不支持的 effort 抛 UNSUPPORTED_REASONING_EFFORT,不钳制),返回深冻结的 PreparedLlmCall:
config:detached + deep-freeze 的实际配置;adapterDefaults: {reasoningEffort?: true; maxTokens?: true}——显式标注「这个字段是 adapter 填的」而非调用方提议的;retryPolicy:注册时捕获的 provider 重试策略;context:contextWindow(来自 exact-model 元数据)。
它的 stream(options) 是一次性的:二次调用或配置不匹配抛 INVALID_PREPARED_CALL。关键在「prepare 到 dispatch 共用一个 registration」:HMR 或配置热更不可能出现「A adapter 的能力解析配到 B adapter 的 dispatch 上」。类比数据库的预编译语句:绑定 plan 之后,执行只认这个 plan。而 adapterDefaults 标记让第 3 章的 loop 能判断哪些字段 sticky(被 adapter 物化过的字段不随 request/header 恢复,每步重解析——见 agent.ts 的 requestProposal)。
markAgentLoopRequest 是进程内 WeakSet<GenerateOptions> 打标:区分「loop 会话请求」与「手建的一次性请求/辅助调用」——llm/stream 的 waterfall 监听者(retry/replay/routing)需要知道请求的出身。loop 请求同时被 deepFreeze,保证其内容只是会话日志的纯函数。
错误是数据,不是异常
LLM 接缝的错误哲学值得单独一节。两条通道归一成一种终态:
- adapter 可以 throw,也可以 in-band 发
finish {kind: 'error'}——adapterStream把 dispatch/迭代期的任何 throw 归一成终态 failure chunk(abort 或ABORTED→aborted,否则 →error); - 消费者只看到一种
LlmFailure。路由只看稳定 code,从不解析文案。
/** Convert one adapter throw into the stream protocol's terminal outcome. */
function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
const failure = normalizeLlmFailure(error)
return {
type: 'finish',
reason: signal?.aborted || failure.code === 'ABORTED'
? { kind: 'aborted', failure }
: { kind: 'error', failure },
}
}
LlmError(index.ts:83)的 code 是稳定机器码(message 仅供人读),构造时校验 status(100–599)/providerRetryAfterMs/requestId,并把可序列化事实冻结进 this.failure。规范常量:CONTEXT_WINDOW_EXCEEDED、QUOTA、EMPTY_RESPONSE(可重试的空响应)、INVALID_CREDENTIAL(畸形而非缺失)。providerRetryAfterMs 是「供应商要求的延迟」而非重试决定——策略层另判(llm-retry 插件挂在 agent/request-error waterfall 上执行 provider 自己的 retryPolicy)。第 3 章 loop 的错误路径就是这么接的:finish 出错 → agent/request-error 瀑布 → 监听器返回 {kind:'retry'} 就重试,否则抛 LlmError。
DeepSeek adapter:零决策的传输层
packages/llm/llm-deepseek 是当前仓库自带的 provider(模型名 deepseek-v4-flash/deepseek-v4-pro,thinking 走 reasoning_content + thinking.type + reasoning_effort 字段)。它是「传输层不做决定」的样板:直接 fetch + eventsource-parser,不依赖 SDK;端点与密钥每请求解析(options() thunk + resolveApiKey(connection),URL 与 secret 永远同代快照);配置经 registerConfigurableProviders + settings 热更(每请求重读、失败保留 lastGood)。
序列化器(serialize.ts)里藏着几个只有做过生产 agent 才懂的细节:
return {
role: 'assistant',
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. ...
content: text,
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
// must return on tool-call turns; it is ignored on plain turns, so we
// drop it there to save tokens.
...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {},
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
}
「assistant 的 content 永远是 "" 而非 null」——官方样例逐字回放 "",gateway 会拒 null,且空内容消息一旦入会话日志会卡死后续回合。还有 mapUsage 的缓存语义:DeepSeek 的 prompt_tokens 包含缓存命中,而 harness 的 TokenUsage 要求 disjoint,所以 inputTokens = prompt_tokens - cacheRead;mapFinishReason:length → max-tokens、tool_calls → tool-calls、未知值(content_filter 等)→ error + 大写 code。终态纪律:block-end/usage/finish 全部推迟到 [DONE] 哨兵(无 [DONE] 即 STREAM_CLOSED 抛错);stop 但零 block → EMPTY_RESPONSE 可重试错误。诚实纪律贯穿始终:空响应是可重试的错误而非静默成功,失败永远有名字。
prepareCall 把「能力解析」与「dispatch」绑进同一个注册、深冻结请求、一次性 dispatch——类比数据库预编译语句绑定 plan。而 adapterDefaults 与 sticky 语义就是「参数绑定与默认值物化」:loop 只恢复「调用方真正提议过」的字段,adapter 物化的默认值每步重解析,因为模型可能变了。默认值是显式 resolve 步骤的产物,而不是隐藏的 ?? default——与第 7 章的 request/spec 分离是同一个原则在 LLM 域的复现。
实践应用
- 协议即类型:封闭的 chunk 联合(新变体编译期炸遍消费者)+ 可扩展的 block/map 词表(新类型必须全家桶落地)——用类型系统把「协议演进」变成可机械执行的纪律。
- 错误双通道归一:adapter 既可 throw 也可 in-band finish,消费者只见一种
LlmFailure;路由只看稳定 code、从不解析文案——「错误即数据」是可移植模式。 - prepareCall 绑定注册:能力解析与 dispatch 用同一 registration,HMR/热更不可能出现「A 的能力配 B 的请求」;
adapterDefaults显式标注「这字段是 adapter 填的」,让 sticky 决策有据可依。 - 传输层不做决定:DeepSeek adapter 是「零决策」的 fetch+SSE 翻译器——端点/密钥/默认值全部注入、thinking 回放规则写死在序列化器、失败都有名字(
EMPTY_RESPONSE/STREAM_CLOSED)。
总结
这一章拆完了 LLM 接缝:block/chunk/finish 词汇、prepareCall 的预编译语义、错误即数据的双通道归一,以及 DeepSeek adapter 的零决策传输层。模型请求的「语言」至此全部定义完毕。下一章是第三部分的收尾:技能系统——当「能力」不是工具而是可发现、可注入、可热更新的文档化技能时,注册表怎么设计。