一个很长的 agent turn,不等于一个很长的 HTTP request。HTTP request 要么成功,要么失败,然后你重试。Agent turn 可能已经深入十次工具调用、跑了七分钟,并且已经写过文件、执行过 build、启动过子进程、和用户协商过审批。如果承载这个 turn 的 daemon 在第九分钟死掉,你没有资格说“重试”——副作用已经发生了。唯一有用的问题是:崩溃之后,什么还能留下来?
这篇文章讲的是我们在 Kocoro 里构建的 mid-turn checkpoint 设计。它来自足够多次 agent stall 和 turn 丢失之后的一轮专门架构整理。Kocoro 是我们的 Go-based agent runtime,由 daemon 提供服务、兼容 MCP,而长时间跨度的 turn 对它来说是常态。
为什么“重试这一轮”是错误答案
大多数 agent 框架对 turn 原子性的理解,像一个天真的 HTTP client 对 request 原子性的理解:如果进程死了,就丢掉内存状态,从最后一个持久化点重新开始。对无状态 RPC 来说,这可行。对 agent turn 来说,这很危险。
三个原因:
- 工具调用有副作用。 第 3 分钟运行的工具可能创建了分支、删除了文件,或者 POST 到了 webhook。第 9 分钟的崩溃不会撤销这些事。从头重启这个 turn,通常会用过时参数重新执行已经跑过的工具。
- 上下文很昂贵。 七分钟的 LLM 输出,包括五次总结和两次 reactive compaction,是真实 token 成本。丢掉它再重跑,不只是慢,而是在为同一件事付两次账。
- 用户预期很重要。 用户已经在第 3 分钟看到了工具输出。Daemon 重启不应该悄悄“取消运行”用户已经看见的东西。
唯一站得住脚的策略是:在工作已经具备持久意义的时刻,持久化部分 turn state,然后从那里恢复。 这就是 mid-turn checkpointing。它不是一个按 timer 触发的“每 N 秒保存一次”。
Turn 不是均匀可 snapshot 的。LLM stream 进行到一半时保存,会得到垃圾。工具执行到一半时保存,会捕获一个不确定状态。Checkpoint 必须在正确的事件上触发,这意味着 loop 需要明确知道什么叫事件。
Phase:显式 Turn State Machine
核心设计动作,是把 turn 编码成一个 phase state machine。Loop 里的每个阻塞边界都会声明自己处在哪个 phase。Watchdog、checkpoint hook 和 recovery policy 都从同一个模型读取状态。
八个 active phase,加上一个 transient phase(InjectingMessage,当用户在 loop 中途输入新消息时触发)。一张转移图:
只有两个 phase 计入 idle:AwaitingLLM 和 ForceStop。它们是 loop 严格等待远端 LLM 响应、且本地没有其他工作可做的状态。其他 phase 都有自己的阻塞 owner,并且已经有边界:ExecutingTools 受每个工具的 timeout 限制,AwaitingApproval 受 approval broker 自己的五分钟上限限制,RetryingLLM 是固定 backoff sleep。
关键不对称在这里:如果用 bump-on-event 模型(另一种方案),每个阻塞调用方都必须记得 bump liveness timer。漏掉一个,就会出现安静的 stall。在 phase model 里,watchdog 读取 phase;loop 一进入 non-idle phase,它就自动停止计数——不需要任何人主动记得。 新增工具类型也不需要新增 bump point。
在 Go 里,这真的就是一个 predicate:
// internal/agent/phase.go
func (p TurnPhase) CountsAsIdle() bool {
return p == PhaseAwaitingLLM || p == PhaseForceStop
}
两个 phase,没有例外。系统其他部分——watchdog、checkpoint hook、UI status——都从这个单一事实源读取。
Checkpoint 边界:不是每次转移都保存
有了显式 phase model,“什么时候该 checkpoint?”就变成了可回答的问题。不是每次 phase exit 都代表持久进展。有些 exit 会在每个 LLM tick 上触发,导致 session store 抖动;另一些则发生在工作确实可持久化的边上。
设计声明了三种值得 checkpoint 的转移,并明确拒绝其他转移:
Idempotency rule 是最需要克制才能写对的一条,因为 diff-append 看起来更便宜。它并不更便宜,而且是错的:reactive compaction 可能会缩短 run message list,所以 append-only 模型第一次遇到 compaction 就会漂移。每次 checkpoint 都从 loop 的 canonical list 重建,才是唯一正确路径。成本很小,因为 compaction 本身才是昂贵部分。
三个 checkpoint site。不是 timer。Daemon 可能在一个 tool-heavy turn 里跑二十分钟才遇到下一次 checkpoint;没关系,因为下一次 tool batch completion 会触发保存,而工具调用之间的状态本来也能从消息历史重建。
真正重要的 Crash Test
最终证明整个设计有意义的测试,说起来很简单,失败起来很残酷:在 mid-turn checkpoint 和下一次 checkpoint 之间 SIGKILL daemon。再从磁盘重新加载。部分状态必须还在。 internal/daemon/checkpoint_crash_test.go:
// Mid-turn checkpoint fires: simulates tool batch completion.
agent.SetRunMessagesForTest(loop, []client.Message{
{Role: "user", Content: client.NewTextContent("do thing")},
{Role: "assistant", Content: client.NewTextContent("[tool_use]")},
{Role: "user", Content: client.NewTextContent("[tool_result payload]")},
})
applyTurnState(sess, loop, nil, base)
sess.InProgress = true
mgr.Save() // checkpoint to disk
// --- DAEMON CRASHES HERE. No final save. ---
sessionID := sess.ID
mgr.Close() // drops in-memory state
// --- Recovery: reload manager + session from disk. ---
mgr2 := session.NewManager(dir)
reloaded, _ := mgr2.Load(sessionID)
// 1. InProgress flag survives on disk.
// 2. Partial transcript is preserved (baseline + tool batch).
// 3. MessageMeta tracks messages (no drift).
三个断言:
reloaded.InProgress是true。如果这个 flag 没有留在磁盘上,UI 就无法区分“mid-turn 崩溃”和“正常完成”。len(reloaded.Messages) == 3。基线用户消息,加上 mid-turn checkpoint 写入的两条消息(assistant[tool_use]和 user[tool_result payload])。len(reloaded.MessageMeta) == len(reloaded.Messages)。Metadata array 和 message array 保持同步。这里漂移,就是“工具确实运行过”在 reload 后失去 timestamp 和 source attribution 的 bug 类型。
开发时失败最多的测试,是 metadata drift 那个。从 canonical loop state 重建 Messages 很容易;把 MessageMeta 重建到匹配,并且不丢掉 pre-turn metadata(比如原始用户消息 timestamp),来回改了三轮才做对。这也是 idempotency rule 重要的一个安静原因:MessageMeta 是按 index 跟 Messages 对齐的;两者一旦漂移,就是数据完整性 bug,而且只会在 crash recovery 时暴露,也就是正常测试里几乎看不见。
伴随测试(TestCheckpoint_ResumeAfterCrash_FinalSaveClears)验证反向情况:恢复后的 session 一旦完成了一个干净 turn,InProgress 必须翻回 false。这个 flag 必须是一个可靠信号,而不是一个只会粘住的单向标记。
Watchdog:活性,不是重启
Checkpoint 告诉你崩溃后什么留下来。Watchdog 告诉你什么时候一个 turn 卡住到应该变成崩溃。
每个 turn 一个 goroutine。每五秒 tick 一次。从 tracker 读取 (phase, timeInPhase):
if !phase.CountsAsIdle() {
continue // phase owns its own bounding
}
if timeInPhase >= softTimeout && !softFired {
softFired = true
emit OnRunStatus("idle_soft", friendlyLabel(phase))
}
if timeInPhase >= hardTimeout {
cancel(ctx, cause=ErrHardIdleTimeout) // LLM call aborts, flow falls through
return
}
Soft timeout(默认 90 秒):发出一个 UI status event(“still waiting on LLM for 90s…”),但不做破坏性动作。给用户信号,但不杀 turn。
Hard timeout(默认 0,首次 rollout 时禁用):取消 LLM call,让 recovery dispatcher 接管。默认禁用,是因为生产里的 hard-cancel 是破坏性动作;它应该先在 dogfood 窗口里按 agent 或按 user 打开。
Transition rearm:进入新 phase 会重置 timeInPhase 并清空 softFired。下一次 LLM call 获得自己的新预算。这是 phase model 的自然结果——没人需要记得重置 timer,因为 transition 就是 watchdog 唯一观察的东西。
Recovery:一个 Dispatch Point
这项工作之前,turn recovery 决策大概散落在 Run() 的五个地方:retryable LLM error、context-length error、loop detector verdict、nudge exhaustion、explicit stop path。每个地方都有略微不同的 state check。新增一个 recovery condition 意味着要审计所有五处。
Phase design 把它们折叠成一个 predicate:
type recoveryAction int
const (
actionRetryLLM recoveryAction = iota
actionCompactThenRetry
actionNudgeThenContinue
actionForceStop
actionAbort
)
func decideRecovery(phase TurnPhase, err error, state *loopState) recoveryAction {
switch {
case errors.Is(err, ErrHardIdleTimeout): return actionAbort
case isContextLengthError(err) && !state.reactiveCompacted:
return actionCompactThenRetry
case isRetryableLLMError(err) && state.retryAttempt < 3:
return actionRetryLLM
case state.loopDetector.ShouldStop(): return actionForceStop
case state.nudgeCount < 3: return actionNudgeThenContinue
default: return actionAbort
}
}
顺序就是语义。第一个匹配 case 获胜,所以这个顺序本身编码了优先级:hard-timeout > context-compaction > retryable > loop-stop > nudge > abort。 重新排序这些 case,就等于改变 recovery policy;如果你真的要改 policy,这正是你希望看到的结果。
重要属性不是这个 switch statement 很短。重要的是:新增一个 recovery condition——比如新的“rate-limit backoff” action——现在只发生在一个地方。 添加它的人必须思考它和现有五个 case 的相对优先级;他不能无意中把它塞进某个 conditional 分支里,然后忘掉其他分支。
五个分散决策,变成一个 predicate。一个 predicate 是可以测试的。
这不能解决什么
诚实清单。Checkpointing 不是万能保存按钮。当前设计明确留下四个缺口:
- 正在 streaming 的 token 不会被保留。 Checkpoint 在 phase 之间触发。崩溃瞬间 LLM 正在 stream 的 token 会丢失。恢复时必须发起一次新的 LLM call,不能“从 stream 断掉处继续”。这大概没问题,因为那些 token 还没有被执行过。
- Kocoro 外部的工具副作用不会被触碰。 如果第 3 分钟运行的工具创建了 GitHub issue,重启后这个 issue 仍然存在。Checkpointing 持久化的是 agent 对这个副作用的记录,不是副作用本身。工具调用的幂等责任在工具里,不在 loop 里。
- Subagent/cloud-delegate turn 是按 parent 粒度 checkpoint 的。 一个 parent turn 派发一个 30 分钟 cloud agent,会在 delegate 返回后 checkpoint,而不是中途 checkpoint。如果 cloud agent 自己死了,那是 cloud runtime 负责的另一套恢复故事。
- Checkpoint 会和并发用户输入冲突。 如果用户在 reload 中途输入新消息,resume 必须协调“崩溃前保存的东西”和“用户在 reload 后新增的东西”。当前设计是:崩溃前 transcript 作为 baseline 获胜,新输入 append。并不优雅,但确定。
把这些写进设计文档,而不是藏起来,是一个刻意选择。一个悄悄掩盖前三个缺口的 checkpoint 系统,比一个大声暴露边界的系统更糟。
教训
每个长时间运行的 agent framework,最终都必须回答:如果进程在 mid-turn 死掉,会发生什么?
错误答案是“隐式活性”:散落在阻塞调用里的 timer、失败后重启 turn、append-only persistence。它能工作,直到不能。而失败恰好发生在你无法在测试中复现的地方。
正确答案是一个显式 phase model。每个阻塞边界声明自己的 phase。Watchdog 读取 phase,而不是读取 timer bump。Checkpoint 在 phase transition 上触发,而不是在 wall-clock tick 上触发。Recovery 从一个 predicate 派发。当四个子系统都读取同一个模型时,它们会保持对齐;当其中任何一个拥有自己的平行 turn state 概念时,它们就会漂移。
如果你正在写 agent runtime,却还没有给 turn phase 命名,那这就是其他一切之前必须完成的工作。要写的代码不多——也许是三个文件里 200 行 Go。真正的架构动作,是给 phase 命名,并承诺遵守这个契约。
审计任何 agent loop 的第一个 invariant:它的 crash recovery 依赖干净的内存状态,还是依赖某个在 mgr.Close() 之后仍然存活的东西? 如果是前者,你没有 recovery。你只是有一个希望。
本地优先的 AI agent runtime——daemon-served、MCP-compatible,使用 Go 构建。