学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 16
第 4 章 · EP 16

LLM 适配层:单次尝试、显式重试、双流持久 · 测试一个非确定性系统

时长 15:07音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 一次诚实的尝试,和一份能当证据的日志1:20
1:20一次调用就是一次尝试1:21
2:42双流持久 · 分片流和消息流分开存1:29
4:11首次关闭优先 · 一个被性质测试抓出来的 bug1:05
5:17重试是持久日志里的一等公民1:47
7:05横向对比 · 重试藏在哪一层1:26
8:31测非确定性系统 · 回放、故障服务器、性质测试2:45
11:16故障服务器与性质测试 · 传输层的花式死法2:10
13:26可带走的设计原则与踩坑点1:40
解读全文

LLM 适配层与测试体系 · 解读与音频稿件

本集对应 DeepSeek Harness 模块 T4 的两节课:「LLM 适配层:单次尝试、显式重试、双流持久」与「测试一个非确定性系统」。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本集要解决什么

一个特别具体、又特别容易被忽略的工程问题:你接了一个大模型,代码跑得好好的,某天用户反馈这一轮特别慢。你去查日志,什么都查不到——因为重试发生在 SDK 的内部循环里,它替你悄悄试了三次,一次都没告诉你。你看到的只有一次成功,和一段说不清的耗时。

本集给出的答案分成两半:上半场是三条硬规矩(一次调用即一次尝试、双流持久、重试作为显式事件),下半场是测试一个非确定性系统的三件武器(确定性回放、脚本化故障服务器、性质测试)。

能力地图

能力落点一句话
单次尝试约定适配器层一次调用就是一次提供方尝试,禁用库自带重试
空闲看门狗远程适配器默认五分钟,超时映射为超时错误
空响应检测适配器层不带内容块的停止信号映射为空响应错误
双流持久agent loop分片流保回放保真,消息流保历史干净
块组装BlockAssembler全仓库唯一分片折叠实现,首次关闭优先
显式重试重试插件每条路由按策略决策,两次事件落盘记账
回放状态隔离LLM 运行时同适配器实例才透传,跨适配器剥离
确定性回放回放插件会话日志直接推导回放脚本,无需密钥
故障注入模拟服务器真 HTTP 服务器,行为脚本队列,权重可复现
性质测试fast-check断言不变式而非具体输出,失败打印种子

逻辑拆解

一、一次调用就是一次尝试

适配器约定里有一条写得很硬:一次适配器调用就是一次提供方尝试,适配器必须禁用库自带的重试。

这条规矩背后的判断很朴素——HTTP 库们都爱替你悄悄重试,看起来是贴心,实际是把信息藏起来了。请求为什么慢、重试了几次、每次因为什么失败,全部消失在库的内部循环里。

剥掉这层之后,适配器只干一件事:发一次请求,把响应转成统一的 StreamChunk 分片吐出来,失败就规范化成一个可序列化的 LlmFailure(带稳定的错误码),别的一概不管。防挂起也在这层解决,两个已交付的远程适配器都带 streamIdleTimeoutMs 看门狗,默认五分钟。还有一条容易漏的规矩:空回复算错误——模型返回不带任何内容块的 stop,映射成 EMPTY_RESPONSE 而非静默成功。

二、双流持久

agent loop 消费分片流时同时干两件事:每个分片原样追加一条 assistant/chunk 事件进会话日志,同时把分片喂给 BlockAssembler。流成功结束,组装结果再作为一条 assistant/message 事件落盘。

核心九行(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)

先落盘,再组装,一个分片都不例外。流结束后走分岔:finish 是错误或中止,交给 agent/request-error 决定重不重试;成功才追加 assistant/message,并把这批 chunk 序号写进 sourceEventSeqs。

失败的半截输出有明确归宿:留在 chunk 流里作证,绝不进派生历史。

三、首次关闭优先

BlockAssembler 处理 block-end 分片时,先看这个块是不是已经关闭过,关过就直接返回,后续重复关闭一律忽略。注释里叫「First close wins」——只有第一次关闭说了算,流式输出和最终组装出的块才能保持一致。

