深入浅出 DeepSeek Harness 第八部:证明与守卫——怎样知道系统真的成立 第 53 章

Mock Snapshot 测试:启动真实子进程,录制一次,重放一万次

你写过这样的单元测试——mock 一个 LLM 接口返回固定 JSON,然后断言 agent loop 的调度逻辑。DSH 嫌它不够:它要 boot 真正的 agent bin 子进程、走真实 ACP JSON-RPC stdio、在 record 模式把会话 JSONL harvest 下来存成快照,之后每次 CI 都重放这份录制,并且用 23 种确定性故障行为把 LLM 能出错的姿势全演一遍。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你在 CI 日志里看到一行 pnpm run test:snapshot。它启动了一个真实的 agent 子进程——不是 import 进来的类,不是进程内桩,是 child_process 拉起来的、跑着真实 Loader 装配、监听 stdin JSON-RPC 的完整进程。它按一份录制好的输入脚本一步步喂请求,背后的”模型”是一个本地 mock server,按剧本吐 chunks。跑完之后,归一化工具把这次 stdout 的每一帧、落盘的 session JSONL 的每一条事件,和仓库里存的快照逐字节对比。

这套测试的哲学是:与其在进程里 mock 出一个”看起来像 agent”的对象然后断言它,不如把 agent 整个真跑一遍,只把最外面那层不可控的 LLM 替换成可控的剧本。 进程内 mock 的问题是你测的是”你想象中的 agent”,真子进程测的是”交付给用户的那个 agent”。

这一章接第 52 章:那章讲历史怎么被重放验证;这一章讲重放验证怎么变成测试装备。

快照测试三层:底层 llm-replay 把录制事件派生为按序剧本(chunks/throw/hang);中层 llm-mock-server 提供 23 种确定性故障行为 + random;顶层 dsh-session-snapshot 的 ACP adapter boot 真实 agent bin 子进程,按 newSession 等输入脚本经 ACP JSON-RPC stdio 驱动,record 模式在 graceful shutdown flush 后 harvest session JSONL,归一化层把 id/cwd/时间戳刷成双花括号占位符后与快照 diff。

不是进程内 mock:真子进程、真协议、真落盘

@deepseek-ai/dsh-session-snapshot 的 README 一句话定位:它服务于”keyless recorded-session tests”(无密钥录制测试)。包头注释更具体——ACP adapter “boots the REAL agent bin subprocess via the cordis Loader, drives a deterministic input script over real ACP JSON-RPC stdio, tees stdout into an SDK client app, and — in record mode — harvests the persisted session JSONL after a graceful shutdown flush”(装配管线本身被测)。

三个”真”字是这套测试的立身之本:

真在哪假测试怎么做这套测试怎么做
进程import agent 类,直接调方法child_process boot 真实 bin,跑完整 Loader 装配
协议函数调用约定ACP JSON-RPC over stdio,逐帧编解码
持久化内存里 mock 一份 storegraceful shutdown flush 后 harvest 真实落盘的 JSONL
模型mock 一个函数返回 JSON本地 mock server 按剧本流式吐 chunks

为什么非得真子进程? 因为装配本身(cordis 插件加载、服务顺序、环境变量、stdio 帧边界)正是 bug 高发区。进程内测试把这些全部跳过了——你以为测了 agent loop,其实只测了一个被函数调用喂出来的状态机。真子进程让”启动到第一个模型调用之间发生的一切”都进入测试射程。

本节源码依据(3 处)

三种模式:record、replay、refresh,一个环境变量

模式切换只有一个开关:DSH_SNAPSHOT 环境变量。harness 把它连同 DSH_SNAPSHOT_FILE(当前 fixture 文件)、DSH_SNAPSHOT_SESSIONS_ROOT(会话根目录)一起塞进子进程环境。

模式干什么什么时候用
replay(默认)跑真子进程 + mock 剧本,对比已提交快照每次 CI
record真跑一遍,把产出 harvest 成新快照新场景第一次落地
refresh合法变更后整体重录快照,review diff产品行为升级

为什么要单设 refresh 而不是让 record 随便跑? 因为 record 模式最大的风险是”跑绿了就提交”——把这次输出当标准答案。refresh 强制你面对一次完整 diff:哪些字段变了、为什么变、是不是你改的那个行为。快照不是不能更新,是更新必须经过 diff 审查。

输入脚本的每一步是一个 Scenario 操作,按序解释:newSession 把服务器下发的随机会话 id 捕获进 {{sessionId}} 变量——因为子进程每次跑,会话 id 都是新随机值,脚本后续步骤引用这个变量才能串起来。还有 newSessionExpectError(预期建会话就失败)、promptAndCancel、waitForFile 等操作。

本节源码依据(4 处)

归一化:快照跨机跨时可 diff 的前提

直接对比 stdout 帧会立刻失败:会话 id 每次随机、cwd 每台机器不同、RPC id 每次自增、时间戳每次不同、goal 时钟和 hook 时长都是墙钟值。归一化层把这些全部刷成占位符:{{sessionId}}、{{messageId}}、{{usedTokens}}、{{cwd}}、{{eventTime}}……

被刷掉的东西为什么必须刷
会话 id / message id每次运行随机,不刷永远 diff
运行 cwd不同机器路径不同,不刷跨机必红
RPC id逐次自增,不刷帧帧不同
时间戳 / goal 时钟墙钟值,两次跑不可能相等
hook 执行时长机器快慢决定,不是行为

