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。
这个取舍很眼熟:数据库里每条记录单独落盘,还是攒一批一起提交(group commit)。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。三家收敛到同一个判断:流是给人看的,结算才是事实。
这个判断不只是为了省空间。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 最后选的是 protobuf 处理未知字段的思路:由写入方在事件上标记“读不懂可以跳过”(ignorable),而不是由读取方维护一张白名单。
迁移也是同样的纪律:v0 到 v4 逐级相邻迁移,每一级都是一个独立的有状态转换;升级只写出新版本的文件,已经写下的旧文件永不改写;不支持降级,旧版程序遇到新格式直接报错,提示去升级。遇到无法确定语义的引用,比如一个插件引用了某个被折叠掉的片段,迁移宁可拒绝,也不去猜。
Claude Code 和 Codex 的日志格式都没有公开文档,理论上可以随意改。但社区里已经有不少工具在解析这些文件:统计用量、查看历史、导出 transcript。按 Hyrum 定律,只要用的人够多,没有文档的格式也会变成事实上的契约。dsh 只是更早地承认了这一点。
两个还没人解决的问题
日志是谁的。 同一份会话日志,同时是回放的输入、审计的证据、评测和训练的语料。dsh 最近把一个上传插件设成了默认开启:只要调用的是 DeepSeek 官方 API,每次请求都会增量附带完整的会话日志(单次上限 8MiB)。模型看不到这个字段,服务端能收到,可以在配置里关掉。这件事本身我不认为有什么问题:开源、可配置、文档写得很清楚。但它说明了一件事:会话日志的价值,已经大到值得模型厂商专门修一条管道来收。 当日志的价值主要在别人手里时,格式由谁来定、保留多久、能不能删,就都不只是技术问题了。
没有通用格式。 三家三种格式,互不兼容。可观测性这一侧有 OpenTelemetry 的 GenAI 语义约定,但它面向的是追踪和指标,不是一份能回放、能重建模型视野的会话。想把 Codex 里的一段会话拿到 Claude Code 里接着跑,或者用同一套工具审计三家的记录,目前都做不到。上一篇说“E2B 兼容”开始像当年的“S3 兼容”;会话日志这一层,还没有出现那个被大家兼容的格式。
为谁而记
写到这里,我脑子里一直浮现的参照物是飞机上的飞行记录仪,也就是俗称的黑匣子。
它不只记飞行员做了什么,也记下飞行员当时看到了什么:仪表读数、高度、告警。事后调查要回答的永远是同一个问题:在那个时刻,凭他能看到的信息,为什么做了这个决定。Agent 的会话日志正在变成同一种东西。三家都开始把“模型的视野”写进日志,原因也一样:模型是不确定的,重跑一次得不到同样的结果,事后能依赖的只有这份记录。
这篇里的其他几个问题,黑匣子也都遇到过。参数按什么频率采样,对应日志记到多细;坠毁前最后几秒的数据能不能保住,对应结算之前的流会整段丢失;谁有权调阅、由谁来解读,对应那份被默认上传的拷贝。航空业花了几十年,才把记哪些参数、保存多久、由谁调阅写进行业规范。
Agent 的会话日志还处在规范出现之前。怎么记,每个问题都有现成的参照:记到多细,是要不要每条都进 WAL、还是 group commit 的老取舍;模型的视野,用 checkpoint 加增量来存;模型视图和界面视图,是单一事实来源和物化视图之争;插件事件由写入方标记可跳过,是 protobuf 式的前向兼容;逐级迁移、旧文件不改写,是 schema 演进的纪律。为谁而记,还没有人给出答案。