你在哪:运行示例的第 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。注意它同时拥有服务定义和消费者两个角色——文档说这是可以的,因为它们是同一件关切。
二、接缝的形状
注册方式:
ctx.llm.registerAdapter(providers,adapter)一个适配器实例可以拥有多条 provider 路由。GenerateOptions.provider选路由,GenerateOptions.model交给那个适配器解释——model 不需要在生命周期开始时就注册。重复的 provider 路由原子失败。
模型目录(listModels())是建议性的,不是白名单:
“That catalog isadvisory 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 objectsre-stringifies atblock-end.”
防的是:解析再序列化导致的字节漂移(键顺序、数字格式、Unicode 转义)。第 6 篇说过tool/call事件里的arguments是"模型原样产出的、未解析的"——那条日志规则的上游保证就在这里。
3. 两条错误路径,一个失败类型
允许throw(传输/协议错误),也允许以finish {kind:'error'|'aborted', failure}结束流(厂商在流内报错,适配器没法在流中间抛)。两条路径携带的是同一个LlmFailure。
防的是:消费者要写两套错误处理。
失败之后的动作(第 7 篇讲过):循环关掉失败的 step,把错误、不可变事实、之前重试过的事实、serving 时捕获的重试策略、turn 信号一起交给agent/request-error。
4. 一次适配器调用 = 一次厂商尝试
“Adaptersdisable 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 carriedno content blockstofinish {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/streamwaterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup.”
这张图回答的问题:为什么"录制/回放模型响应"这种功能不需要改任何核心代码。
有一条边界很诚实:驱动在外层 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, solater 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 entriesaligned 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 · 工具注册表与执行管线:一次工具调用要过五道关
回到→ 系列索引
📚 返回专栏目录