第21章 事前之图,事后之树
2026.07.29Agent 需要两个不同的持久化结构:一个是"计划做什么",一个是"已经做了什么"。它们不是同一个东西。
引子
你让 agent 做三次重构。它先理解代码结构,再拆步骤,然后一步步执行。做到第三步时,你关了终端。第二天重新打开,Session 没了。但你记得那个计划——5 个步骤,第 3 步做到一半。
这时候你希望 agent 知道两件事:第一,"我们昨天有一个做了一半的计划";第二,"第三步具体做到了什么程度"。
这两个问题看似接近,但答案指向的是两个完全不同的东西。第一个问题的答案是"图"——你事前画好的里程碑依赖图。第二个问题的答案是"树"——事后记录下来的完整对话和操作记录。一个是意图的载体,一个是事实的载体。 它们不应该合并成一个。
事前之图:Work Graph
大多数 agent 系统用 todo list 来管理多步骤任务。Todo list 的问题是:它假设步骤是线性的。
现实的工程任务很少是线性的:你可能需要先做完 A 和 B 才能开始 C,D 可以在 C 的同时跑,E 要等 C 和 D 都完成才能开始。这些依赖关系用线性列表表达不清楚——你只能在列表里排序,但排序丢失了"为什么 A 必须在 B 前面"的信息。
Work Graph 用一个 DAG(有向无环图)来表达这些关系。每个节点是一个里程碑,每条边表达一个依赖。CodeCoder 的实现用以下六种操作管理这个图:
add— 增加一个里程碑,声明它的依赖和验收条件start— 标记一个里程碑为"进行中"done— 标记为"完成"needs_fix— 标记为"需要修复"(验收不通过时的唯一状态)next— 查询"下一个可以做的是什么"(依赖全部 done 且为 pending 的最低 id 节点)list— 列出全部里程碑和当前状态
关键的设计决策是:图在构造阶段完成后就不再修改结构。 你可以修改里程碑的状态——从 pending 到 done、从 done 到 needs_fix——但你不能在执行过程中随意增减节点或改写依赖关系。
这个约束确保了"事前之图"的完整性。如果 agent 在第三步发现第五步不对,它不能偷偷改第五步的验收条件。它只能把第五步标记为 needs_fix,然后说明为什么需要调整。结构修改要回到规划阶段——但规划阶段的完整对话已经记录在 session 里了。
事后之树:Session
Session 是另一个东西。它不是计划,而是记录——从用户的第一句话到最后一个工具结果,全部保存为可恢复的结构化数据。
在 CodeCoder 里,每个 session 是一个带版本号的 JSON 文件,内容是一个消息树:
session.json
├── schema_version: 5
├── messages: [
│ ├── { role: "user", items: [Text("帮我重构这个模块")] }
│ ├── { role: "assistant", items: [
│ │ Text("好的我看一下"),
│ │ ToolCall{id: "call_1", name: "glob", args: ...},
│ │ ToolCall{id: "call_2", name: "read_file", args: ...}
│ │ ]}
│ ├── { role: "tool", tool_call_id: "call_1", items: [ToolResult("src/mod.rs")] }
│ └── ...
]
树状结构的关键在于:它保留了工具调用的父子关系。一个 assistant 消息可以包含多个 ToolCall,每个 ToolCall 对应一个 tool 角色的 ToolResult。agent 工具创建的 sub-agent 会话嵌入为子树。这个结构使得事后查看时可以精确还原"当时发生了什么"——不只是用户说了什么、agent 回了什么,还包括 agent 在思考过程中调用了哪些工具、结果是什么、子 agent 做了什么。
Session 文件通过 /resume 命令加载。你关了终端再开,/resume 4 恢复会话 4 的完整上下文——agent 知道之前的对话历史、工具调用结果、以及之前的推理路径。
为什么必须是两个?
到这里已经可以回答最初的问题了:为什么 Work Graph 和 Session 不能合并成同一个文件?
第一个原因:生命周期不同。 Session 永不"完成"。只要你一直在对话,session 就在增长。关机了它还在磁盘上等恢复。Work Graph 会"完成"——所有里程碑 done 了,图就终结了。一个永不结束的数据结构和一个有终态的数据结构合并在一起,管理复杂性会爆炸。
第二个原因:角色不同。 Work Graph 是意图——"我打算怎么做"。Session 是事实——"实际发生了什么"。意图可能会在执行中被推翻(第三步骤发现第二步骤的方向错了),但推翻过程本身是事实,应该记在 Session 里。如果把意图和事实合在一个文件里:要么修改意图时丢失了"为什么修改"的记录,要么事实增长过快淹没了意图的清晰结构。
第三个原因:压缩策略不同。 Session 会膨胀——一个长对话可能几千条消息。CodeCoder 对 Session 有两级压缩策略(完整论述见篇 8「Agent 应该忘记什么」)。Work Graph 不需要压缩——它的节点数取决于任务复杂度,正常的里程碑图不会超过几十个节点。如果把图嵌在 Session 里,压缩时的语义边界会变得模糊——哪些消息可以安全压缩而不影响图的完整性?
实践中的关系
Work Graph 和 Session 虽然在存储上分离,但在运行时有明确的引用关系。一个典型的流程是:
- agent 构造 Work Graph(产生图文件)
- agent 按图推进每个里程碑(每一步的对话记录在 Session 中)
- 完成一个里程碑时,agent 在 Session 中记录"里程碑 X 已完成"
- 如果需要回溯,"为什么里程碑 X 被标记为 needs_fix"——去 Session 里找当时的那段对话
Session 的消息可以引用里程碑 ID,但反之则不成立:Work Graph 的节点不应该引用 Session 中具体的消息行号。理由是:Session 在压缩时消息会变(丢弃、摘要、占位化),引用会失效。里程碑只关心"验收是否通过"这个结果,不关心验收过程中具体哪句话说了什么。
代价与权衡
图与树的分离不是没有代价。最直接的代价是两个结构之间的同步问题。
Work Graph 说"里程碑 3 已完成",Session 中记录的"第三步执行到一半时你取消了"。哪个为准?在 CodeCoder 的设计中,Work Graph 的状态变更必须经由 tool 调用(milestone done),而这个调用本身会被记录在 Session 中。所以如果 Session 显示工具调用尚未完成而 Work Graph 显示 done,应该优先相信 Session——因为 Work Graph 的状态可能是在一次性操作中误标记的。但反过来,如果 Session 因 compaction 压缩丢弃了部分消息,而 Work Graph 是完整的——谁更权威?没有完美的答案,两边的权威性取决于你信任"确定性的状态机"还是"完整性的事后记录"。
第二个代价是存储翻倍。两套持久化结构意味着两套文件、两套加载路径、两套备份策略。对于有 100+ 个里程碑的大型项目,Work Graph 本身的体积不大(几个 KB),Session 才是体积大头。但管理两套文件的运维开销是实际存在的——特别是在崩溃恢复场景下,两边都需要恢复到一致状态。
第三个代价是引用断裂的风险。Work Graph 节点内不引用 Session 消息行号——但实践中,当 agent 在完成一个里程碑时在 Session 中写下"里程碑 X 已完成",这个序列是人工阅读的依据。如果 Work Graph 和 Session 因某种原因不同步(文件系统损坏、手动编辑、版本回退),人工排查"到底发生了什么"需要同时看两份文件并做交叉比对。
这些问题都不是放弃分离设计的理由——它们只是一个提醒:分离降低了一部分复杂性,但引入了另一部分复杂性。好在对大多数使用场景来说,引入的这部分复杂性远低于把两者合在一起带来的混乱。
收尾:最小实践
你不需要造一个 Work Graph 引擎才能用这个分离模式。两个文件就可以开始:
plan.json— 事前图。一个简单的 JSON 数组,每个条目有 id、title、status、dependenciesjournal.jsonl— 事后树。追加写入的 JSON 行,每条记录一个工具调用及其结果
前者在开始时写好,执行中只改状态不改结构。后者一直在增长,永不删除。当 plan.json 的所有条目都是 done 时,归档。journal.jsonl 继续存在——它是完整的事后审计依据。
做好这个分离之后,你就能回答两个问题而不需要查看对方的数据:"我们还要多久?"(看图),"刚才哪里出错了?"(看树)。这两个问题有不同的时效性、不同的受众、不同的数据访问模式——让它们住在同一个文件里只会让两者都变得不可用。
下一篇,我们换个角度——把"用户"从系统中拿掉。有用户在场和无用户在场不是同一个模式的开启关闭,是两种完全不同的设计。