你在哪 :运行示例的第 6--7 步。上一篇把前缀装配好了,这一篇讲它怎么变成一次 HTTP 请求、以及流回来的东西怎么变成一条
assistant/message。读完你会知道 :
LlmAdapter的八条契约(每一条都对应一个真实事故)、llm/streamwaterfall 为什么在适配器查找之前、空回复为什么算错误,以及推理链 replay 状态的归属规则。
缩写对照表
| 缩写 | 英文全称 | 中文 |
|---|---|---|
| LLM | Large Language Model | 大语言模型 |
| HTTP | HyperText Transfer Protocol | 超文本传输协议 |
| JSON | JSON Object Notation | 一种数据交换格式 |
| API | Application Programming Interface | 应用程序编程接口 |
| SDK | Software Development Kit | 软件开发工具包 |
一、角色回顾
拥有 :消息与流式分片的词汇表(Message / ContentBlock / StreamChunk)、适配器契约、路由与重试策略。
刻意不做 :不管 agent 级恢复(一次适配器调用 = 一次厂商尝试)、不管块重组(统一由 BlockAssembler 处理)。
在示例中出场:第 6 步(发请求)、第 7 步(收流)。
服务 key 是 ctx.llm。注意它同时拥有服务定义 和消费者两个角色------文档说这是可以的,因为它们是同一件关切。
二、接缝的形状
#mermaid-svg-QP1SO4q8EsQ4Z0NT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .error-icon{fill:#552222;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .marker.cross{stroke:#333333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT p{margin:0;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster-label text{fill:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster-label span{color:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster-label span p{background-color:transparent;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .label text,#mermaid-svg-QP1SO4q8EsQ4Z0NT span{fill:#333;color:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .node rect,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node circle,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node ellipse,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node polygon,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .rough-node .label text,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node .label text,#mermaid-svg-QP1SO4q8EsQ4Z0NT .image-shape .label,#mermaid-svg-QP1SO4q8EsQ4Z0NT .icon-shape .label{text-anchor:middle;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .rough-node .label,#mermaid-svg-QP1SO4q8EsQ4Z0NT .node .label,#mermaid-svg-QP1SO4q8EsQ4Z0NT .image-shape .label,#mermaid-svg-QP1SO4q8EsQ4Z0NT .icon-shape .label{text-align:center;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .node.clickable{cursor:pointer;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .arrowheadPath{fill:#333333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QP1SO4q8EsQ4Z0NT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QP1SO4q8EsQ4Z0NT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster text{fill:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .cluster span{color:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QP1SO4q8EsQ4Z0NT rect.text{fill:none;stroke-width:0;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .icon-shape,#mermaid-svg-QP1SO4q8EsQ4Z0NT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .icon-shape p,#mermaid-svg-QP1SO4q8EsQ4Z0NT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .icon-shape .label rect,#mermaid-svg-QP1SO4q8EsQ4Z0NT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QP1SO4q8EsQ4Z0NT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QP1SO4q8EsQ4Z0NT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QP1SO4q8EsQ4Z0NT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 提供者
消费者
Agent Loop
其他直接调用方
服务定义 ctx.llm
Message · ContentBlock
StreamChunk · 适配器契约
DeepSeek 适配器
直连 fetch
pi-ai 适配器
库支撑,通用兼容
你自己的适配器
注册方式:
ts
ctx.llm.registerAdapter(providers, adapter)
一个适配器实例可以拥有多条 provider 路由。GenerateOptions.provider 选路由,GenerateOptions.model 交给那个适配器解释------model 不需要在生命周期开始时就注册 。重复的 provider 路由原子失败。
模型目录(listModels())是建议性的,不是白名单:
"That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids."
这条很实用------你自建的 endpoint 上有一个目录里没有的模型名,照样能用。
三、适配器契约:八条,每条都对应一个真实事故
文档原话是 "Every adapter MUST obey these, and every consumer may rely on them"。逐条读,并说明它防的是什么:
1. usage 必须在 finish 之前,finish 之后什么都不许有
"Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering."
防的是:消费者以为流结束了,结果又来了一个只带 usage 的分片。
2. 工具调用的 arguments 全程保持原始 JSON 字符串
"Partial fragments stream via
argumentsDelta; a provider that hands back parsed objects re-stringifies atblock-end."
防的是 :解析再序列化导致的字节漂移(键顺序、数字格式、Unicode 转义)。第 6 篇说过 tool/call 事件里的 arguments 是"模型原样产出的、未解析的"------那条日志规则的上游保证就在这里。
3. 两条错误路径,一个失败类型
允许 throw (传输/协议错误),也允许以 finish {kind:'error'|'aborted', failure} 结束流 (厂商在流内报错,适配器没法在流中间抛)。两条路径携带的是同一个 LlmFailure。
防的是:消费者要写两套错误处理。
失败之后的动作(第 7 篇讲过):循环关掉失败的 step,把错误、不可变事实、之前重试过的事实、serving 时捕获的重试策略 、turn 信号一起交给 agent/request-error。
4. 一次适配器调用 = 一次厂商尝试
"Adapters disable library retries . Agent-level recovery opens another durable numbered turn; direct
ctx.llm.stream()callers remain single-attempt."
防的是 :底层库悄悄重试三次,导致"日志里一次请求,账单上三次"、以及重试对上层不可见。要重试就开一个新编号的持久 turn------重试这件事本身也是可追溯的。
5. 厂商卡住由传输层兜底
两个正式适配器都暴露 streamIdleTimeoutMs,默认五分钟 。看门狗只在 next() 挂起期间上膛 ,整个请求用一个稳定信号,自己到期映射成 TIMEOUT,而更早的调用方中止仍然记为 ABORTED。
防的是:厂商流不结束也不报错,agent 永远挂着;以及"超时"覆盖掉"用户取消"这个更准确的原因。
6. 上下文溢出只有一个规范错误码
两个 DeepSeek 适配器都通过 isContextWindowExceededError() 归一成 CONTEXT_WINDOW_EXCEEDED,无论它是抛出来的 HTTP 错误还是流内 finish 错误。
"Consumers route on the code, never provider text."
防的是:压缩插件靠正则匹配厂商的错误文案来判断"是不是超长了"------厂商改一次文案就全崩。这也正是第 7 篇那条压缩恢复路径的前提。
7. 每个厂商 HTTP 请求都带应用归属头
attributionHeaders()(User-Agent 基线),并且用一个 wire 级测试证明它确实加上了。
8. 空回复是可重试错误,不是静默成功
"Both adapters map a terminal
stopfinish that carried no content blocks tofinish {kind:'error'}with the canonicalEMPTY_RESPONSEcode, anddsh-llm-retryretries it by default."
防的是:模型返回空,循环以为"它没什么想说的",于是这个 turn 就这么结束了------用户看着一个空气泡。
四、llm/stream:为什么拦截点在适配器查找之前
这是一个容易忽略但很有价值的设计:
"Adapter lookup happens at the terminal continuation of the llm/stream waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup."
厂商 适配器 终端续延 llm/stream 监听器 (缓存 / 录制 / 改路由) 驱动 厂商 适配器 终端续延 llm/stream 监听器 (缓存 / 录制 / 改路由) 驱动 #mermaid-svg-pyKBIKjRQrST9c2Q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pyKBIKjRQrST9c2Q .error-icon{fill:#552222;}#mermaid-svg-pyKBIKjRQrST9c2Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pyKBIKjRQrST9c2Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pyKBIKjRQrST9c2Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pyKBIKjRQrST9c2Q .marker.cross{stroke:#333333;}#mermaid-svg-pyKBIKjRQrST9c2Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pyKBIKjRQrST9c2Q p{margin:0;}#mermaid-svg-pyKBIKjRQrST9c2Q .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pyKBIKjRQrST9c2Q text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pyKBIKjRQrST9c2Q .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-pyKBIKjRQrST9c2Q .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-pyKBIKjRQrST9c2Q #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-pyKBIKjRQrST9c2Q .sequenceNumber{fill:white;}#mermaid-svg-pyKBIKjRQrST9c2Q #sequencenumber{fill:#333;}#mermaid-svg-pyKBIKjRQrST9c2Q #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-pyKBIKjRQrST9c2Q .messageText{fill:#333;stroke:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pyKBIKjRQrST9c2Q .labelText,#mermaid-svg-pyKBIKjRQrST9c2Q .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .loopText,#mermaid-svg-pyKBIKjRQrST9c2Q .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pyKBIKjRQrST9c2Q .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-pyKBIKjRQrST9c2Q .noteText,#mermaid-svg-pyKBIKjRQrST9c2Q .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-pyKBIKjRQrST9c2Q .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pyKBIKjRQrST9c2Q .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pyKBIKjRQrST9c2Q .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pyKBIKjRQrST9c2Q .actorPopupMenu{position:absolute;}#mermaid-svg-pyKBIKjRQrST9c2Q .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-pyKBIKjRQrST9c2Q .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pyKBIKjRQrST9c2Q .actor-man circle,#mermaid-svg-pyKBIKjRQrST9c2Q line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-pyKBIKjRQrST9c2Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 适配器根本没被查找 alt 监听器接管 委托下去 llm/stream(request) 直接返回一个流 (回放录像 / 命中缓存) next() 此时才查找适配器 HTTP 流 StreamChunk*
这张图回答的问题:为什么"录制/回放模型响应"这种功能不需要改任何核心代码。
有一条边界很诚实:驱动在外层 waterfall 返回流句柄时就认为"发生了一次请求尝试",但这个边界不证明惰性的终端适配器真的被构造了、或者真的开始了厂商 I/O。
五、请求准备:prepareCall() 与它解决的问题
直接调用方用 resolveCallConfig();agent loop 用 prepareCall(),区别在于后者:
- 在模型解析、持久化 header 记录、派发 这三件事之间保持同一个注册;
- 保留那次精确查找得到的上下文元数据;
- 报告哪些配置字段是适配器补的默认值(而不是调用方提出的)。
PreparedLlmCall.stream() 还有个防呆:请求的调用配置字段必须和准备时一致,重用或不匹配都会以 INVALID_PREPARED_CALL 失败。
为什么要这么讲究?因为路由是可以被热替换的(第 4 篇:配置改一行,子树重组)。如果一次请求的"解析模型 → 写 header → 派发"三步跨越了一次路由替换,你会得到一条记录的 header 和实际发出的请求不一致的日志。而这直接违反第 6 篇那条不变量。
同理,重试策略也是在 serving 注册那一刻捕获的:
"
llmRetryPolicyOf(stream)returns the value captured from the serving registration after the call selects it, so later route disposal or replacement cannot change an in-flight failure's recovery policy."
六、Replay 状态:推理链复用的归属规则
新一代推理模型往往可以复用上一轮的内部推理状态。DSH 的处理方式很值得学:
"A successful
finishmay carry aReplayEnvelope: opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence."
三条规则:
- 对齐是 harness 的词汇。 装配丢掉某个内容块时,同一位置的 replay 条目也丢掉------所以存下来的元数据永远描述的是存下来的内容。
- 只在"完全同一个适配器实例"时才传回去。 历史 provider 和目标 provider 当前必须注册在同一个适配器实例上,才把这份私有状态交给它;其他适配器只拿到 provider 中立的内容。
- 持久内容永远权威。 读取方适配器用不了这份状态时,这一条消息降级 成 provider 中立转换 + 一条诊断,而不是让整个请求失败。
第 3 条是这个仓库的典型风格:优化失效时降级,而不是报错。
七、失败行为小结
| 出什么事 | 怎么办 |
|---|---|
| 传输/协议错误 | 适配器 throw,LlmError.failure 带 LlmFailure |
| 厂商在流内报错 | 以 finish {kind:'error', failure} 结束流,同一个失败类型 |
| 厂商流卡住 | 五分钟默认空闲超时 → TIMEOUT;更早的调用方中止仍记 ABORTED |
| 上下文超了 | 归一成 CONTEXT_WINDOW_EXCEEDED → 交给压缩恢复路径(第 7 篇) |
| 模型返回空 | EMPTY_RESPONSE,默认重试 |
| 重试策略配置里既有 always 模式又留着 normal-only 字段 | 解析器忽略失效字段,捕获纯 always 策略 |
| 省略 provider 策略 | 用 normal 默认值:五次重试 |
| 存下来的 replay 状态读不懂 | 这一条消息降级为中立转换 + 诊断,不失败 |
⚓ 回到示例
第 6 步完整展开:
- 驱动调
prepareCall():选中deepseek路由 → 解析模型(拿到精确模型标识、上下文容量、适配器默认的maxTokens)→ 捕获这条注册; request/header落日志(第 6 篇seq 4);agent/requestwaterfall:没人拦,直接过;llm/streamwaterfall:也没人拦 → 终端续延此时才查找适配器 → DeepSeek 适配器发出 HTTP 请求,头里带attributionHeaders();- 五分钟空闲看门狗上膛。
第 7 步 :分片流回来。适配器只需要吐出格式正确的 StreamChunk------block-start / block-end 的 index 相关性加上 BlockAssembler,让块重组不是每个适配器各自的问题 。驱动把每一个分片原样写进 assistant/chunk(第 6 篇的 seq 5--40),流结束后组装成 assistant/message 并带上 usage。
这一步里模型输出了一个 tool_use 块,arguments 是 '{"path":"package.json"}' ------ 原样字符串,一路没被解析过 (契约 2)。它会以完全相同的字节落进第 8 步的 tool/call 事件。
如果这次请求失败了会怎样?假设厂商返回 503:
- 适配器
throw,LlmFailure里带错误码; step/end关掉第 1 步;agent/request-errorwaterfall 拿到错误 + serving 时捕获的重试策略(默认五次);dsh-llm-retry判定可重试 → 修复 → 返回{ kind: 'retry' };- 另开一个新编号的 turn 重试------所以日志里你会看到
turn: 2,而不是"turn 1 的第二次尝试"。重试在日志里是显式的。
上一篇 ← 08 · 系统提示装配
下一篇 → 10 · 工具注册表与执行管线:一次工具调用要过五道关
回到 → 系列索引
📚 返回专栏目录