返回《DeepSeek Harness 架构设计指南》

会话日志作为唯一事实来源

IT周瑜

想象你在审计一个 Agent 的工作。它昨天替你改了七个文件,你想知道每一步它看到了什么、想了什么、动了什么。如果这些信息分散在内存对象、日志文件和数据库的几张表里,重建现场就是一场考古。dsh 对这个问题给出的解法非常直接。整个会话只有一份权威记录,一份只追加、不修改的事件日志,其余一切都是它的投影。

这份日志叫会话日志,由 ctx.sessions 服务持有。它的单位是事件,类型叫 SessionEvent。用户发来一条消息,追加一个 user/message 事件。模型返回一段回复,先追加若干 assistant/chunk 事件保存原始流式分片,再追加一个 assistant/message 事件保存整理后的完整消息。模型请求调用工具,追加 tool/call,工具执行完,追加 tool/result。回合与步骤的开合,对应 turn/startturn/endstep/startstep/end。这些事件只增不改,谁也不允许回头编辑历史。

会话日志 只追加,不修改 turn/start user/message assistant/message tool/call tool/result step/end turn/end 模型历史 deriveMessages() 界面回放 逐事件重建对话 标题与统计 遥测与账单口径 模型可见的一切,都必须能从这份日志重建
图 4-1 只追加的事件日志,模型历史、界面回放、统计全都由它推导而来

dsh 有一条贯穿全局的纪律,写在架构文档里。模型可见即可重建。任何到达模型请求的内容,都必须能从这份日志重建出来。这不是一句愿景,运行时有一个断言在持续检查它,违背就报错。这条纪律反过来约束了开发方式。想给模型增加一种新的可见输入,路径是固定的,先扩展事件表,定义新的事件类型,再让渲染逻辑从日志里读它。私底下往请求里塞日志里没有的内容,这条路在 dsh 里走不通。

为什么值得为一条日志立这么重的规矩。看从它派生出的东西就明白了。模型历史由 deriveMessages() 函数从日志投影而来,每次组装请求时现算,内存里不再维护一份第二手的状态。界面回放逐事件重演,于是任何一屏对话都能精确复现。会话分叉从日志的某个边界复制出一条新日志。断点续跑、导出文本记录、用量统计,全部从同一个流推导。一份事实,无数视图,这是事件溯源模式在 Agent 运行时里的完整落地。

持久化层也是日志的一个普通投影。dsh 支持 JSONL 与 SQLite 两种后端,事件以单调递增的序号落盘,格式版本有明确的演进规则。对使用者来说,备份一个会话就是备份这个文件,迁移一个会话就是把它交给另一台机器上的 dsh 重放。没有私有内存状态需要额外导出。

把日志立为唯一事实来源,还改变了排查问题的方式。dsh 的测试体系里有一类快照测试,断言一次完整任务的会话输出与预期逐事件一致。模型输出有随机性怎么办。测试框架把模型请求挡在假适配器后面,假适配器返回预定的脚本回复,于是整条事件流变成确定的。这类测试能抓住的回归,恰好是产品最怕的那种,模型可见的内容悄悄变了,或者工具结果的记录悄悄缺了。

这条纪律对插件开发者的含义值得单独强调。你的插件想让模型多看见一点东西,正确做法是发明一个事件,把它记进日志,再从日志渲染进请求。图省事的做法是直接在组装请求时注入一段内容,日志里没有痕迹。第二种做法在 dsh 的运行时断言面前会直接失败。设计者用机制而不是文档来维护这条线,这是整个设计里我认为最值得学习的一手。

日志本身也有格式治理。事件类型集中声明在一张事件表里,新增类型走类型系统登记,负载字段逐个写明。格式版本有单调递增的编号,某个事件对旧读取方可忽略时,要在声明里显式标注,不标注的事件在陌生读取方面前一律拒绝,宁可读不出,不可读错。这类细节平时不显眼,却是长寿命数据的真正护栏。

还有一个派生件值得一提,上下文压缩。会话长了,模型窗口装不下,压缩插件把早期历史折叠成摘要。这件事在别的系统里往往是对内存里对话数组的就地修改,在 dsh 里它同样走日志,压缩的结果作为一种事件记下来,模型历史投影时按最新一次压缩生效。也就是说,连遗忘本身也是一份可审计的事实。这个处理方式把第 4 章的纪律贯彻到了最后一块角落。

日志记录了发生了什么,下一章讲这些事情如何被组织成 Agent 的执行周期,Turn 与 Step。