这行防御是性质测试抓过真 bug 的地方:同一索引处重复的 block-end 会改写已完成的块,而这个 bug 在 happy path 100% 行覆盖率之下存活。

四、重试是一等公民

重试插件监听 agent/request-error,按每条 provider 路由注册时捕获的策略决策。默认 normal 策略只对 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT 五种 code 重试,最多两次,退避 500 毫秒到 10 秒,带 10% 抖动;provider 用 Retry-After 指定的合法延迟会替换本地退避。

记账方式才是关键:

  1. 等待退避之前,追加 llm/retry 事件——重试 id、提供方、策略 mode、完整失败信息、计划延迟;
  2. 退避结束真要动手,再追加 llm/retry-started。

这两条事件不进模型可见的表层,模型对重试一无所知,但 UI 和事后分析全靠它们。重试开新编号轮次,从持久历史重建同样请求重发,旧轮次记录一字不改。

提示1 · 排障时先问「日志能不能复述这一轮」

判断标准很简单:你能不能从日志里说出这一轮慢在哪里、等了几次、每次因为什么。如果说不出,说明重试还藏在库里。把 llm/retry 做成显式事件,代价只是多写两条日志,收益是每次线上排障省下的半小时。

五、确定性回放

dsh-llm-replay 插件把落盘机制反过来用:拿录好的 session.jsonl,把 chunk 事件按 (turn, step) 分组,每组就是当年一次模型调用的完整分片序列。测试时真实 agent 照常跑,模型那头换成回放适配器逐帧吐回。不确定性只存在于录制那一次。

fork 会话有个精巧细节:子会话日志开头继承父会话种子事件,推导回放脚本时必须从 seedLength 边界之后切,否则父会话分片会被误当子会话调用重放。


