
前两周一个朋友把他们的 Agent 服务代码发给我看让我帮忙 review。我扫了几百行最后发现问题不在某个工具函数也不在 prompt而在主干逻辑——一段典型的 while 循环把用户消息塞给 LLM解析出工具调用执行工具把结果拼回上下文再问 LLM直到它输出“完成”。我当时的评语很直接这套代码在 demo 阶段没问题但如果你想上生产、想抗并发、想排查线上问题我建议你把 while 循环拆掉。他愣了一下问我拆掉之后用什么来承接“不断思考下一步”的逻辑。这篇文章就是我对那个问题的完整回答也是我踩过不少坑之后沉淀下来的一套 Agent 架构思路。这套思路本质上做一件事把 Agent 从“一段会自我迭代的循环控制流”变成“一张事件驱动的状态转移表”。循环不再藏在一个个 while 关键字里而是显式地分布在状态、事件和处理器之间。听起来抽象但落地之后你会发现Agent 突然变得像普通后端服务一样可控、可观测、可恢复。这篇文章会讲清楚传统 while 循环到底把债务藏在了哪里再把拆掉循环后的事件驱动状态机架构一步步拆开最后把我在迁移过程中踩过的七个坑原原本本写出来。1. while 循环 Agent一套看着顺手但很难长大的样板很多人第一次写 Agent基本都会落成同一个结构。这不是偶然因为 ReAct 范式、各类开源框架、甚至大模型厂商的官方示例都在反复强化这个模式。它足够简单简单到用一个 while 就能装下“智能”的全部幻觉。1.1 循环里包的是什么观察-推理-行动三部曲传统的 while 循环 Agent 长成这个样子history [user_input] while True: response llm.chat(history) # 推理 action parse_action(response) # 解析动作 if action.type final_answer: # 退出条件 return action.content result execute_tool(action) # 执行动作 history.append(result) # 观察结果 # 然后回到循环顶部再来一轮每一步的语义非常直白让 LLM 看当前的上下文决定下一步要干什么调用工具把工具的输出作为新的观察写回历史然后再让 LLM 继续。整个过程像一个人在黑暗里摸象——摸一块思考一下再摸下一块。这种模式的优点是真的很多。第一理解成本趋近于零实习生看五分钟就能照葫芦画瓢。第二调试很直观print 一下 history 就能看到完整轨迹。第三对 LLM 的约束极少每一步都给足上下文模型可以自由探索。说白了这个模式的本质是“把控制权完全交给模型循环只是一个伺候模型的仆人”。所以我不反对 while 循环本身。它完全符合“快速验证一个想法的成本最低方案”。问题在于它把 Agent 的所有状态都隐式地放在了一个局部变量 history 里把“接下来要干什么”放在了一个隐式的控制流里。当业务复杂度超过某个临界点这些“隐式”会变成一笔笔需要偿还的债。1.2 循环的隐性成本状态、并发、恢复三大债我们一件一件说。第一笔债是状态不可见。while 循环里面Agent 当前在哪一步、已经调用过哪些工具、距离最终输出还差什么全部被压缩在 memory 这个列表里。程序外部完全感知不到。你想做一个“任务详情页”显示这个 Agent 跑到第几步了做不到。你想在中途插入一个人工审批也做不了因为循环根本不会停在一个可以被外部干预的位置上。第二笔债是并发能力极差。多用户同时用就得为每个用户开一个线程或进程每个线程各自跑一个 while 循环。这个模型下并发量跟线程数直接挂钩线程一多上下文切换开销、共享内存竞争、内存占用都上来了。更麻烦的是如果中途某个用户的 Agent 卡在等待外部工具响应这个线程就被白白占住什么活也干不了。这不叫并发这叫“并行地各自阻塞”。第三笔债是最致命的不可恢复。循环跑着跑着进程崩溃了你怎么办从头再来一遍。如果是运行 30 秒的一次性任务从头再来还能忍如果是已经运行了几个小时、已经调用了十几个外部 API、已经花掉了大量 token 的长任务重新跑一遍就是灾难。我之前见过一个数据采集型的 Agent跑到第 40 步时因为一个网络抖动崩了用户第二天发现任务卡死重新发起之后又重复消耗了上千次 LLM 调用。这种问题while 循环结构本身是解不了的因为它根本没有“检查点”的概念。这三个债放在一起本质上是同一个问题的三个侧面while 循环把 Agent 的控制流和状态全部私有化、隐藏化了。你把它当脚本用它什么问题都没有你把它当服务用它到处都是坑。2. 拆掉循环把 Agent 重新建模成事件驱动状态机拆掉 while 循环之后新的主干逻辑不再是“循环 局部变量”而是一个显式的状态机加一张事件表。这是架构层面的转向从“主动地一轮一轮往下转”变成“对每个到达的事件做一次状态转移”。2.1 核心转变从主动轮询到被动响应如果只挑一个最重要的变化我会说原来的 Agent 是主动的新架构里的 Agent 是被动的。while 循环像一条不断抽打的鞭子逼着 Agent 往前走事件驱动的状态机则像一套自动变速箱整车在什么状态、收到什么信号、该换到什么挡位全都写在明面上。一辆车的变速箱肯定不会在代码里写while (车速还能加) { 换挡(); }。它是由一堆机械结构和电控逻辑组成的状态网当前在几挡、转速多少、油门踩多深共同决定下一步怎么走。Agent 也是一样。它的每次动作都应该由“当前状态 到达事件”共同决定而不是由循环体里的一行行代码隐式驱动。落到代码上新架构的核心不再是 while而是一个分发器class Agent: def __init__(self, state_id, snapshot): self.state_id state_id # 当前状态比如 planning self.snapshot snapshot # 状态快照包含历史、上下文、预算等 def handle(self, event: Event) - AgentResult: handler self.handlers[self.state_id][event.type] return handler(self, event)每来一个事件Agent 就根据当前状态找到对应的事件处理器执行完逻辑之后要么迁移到新状态要么保持当前状态等待下一个事件。循环还在但它从“while True”变成了“谁都看不见的调度器内部循环”业务代码里不再有任何循环。2.2 状态与事件怎么定义一张能直接抄的转移表真正动手设计的时候最大的难点不是写代码而是定义状态和事件。定义得好整个系统清晰得像一张地图定义得不好状态机比 while 循环还乱。我常用的定义方法是把 Agent 的整个生命周期拆成几个稳定的阶段idle等待任务、planning制定或更新计划、await_tool等待工具结果、await_human等待人工审批或补充信息、finalized已完成、error出错终止。每个阶段就是一个状态状态之间通过事件迁移。下面这张表是我做过的一个“企业数据分析 Agent”的状态转移表可以当成一个可参考的起点当前状态到达事件执行动作下一状态idleuser_message初始化历史、生成初始计划planningplanningplan_ready发起第一个工具调用await_toolplanningplan_rejected向用户说明限制finalizedawait_tooltool_result判断结果是否关键、决定下一步planning / await_tool / finalizedawait_tooltool_timeout记录重试、再次发起调用await_tool / errorawait_toolhuman_approve继续执行待审批动作planningawait_toolhuman_reject中止当前动作、回到规划planningplanningbudget_exceeded保存现场、停止运行errorerroruser_message从快照恢复现场、重新规划planning这张表的价值不在于状态名称多么标准而在于它把 Agent 所有可能的行为路径都显式化出来了。你看一眼就知道工具超时了会去哪里、人工拒绝了会发生什么、预算超了会怎样。这就是把控制权从“模型自由发挥”收回到“系统明确约束”的过程。LLM 依然负责想“怎么办”但整个“什么时候想、想多久、想完能干什么”都由状态机说了算。状态快照则是另一个关键。每个 Agent 实例的 snapshot 通常包含当前的用户目标、历史消息列表、已经用掉的 token 预算、已经执行过的工具调用记录、当前正在等待的事件编号等。这个快照是一个普通数据结构可以序列化成 JSON、存进 MySQL、扔进 Redis随时可以拿出来恢复。快照就是整个状态机对外暴露的“记忆”。3. 实操落地从 while 循环迁移到状态机的完整过程理论讲再多不如一次真实的重构。这一节我会用一个极简例子演示一个“搜索资料并撰写摘要”的 Agent 如何从 while 循环版本迁移到状态机版本。3.1 原循环代码怎么拆逐步重构示例原始版本长这样async def run_search_agent(query): history [{role: user, content: query}] final_answer None while final_answer is None: resp await llm.chat(history) action parse_action(resp) if action.type search: results await search_web(action.query) history.append({role: tool, content: results}) elif action.type summarize: history.append({role: user, content: f请用以上搜索结果撰写摘要}) final_answer await llm.chat(history) elif action.type done: final_answer action.content else: raise ValueError(funknown action: {action.type}) return final_answer功能上它能跑但问题也很明显如果 search_web 要等五秒整个进程就在等如果中途进程挂了上一次的搜索结果全丢如果 LLM 连续十次都输出 search 而不收敛用户只能眼睁睁看着 token 烧没。这些痛点的根源都在“循环”两个字上。迁移的第一步是拆分职责。把 while 循环体里的“一次迭代”提取成一个独立函数这次迭代的输入是当前快照和当前动作结果输出是一个事件列表。第二步是把所有的 return 和 break 改写成事件。第三步是把工具调用从同步顺序调用改成异步事件投递。第四步是引入快照的持久化。重构后的核心代码大致是这个形态class SearchAgent: # 状态和事件类型 STATE_PLANNING planning STATE_AWAIT_TOOL await_tool STATE_FINALIZED finalized EVENT_PLAN_READY plan_ready EVENT_TOOL_RESULT tool_result EVENT_TOOL_TIMEOUT tool_timeout EVENT_BUDGET_EXCEEDED budget_exceeded def __init__(self, snapshot: dict): self.state snapshot.get(state, self.STATE_PLANNING) self.snapshot snapshot self.snapshot.setdefault(history, []) self.snapshot.setdefault(steps, 0) def handle(self, event: Event) - AgentResult: if self.state self.STATE_PLANNING: if event.type self.EVENT_PLAN_READY: # 根据计划发起工具调用并投递一个“等待工具结果”的事件 self.state self.STATE_AWAIT_TOOL return AgentResult( actioncall_tool, toolsearch_web, args{query: event.payload[query]}, on_resultself.EVENT_TOOL_RESULT, ) elif self.state self.STATE_AWAIT_TOOL: if event.type self.EVENT_TOOL_RESULT: results event.payload[data] self.snapshot[history].append({role: tool, content: results}) self.state self.STATE_PLANNING # 继续回到 planning等待下一次计划事件 return AgentResult(actionllm, prompt根据工具结果继续规划) if event.type self.EVENT_TOOL_TIMEOUT: self.snapshot[steps] 1 if self.snapshot[steps] 10 or self.snapshot[budget] 0: self.state self.STATE_FINALIZED return AgentResult(actionstop, reasonbudget_exceeded) # 否则重试同一工具 return AgentResult(actioncall_tool, toolsearch_web, argsevent.payload.get(args)) return AgentResult(actionidle)你还是能在这个状态机里找到“循环”的影子planning 收到 plan_ready 会跳去 await_toolawait_tool 收到 tool_result 会跳回 planning。但这个循环是显式写在状态转移表里的每一步都是由外部事件驱动的。这个循环虽然逻辑上存在但它在代码结构上被拆掉了取而代之的是状态和事件。3.2 排队与并发事件泵、消息队列和 Agent 实例拆掉 while 循环之后的并发模型完全变了。原来是一个线程跑一个循环现在变成了一个事件泵 多个 Agent 实例的模式。在单机部署里事件泵可以简单到一个进程内的队列async def event_loop(queue): while True: event await queue.get() agent load_agent(event.agent_id) # 从存储加载 Agent 快照 result agent.handle(event) # 执行状态转移 save_agent(agent) # 持久化新快照 for new_event in result.outbox: await queue.put(new_event) # 投递后续事件注意这个 while 跟业务无关它是一个基础设施层的消费循环跟 Agent 架构解耦了。你把它替换成 RabbitMQ worker、Kafka consumer、SQS listener完全不影响业务代码。这就是拆掉循环的关键收益之一Agent 的执行模型不再绑定在某个线程栈上而是绑定在可路由的事件流上。并发量上来之后你会自然地走向分布式版本事件进 MQ多个 worker 同时消费每个事件里带一个 agent_id同一个 agent_id 的事件必须路由到同一个 worker不同的 agent_id 可以并行处理。这个约束很关键状态机一次只能处理一个事件同一 Agent 实例的事件如果被两个线程并发处理状态必然打架。所以正确的做法不是让 Agent 内部支持并发而是让“不同 Agent 并行、同一 Agent 串行”。3.3 记忆与恢复状态快照、检查点与事件重放状态机架构还有一个 while 循环望尘莫及的能力任意时刻保存现场、随时恢复继续跑。我的做法是给每个 Agent 实例维护一个持续追加的事件日志同时每隔 N 个事件生成一个快照。快照是“截至某个时间点的完整状态”事件日志是“快照之后发生的增量”。重启之后先读快照再重放增量事件就能恢复到一个精确的位置。这个机制一旦跑通Agent 的可靠性从“看运气”变成“看设计”。我做系统设计时凡是涉及长时间运行、外部工具调用、大额 token 消耗的 Agent都会显式加一层周期快照。之前那个数据采集 Agent崩溃恢复就变成了打开任务详情页点一下“恢复”系统自动从上次快照继续不用再重新消费 API。事件日志还有一个附加价值它是训练和分析的数据金矿。日志里记录的是每一步决策背后的输入输出、工具返回、异常事件。想分析“为什么 Agent 在这个 case 上表现差”直接查事件日志就行不用再靠用户截图描述。4. 踩坑实录拆掉循环之后我遇到过的 7 个典型问题拆掉 while 循环绝对不是没有代价。状态机的写法比循环啰嗦至少三倍而且它把很多以前“靠循环自动地串起来”的东西变成了“靠代码显式地连起来”漏掉一个事件类型Agent 就可能卡死在某个状态。这里把我踩过的最典型的七个坑写出来当成速查表供你参考。问题现象根本原因排查思路预防方案Agent 卡死既不调用工具也不输出事件类型没有对应的处理器状态机不知道自己该干什么检查状态转移表看当前状态对当前事件是否有 handler加一个兜底 handler遇到未知事件统一进入 error 状态崩溃恢复后状态错乱快照保存和事件落库不是原子的查快照写入时间和事件日志时间戳用事务把快照和事件日志一起提交或引入版本号同一个 Agent 实例的事件乱序消费者并发处理了同一个 agent_id 的事件看队列的消费模式是否按 key 路由强制按 agent_id 哈希到固定消费者工具回调超时之后重复执行客户端超时但服务端实际执行了回调又重发检查工具侧幂等性看重复执行返回的结果在事件里加 request_id状态机记录已执行过的 request_idtoken 预算超了但 Agent 还在转状态机只判断“工具是否超时”没有触发预算事件检查预算是从哪个事件路径检查的在每一个事件处理器的入口统一扣减预算预算耗尽直接转移人工审批环节卡死一整天await_human 状态没有超时事件看人工审批的事件是否真的投递到了审批系统await_human 挂一个超时计时器超时自动回滚或提醒状态 schema 升级后旧的快照读不了快照缺少版本号老数据格式不兼容查快照里是否带 schema_version快照加版本号写 migration 兼容旧版本七条问题里我实际花费时间最多的反而是第二条。快照和事件日志的原子性一度让我恢复出来的 Agent 回到一个不存在的状态。后来我把“保存快照”和“追加事件日志”放进了同一个数据库事务里问题才彻底消失。原则就一句话状态转移必须和状态记录同时提交不能先改状态再补日志也不能先记日志再改状态。另一个让我记忆深刻的是第三条。我在生产环境跑了一段时间之后发现偶发出现“Agent 明明上下文中已经有工具结果下一轮却还在等那个结果”的情况。排查半天发现原因是两个 worker 同时消费了同一个 agent_id 的两个事件后一个事件处理完之后覆盖了前一个事件的状态。加了 agent_id 的一致性哈希路由之后这个问题再没出现过。5. 边界判断什么场景才值得拆掉 while 循环如果方案只是“状态机更好”那我这篇文章就没有太大价值了。现实情况恰恰相反——有一类场景while 循环依然是最优解硬套状态机反而自找麻烦。这一节我把判断标准写清楚。5.1 其实可以不拆的三种情况第一种是纯教学和验证思路的 demo。目标只是“看 LLM 能不能自主调用工具完成任务”这时候循环的直观性胜过一切。你不需要它可恢复也不需要它抗并发就更不用拆。第二种是一次性脚本任务。跑完之后就结束任务生命周期就在几秒到几十秒之间出错了重跑整个脚本即可。给这种任务专门设计状态快照属于过度设计。第三种是单步工具链。也就是 Agent 的路径是完全确定的先调用 A再调用 B再调用 C最后生成结论。这种场景本质是 pipeline用一个顺序列表保存步骤就够了状态机的灵活性反而是多余的。在这三种场景里强行拆掉 while 循环只会让代码变长、变绕、变量增多收益几乎为零。所以我给朋友的建议从来不是“所有 Agent 都必须状态机”而是“先看清楚你的 Agent 是否会出现不可控的循环、中断、并发、长时运行这四个信号”。5.2 我的选择标准四个问题帮你快速决策我在评估一个新 Agent 项目要不要拆 while 循环时会问自己四个问题。每个问题只要命中一个我就会在架构设计里优先考虑事件驱动状态机。第一个问题这个 Agent 会运行超过一分钟吗超过一分钟意味着中途崩溃的概率不是零而一旦中途崩溃恢复成本就成了核心指标。第二个问题这个 Agent 会在运行过程中等待外部资源吗等待人工审批、等待第三方 API、等待下游工人执行操作这些都是“外部事件”while 循环天然处理不好。第三个问题会不会有多个用户同时使用同一个 Agent 服务只要涉及“多人使用”串行循环就一定会被并发问题拖垮。第四个问题我们能不能接受“从头再来”如果答案是不能那说明你需要检查点而检查点恰恰是循环结构最不擅长提供的东西。如果四个问题全是“否”那我还挺乐意在你的代码里看到一个干净的 while 循环。如果有一到两个答案是“是”我的经验是提前拆不要等出线上事故再拆——出事故的时候不仅是代码要改用户信任、任务数据、团队心态都有折损。把 while 循环拆成事件驱动状态机本质上不是一种“更高级”的做法而是一种“更符合服务化需求”的做法。Agent 在 demo 阶段可以是一个脚本一旦它要承载真实用户、真实任务、真实成本它就必须像普通后端服务一样拥有状态管理、事件路由、故障恢复、并发隔离的能力。这跟是不是用某个框架无关跟用不用 state machine 库也无关核心在于你是否愿意把 Agent 的控制流和状态从“隐藏”变成“显式”。我个人的体会是状态机迁移最大的收获不是代码架构本身而是让我终于能在任何时刻回答三个问题这个 Agent 现在在哪个状态它正在等什么事件它已经做了什么如果哪天你的 Agent 出了问题、而你发现你答不上来这三个问题那大概率就是 while 循环该拆的时候了。迁移启动前还有一个值得记住的窍门第一版不要一次性把所有状态都定义完。把 while 循环里已有的分支整理成状态和事件先跑起来再逐步扩展你会发现这套架构的扩展性远比你想象的宽松。