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. 落地实操从零搭一个可恢复的 Agent4.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 事件顺序错乱怎么办现象状态转移报错说收到了seq7但当前只到seq5。原因通常是消息队列乱序或重复投递。我的处理是编排层严格按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 循环挺好别过度设计。最后分享一个我在实践中反复验证的小技巧先把状态转移函数写成纯函数并用单元测试覆盖再考虑持久化和分布式。很多人一上来就搭消息队列、搭存储结果核心逻辑没测清楚出了问题根本不知道是架构的锅还是逻辑的锅。我这次重构就是先把转移函数单独抽出来测了两天确认逻辑无误后才接的持久化层整个过程顺畅很多。架构是给逻辑服务的别让架构的复杂度掩盖了逻辑本身的问题。