TL;DR(核心总结)#
核心结论:状态落盘不能只停留在对话历史(Messages),必须下沉到执行状态(Tasks & Tool Progress),并结合工具的幂等性策略(Replay Policy)做分级恢复——即将“恢复给模型看的对话上下文”升级为“恢复给调度器看的执行状态机”。
实现 Agent 的 Durable(中断恢复)特性,核心在于将状态落盘从“对话历史层”下沉到“执行运行时层”。
在 Agent 运行过程中,不仅要把常规的 User、Assistant 消息及最终结果做历史落盘,更重要的是对每一轮模型生成和工具调用的执行进度做细粒度的状态持久化。当模型发起工具调用时,框架遵循“先落盘意图,再触发执行”的原则,在真正调用外部接口前先原子提交 Checkpoint,从而精确记录每个工具的生命周期状态——区分它是“尚未开始(Pending)”、“正在执行中(Executing)”还是“已经完成(Completed)”。
当进程崩溃重启后,系统不需要让大模型重新推理或整轮重跑,而是直接由运行时调度器(Scheduler)接管恢复:
- 已完成的工具:直接复用落盘结果,绝不重跑;
- 尚未开始的工具:继续按序或并行推进;
- 执行中途被打断的工具:结合其幂等性策略分级处理——声明为幂等或只读安全(Replay Safe)的工具,直接利用落盘参数自动重放;非幂等的有副作用工具则拒绝盲目重试,将“中断且状态未知”的结果交还给模型或人工做后续补偿决策,待所有工具状态收敛后再无缝接续下一轮模型生成。
一、从“恢复对话”到“恢复执行”的架构对比#
| 维度 | 普通 Agent(仅消息落盘) | Durable Agent(状态机落盘) |
|---|---|---|
| 持久化粒度 | 仅保存 User、Assistant、已完成的 Tool Result | 消息历史 + 请求生命周期(Submission)+ 任务树(Task)+ 阶段检查点(Checkpoint) |
| 并发工具中途崩溃 | 丢失在途状态,无法区分“没调”还是“调了一半” | 通过执行前 Checkpoint 精确识别每个子任务所处阶段 |
| 重启恢复主导方 | 大模型(LLM)根据残缺历史去“猜”下一步,或整轮重跑 | 运行时调度器(Scheduler)先走完已确定的程序步骤,收敛后再唤醒模型 |
| 副作用安全保障 | 极易发生重复调用或状态不一致 | 按 Replay Policy 分级恢复(安全重放 vs 异常交还) |
二、通用 Durable Agent 的 4 个核心工程设计#
1. 显式建模:将 Agent Loop 拆解为可持久化的任务树与状态机#
把运行时的调度状态和给模型看的对话历史解耦。除了只追加(Append-only)的对话消息外,显式持久化三类运行时实体:
- 请求与输入队列(Submission & Inbox):为每次用户输入分配唯一
requestId,记录“排队中inbox→ 处理中 → 已回答”的生命周期。既防止客户端断线重连时重复创建任务,也保证运行期间用户追加的 follow-up 消息持久化在队列中不丢失。 - 生成父任务(Generation Task):代表一轮模型推理及其后续的工具等待过程(如
prepare→request→waiting)。 - 工具子任务(Tool Task):代表每个具体的工具调用,挂载在当前生成任务下(便于统一等待和级联取消)。
2. 边界原则:先落盘意图(Pre-execution Checkpoint),再触发外部副作用#
不能写完 Assistant 消息就凭内存去调工具,也不能等工具跑完了才落盘。必须严格卡住两个原子提交(Atomic Commit)边界:
- 模型决定调工具时(扇出边界):在同一个事务里,同时写入包含 Tool Call 的 Assistant 消息、创建所有子工具 Task(初始为
call阶段),并将父生成 Task 更新为waiting (on: [T1, T2, T3])。 - 工具真正执行 I/O 前(副作用边界):完成参数校验和拦截器逻辑后,在发起外部请求前必须先 Commit 一次,将工具 Task 的 Checkpoint 推进到
execute阶段(记录最终参数与重放策略replay)。 - 核心价值:崩溃重启时,系统能严格区分一个未完成工具是“还没发出请求(停在
call)”还是“可能已经产生外部副作用(停在execute)”。
3. 恢复调度:按工具幂等性策略(Replay Policy)分级收敛#
进程恢复后,调度器先扫描未完成的任务树,按状态与策略分类收敛,不急于调用大模型:
进程重启 -> 调度器扫描未完成 Generation Task 依赖的 Tool Tasks ├─ 状态为 Completed ──> 直接复用已落盘的 Tool Result(不重跑) ├─ 停留在 Call 阶段 ──> 尚未产生外部调用,正常启动执行 └─ 停留在 Execute 阶段(中途崩溃,外部状态未知) ├─ Replay: Safe(只读/幂等工具) ──> 使用落盘的最终参数自动重放 └─ Replay: Unsafe(写操作/发邮件等)──> 拒绝自动重试!写入“执行中断、状态未知”结果 ──> 待所有工具收敛后交还 LLM(查询核实)或触发人工介入只有当父任务等待的所有子工具任务都到达终态后,调度器才会在同一事务中完成当前生成轮次,并开启下一轮 Generation Task 请求模型汇总。
4. 明确边界:运行时控制流恢复 vs 外部副作用与工作区回退#
Durable Runtime 解决的是“已确定的程序控制流与已提交状态的精确接续”,但要清晰划定架构边界:
- 外部 API 的真正幂等:框架层的
replay: "safe"只是开发者的安全声明。如果工具在外部服务已执行成功、但未落盘结果前崩溃,仍需依赖外部接口的幂等键(Idempotency Key)或状态查询接口来彻底消除不确定性。 - 执行恢复(Durable) vs 状态回退(Undo / Workspace History):
- Durable 解决的是“未完成的工作如何跨崩溃继续往前走”(面向后台长程任务、抗进程退出)。
- Workspace History(如 Git 快照回滚) 解决的是“模型方向错了,如何把已修改的本地文件和对话一起撤回到过去某个节点”(面向交互式探索)。两者正交,生产环境中通常配合使用。
三、高频面试追问与回答模板#
1. 完整口播模板(1~2 分钟,适合“如何设计高可靠/可恢复 Agent”问题)#
在设计生产级 Agent Runtime 时,实现 Durable(中断恢复)的核心思想是:将状态落盘从“对话历史层”下沉到“执行运行时层”,把“恢复给模型看的对话上下文”升级为“恢复给调度器看的执行状态机”。
普通 Agent 只对 User、Assistant 和已完成的 Tool Result 做消息级落盘。如果模型并发调用了 3 个工具,前两个跑完、第三个跑到一半进程挂了,只恢复消息历史是没法自动接续的——要么丢失在途状态,要么得让模型重新推理猜进度。要实现通用的 Durable 机制,我们在架构上主要做四件事:
第一,将 Agent Loop 显式建模为可持久化的任务树与状态机。我们把运行时的调度状态和对话历史分离。除了 Append-only 的对话消息,还会持久化三类执行状态:用户的请求生命周期(Submission 与持久化排队队列,带 RequestId 防客户端重试重复触发)、每一轮模型生成的父任务(Generation Task),以及每个具体工具调用的子任务(Tool Task)。当模型决定调用多个工具时,框架会在同一个原子提交里,同时落盘模型回复、创建所有工具子任务,并把当前生成任务标记为 Waiting 状态、记录依赖的子任务 ID 列表。
第二,严格遵循“先落盘 Checkpoint,再产生外部副作用”的边界原则。每个工具调用内部还会细分阶段。在完成参数校验、真正执行外部 I/O 之前,必须先做一次 Commit,把状态推进到 Execute 阶段,并持久化最终参数和该工具的重放策略(Replay Policy)。这样崩溃重启后,调度器能精确区分一个工具是“还没开始调”还是“已经在执行中途被打断”。
第三,重启时由调度器按工具的幂等性策略分级恢复,而不是先调模型。进程恢复后,调度器先扫描未完成的任务树:已完成的工具直接复用落盘结果,绝不重跑;还没开始执行的工具正常启动;而卡在 Execute 阶段(中途中断)的工具,则检查其重放策略——如果是只读或声明为幂等安全(Replay Safe)的工具,直接用落盘参数自动重放;如果是发邮件、写操作等非安全工具,框架绝不盲目重试,而是将其标记为“执行中断、状态未知”写回工具结果,等所有工具汇总完后交还给 LLM(让模型决定是调查询接口确认状态,还是请求人工介入)。只有当所有依赖的工具子任务都收敛到终态后,调度器才会唤醒下一轮模型生成。
第四,明确框架与外部环境的边界。Durable Runtime 保证的是“控制流与已提交状态”的精确接续,而不是魔法般地回滚外部世界。对于外部 API 副作用,需要结合工具层的幂等键(Idempotency Key)去保障;而对于本地编码场景下的“代码改错了想撤回”,这属于工作区文件快照回退(Workspace History),和负责“未完成任务继续往前走”的 Durable 执行恢复是正交的两套机制,实际工程中通常会配合使用。2. 常见深挖追问#
- 追问 1:如果模型正在流式输出(Streaming)的时候进程挂了,怎么恢复?
- 回答:流式输出期间不会每个 Token 都写盘,而是对结构化运行状态做定期合并提交(让前端断线重连或新客户端加入时也能看到部分回复)。如果进程在流式生成中途彻底崩溃,由于无法恢复大模型服务端的 KV Cache 和内部推理现场,恢复时会将半截回复标记为中断,并在当前 Generation Task 的
request阶段直接重新发起该轮模型请求;如果底层模型厂商支持后台异步生成接口(Background/Async Response API),则可以在 Checkpoint 中保存远端请求句柄(Handle),重启后直接轮询拉取结果。
- 回答:流式输出期间不会每个 Token 都写盘,而是对结构化运行状态做定期合并提交(让前端断线重连或新客户端加入时也能看到部分回复)。如果进程在流式生成中途彻底崩溃,由于无法恢复大模型服务端的 KV Cache 和内部推理现场,恢复时会将半截回复标记为中断,并在当前 Generation Task 的
- 追问 2:把这么多中间状态和 Checkpoint 落盘,存储怎么设计才不会性能差或文件无限膨胀?
- 回答:核心是读写分离:将“只追加的不可变历史”与“高频突变的中间态”分开存放,并在任务收敛后清理中间态。
- SQLite 模式:分表存储。对话历史写入
entries表(只追加);任务状态写入tasks表(按 Task ID 原地更新最新状态与 Checkpoint,不保留废旧中间行)。 - JSONL 文件模式:采用主日志 + 辅助文件架构。
main.jsonl作为主提交日志(类似 WAL,只追加消息、任务最终记录和事务确认引用);未结束任务的中间 Checkpoint 写入独立的task-<id>.jsonl。一旦某个子任务执行完毕,最终记录写入main.jsonl后,对应的task-<id>.jsonl辅助文件即可直接整文件删除,既避免了修改大文件中间行,又实现了旧 Checkpoint 的自动垃圾回收。
- SQLite 模式:分表存储。对话历史写入
- 回答:核心是读写分离:将“只追加的不可变历史”与“高频突变的中间态”分开存放,并在任务收敛后清理中间态。
评论
欢迎交流,使用 GitHub 账号登录后即可留言。
在 GitHub Discussions 中查看讨论 ↗