模型看到的一切都必须落盘——DeepSeek Harness的事件溯源Session日志与可重建的请求

我们的底座现在支持回放评测:拿一段线上的真实对话,去掉记忆、换一套策略,或者设一个对照组,重新跑一遍,看结果有什么不同。再配合一个坏例集,每次改提示词之前都回归一遍。

这个能力能做起来,前提是能回答一个问题:线上那一轮,模型当时到底看到了什么?系统提示词是哪个版本,注入了哪些记忆,检索回来了什么,用的哪个模型、什么参数。少一样,回放就不是"那一轮"了。

DeepSeek Harness(dsh)对这个问题的回答非常彻底。这是 dsh 系列的第三篇。

一、模型可见,就必须被记录

dsh 的 Session 是一条只追加的类型化事件日志,它是唯一的真相来源。模型看到的消息历史,不是另外存了一份,而是从日志里派生出来的。

在这之上,有一条贯穿全局的原则:

Model-visible means logged:任何到达模型请求的东西,都必须能从日志逐字节地重建出来。

简单的说,可以把它写成一个公式:

请求 = f(日志前缀, 日志引用的内容, 固定的代码)

请求是日志的一个纯函数。同样的日志前缀,永远得到同样的请求。

写 Java 的同学对这个思路应该不陌生,这就是事件溯源(Event Sourcing):状态不直接存,存的是导致状态变化的事件,当前状态由事件重放得到。dsh 只是把它用在了"模型请求"这件事上。

二、三个推论

这条原则立住以后,有三件事是自然而然得到的,不需要单独去管。

一是前缀缓存的稳定是"涌现"的。

日志只追加,消息由纯函数逐条投影出来,那么只要请求配置不变,每一次请求都是上一次请求的追加扩展。前缀天然就是稳定的。

之前写 Claude Code 的缓存设计时,我们看到它为了缓存稳定做了大量的防御:锁存、记忆化、动态边界。dsh 换了一个角度:不去"管理"缓存的稳定,而是让它从架构里自然长出来。

二是可以逐字节地审计和回放。 这正是我们回放评测想要的。

三是 resume 和 fork 时的漂移可以归因。 进程重启以后恢复会话,请求为什么和之前不一样了?日志里会有一条专门的快照记录这次恢复时的配置,一对比就知道。

反过来也一样:在 dsh 里,真正花钱的事件都是被记录的。全价的请求只来自几种情况:压缩、系统提示词真的变了、工具或配置真的变了、跨进程边界的恢复。每一项在日志里都看得见。如果哪一步的缓存命中突然掉了,去日志里找对应的事件就行。

三、改写历史:替换,而不是修改

只追加的日志,那历史要改怎么办?比如压缩,比如某个工具结果太大要裁剪。

dsh 引入了一个概念叫 surface,就是"模型可见的那一面"。会产生消息的事件,必须声明自己对 surface 做什么操作:

type SurfaceOp =
  | 'append' // 追加到尾部
  | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq } // 用本节点替换一段已有节点

改写历史,就是追加一条新事件,声明"我替换了第几号到第几号节点",同时用 sourceEventSeqs 引用被替换掉的那些事件。原来的事件还在日志里,一个字都没改,只是在 surface 上被遮蔽了。

以裁剪一个巨大的工具结果为例:

  1. 第 42 号事件是一个很大的工具结果;
  2. 追加一个新的工具结果事件,内容是裁剪后的版本,声明替换 42 到 42,来源引用 42;
  3. surface 上 42 号节点被遮蔽,一个叫 replaceGeneration 的计数加一;
  4. 下一次请求从这里开始一个新的序列,这次缓存失效是被记录在案的。

还有一个很妙的地方:同一份日志,有两种投影。模型历史读 surface,看到的是裁剪后的版本;给人看的对话记录读原始的追加事件,看到的仍然是完整的结果。两种投影各司其职。

压缩、工具结果裁剪、大输出落盘,全部都用这一套机制实现,不需要任何特殊通道。

四、请求配置也是被记录的状态

消息历史解决了,还剩下"非历史"的部分:用哪个模型、什么参数、有哪些工具。

dsh 把这些记录成一条 request/header 事件,而且是完整的快照,不是增量。写入的时机有四种:

reason什么时候写
initial第一个 loop 实例启动
resume之后的 loop 实例启动(跨进程边界)
change会话内配置或工具真的发生了变化
series配置没变,但开始了一个新的消息序列,比如压缩之后

