Agent 的黑匣子:会话日志
上一篇把一次 Agent 任务的状态拆成了六处:内存里的、磁盘上的、账本里的、钥匙串里的、日志里的、外部系统里的。那篇的重点在各家基础设施怎么越界,六处只是一张地图。这篇只看其中一处,日志里的。
起因是最近关注到 DeepSeek Harness(下文简称 dsh)写入的会话日志格式在三周里从 v0 跳到了 v4,中间经过三次结构性改动。一个 append-only 的 JSONL 文件,为什么值得三周改三次?带着这个问题,我把本机上 Claude Code 和 Codex 的会话文件也翻出来对照着看。三家都是“一个会话一个 JSONL”,看上去差不多,但它们对什么才算事实的回答完全不同。
同样是 JSONL,记的不是同一种东西
先把三家摊开:
| dsh(v4) | Claude Code | Codex | |
|---|---|---|---|
| 模型输出的主要记录单位 | 一次模型调用的结算,流式片段嵌在里面 | 一个 API 内容块(思考、文本、一次工具调用各占一行) | 一个模型侧条目,另有一条平行的界面事件流 |
| 行与行的关系 | 连续递增的 seq,线性 |
parentUuid 指针,串成一棵树 |
按 turn 分组,两条流交错 |
| 模型看到的上下文 | 系统提示词、工具变更都是事件,“模型可见 ⟺ 已记录” | 较新版本开始写入 prompt_snapshot(系统提示词加工具 schema) |
开头记一次系统指令,每个 turn 记 turn_context,环境状态按“全量快照 + 增量”记 |
| 压缩(compaction) | surface 替换操作,标明替换了哪段 seq |
compact_boundary 记下保留段的首尾指针 |
compacted 原样存下替换后的完整历史 |
| 推理内容 | 明文(DeepSeek 的推理本来就是明文) | 思考块带签名,正文常常为空 | encrypted_content 密文,部分附带摘要 |
版本:dsh master(0.1.7-rc.2);Claude Code 2.1.220–2.1.283;Codex CLI / Desktop 0.148–0.154。
下面顺着这张表看三件事:粒度、模型视角、两份真相;最后再回到 dsh 为什么要一改再改。
粒度:流的细节,调用的边界
dsh 早期的做法最“忠实”:模型流式吐出的每一个 token 片段,都是日志里一条独立的顶层事件,有自己的序号和时间戳。好处显而易见,回放时可以一字一顿地还原当时的输出。
代价在 dsh 自己的设计文档里写得很具体。一份压缩后 116MB 的真实会话日志,读进来要展开成 914 万个事件,常驻堆 2.0GB;第一版整体迁移的实现甚至在一个 16GB 的 Node 进程里直接 OOM。v2 把每次模型调用的流式片段收进一条结算事件之后,同一份会话只剩 7.3 万个事件,常驻堆 477MB。
这里其实有两个相互独立的选择:
- 记录单位:每个片段一条事件,还是每次模型调用一条事件。
- 落盘时机:边生成边保存,还是等这次调用结算之后再保存。
v2 两件事都改了。片段不再各占一条顶层事件,而是按顺序、连同精确的时间间隔,嵌进这次调用的结算事件里,需要时可以原样展开。上面那组数字的收益,主要来自逻辑事件数量的减少。代价则来自落盘时机,v2 的设计文档老老实实写了出来:
v2 has no durable attempt evidence until settlement. A hard process or host loss before settlement discards the complete in-flight stream;
agent/assistant-streamis not a write-ahead log.
结算之后,流的细节仍然可查;结算之前进程崩了,已经展示给用户的那段输出,可能整段丢失。
另外两家在记录单位上做了相似的选择。我那份 11,076 行的 Codex 会话里,token 级的 delta 事件是 0 条;Claude Code 一行一个内容块(这次会话里一次 API 响应最多拆成四行),也不记 delta。区别在于,dsh 把流的细节嵌在调用里一并保存,另外两家只保存结果。三家都认同一点:逐个 token 的片段,不值得各占一条顶层记录;一次调用,才是记录的基本边界。
这不只是为了省空间。token 片段的边界取决于网络和服务端怎么切分,本身没有语义;一次调用“成功了、失败了、被取消了、重试了”,才是后续逻辑要依赖的事实。dsh 为失败和重试专门留了一种只进日志、不回模型的 assistant/attempt 事件,区分的就是“发生过”和“算数”这两件事。
模型视角:真相是“模型看到了什么”
日志要回答什么问题?如果只为了在界面上重新渲染对话,记下用户说了什么、模型答了什么就够了。但只要你想追问模型当时为什么这么做,就得先知道它当时看到了什么:系统提示词、工具列表和 schema、注入的环境信息、压缩之后剩下的历史。这些还不足以单独解释一次决定,却是分析的前提。
dsh 把这件事定成了硬规矩,写在仓库的约定里:
Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
凡是进入模型请求的东西,都必须能从日志重建。于是系统提示词变更是事件,工具的增删是事件(developer/message,连同当时的工具 schema 一起记下),压缩是一次带区间的替换。
另外两家没有这样明说,但从数据上能看到它们在往同一个方向走:
- Codex 在会话开头记一次系统指令(我的样本里约 1.8 万字符),每个 turn 记一份
turn_context(模型、审批策略、沙箱策略、日期),还有一种world_state记录:第一次是全量快照(full: true,包含 AGENTS.md、skills、权限等),之后只记变化的部分。 - Claude Code 在较新的版本里开始写入
prompt_snapshot附件,里面是完整的系统提示词和工具 schema。本次会话的一份快照里,工具 schema 就有约 11 万字符。我翻了本机几十个会话,这类记录大约从 2.1.247 前后开始出现,而且不是每个会话都有。
三家不约而同地在把“模型的视野”写进日志。 模型的视野太大,十几万字符的工具定义不可能每轮都整份存一遍,所以手法也是老办法:一份全量快照,之后只记变化,和数据库的 checkpoint 加日志回放一个思路。
视野之中有一段,三家的处理截然不同:推理本身。dsh 记的是明文,因为 DeepSeek 返回的推理本来就是明文。Codex 的推理以密文落盘,我样本里的 1,874 条推理记录全部是 encrypted_content,约三分之一附带可读摘要。Claude Code 的思考块带签名,我抽查的 943 个思考块里,558 个正文为空,只留签名。后两家是有意这样设计的:密文和签名要在下一轮原样回传,让服务端校验并接续推理,而不是给人读的。
所以说一份日志“完整”,得先问:对什么用途完整?
| 用途 | 要回答的问题 |
|---|---|
| 界面回放 | 当时展示了什么? |
| 模型输入重建 | 那次请求到底带了什么? |
| 任务恢复 | 哪些工作做完了,哪些还要接着做? |
| 结果复现 | 重新执行一遍,能不能得到同样的结果? |
这四种能力不能互相替代。dsh 的明文推理,方便人直接阅读;Claude Code 和 Codex 的密文和签名,服务的是模型输入重建。它们支持的用途不同,没法简单比较谁更完整。日志保留下来的上下文,决定了我们能把问题追查到哪一步。
两份真相:模型视图和界面视图
同一次工具调用,模型要看的和人要看的不一样。模型要精简的文本结果,界面要 diff、文件路径、耗时、退出码。于是出现了一个设计分歧:这两份东西,存一份还是存两份?
Codex 选了存两份。模型侧的 response_item 和界面侧的 event_msg 各记各的。在我那份 137MB 的样本里,界面侧的 item_completed 事件约占 45MB,比模型侧的消息本身(约 23MB)还大;9 次压缩各存了一份完整的替换后历史,又占了约 39MB。冗余换来的是简单:任何消费者都能直接读到自己要的视图,不用推导。
Claude Code 折中:同一行里既有发给模型的 message,又有给界面用的 toolUseResult,两份并排存。
dsh 选了只存一份。日志里只有规范事件,界面需要的东西都从事件推导成 projection(投影)。投影可以缓存,但设计文档明确说它“不是恢复的依据”,丢了就重建。
这是“单一事实来源加物化视图”和“反范式冗余”之间的老问题。冗余的一方读起来便宜,但两份会不会漂移、以哪份为准,得靠纪律维持。单一来源的一方明确了以哪份为准;投影仍然可能因为缓存滞后、实现错误或版本差异而不一致,但总能从事件重建。代价是每个新界面都要写推导逻辑,推导逻辑还得跟着格式一起演进。
格式即契约:当插件写进了日志
回到开头的问题:一个 JSONL 为什么三周改三次?前面已经看到了一种压力:记录粒度带来的体积和性能开销。另一种压力来自兼容性,因为 dsh 是一个全插件的框架,第三方插件的输出也会写进日志。
这让兼容性从 API 问题变成了数据问题。dsh 的读取路径遇到不认识的事件类型会直接拒绝,理由是这很可能是更新版本写的日志,悄悄跳过一个必需事件,就会重建出一个错误的会话。那第三方插件自己的事件怎么办?一个自然的方案是让插件注册自己的事件类型。dsh 拒绝了,理由写在源码注释里:
event-name registration was rejected because it does not classify omission safety and would make reads composition-dependent.
如果注册表决定了日志能不能读,那同一份日志在装了不同插件的机器上,就会读出不同的结果。 日志的含义不能取决于读者装了什么。dsh 最后的做法是由写入方在事件上标记“读不懂可以跳过”(ignorable),而不是由读取方维护一张白名单。在兼容性的目标上,这和 protobuf 容忍未知字段有些相似,只是机制不同:这里是写入方显式声明哪些事件可以安全忽略。
迁移也是同样的纪律:v0 到 v4 逐级相邻迁移,每一级都是一个独立的有状态转换;升级只写出新版本的文件,已经写下的旧文件永不改写;不支持降级,旧版程序遇到新格式直接报错,提示去升级。遇到无法确定语义的引用,比如一个插件引用了某个被折叠掉的片段,迁移宁可拒绝,也不去猜。
Claude Code 和 Codex 的日志格式都没有公开文档,理论上可以随意改。但社区里已经有不少工具在解析这些文件:统计用量、查看历史、导出 transcript。按 Hyrum 定律,只要用的人够多,没有文档的格式也会变成事实上的契约。dsh 只是更早地承认了这一点。
两个仍未统一的问题
日志是谁的。 同一份会话日志,同时是回放的输入、审计的证据、评测和训练的语料。dsh 最近把一个上传插件设成了默认开启:调用 DeepSeek 官方 API 时,会把会话日志中服务端还没确认收到的部分,增量附带在请求里,单次上限 8MiB,积压的部分分多次送完。模型看不到这个字段,服务端能收到;可以在配置里关掉,新版本的设置页也加了一个开关。开源、可配置、文档写得清楚,意味着这个行为可以被检查、被控制;但服务端拿这些日志做什么、保留多久、能不能删除,文档没有回答。它至少说明了一件事:会话日志的价值,已经大到值得模型厂商专门修一条管道来收。
没有通用格式。 三家三种格式,互不兼容。统一审计还可以靠适配:把不同格式映射到同一套分析工具里,只是字段覆盖和语义未必一致,还要跟着各家的版本变化。跨产品接着跑就难得多:除了对话本身,还牵涉工具状态、权限,以及只有原厂服务端才解得开的推理状态。可观测性这一侧,OpenTelemetry 的 GenAI 约定已经有输入消息、输出消息、系统指令这类可选的内容字段,但它仍处在开发阶段,也不是为“恢复一个会话”设计的。上一篇说“E2B 兼容”开始像当年的“S3 兼容”;会话日志这一层,还没有出现那个被大家兼容的格式。
为谁而记
写到这里,我脑子里一直浮现的参照物是飞机上的飞行记录仪,也就是俗称的黑匣子。
它不只记飞行员做了什么,也记下飞行员当时看到了什么:仪表读数、高度、告警。事后调查要回答的永远是同一个问题:在那个时刻,凭他能看到的信息,为什么做了这个决定。Agent 的会话日志正在变成同一种东西。三家都开始把“模型的视野”写进日志,原因也一样:模型是不确定的,重跑一次得不到同样的结果,事后能依赖的只有这份记录。
这篇里的其他几个问题,黑匣子也都遇到过。参数按什么频率采样,对应日志记到多细;坠毁前最后几秒的数据能不能保住,对应结算之前的流可能整段丢失;谁有权调阅、由谁来解读,对应那份被默认上传的拷贝。航空业花了几十年,才把记哪些参数、保存多久、由谁调阅写进行业规范。
黑匣子也有它的边界:它只能证明自己记下了什么。设想一个工具已经改了文件,进程却在写下结果之前崩溃了。恢复之后,日志里没有这次成功的记录,系统能不能据此认定操作没有发生?不能。记下的,是恢复时可以依赖的证据;没记下的,恢复时必须重新核实。中断之后怎么接着做,是下一篇的话题。
Agent 的会话日志还处在规范出现之前。怎么记,每个问题都有现成的参照:记到多细,是记录单位和落盘时机的老取舍;模型的视野,用 checkpoint 加增量来存;模型视图和界面视图,是单一事实来源和物化视图之争;插件事件由写入方标记可跳过,是前向兼容的老问题;逐级迁移、旧文件不改写,是 schema 演进的纪律。为谁而记,还没有人给出答案。