拆掉Agent的while循环:基于Durable Execution的可恢复架构实践
1. 从一次线上事故说起:为什么我要拆掉 Agent 的 while 循环
去年年底我接手了一个内部工具型 Agent 项目,功能不复杂:接收用户任务,调用几个外部 API,把结果整理后返回。最初的原型用了一个非常经典的写法——一个while(true)主循环,里面按状态机推进:思考、调工具、观察结果、再思考,直到模型输出终止信号才break。这套写法在 demo 阶段跑得飞快,本地测试几十轮对话都没出过问题。
上线第三天,事故来了。一个用户提交了一个需要连续调用七八次外部接口的任务,中途某个接口响应变慢,整个请求在服务端挂了将近四分钟。更糟的是,进程重启后这个任务直接消失了——因为循环状态全在内存里,重启即归零。用户那边看到的是转圈转到超时,我们这边看到的是日志断在第三次工具调用之后,没有任何后续。那天晚上我盯着那段while循环看了很久,突然意识到一个问题:我把 Agent 的执行逻辑和进程的生命周期绑死在一起了。
这个认知直接催生了后来这次重构。我把主循环彻底拆掉,换成了基于Durable Execution(持久化执行)思路的架构,把 Agent 的每一步都变成可持久化、可恢复、可独立调度的事件。标题里说的"你可能没想到",其实不是指这个思路有多新奇——工作流引擎领域早就这么干了——而是指大部分 Agent 开发者默认 while 循环是唯一解,从来没想过它其实是个可以拆掉的实现细节。
这篇文章适合两类人看:一类是正在写 Agent、被长任务、断点恢复、并发控制折磨的开发者;另一类是想理解 Agent 架构演进方向、但被各种框架名词绕晕的工程师。我会把整个拆解过程、背后的取舍、踩过的坑、以及可以直接抄的落地步骤都摊开讲。核心关键词就几个:Agent、while 循环、架构、Durable Execution、微服务,全文围绕它们展开,不跑题。
先说结论,免得你看到一半觉得我在绕弯子:while 循环本身没错,错的是把"循环"当成"状态"。循环是控制流,状态应该活在循环之外、活在持久化存储里。想通这一点,整个架构就顺了。
2. 拆解 while 循环:Agent 执行模型到底在循环什么
2.1 一个典型 Agent 主循环长什么样
先把我重构前的那段代码抽象出来,方便后面逐层拆。典型的 Agent 主循环大致是这样:
def run_agent(task): state = init_state(task) while True: thought = llm_think(state) # 模型推理 if thought.is_final: return thought.answer action = thought.action # 决定调用哪个工具 result = call_tool(action) # 执行工具 state = update_state(state, thought, result) # 更新状态这段代码里其实藏着四件事,很多人写的时候没意识到它们是独立的:
- 控制流:
while负责"什么时候继续、什么时候停"。 - 状态:
state变量承载了到目前为止的全部上下文。 - 副作用:
call_tool会真的去调外部接口,可能改数据、可能花钱。 - 生命周期:整个循环活在一个函数调用栈里,函数返回,一切结束。
问题就出在最后一点。当这四件事被塞进同一个函数栈帧时,进程的生死就等于任务的生死。进程崩了,状态没了,副作用可能已经发生了一半,控制流却再也接不回去。这就是我那次事故的根因。
2.2 while 循环的三个隐性成本
我把当时踩的坑整理成三条,都是真金白银换来的:
第一,状态易失。内存里的state一旦进程重启就蒸发。有人会说那我每轮存一次数据库不就行了?可以,但你得自己处理"存到一半崩了"的一致性问题,还得处理并发写、版本冲突。这些活儿在循环里手写,代码会迅速腐化成一坨。
第二,长任务无法优雅中断。一个任务跑十分钟,用户想取消怎么办?在while里加个if cancelled: break看似简单,但如果此刻正卡在call_tool的网络等待里,你根本 break 不出来。要么等它超时,要么强杀进程——强杀又回到状态丢失问题。
第三,横向扩展困难。循环跑在单进程里,想加机器只能靠多开进程,每个进程各跑各的循环,任务之间无法协同。想做成微服务?循环和业务逻辑耦合在一起,拆都拆不动。
注意:这三条不是"while 循环的错",而是"把状态、副作用、生命周期全塞进循环"的错。循环只是背了锅。
2.3 换个视角:Agent 本质是一串可持久化的事件
想通之后,我把 Agent 的执行重新建模。不再问"循环下一轮该干嘛",而是问"到目前为止发生了哪些事件,基于这些事件下一步该干嘛"。
一次 Agent 任务被拆成若干步骤(Step),每个步骤是一个纯函数式的状态转移:
Step = (当前状态, 输入事件) -> (新状态, 待执行的副作用列表)关键点在于:状态转移是纯的,副作用是显式声明的。状态转移算完立刻持久化,副作用交给独立的执行器去跑,跑完把结果作为新事件写回来,再触发下一次状态转移。这样一来:
- 进程崩了?从持久化存储里读出最后一个状态,接着算。
- 要取消?标记一个取消事件,下次状态转移时自然处理。
- 要扩展?状态转移是无状态的,随便加机器;副作用执行器也可以独立扩容。
这就是Durable Execution的核心思想:把"执行"变成"可重放的事件序列"。Temporal、Cadence 这类工作流引擎玩的就是这套,只不过 Agent 圈子里大家习惯了自己写循环,反而忽略了这套成熟范式。
3. 新架构设计:把循环拆成三层
3.1 整体分层:编排层、执行层、状态层
拆完之后我的架构分成三层,对应到微服务的思路:
| 层级 | 职责 | 是否无状态 | 可扩展性 |
|---|---|---|---|
| 编排层 Orchestrator | 驱动状态转移,决定下一步 | 是 | 水平扩展 |
| 执行层 Executor | 执行具体副作用(调工具、调模型) | 是 | 按工具类型分组扩展 |
| 状态层 State Store | 持久化状态与事件日志 | 否(有状态) | 靠存储本身扩展 |
编排层就是原来while循环的"大脑",但它不再自己持有状态,每次被事件唤醒,读状态、算下一步、写状态、派发副作用,然后退出。执行层是"手脚",只负责把副作用干完,把结果写成事件。状态层是"记忆",用事件日志(Event Log)记录一切。
这个分层最大的好处是每一层都能独立替换。模型换了?只改编排层里的推理逻辑。工具换了?只改执行层。存储扛不住了?换状态层的实现。原来那个铁板一块的while循环,现在变成了三个可以各自演进的部件。
3.2 为什么选事件日志而不是直接存状态快照
有人会问:直接每轮把state存成 JSON 不就行了,搞什么事件日志?我一开始也这么想,后来发现事件日志有三个快照给不了的好处:
可重放。出了 bug,把事件日志从头重放一遍,能精确复现当时的执行路径。快照只能看到"某一刻的状态",看不到"怎么走到这一步的"。
可审计。Agent 调了什么工具、花了多少 token、哪一步决策的,全在事件流里。这对排查"Agent 为什么乱调工具"这类问题至关重要。
可增量。事件是追加写的,天然适合高并发;快照是覆盖写,容易冲突。当然代价是读的时候要重放,所以实践中我做了个折中:事件日志 + 定期快照,重放时从最近快照开始,不用从头来。
3.3 状态转移函数怎么写才不腐化
状态转移是整个架构的心脏,写不好照样烂。我给自己定了三条规矩:
- 纯函数:给定相同状态和事件,输出必须完全一致。不许在里面读时钟、读随机数、读全局变量。需要这些就作为事件传进来。
- 副作用声明式:转移函数不直接调工具,而是返回一个"待执行副作用列表",由执行层去跑。
- 幂等:同一个事件重放多次,结果不变。这是断点恢复能正确工作的前提。
举个具体的,原来call_tool直接执行,现在变成:
def transition(state, event): if event.type == "tool_result": state = apply_result(state, event) # 决定下一步 next_action = decide_next(state) if next_action.is_final: return state, [EmitFinal(next_action.answer)] return state, [CallTool(next_action.tool, next_action.args)]注意CallTool只是个描述,不是真的调用。执行层拿到它才去跑,跑完把tool_result事件写回来。这样即使执行层崩了,编排层状态也是干净的,重放时重新派发即可。
4. 落地实操:从零搭一个可恢复的 Agent
4.1 状态存储选型与表结构设计
状态层我选了 PostgreSQL,理由很朴素:团队熟、事务可靠、JSONB 够用。表结构就两张:
CREATE TABLE agent_run ( run_id UUID PRIMARY KEY, status TEXT NOT NULL, -- running / completed / failed / cancelled current_state JSONB NOT NULL, -- 最新状态快照 updated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE agent_event ( event_id BIGSERIAL PRIMARY KEY, run_id UUID NOT NULL, seq INT NOT NULL, -- 单调递增序号 event_type TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (run_id, seq) );seq这个单调序号是关键,它保证了事件顺序,也方便做乐观并发控制——写事件时带上期望的seq,不匹配就说明有并发,重试即可。
实操心得:
current_state别存太大。我一开始把完整对话历史都塞进去,单行几十 KB,更新频繁时数据库压力很大。后来改成只存"决策必需的最小状态",完整历史放事件日志里按需重放。
4.2 编排层的唤醒机制:别用轮询
编排层怎么知道有新事件了?最土的办法是轮询数据库,但延迟高、压力大。我用了两条路:
- 同步路径:执行层写完事件后,直接通过消息队列(我用的是 Redis Stream,轻量够用)发一条通知,编排层消费后立即处理。
- 兜底路径:定时扫描
status = 'running'且updated_at超过阈值(比如 30 秒)的 run,重新触发。防止消息丢失导致任务卡死。
这两条路配合,既保证了正常情况下的低延迟,又保证了异常情况下的自愈。实测下来,99% 的任务在事件写入后 100ms 内被推进,剩下 1% 靠兜底扫描捞回来。
4.3 执行层的幂等设计:副作用只做一次
执行层最怕的是"副作用重复执行"。比如调支付接口,重试一次就多扣一次钱。我的做法是给每个副作用分配一个幂等键(Idempotency Key),通常是run_id + seq + action_hash。执行前先查一张executed_action表,有记录就直接返回上次结果,没有才真执行,执行完立刻落库。
def execute(action, idem_key): cached = db.get_executed(idem_key) if cached: return cached.result result = do_real_call(action) db.save_executed(idem_key, result) # 与真实调用在同一事务语义下 return result这里有个坑:do_real_call和save_executed之间如果崩了,下次重试还是会重复调用。所以对于真正敏感的副作用,得要求被调方支持幂等键,把幂等性下沉到对方。这个没法完全靠本地解决,得在选工具时就考虑进去。
4.4 断点恢复的完整流程演示
假设一个任务跑到第 5 步,执行层正在调一个慢接口时进程崩了。恢复流程是这样的:
- 编排层兜底扫描发现该 run 超时未更新,重新加载。
- 从
agent_run读出current_state,从agent_event读出seq <= 5的事件。 - 重放事件到状态(如果
current_state已是最新,这步可跳过)。 - 发现第 5 步的副作用没有对应的
tool_result事件,重新派发CallTool。 - 执行层用幂等键检查,若上次其实已执行成功,直接返回缓存结果;否则真执行。
- 写入
tool_result事件,任务继续。
整个过程对用户无感,任务从第 5 步接着跑,不会从头再来,也不会重复扣费。这就是拆掉 while 循环换来的最大收益。
5. 常见问题与排查技巧实录
5.1 事件顺序错乱怎么办
现象:状态转移报错,说收到了seq=7但当前只到seq=5。原因通常是消息队列乱序或重复投递。我的处理是编排层严格按seq递增处理,收到跳跃的seq就暂存,等中间的事件到齐再处理;收到重复的seq直接丢弃。数据库的UNIQUE (run_id, seq)约束是最后一道防线,重复写入会直接报错,逼着你处理。
5.2 任务卡在 running 状态不动
排查顺序我总结成一张表:
| 排查点 | 检查方法 | 常见原因 |
|---|---|---|
| 消息是否投递 | 看队列积压 | 队列挂了或消费者挂了 |
| 编排层是否消费 | 看编排层日志 | 消费逻辑抛异常被吞 |
| 状态转移是否死循环 | 看事件 seq 是否疯涨 | 转移函数逻辑错误 |
| 执行层是否卡住 | 看执行层超时日志 | 外部接口无响应 |
我遇到最多的是第三种:转移函数里decide_next逻辑写错,导致一直派发同一个工具调用,事件无限增长。后来加了个保护——单个 run 的事件数超过阈值(比如 200)就强制标记失败,防止无限烧钱。
5.3 状态快照和事件日志不一致
这是最隐蔽的问题。表现是重放出来的状态和快照对不上。根因通常是快照写入和事件写入不在同一事务里。我的修复方案是把"写事件 + 更新快照"放进同一个数据库事务,要么都成功要么都失败。虽然牺牲了一点性能,但换来了一致性,值。
避坑技巧:定期跑一个对账任务,随机抽样 run,从事件日志重放状态,和快照比对。不一致就告警。这个对账任务帮我抓到过两次隐蔽的并发 bug。
5.4 微服务拆分后调试变难了
拆成三层后,一个请求跨三个服务,本地调试确实麻烦。我的应对是给每个 run 打一个 trace_id,贯穿三层日志,再用一个轻量的链路追踪工具串起来。另外本地开发时允许"单体模式"——三层跑在一个进程里,通过接口切换。这样开发时享受单体的便利,上线时享受分布式的扩展性。
6. 这套架构适合谁,不适合谁
拆掉 while 循环不是银弹,我得说清楚边界。适合的场景:任务执行时间长(分钟级以上)、副作用昂贵不能重复、需要断点恢复、需要横向扩展、需要审计追溯。不适合的场景:纯问答型 Agent(一问一答,没有多步循环)、原型验证阶段(上这套太重)、任务极短且无副作用(拆了纯属自找麻烦)。
我个人的判断标准很简单:如果你的 Agent 任务失败一次,用户会心疼,那就值得上 Durable Execution。如果失败了重跑一遍毫无成本,那 while 循环挺好,别过度设计。
最后分享一个我在实践中反复验证的小技巧:先把状态转移函数写成纯函数并用单元测试覆盖,再考虑持久化和分布式。很多人一上来就搭消息队列、搭存储,结果核心逻辑没测清楚,出了问题根本不知道是架构的锅还是逻辑的锅。我这次重构就是先把转移函数单独抽出来测了两天,确认逻辑无误后才接的持久化层,整个过程顺畅很多。架构是给逻辑服务的,别让架构的复杂度掩盖了逻辑本身的问题。