DeepSeek Harness · 模型与外部接入

LLM 适配层:单次尝试、显式重试、双流持久

推理流与正文流分开存,重试是显式事件。核心源码:packages/llm/llm/src/assembler.tspackages/core/agent-loop/src/agent.ts

课程目标读完你能说清三件事:DSH 为什么规定一次适配器调用就是一次提供方尝试、连 SDK 自带的重试都要禁用;一次流式响应怎么以两种形态落盘,原始分片流管回放保真、派生消息流管对话历史,失败的半截输出为什么进得了前者进不了后者;以及重试为什么被做成持久日志里的显式事件,UI 靠它撤回半截输出,事后靠它解释每一秒都花在了哪。
交互演示 · 请求生命周期播放器

先玩再讲。左边是网线上的 SSE 分片(provider 逐帧吐出来的原始数据),右边是会话日志(每个分片立刻落一条 assistant/chunk,流结束再派生一条 assistant/message),上方是用户看到的 UI。三个情景:A 是一次顺利的双流落盘;B 中途断线,看重试怎么以显式事件出现在时间线上;C 是反面教材,SDK 静默重试模式,同一个故障,日志里无迹可查。

用户看到的 UI
(等待模型回复…)
网线 · provider 吐出的 StreamChunk
(尚无分片)
会话日志 · 持久事件(append-only)
(空日志)
选择情景后点「播放」,或滚动到此处自动播放情景 A。
逻辑拆解 · 一次调用就是一次尝试

先立规矩。DSH 的适配器约定里有一条写得很硬:一次适配器调用就是一次提供方尝试,适配器必须禁用库自带的重试(docs/subsystems/llm-streaming.zh.md 适配器约定一节)。HTTP 库们都爱替你悄悄重试,看起来是贴心,实际是把信息藏起来了:请求为什么慢、重试了几次、每次因为什么失败,全部消失在库的内部循环里。DSH 把这层全部剥掉,适配器只干一件事:发一次请求,把响应转成统一的 StreamChunk 分片吐出来,失败就规范化成一个可序列化的 LlmFailure(带稳定的错误 code),别的不管。

防挂起也在这层解决:两个交付的远程适配器都带 streamIdleTimeoutMs 看门狗,默认五分钟,provider 停顿超时就映射成 TIMEOUT 错误。还有一条容易漏的:空回复算错误。模型返回一个不带任何内容块的 stop,适配器把它映射成 EMPTY_RESPONSE 错误而非静默的成功,这样重试层才有机会救它。

逻辑拆解 · 双流持久:分片流和消息流分开存

然后看落盘。agent loop 消费分片流的时候干两件事:每个分片原样追加一条 assistant/chunk 事件进会话日志,同时把分片喂给 BlockAssembler(组装器)。流成功结束,组装结果作为一条 assistant/message 事件再落一次盘。这就是双流:chunk 流是原始录像,回放测试靠它逐帧重建当年的响应;message 流是派生历史,下一次请求的对话上下文从它派生。推理块(reasoning,模型的思考过程)和正文块(text)在分片协议里就是不同类型,各自独立组装、各自落盘。

packages/core/agent-loop/src/agent.ts第 343 至 351 行
      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)
      }
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 packages/core/agent-loop/src/agent.ts,核对日期 2026-08-13。代码块保留源码原文。

这九行是双流的心脏:先落盘,再组装,一个分片都不例外。流结束后走分岔:finish 是错误或中止,就把失败交给 agent/request-error 事件去决定重不重试(第 354 到 371 行);成功才在第 381 行追加 assistant/message,并把这批 chunk 的序号写进 sourceEventSeqs,标明这条消息是从哪些分片派生的。所以失败的半截输出有明确的归宿:留在 chunk 流里作证,绝不进派生历史。下一次请求重建上下文时,那半截就像没发生过。

组装器本身也值得停一下。BlockAssembler 是全仓库唯一的分片折叠实现,适配器只要按 index 吐格式正确的分片,块重组不用每家自己写。它对畸形流有明确态度:处理 block-end 分片时,先看这个块是不是已经关闭过,关过就直接返回,后面再来的重复关闭一律忽略。注释里管这叫「First close wins」,理由是只有第一次关闭说了算,流式输出和最终组装出的块才能保持一致。这一行防御恰好是性质测试抓过真 bug 的地方(复盘见 测试一个非确定性系统)。

出处:packages/llm/llm/src/assembler.ts 第 75 至 82 行的 block-end 分支,核对日期 2026-08-13。

逻辑拆解 · 重试是持久日志里的显式事件