这里有一个反模式很值得警惕:每次调用都可以随意修改的请求参数。如果某个插件在请求发出前悄悄把模型换了,日志里没有任何记录,回放时就永远复现不出来,缓存也白白丢了。dsh 的说法是:"waterfall 提议,日志记录。"插件可以提议修改配置,但修改必须落到日志里。

更进一步,dsh 追求的是让错误的请求在接口层面就无法表示。请求构建完成后就被冻结,再去修改会直接抛错。而不是事后比较前后两次请求,发现不一致再告警。告警的时候,错误的请求已经发出去了。

五、系统提示词是第 0 条消息

系统提示词放在哪?dsh 一开始也是放在请求配置里的,后来改了:系统提示词就是 surface 上第 0 号节点的一条普通消息。

为什么要改?放在配置里的时候,"模型看到了什么"要从两个地方拼:配置和历史。压缩器、token 计量、UI、快照,每个读者都要去配置里再读一次。而且系统提示词变了和工具变了,在日志里都是同一种"配置变化",分不清。

改成第 0 条消息以后,一个模型可见的事实只有一个家。系统提示词变了,就是对 0 号节点的一次替换。即使系统提示词一开始是空的,也要预留一个空的 0 号节点,不然以后它变成非空时,会被追加到用户消息的后面。

顺着这个思路,dsh 把上下文分成了三层,每一层的缓存代价不同:

  1. 提示词段落:在最前面,一变就要重写头部,最贵;
  2. 运行时上下文:时间、工作目录这类,作为快照追加在历史之后,增量地付费;
  3. 注入的消息:一次性付费,之后就被前缀缓存住了,直到压缩。

还有一个细节:"当前请求"和"下一个边界"是两种不同的承诺。必须影响当前这次请求的上下文,要从 agent/pre-step 的返回值里给出;下一次再生效也可以的,才用 inject()。

六、崩溃恢复:补齐,不截断

最后简单说一下崩溃恢复。进程在一个 turn 的中途挂了,日志的结尾是一个没关闭的 turn。

dsh 的做法是不截断。一个长任务的 turn 可能非常大,里面的事件都已经持久化了,截掉就等于白干。它只丢弃那些从来没写完的残缺尾巴,然后由恢复的那一方,在拥有写权限的前提下,补上缺失的东西:没有结果的工具调用补一个错误结果,没关的 step 补一个结束,最后补一个"中断"的 turn 结束事件。

七、对照我们的底座

我们的底座在"会话日志能重建每一次模型请求"这一点上,方向和 dsh 是一致的,这也是回放评测能做起来的基础。读完这一章,还有几点可以借鉴:

  1. 系统提示词进历史。我们现在是把每次请求用到的系统提示词版本记在请求的快照里,和对话历史分开存。按 dsh 的思路并到历史里作为 0 号节点,排查时就只需要看一个地方;
  2. 请求配置只允许"提议 + 记录"。我们有几个插件会在请求前调整参数,比如按场景调温度。这类调整应该统一落成配置变化事件,而不是直接改请求;
  3. 两种投影。这一点对语音产品特别有用。

展开说一下第 3 点。语音对话里,用户打断的时候,模型其实已经生成了一大段话,但用户只听到了前面一部分。如果对话历史里存的是模型生成的全文,下一轮模型就会以为"这些我都说过了",而用户其实根本没听到后半段。

用 dsh 的 surface 思路,这个问题就很清楚了:完整的生成结果照常追加到日志里,作为事实保留,排查时能看到;同时追加一条替换事件,把 surface 上的这条消息换成"用户实际听到的部分"。模型历史读 surface,看到的是用户真正听到的;原始日志里,完整的生成内容一个字也没丢。

总结

  1. 模型可见的,就必须被持久记录,请求是日志的纯函数,这本质上是事件溯源;
  2. 缓存稳定、逐字节回放、恢复时的漂移归因,都是这条原则的自然推论,而不是单独管理的东西;
  3. 改写历史等于追加一条声明了替换范围和来源的事件,原始事件永远不改,模型历史和人类记录是两种投影;
  4. 请求配置是被记录的完整快照,插件只能提议修改,错误的请求要在接口层面就不可表示;
  5. 系统提示词就是第 0 条消息,上下文按缓存代价分三层;崩溃恢复补齐缺失的事件,而不是截断已有的工作。

相关资料