为什么归一化宁可”洗过头”也不留活口? 因为快照的价值在于”只看行为差异”。任何一个没被归一化的字段,都会在 CI 里变成一个与代码无关的红——开发者被无关 diff 教育得麻木之后,真有行为变更反而被忽略。把可变项全部刷成占位符,剩下的 diff 才值得逐行审查。

本节源码依据(3 处)

23 种故障行为:把 LLM 出错的姿势钉成可复现

llm-mock-server 不只有”成功吐 chunks”一种演法。它的 MOCK_LLM_BEHAVIORS 列表收了 23 种确定性行为加 random 随机组合:

故障族行为测什么
连接层connection_reset、stream_disconnect、stream_eof、partial_eof、partial_disconnect断连后的重连/恢复路径
挂起层stall永不响应时取消信号是否生效
协议层malformed_json、malformed_event、wrong_content_type畸形响应的容错与拒绝
限流/服务端rate_limit、server_error、service_unavailable、quota_exceeded退避与重试边界
鉴权/请求auth_error、invalid_request凭证与参数错误路径
容量层context_overflow、max_tokens上下文超限与截断策略
正常层success、reasoning_success、tool_call_success、slow_success基准与慢响应
随机random按权重随机抽一种

请求侧约定也很关键:“Each accepted Messages request consumes one behavior; the server never retries or interprets harness policy”——每个请求消费一种行为,mock server 不替 harness 做重试决定。故障是测试参数,不是 mock 的自作主张。

本节源码依据(3 处)

证据自证:不采信 agent 的自述

README 最后一句是这套测试的价值观:“Every scenario owns enough committed evidence to prove model-visible output and filesystem effects without trusting the agent’s report.”

为什么”不采信自述”是关键?因为 agent loop 内部有大量”自以为成功”的路径——它 append 了一条 turn/end 事件就觉得任务完成了。快照测试的断言对象不是 agent 返回的那句”done”,而是:归一化后的 stdout 帧序列是什么样、落盘的 session JSONL 里事件流长什么样、文件系统里实际写了什么文件。三重证据互相独立:协议帧、日志事件、文件效果。 agent 可以在某一层自欺,但三层同时造假才骗过测试——而那正是真实事故的形态。

本节源码依据(2 处)

剧本绑定:为什么按”第几次调用”而不是按 id

llm-replay 从录制事件派生剧本时有个关键决定:父子会话各拿各的剧本,靠首次模型调用顺序绑定,而不是靠 sessionId。原因在重放环境里很直白——子进程每次跑,live session 的 id 都是新随机值,录制快照里那个旧 id 在新进程里根本不存在。

这和第 52 章 readSession 的快照哲学是同源的:重放交付的是那一刻的形态,不是某个具体 id 的对象。 剧本对齐的是调用序列的形状(第 1 次模型调用吐什么、第 2 次吐什么),不是某个名字。

剧本条目语义测试目的
吐 chunks(text/reasoning/tool-call)正常流式输出重放当时的模型回答
throw(前缀 chunks 后失败)吐到一半报错错误路径恢复
hang(永不响应)模型端卡死取消信号是否生效

ReplayOverrideDoc 还允许两种干预:整体替换为一份新剧本,或用 { patches } 补丁式改其中几条、保留其余。测试可以精确地”让第 3 次调用失败”,而不用重录整个场景。

本节源码依据(3 处)

为什么快照测试不是 e2e 测试

有人会问:真子进程真协议,这不就是 e2e 吗?不是。区别在”谁是不可控的外部世界”:

维度e2e快照测试
模型真实 LLM API本地 mock server 剧本
网络真实外网无
结果稳定性每次输出都可能变逐字节可复现
失败定位模糊(是代码错还是模型变了)精确(diff 指向哪条事件)
成本每次跑都花钱零成本

快照测试把”不可控”压缩到最小:只保留装配、协议、日志、文件系统这些必须真跑的层,把唯一会漂移的外部依赖(LLM)换成录制剧本。 它回答的问题是”这次代码改动有没有改变 agent 的行为形态”,而不是”agent 在真实世界里表现如何”——后者是 e2e 和灰度的事。

仓库里 snapshots/session 下录了几十组真实场景:bash-spill、bash-tool-turn、compaction-recovery、compaction-summary-headroom、background-job-admission、browser-use-chrome-devtools-mcp……每一组都是一次真实运行的录制,把那条路径的行为形态永久钉住。

本节源码依据(2 处)

小结

  • 真子进程真协议:boot 真实 agent bin、走 ACP JSON-RPC stdio、flush 后 harvest 真实 JSONL,不测”想象中的 agent”。
  • 三模式一个开关:replay/record/refresh 由 DSH_SNAPSHOT 切换;refresh 强制 diff 审查,防止把当次输出当标准答案。
  • 归一化是 diff 的前提:id/cwd/RPC id/时间戳/时钟/hook 时长全刷占位符,剩下的 diff 才值得逐行看。
  • 23 种故障 + random:LLM 出错的姿势被钉成可复现参数,mock server 不替 harness 做重试决定。
  • 证据三层独立:协议帧、日志事件、文件效果互证,不采信 agent 自述。

下一章:测试和守卫不能只靠人肉快照——CI 怎样强制每个包自带 invariant——第 54 章讲 invariant 目录文档化。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应为 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "DSH_SNAPSHOT" "$repo/packages/test-support/session-snapshot/src/harness.ts"
grep -n "connection_reset\|context_overflow" "$repo/packages/test-support/llm-mock-server/src/index.ts"
ls "$repo/snapshots/session" | head