重试住在更高一层。dsh-llm-retry 插件监听 agent/request-error,按每条 provider 路由注册时捕获的策略决定救不救。默认的 normal 策略:只对 EMPTY_RESPONSERATE_LIMITSERVERTIMEOUTTRANSPORT 五种 code 重试,最多两次,退避从 500 毫秒到 10 秒,带 10% 抖动;provider 用 Retry-After 指定的合法延迟会替换本地退避(packages/llm/llm-retry/README.zh.md)。

关键在于它怎么记账。等待退避之前,插件先往会话日志追加一条 llm/retry 事件,里面装着重试 id、提供方、策略 mode、完整的失败信息和计划延迟;退避结束真要动手了,再追加一条 llm/retry-started。这两条事件不进模型可见的表层,模型对重试一无所知,但 UI 和事后分析全靠它们:UI 据此撤回失败的半截输出、显示「第 1/2 次重试,3 秒后」;排查问题时,日志里每一次尝试、每一段等待都有时间戳。重试本身则开一个新的编号轮次,从持久历史重建同样的请求重发,旧轮次的记录一个字不改。

还有一个跨适配器的细节:replayState。成功的 finish 分片可以携带适配器私有的回放状态(比如 DeepSeek 推理内容的原生表示),跟着 assistant/message 一起存。下次请求把历史发给适配器前,LlmRuntime.forAdapter() 检查每条历史消息:历史 provider 和目标 provider 归同一个适配器实例,状态才透传;换了适配器,状态被剥离,对方只拿到提供方无关的内容(packages/llm/llm/src/index.ts 第 822 至 836 行)。一家的私货绝不喂给另一家。

重试开新轮次

失败轮次正常关闭,重试轮次带新编号从头开始。没有任何记录被改写,两次尝试在日志里各自完整,时间线永远向前。

半截输出进 chunk 不进 message

失败前收到的分片都留在 assistant/chunk 流里,回放能精确复现事故现场;但派生历史里没有这半截,模型下一步看到的上下文是干净的。

空回复是可重试错误

不带内容块的 stop 被映射成 EMPTY_RESPONSE 错误,默认策略会重试它。静默接受一个空成功,等于把退化响应写进对话历史。

横向对比 · 重试藏在哪一层

Grok Build:重试内建在采样器里

Grok Build 把流式与重试一起放进 xai-grok-samplerretry.rs 是纯逻辑的分类与退避模块,actor 层包重试循环。预算给得很足:默认最多重试 15 次(DEFAULT_MAX_RETRIES = 15,30 秒退避上限下约 6 分钟),429 限流单独降到 2 次就上报(RATE_LIMIT_RETRY_THRESHOLD = 2),413 图片超限走特殊通道,剥掉图片再试一次且不占预算,服务端还能用 x-should-retry 头一票否决(crates/codegen/xai-grok-sampler/src/retry.rs 第 1 至 34 行的行为总结注释)。分类做得细,但重试循环发生在采样器内部,对上层就是一次格外慢的调用。

Claude Code:API 客户端层的重试包装器

还原源码里,重试住在 restored-src/src/services/api/withRetry.ts:一个包在 Anthropic SDK 外面的应用层包装器,默认最多 10 次(第 52 行 DEFAULT_MAX_RETRIES = 10),带 Retry-After 感知和 fast mode 的冷却处理。重试发生在 API 客户端内部的 for 循环里,会打调试日志,但基于已公开证据,没有看到把每次重试作为持久会话事件落盘的机制。

三家对齐来看,差别就一句话:Grok 和 Claude Code 的重试是函数内部的循环,DSH 的重试是日志里的一等公民。前两家省事,后者可审计:策略、延迟、失败原因、第几次尝试,全部持久化,UI 和回放测试都能拿它当事实依据。代价是 DSH 的重试边界只有 agent 轮次这一层,绕过 loop 直接调 ctx.llm.stream() 的调用方就是一次裸尝试。

课堂练习
01

推演一次断在 block 中间的流

模型正在输出第 2 个 text 块,吐了 3 个 text-delta 之后连接被重置,适配器以 finish {kind:'error', TRANSPORT} 收尾。请回答:日志里此刻有几类事件、各多少条?assistant/message 会不会出现?UI 上那半句话怎么被撤回,依据是哪条事件?重试成功后,模型重建上下文时能看到那 3 个 delta 吗?再想一层:如果把这段历史发给另一家 provider 的适配器,上一条成功消息里的 replayState 去哪了?

Takeaway:适配器只负责一次诚实的尝试,重试住在 agent 层并作为 llm/retry 事件落进持久日志,每一次等待都可审计。一次响应存两份:chunk 流保回放保真,message 流保历史干净,失败的半截进前者不进后者。把重试藏进 SDK 省的是代码,丢的是证据。