第三部分 · 能力接缝

第 9 章:LLM 接缝与流式

一张封闭的 chunk 协议、一个可扩展的 block 词表,和一座「零决策」的 DeepSeek 传输层

词汇表即架构

LLM 接缝(packages/llm/llmctx.llm)是全书最「类型驱动」的一章——它先把三张判别联合钉死在 types.ts 里,再让 adapter、loop、UI 三方围着它们转:

词汇性质内容
ContentBlockMapmerge-extensible(插件可加)text / reasoning / image / tool-call / tool-result 五种 block
StreamChunk封闭判别联合assertNever 收尾)block-start / text-delta / reasoning-delta / tool-call-delta / block-end / usage / finish
FinishReasonMapmerge-extensiblecompleted / 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-deltaargumentsDelta字符串增量——模型在流式吐 JSON 参数,adapter 不解析它,原样累积;finish.replayState 是 adapter 私有的重放状态,随 assistant/message 持久化(第 4 章),让「重放一次成功响应」成为可能。

三件套:从 GenerateOptions 到 LlmAdapter

GenerateOptionstypes.ts)是「完全组装好的单次请求」:provider+model 选路由、messages/system/tools/采样标量/超时/取消。它的 epoch 级子集 LlmCallConfigcall-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.tsrequestProposal)。

markAgentLoopRequest 是进程内 WeakSet<GenerateOptions> 打标:区分「loop 会话请求」与「手建的一次性请求/辅助调用」——llm/stream 的 waterfall 监听者(retry/replay/routing)需要知道请求的出身。loop 请求同时被 deepFreeze,保证其内容只是会话日志的纯函数。

错误是数据,不是异常

LLM 接缝的错误哲学值得单独一节。两条通道归一成一种终态:

  • adapter 可以 throw,也可以 in-bandfinish {kind: 'error'}——adapterStream 把 dispatch/迭代期的任何 throw 归一成终态 failure chunk(abort 或 ABORTEDaborted,否则 → 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 },
  }
}

LlmErrorindex.ts:83)的 code 是稳定机器码(message 仅供人读),构造时校验 status(100–599)/providerRetryAfterMs/requestId,并把可序列化事实冻结进 this.failure。规范常量:CONTEXT_WINDOW_EXCEEDEDQUOTAEMPTY_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 - cacheReadmapFinishReasonlengthmax-tokenstool_callstool-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 域的复现。

实践应用

  1. 协议即类型:封闭的 chunk 联合(新变体编译期炸遍消费者)+ 可扩展的 block/map 词表(新类型必须全家桶落地)——用类型系统把「协议演进」变成可机械执行的纪律。
  2. 错误双通道归一:adapter 既可 throw 也可 in-band finish,消费者只见一种 LlmFailure;路由只看稳定 code、从不解析文案——「错误即数据」是可移植模式。
  3. prepareCall 绑定注册:能力解析与 dispatch 用同一 registration,HMR/热更不可能出现「A 的能力配 B 的请求」;adapterDefaults 显式标注「这字段是 adapter 填的」,让 sticky 决策有据可依。
  4. 传输层不做决定:DeepSeek adapter 是「零决策」的 fetch+SSE 翻译器——端点/密钥/默认值全部注入、thinking 回放规则写死在序列化器、失败都有名字(EMPTY_RESPONSE/STREAM_CLOSED)。

总结

这一章拆完了 LLM 接缝:block/chunk/finish 词汇、prepareCall 的预编译语义、错误即数据的双通道归一,以及 DeepSeek adapter 的零决策传输层。模型请求的「语言」至此全部定义完毕。下一章是第三部分的收尾:技能系统——当「能力」不是工具而是可发现、可注入、可热更新的文档化技能时,注册表怎么设计。