ARTICLE · 1235531
DeepSeek-Harness 源码专题(4):测试里假装一个模型,剧本为什么不用录
摘要:回放测试的剧本不用录:它是会话日志的免费副产品;抛错和挂起日志推不出,replay.override.json 边车补写;匹配纯位置(cursor++),动态值 {{fromRequest:}} 取自请求;多了少了都抛错;replay/refresh 永不读 .env。
上一篇拆 steer 的时候反复出现一句话:每个请求输入完整、日志 append-only、可重放。这两条性质撑起的远不止「插话不打断」,这篇把它兑现成工程上最值钱的东西:测试。
场景还是那条会话。47 个 deprecated 清理进入收尾,模型跑了全量测试,被一句「fixtures 里的快照别动」纠正方向,改完收工。现在我们想给这次纠偏写个回归测试:steer 递进去之后,下一次模型请求里组装进了什么,模型看到的历史对不对。
直觉方案是 mock 一个 LLM,手写几条响应。马上撞墙:模型对那条 steer 的真实反应,你手写不出来,写出来的只是你想象的模型。真调 API 呢?贵、慢、结果不可重复,CI 里更不能塞一把生产密钥。
先给结论。dsh 的答案是不录剧本,剧本已经在会话日志里了。跑一次真实场景,session.jsonl 落盘那一刻,剧本免费到手;回放测试把它交给一个假模型照着演,全程一个真实的 API 请求都不发。官方给这套机制起的名字很直白:keyless,无密钥。
我们把这台回放机器拆成六个问题看:剧本从哪来、日志表达不了的怎么办、演员怎么上岗、剧本里的死值怎么办、剧本对不上怎么办、keyless 到底 keyless 到什么程度。
一、剧本是日志的免费副产品
先回忆日志记了什么。专题第一篇《模型看到的历史被换了,日志一个字没动》和深读篇《守不变量的不是测试,是编译器和 Object.freeze》都拆过:每次模型调用的每个流式 chunk,都以 assistant/chunk 事件原样落盘,append-only。这本是为崩溃恢复和审计服务的。deriveReplayScript 做的事,只是把事件流按调用切开(index.ts:281-292):
const { turn, step, chunk } = event.data const key = `${turn}/${step}` if (current.length > 0 && key !== currentKey) { close(currentKey, current) } if (current.length === 0) currentKey = key current.push(chunk) if (chunk.type === 'finish') { close(currentKey, current) currentKey = undefined current = [] }这段是剧本切分的主循环(llm-replay/src/index.ts:281-292)。注意看切分规则:chunk 按 turn/step 拼 key,key 一变就结算上一条;finish chunk 一到也结算。切出来的每一份,就是一次完整 stream() 调用的全部输出。唯一的例外是 compaction 调用,压缩摘要不走流式记录,从落盘的 rawOutput 块重建出 block-start/block-end/finish 序列再拼成一条(index.ts:269-276)。
每条剧本长什么样?一个 ReplayEntry(index.ts:37-44):
export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { kind: 'hang' /** Optional marker written after the prefix chunks are consumed and before the stream waits for cancellation. */ readyFile?: string }这段是剧本条目的类型(index.ts:37-44)。注意看它是个三值联合:chunks 是正常流,throw 是先吐若干 chunk 再失败,hang 是吐完前缀后挂起等取消。后两个变体是给错误注入准备的,下一节马上用到。
回到主线案例:那次会话的 session.jsonl 拷进测试目录,deriveReplayScript 一跑,测试前的规划调用、带工具调用的中间几步、steer 之后被纠正的那一步,各自一条 entry。录制这个动作的额外代码,零行。
二、日志说不出口的两种死法
问题跟着来了。日志只记发生过的事,而测试最想测的偏偏是出事的路:某次调用抛了限流该怎么重试、流挂起了取消该怎么收场。抛错的流没有 finish,挂起的流连结尾都没有。这两种剧本,日志推不出来。
close() 面对这种情况的反应不是猜,是拒绝(index.ts:243-252):
const close = (key: string | undefined, chunks: StreamChunk[]): void => { if (chunks.length === 0) return if (chunks[chunks.length - 1]?.type !== 'finish') { throw new Error( `llm-replay: model call ${key} ended without a finish chunk (a thrown stream); ` + 'this scenario needs a replay.override.json sidecar', ) } script.push({ kind: 'chunks', chunks }) }这段是切分的收口函数(index.ts:243-252)。注意看报错干了什么:不但拒绝,还把解决方案写进报错里,this scenario needs a replay.override.json sidecar。补写的入口叫边车:剧本 JSONL 旁边放一个 replay.override.json,显式声明日志推不出来的调用。这不是纸面设计。ralph-loop 场景目录下就躺着这么一个边车,和 session.jsonl、两个子会话日志并排。
边车文档两种形态(index.ts:311-317):
export type ReplayOverrideDoc = ReplayEntry[] | { patches: ReplayOverridePatch[] }这一行是边车的类型(index.ts:317)。二选一:要么整个数组,整本替换派生剧本;要么 { patches },只按调用序号换掉指定条目,其余照日志回放。patch 的注释写了追加语义:at 等于剧本长度时不是报错,是追加一条「事后补录」,比如注入瞬时错误之后的那次重试(index.ts:300-302);类型自己的注释则把 patches 形态一句话说透,turn N errors, everything else replays as recorded,第 N 次出错,其余照录(index.ts:311-315)。
这里的设计立场值得停一下:日志能推导的,免费给你;推导不出的,必须显式补写,且可审计。测试资产的来源只有两种:录的,写的。没有第三种,猜的。
三、演员上岗:两条安装路,一个游标
剧本有了,谁在测试进程里扮演模型?安装逻辑只有四行(index.ts:781-784):
const providers = config.providers ?? [] const dispose = providers.length > 0 ? ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay)) : ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))这段是回放演员的两种上岗方式(index.ts:781-784)。场景配置声明了 provider 目录时走第一条路:registerAdapter 把 ReplayAdapter 注册成这些模型的路由。ReplayAdapter 不只吐 chunk,它把 contextWindow、reasoningEfforts、inputModalities 按配置原样发布(index.ts:584-659),按模型能力做的门禁在回放里照常演练。没声明 provider 就走第二条路:在 llm/stream 事件上挂 catch-all 监听,所有模型调用落进回放闭包。
请求和剧本怎么对上?纯位置。会话级:带新 sessionId 进来的请求,领走下一条未绑定的剧本,主会话排最前,子代理会话按创建时间排后(index.ts:577-580),没带 sessionId 的进匿名槽。调用级只剩一行(index.ts:763):
const index = boundState.cursor++这句是调用级的全部匹配逻辑(index.ts:763)。cursor 自增取条目,请求长什么样一概不看。不看内容的匹配便宜,但有个洞,下一节补。
四、剧本里有个录制时不存在的值
位置匹配的前提是剧本内容可以照搬,可有一类响应必须回显请求里一个录制时根本不存在的值。ralph 循环场景是真例子:模型创建一个 goal 之后要在后续输出里引用它的 id,id 是运行时生成的,回放时每次都不一样。剧本是死的,id 是活的。
答案是占位符。剧本里可以写 {{fromRequest:正则}},回放那一刻从真实请求里取值替换(index.ts:412-417):
export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry { if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry const leaves: string[] = [] collectStrings(messages, leaves) return substituteValue(entry, leaves.join('\n')) as ReplayEntry}这段是占位符的解析入口(index.ts:412-417)。注意看语料的构成:请求 messages 里所有字符串叶子收集起来拼成多行文本,正则取最后一次匹配的首捕获组,替换进剧本的深拷贝;没有占位符就原样返回,匹配不到照样抛错。它的边界因此很清楚:只救「响应必须回显请求」这一类动态值,不做请求内容断言。断言请求长相是另一套机制的事,§六 见。
五、剧本多了少了,都不许静默
测试基础设施自己失效,比没有测试更糟:剧本没对上,测试还绿着,你会以为回归有人看住。dsh 把这件事当正式 bug 处理,两个方向都抛。
先看请求比剧本多的方向。没登记过的会话发来调用,在生成器第一帧抛(index.ts:766-771):
if (unrecorded) { throw new Error( `llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); ` + `the scenario recorded only ${totalScripts} session(s) — re-record it`, ) }这段是超支方向的第一道守卫(index.ts:766-771)。连「场景只录了几个会话」都写进报错。紧跟着的第二道守卫管剧本耗尽,报错同样自描述:script exhausted,session 要第 N 次调用但剧本只有 M 条,结尾还是那句 re-record the scenario(index.ts:772-777)。
一个细节:unrecorded 的 throw 为什么在生成器里而不是当场?闭包上方的注释写了原因,waterfall 监听必须返回一个 AsyncIterable,不能当场 throw,所以先打标记,等消费第一个 chunk 时再炸(index.ts:749-751)。错误照样冒到测试报告里。
我们再看请求比剧本少的方向。拆卸时对账,assertConsumed 遍历所有绑定状态(index.ts:789-797):
if (nextScript < scripts.length) { problems.push(`${scripts.length - nextScript} recorded script(s) never bound to a live session`) } for (const [key, state] of bound) { if (state.cursor < state.entries.length) { const who = key === ANON ? 'the anonymous session' : `session ${key}` problems.push(`${who} consumed ${state.cursor}/${state.entries.length} recorded call(s)`) } }这段是欠账方向的对账(index.ts:789-797)。剧本没被任何会话领走、会话只消费了 cursor/entries,全部记进 problems;数组非空就汇总抛出 fixture not fully consumed,场景驱动的模型调用比录的少(index.ts:798-800)。谁来调它?web e2e 的脚手架在拆卸处调 replayHandle?.assertConsumed(),旁边注释写明用途:recorded 漂移绿时在这里红(apps/web/tests/scaffold.ts:670-677)。少一次,都过不了拆卸。
六、keyless:CI 里没有密钥这回事
最后看这套东西跑在哪。vitest 快照配置文件里有一段纲领注释(vitest.snapshot.config.ts:24-28),节引原文:
recordcalls the real API and updates fixtures and expected outputs;refreshreplays committed scripts and updates current expected outputs. Replay/refresh never load.env; only record reads a key from the environment or root.env.
翻译过来三层:replay 是默认档;record 才碰真 API、读密钥;replay 和 refresh 连 .env 都不加载。三种模式对应三条 npm script(package.json:40-42),record 是唯一花钱的那条。
keyless 之外还有一层更狠的:全链路真实。场景测试不是对着函数做单元调用,acp-snapshot harness 拉起真实 CLI 子进程,bash 工具真跑,持久化真写盘,权限应答按 FIFO 队列脚本化派发(harness.ts:263-294)。环境变量这样接进子进程(harness.ts:251-260):
DSH_SNAPSHOT: opts.mode, DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, DSH_SNAPSHOT_SPILL_ROOT: spillRoot, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, ...opts.childFiles !== undefined && opts.childFiles.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } : {},这段是子进程的环境接线(harness.ts:251-260)。注意看变量名:模式、剧本路径、边车路径、子会话剧本列表,一条条传给真实进程。全链路里唯一假的,就是模型。
断言什么?三样:组装出的请求 diff、规范化协议输出、持久化日志期望文件。ralph-loop 目录里的 stream-json.expected.jsonl 是第三种,回放跑完的真实输出和它逐字节对。surfaceOp、事件序列在这里被精确比对,「模型看到的历史」不是感觉,是文件 diff。
顺带把另一套演员分清楚。agent-loop 的单元测试里还有个 MockAdapter,那是手写剧本的(mock-adapter.ts:4-13):
/** Helpers to write scripted responses tersely. */export function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } }, { type: 'finish', reason: { kind: 'stop' } }, ]}这段是手写剧本的便捷函数(mock-adapter.ts:4-13)。textResponse 把一段文字铺成完整 chunk 序列,五种 chunk 一个不少。两套演员的分工:MockAdapter 演你想象中的模型,单元级;llm-replay 演真录下来的模型,场景级,标题里「剧本不用录」说的是后者。
七、对账与边界
六个问题对一次账:
| 问题 | 答案 | 锚点 |
|---|---|---|
| 剧本哪来 | 会话日志免费派生,finish 切分 | index.ts:281-292 |
| 错误路径 | 日志推不出,边车补写 | index.ts:243-252 / :317 |
| 怎么匹配 | 会话绑定 + cursor++,纯位置 | index.ts:763 |
| 动态值 | {{fromRequest:}} 从请求取值 | index.ts:412-417 |
| 多了少了 | 双向抛错 + 拆卸对账 | index.ts:766-777 / :787-801 |
| 密钥 | replay/refresh 永不读 .env | vitest.snapshot.config.ts:24-28 |
边界也摆在明处。调用次数一变,位置匹配整体错位,只有重录一条路,而 record 花的是真钱(package.json:41)。语料绑模型:goal/ralph 语料录在 flash 上,cordis.yml 里一行注释钉死,「Stays on flash: the goal/ralph replay corpora were recorded on it」(cordis.yml:48-50)。并发子代理是已知缺口,排序处挂着 XXX(concurrent-subagents) 标记,等一个显式的 first-call ordinal(index.ts:578)。一次录制的成本,换来此后每次 CI 的零 token 全链路重演,代价是调用序列被冻结。
回到那条 fixtures 会话。录完那一次,session.jsonl 躺进 snapshots 目录;之后每次 CI,harness 拉起真实子进程,catch-all 拦下第一次 llm/stream,cursor 从 0 走到最后一条 entry,每条流逐 chunk 吐出,bash 真跑、日志真写,assertConsumed 报一声全绿,期望文件里那条 user/message 的 surfaceOp 一字不差。零密钥,零 token。
回放把「模型看到什么」钉死了。可真实运行里还有一种病剧本治不了:模型原地转圈,同一个文件读八遍、同一把锤子抡八次,每一步都合法,每一步都白费。dsh 给这个病派了个计数员,只数、只提醒,一次都不拦。它数什么、为什么数到也不拦,下一篇讲这个计数员。
你家的模型测试 mock 到哪一层?手写单元级,还是回放场景级?评论区聊聊。















































