const ownEvents = parseSessionLog(text).slice(header.seedLength)
children.push({
  recordedId: header.id,
  createdAt: header.createdAt,
  entries: deriveReplayScript(ownEvents),
  primary: false,

六、专门骗 LLM 客户端的服务器

进程内 mock 测不到传输层——它绕过了 fetch、SSE 分帧、socket 终止和空闲看门狗。所以 DSH 造了 dsh-llm-mock-server:一台真的 Node HTTP 服务器,说 OpenAI 方言,行为完全由脚本控制,每接一个请求消耗一个行为,脚本耗尽就明确报错。

默认随机权重表本身就是一份「LLM 客户端在野外会遇到什么」的清单:正常成功 48、慢速成功 10、半途掐线 10、连接重置/断流/空回复/限流各 5、服务器错误 4、触顶 max_tokens/停滞挂起/503 各 2、流正常收尾却没发完的 partial_eof 与坏 JSON 的 malformed_json 各 1。源码注释专门提醒:这是可调的测试压力配置,并非生产事故频率的估算。

提示2 · 测试装置只报事实,不做策略判断

故障服务器的操守很克制:只报告协议层事实,不判断可不可以重试,策略归 harness 自己。一旦测试装置开始替被测系统做决定,你就再也分不清是代码对了还是夹具对了。

七、性质测试,第一枪就见血

协议形态的代码(分片流、事件日志、收件箱调度)输入空间是组合爆炸的。DSH 给每个协议形态的包配 fast-check 驱动的性质测试:生成器造逼真但对抗性的输入(重复索引、滞后分片、缺 block-start 的畸形流),断言的是不变式而非具体输出——比如组装出的块数不能超过见过的不同索引数、重复调用结果必须稳定。失败自动打印可复现 seed。

战绩写在 Agent Note 第一行:「属性测试套件首次运行即发现了 BlockAssembler 重复 block-end 的真实 bug。」

八、跨平台与真实 API 纪律

AGENTS.md 第 123 行原话「fix fixtures, not normalizers」——修 fixture,别写归一化器。归一化器是在测试和现实之间垫棉花,垫多了就测不到现实了。

带密钥策略那节有句很带身份特色的话:「我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent 能对接真实模型正常工作。」缺密钥环境自动跳过,不阻塞任何人。

提示3 · 覆盖率是必要不充分条件

CI 对 packages/*/*/src 按文件要求 100% 行覆盖率,但文档同时把话说死:行覆盖率是必要条件,永远不是充分条件。没跑过的行往往是该删的死代码,而非该补的测试。真 bug 藏在交错序列里(性质测试的地盘),藏在传输边界里(故障服务器的地盘)。

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

Grok BuildClaude CodeDeepSeek Harness
位置采样器内部 retry.rsAPI 客户端 withRetry.tsagent 轮次层插件
默认上限15 次(约 6 分钟)10 次2 次
429 处理降到 2 次即上报Retry-After 感知Retry-After 替换本地退避
特殊通道413 剥图片重发不占预算fast mode 冷却无
落盘未见持久会话事件未见持久会话事件llm/retry + llm/retry-started

差别一句话:Grok 和 Claude Code 的重试是函数内部的循环,DSH 的重试是日志里的一等公民。前两家省事,后者可审计。代价是 DSH 的重试边界只有 agent 轮次这一层,绕过 loop 直接调 ctx.llm.stream() 的调用方拿到的就是一次裸尝试。

审查清单

落地自查按这七条走:

  1. 适配器是否显式禁用了库自带的重试?
  2. 是否有空闲看门狗?超时是否被映射成可重试错误?
  3. 空回复(无内容块的 stop)是否被映射成 EMPTY_RESPONSE 而非静默成功?
  4. 是否每个分片都先落盘再组装?失败的半截是否只进 chunk 流、不进 message 流?
  5. 重试前是否落 llm/retry、重试时是否落 llm/retry-started?模型是否对重试无感知?
  6. 回放脚本是否从 seedLength 边界之后切,避免父会话分片混入?
  7. 协议形态的包是否配了性质测试,断言的是不变式而非固定输出?

约束说明

本集结论的适用范围与边界,需要说清楚:

  • 重试边界约束:DSH 的显式重试只在 agent 轮次这一层生效。绕过 loop 直接调用流式接口,就是一次裸尝试,没有重试、没有事件。这是便利换来的边界,不是缺陷,但调用方必须知道。
  • 回放状态隔离约束:replayState 只在历史 provider 与目标 provider 归属同一适配器实例时透传,跨适配器会被剥离。一家的私货绝不喂给另一家,代价是换家时可能丢失原生推理表示。
  • 权重表约束:模拟服务器的随机权重是测试压力配置,不代表生产事故频率。拿它做容量规划会得出错误结论。
  • 覆盖率约束:100% 行覆盖率是 CI 门禁的必要条件,不构成正确性证明。
  • 源码核对约束:本文引用的源码行号依据本地仓库 deepseek-harness-master,核对日期 2026-08-13。仓库演进后行号可能漂移,以语义为准。
  • 对比证据约束:关于 Claude Code 测试体系、Grok 回放机制的结论均标注「基于已公开证据」,还原产物不随测试代码发布,外界无法完整评估。
  • 取材约束:本集只使用本集素材内的资料,不跨集引用,不虚构源文档未出现的数据、案例与引文。

可带走的设计原则

  1. 把重试从库里拿出来,做成显式事件。 省的是代码,丢的是证据。
  2. 原始层与派生层分开。 分片流保回放保真,消息流保历史干净,失败半截进前者不进后者。
  3. 空成功比报错更危险。 任何看起来成功但没内容的返回,都要显式映射成错误。
  4. 覆盖率必要不充分。 交错序列交给性质测试,传输边界交给故障服务器。
  5. 测试基础设施保持中立。 夹具挂了修夹具,别写归一化器把它抹平。

音频与稿件

  • 音频:dsh16-播客.mp3
  • 字幕:dsh16-播客.srt
  • 口播稿:script.txt
  • 集页:https://xueai-podcast.pages.dev/t/dsh16/

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版。

来源:xueai.miyang.cn(小山学堂 · 洛小山)