从 0 开始实现一个 Coding Agent · 第 6 章:会话日志
/ 8 min read
第 6 章 · 会话日志:append-only、投影推导与崩溃恢复
对应代码:code/mini-agent/session.ts;回归测试见本章“验证”一节
学习目标
- 把“内存里的 messages 数组”替换成追加式事件日志,让模型历史从日志投影推导。
- 实现 surface(模型可见)与 log-only(仅记账)两类事件,以及
replace改写投影的机制(第 7 章压缩的地基)。 - 实现 resume:重放日志重建历史,并自动修复崩溃留下的悬空工具调用。
这是全书最重要的一章。前面所有章节的“消息数组”从此消失——它是所有一致性 bug 的根源。
概念:日志是唯一事实源
前面章节的模型历史 = 一个内存数组。它的死穴:
- 进程崩溃,数组没了,对话断了;
- 历史要被压缩改写(第 7 章)时,数组要原地变形,日志和数组迟早不一致;
- fork(复制一段历史开新会话)、转写、审计,都要从数组再抄一遍。
解法是把方向反过来:程序只追加事件;模型历史任何时候都从事件流推导。这就是“model-visible ⟺ logged”原则:凡是模型看得到的,必然在日志里;凡是日志里没有的,绝不许发给模型。
事件模型
信封三件套 + 可选的投影信息:
export type SessionEvent = | { seq: number; time: string; type: 'session/header'; data: { version: number; ...; delegationDepth: number } } | { seq: number; time: string; type: 'turn/start'; data: { turn: number } } | { seq: number; time: string; type: 'turn/end'; data: { turn: number; reason: TurnEndReason } } | { seq: number; time: string; type: 'system/message' | 'user/message'; data: { message: WireMessage }; surfaceOp?: SurfaceOp } | { seq: number; time: string; type: 'assistant/message'; data: { message: WireMessage; usage?: Usage; interrupted?: boolean } } | { seq: number; time: string; type: 'tool/call'; data: { callId: string; name: string; arguments: string } } | { seq: number; time: string; type: 'tool/result'; data: { message: WireMessage; isError?: boolean }; sourceEventSeqs?: number[] } | { seq: number; time: string; type: 'compaction/start' | 'compaction/summary'; data: ... }四条规则:
- seq 单调连续,打开时校验,跳号即损坏。
- 只有四类 surface 事件(system/user/assistant 消息、tool 结果)出现在模型历史里;
tool/call、turn/*、compaction/*是 log-only 记账,只留审计。(真实实现还有第五类 developer 消息。) - surface 事件可携带
surfaceOp:append(默认)或replace(startSeq, endSeq)——replace 是“投影改写”:旧事件仍在文件里,只是不再投影为模型可见历史。日志永不删改。 tool/result用sourceEventSeqs指回配对的tool/call,这是崩溃恢复能精确定位的钥匙。
落盘就是一个 JSONL 文件,每事件一行;第一行是 header(含格式版本号)。追加用同步 appendFileSync——教程版用吞吐换简单与崩溃安全(真实实现用异步缓冲 + 显式 flush 检查点)。
投影:fold 与 derive
private fold(event: SessionEvent): void { switch (event.type) { case 'system/message': case 'user/message': case 'tool/result': { const node = { seq: event.seq, message: event.data.message } if (event.surfaceOp?.op === 'replace') this.applyReplace(event.surfaceOp, node) else this.nodes.push(node) break } case 'assistant/message': this.nodes.push({ seq: event.seq, message: event.data.message }) break default: break // log-only }}循环从此每次请求前 session.deriveMessages() 现场推导,不再持有平行数组。
resume 与崩溃恢复
Session.open 的三步:读全部行并校验 seq 连续性与版本号(过新直接拒绝)→ 逐事件 fold → 修复悬空调用:
repairInterruptedCalls(): void { // 找出所有"有 tool/call、无配对 tool/result"的调用,补一条 isError 占位结果 for (const call of requested) { if (answered.has(call.callId)) continue this.append('tool/result', { message: { role: 'tool', tool_call_id: call.callId, content: 'Error: 上次运行在该调用产生结果前被中断,结果未知。只读或幂等操作可以重试;有副作用的操作请先核实状态。', }, isError: true, }, { sourceEventSeqs: [call.seq] }) }}为什么不回滚、不丢弃?“调用可能已经执行过了”——rm 也许已经发生。诚实的做法是留一条占位结果,明确告诉模型“结果未知,别盲目重试”。注意措辞区分(与真实 harness 相同):已启动但结果未知(只读/幂等才可重试)与从未启动(可重试)是两种不同的恢复码。
验证(不依赖 API,可直接跑)
code/mini-agent/test-session.ts 用三个场景验证上述机制:落盘重放一致;悬空调用打开时自动补占位结果;replace 折叠后原始事件仍在文件里但不再投影:
const reopened = Session.open(path) // 与写入方 events 数量一致reopened.deriveMessages().map((m) => m.role) // system,user,assistant,tool// 崩溃场景:tool/call 没有结果 → open 时补 1 条 tool/result// 压缩场景:replace 后 deriveMessages 只剩 [system, 摘要],而 events.length 不变node --experimental-strip-types test-session.ts # 输出全 OK 即通过运行 --resume 试试真实恢复:Ctrl+C 打断一次执行,再 node main.ts --resume .mini-agent/sessions/<file>.jsonl,日志原样接上。
对照 DeepSeek Harness
- 事件词汇:
SessionEventMap声明了turn/start|end、step/start|end、system/developer/user/assistant/message、tool/call|result、request/header|context等核心事件(packages/core/session/src/types.ts:281),插件可用 declaration merging 增加自己的事件(第 7 章的压缩事件就是这么加的)。事件信封还有个ignorable: true标志:旧读者遇到不认识的事件,只有带这个标志的才可安全跳过——这是“加事件不升版本”的护栏。 - 格式版本与不可变历史代:
SESSION_FORMAT_VERSION只在结构性变化时 bump(普通加事件不算);历史版本文件永不改名、改写、删除,迁移是“生成一个 vN+1 新文件”而不是原地升级(packages/core/session/src/types.ts:89;packages/session/session-persistence-jsonl/src/generation.ts:946-1000,发布用硬链接 + 原子可见性,:833-843)。文件名规则:session.jsonl(v0)→session.v4.jsonl[.zstd](packages/session/session-format/src/filename.ts:14-17)。教程的version > 当前就拒绝打开是同一原则的最低配。 - 投影推导 + 增量缓存:
deriveMessages()不是每次全量重算,而是按surfaceOp维护增量投影、尾部 O(新节点)(packages/core/session/src/index.ts:860-881);消息深冻结,调用方拿到引用也改不了历史。 - fork:复制前缀 + 打
session/end-seed切割标记 + 合成 open turn 的收尾事件(packages/core/session/src/fork.ts:21-30)。第 8 章的子代理会用到同样的思路。 - 恢复措辞:两种恢复码
TOOL_OUTCOME_UNKNOWN/TOOL_NOT_STARTED见packages/core/agent-loop/src/repair.ts:15-41;step 内失败时的即时补账逻辑在agent.ts:342-353。
练习
- 实现
fork(boundarySeq):复制前缀事件 + 补合成收尾,产出新会话文件。 - 给信封加
ignorable并在open时验证:未知事件类型若无ignorable标志则拒绝打开。 - (思考)为什么 header 里的
version过新要拒绝,过旧却允许(然后迁移)?两种方向的错误各意味着什么?
本章留下的问题
日志解决了一致性,但没解决成本:50 轮对话后,每次请求都把 50 轮全部重发,费用和延迟线性上涨,最后撞上上下文窗口。下一章:压缩——用 replace 投影,把旧历史换成一条摘要。