09. LLM 接缝:把“厂商协议“关进一个可替换的盒子

发布时间:2026/9/15 2:22:09
09. LLM 接缝:把“厂商协议“关进一个可替换的盒子 你在哪运行示例的第 6–7 步。上一篇把前缀装配好了这一篇讲它怎么变成一次 HTTP 请求、以及流回来的东西怎么变成一条assistant/message。读完你会知道LlmAdapter的八条契约每一条都对应一个真实事故、llm/streamwaterfall 为什么在适配器查找之前、空回复为什么算错误以及推理链 replay 状态的归属规则。缩写对照表缩写英文全称中文LLMLarge Language Model大语言模型HTTPHyperText Transfer Protocol超文本传输协议JSONJSON Object Notation一种数据交换格式APIApplication Programming Interface应用程序编程接口SDKSoftware Development Kit软件开发工具包一、角色回顾拥有消息与流式分片的词汇表Message/ContentBlock/StreamChunk、适配器契约、路由与重试策略。刻意不做不管 agent 级恢复一次适配器调用 一次厂商尝试、不管块重组统一由BlockAssembler处理。在示例中出场第 6 步发请求、第 7 步收流。服务 key 是ctx.llm。注意它同时拥有服务定义和消费者两个角色——文档说这是可以的因为它们是同一件关切。二、接缝的形状提供者消费者Agent Loop其他直接调用方服务定义 ctx.llmMessage · ContentBlockStreamChunk · 适配器契约DeepSeek 适配器直连 fetchpi-ai 适配器库支撑通用兼容你自己的适配器注册方式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 viaargumentsDelta; 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; directctx.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 terminalstopfinish 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 thellm/streamwaterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup.”厂商适配器终端续延llm/stream 监听器缓存 / 录制 / 改路由驱动厂商适配器终端续延llm/stream 监听器缓存 / 录制 / 改路由驱动适配器根本没被查找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, solater route disposal or replacement cannot change an in-flight failure’s recovery policy.”六、Replay 状态推理链复用的归属规则新一代推理模型往往可以复用上一轮的内部推理状态。DSH 的处理方式很值得学“A successfulfinishmay carry aReplayEnvelope: opaque response-level metadata plus optional per-block entriesaligned with the emitted block sequence.”三条规则对齐是 harness 的词汇。装配丢掉某个内容块时同一位置的 replay 条目也丢掉——所以存下来的元数据永远描述的是存下来的内容。只在完全同一个适配器实例时才传回去。历史 provider 和目标 provider 当前必须注册在同一个适配器实例上才把这份私有状态交给它其他适配器只拿到 provider 中立的内容。持久内容永远权威。读取方适配器用不了这份状态时这一条消息降级成 provider 中立转换 一条诊断而不是让整个请求失败。第 3 条是这个仓库的典型风格优化失效时降级而不是报错。七、失败行为小结出什么事怎么办传输/协议错误适配器throwLlmError.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 4agent/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适配器throwLlmFailure里带错误码step/end关掉第 1 步agent/request-errorwaterfall 拿到错误 serving 时捕获的重试策略默认五次dsh-llm-retry判定可重试 → 修复 → 返回{ kind: retry }另开一个新编号的 turn重试——所以日志里你会看到turn: 2而不是turn 1 的第二次尝试。重试在日志里是显式的。上一篇← 08 · 系统提示装配下一篇→ 10 · 工具注册表与执行管线一次工具调用要过五道关回到→ 系列索引 返回专栏目录