当前主流 Agent 框架(Claude Code、CoDEX 等)普遍采用 while(true) 循环架构,作者指出这会导致中断处理粗糙、重试逻辑散落、并行工具调用别扭等工程难题,并给出状态机化、层级化等替代思路。
打开任何一个 Agent 代码库——无论是 Claude Code、Codex、Pi,还是大多数开源实现——你都会看到同样的骨架。大致是这个样子:
let state = {};
while (true) {
const plan = await llm.plan(state);
const results = await runTools(plan);
state = updateState(state, results);
if (isDone(state)) break;
}
这是最自然的第一设计方案。模型是大脑,循环是心脏,state 就是你一路积累下来的各种对象袋。在 Demo 里它运行得非常好。
但当你真正和这类 Agent 共处一段时间之后,同样的裂缝就会在各个地方显现出来。
中断是个 hack。如果用户在某一轮中途 kill 了进程,或者某个工具卡住了,或者模型请求澄清,你手里就会有一个尴尬地停在半迭代状态的 state。你要么丢弃它,要么把它 patch 进去。无论哪种方式,state 都在对你撒谎——它声称的是"这一轮顺利结束",但实际上并没有。
重试是特殊处理。如果一个工具失败了?就在外面包一层 try/catch,也许重试一下,也许把错误传给模型,记得把它加到 state 里。几个月下来,你就有了十几个 ad-hoc 分支,专门处理"如果这一轮没干净地结束怎么办"。
并行工具调用很别扭。模型想同时调用 read 和 grep。现在你的循环要么把它们串行化,要么 spawn 一堆 promise,然后在下一个 llm.plan() 之前把结果重新组装好。同样,又是更多的 state 要管理。
你无法回退。state 是一堆可变对象。你无法从对话中更早的时间点分支出去——除非手动重建。你无法重放模型实际看到了什么。想调试一个长会话?祝你好运。
这些问题不是实现细节。它们是 while(true) 这种形态的直接产物:一个可变 state 对象,试图替代整个对话历史的角色。
几个月前我开始构建一个个人 Agent,Pizza,它基于一个不同的前提:日志是真相的来源,而 state 只是该日志的一个投影。
每一条消息、工具调用、结果和文件编辑都成为 EventStore 中的一行:
CREATE TABLE events (
sequence INTEGER PRIMARY KEY,
event_id TEXT,
type TEXT,
payload_json TEXT,
caused_by TEXT,
thread_id TEXT,
...
);
运行时不在内存中持有 state。它读取这个日志的尾部,将下一个事件分发给一个 handler,然后把结果追加回去。一个"轮次"是一次状态转换,而不是又一次循环迭代。
UI、LLM context 和会话树都是对同一日志的查询。如果你想看发生了什么,就读事件。如果你想分支对话,就从一个更早的 sequence 启动新的 thread_id。如果你想重放,就重新应用这些事件。
一旦你认定事件日志是真相的来源,许多原本很麻烦的特性就不再是特殊处理了。
任务之间没有硬边界。你可以连续几天、几周甚至几年一直在同一个工作空间里聊天——之前的每条消息、每次编辑、每次工具调用都作为事件存在,随时可以查询。Agent 通过投影日志尾部来管理自己的 context,而不是让你清空聊天重新开始。可以把它想象成和一个记住一切的老朋友的长聊。
一条 CLI 工具,而不是 JSON 菜单
模型拿到的是一条 cli 工具,而不是一长串 read_file / write_file / grep / git 工具。内置命令如 read、write、edit 由结构化的内部 handler 处理,但其他所有命令——grep、sed、git、npm、python、ls——都直接传递给用户的 shell。模型必须学会 shell,但它也因此能够组合真实的管道。而且因为每条 cli 调用就是一条事件,日志始终保持一致:read 一行,git diff 一行,npm test 一行。
类 Git 的会话树
因为每条消息都是一条带 caused_by 指针的事件,对话本身已经是一棵树了。你可以从任意更早的消息分叉、回退、分支并继续。它不是事后叠加的撤销栈,而是数据本身的形状就是如此。
所有界面共用同一运行时
TUI、桌面应用、JSON-RPC 服务器和一次性 CLI 都消费同一个 SessionFacade 事件流。它们是同一日志的不同投影。如果你在终端里启动了一个会话,后来又打开了桌面应用,它们是同一个事件流。
Agent 之间可以互相通信
工作空间 A 中的一个 Pizza Agent 可以发送一条 tell 事件给工作空间 B 中的 Agent。B 的 Agent 在它自己的事件日志中处理任务并把结果写回来。B 的实际项目文件和 context 永远不会泄漏到 A 的日志中——只有 tell 请求及其响应是事件。
它甚至可以修复自己(如果你启用了的话)
有一个可选技能叫 pizza-self-optimization,它读取本地事件日志作为证据,分叉 Pizza 仓库,从日志中复现一个 bug,写一个测试,然后开一个 PR。它之所以能工作,正是因为事件日志是对 Agent 实际做了什么的一份可复现记录。
事件溯源不是免费的。你现在在热路径上有一个真实的数据库。你必须考虑重放成本、日志大小和快照策略。如果一个会话有几千条事件,每次分叉时从日志重建 context 就会变慢;你需要定期的物化快照。我现在还没有实现这一块,但它显然是下一个要做的。
你还失去了"在内存中维护一个大 state 对象"这种简单性。如果你的运行时需要知道什么,它必须存在于一条事件中。任何在日志之外的东西都是看不见的副作用,是一个等待发生的 bug。
while(true) 模式对很多 Agent 来说仍然是正确的默认选择。它简单、能跑,而且适合放进教程。但如果你想要长会话运行、分支对话、多 Agent 协作,以及审计或重放 Agent 实际做了什么的能力,它就开始感觉像是一个错误的抽象。
EventStore 方案有自己的成本,但它把困难的事情——分叉、重放、多 Agent 协作、调试——变成了普通的数据库操作。这就是我在 Pizza 项目上下的赌注,到目前为止,这是设计中经受住考验最好的部分。
如果你感兴趣,代码在 github.com/tomsun28/pizza 开源。欢迎反馈和讨论。