你随便打开一个 Agent 框架的源码,十有八九会在核心位置看到一行while True。我之前的 Agent 项目也一样,每个 Agent 都是一个无限循环:看环境、让大模型想、执行动作、再看环境。这套经典的“感知—思考—行动”循环用了很久,直到项目开始扛并发,我才意识到这个 while 循环可能是整个架构里最碍事的部分。于是我做了一个有点反常规的决定——把 Agent 的 while 循环拆掉,换成一种以事件驱动和状态机为核心的架构。
这篇文章里,我会把拆循环的思路、替换方案、关键代码结构、实测对比数据和迁移时踩过的坑完整讲一遍。适合正在做 Agent 架构选型、被并发和稳定性问题折腾过、或者对“Agent 除了循环还能怎么跑”感到好奇的人。用一句话概括核心思路:Agent 不再自己决定下一轮做什么,而是把每一轮的感知、思考、行动拆成离散事件,交给外部调度器按需推进。
1. 传统 Agent 为什么都长成 while 循环
1.1 一个 while True 撑起的“感知—思考—行动”
先看最基本的形态。一个最简单的 Agent 通常长这样:
while True: observation = get_observation(env) # 感知 action = llm.think(observation) # 思考 result = execute(action) # 行动 if goal_achieved(result): break这种“超级循环”的写法几乎是所有 Agent 教程、Demo、框架初版的标准形态。为什么大家都这么写?因为大模型交互本质上是一个“发请求、等响应”的模型:你给模型一个 prompt,它返回一段内容,你根据内容再决定下一步。最直觉的实现方式就是一个循环——每次都重新看世界、重新思考、重新行动,直到任务结束才 break。
这个模式在单个任务、单用户请求的场景下非常好用。比如你写个脚本让 Agent 帮你搜资料、写报告,一个 while 循环把步骤串起来,代码很简单,逻辑也清楚,出了问题直接在循环里打断点调试。我自己最早做 Agent 的时候也是这么干的,还给循环里加了最大轮数限制,防止模型一直不退出。
1.2 while 循环里藏着三个不太好发现的问题
随着项目长大了,这个 while 循环开始露出麻烦。我总结了三个最明显的问题,每个都在实际项目里让我吃到苦头。
第一是空转。Agent 没有新任务的时候,循环不能直接停,因为你需要它“待命”。那怎么办?最常见的就是让循环里 sleep 一段时间,或者反复去拉消息队列看有没有活。10 个 Agent 这么跑还好,100 个 Agent 同时挂在那空转,CPU 占用和日志噪音都很难看。这本质上是用“轮询”模拟“事件通知”,代价是浪费。
第二是阻塞。循环一旦进去了,Agent 的所有流程都在这个循环里。如果某一个工具调用是同步等待的(比如等另一个服务的 HTTP 响应),整个 Agent 就卡在那。你想在执行中途插一条消息进去?做不到。想让它停一下去处理一个更紧急的任务?也不行。外部事件对循环里的 Agent 来说,只能排队,而且排队的优先级还不可控。
第三是状态全在内存里。循环里维护的所有上下文、中间结果、已执行步骤,都堆在进程内存里。这带来两个麻烦:一是没法轻易暂停和恢复,一旦进程重启,整个 Agent 状态就丢了;二是没法把一个 Agent 的运行状态从一台机器迁到另一台机器,做不了负载均衡和故障转移。对于单个 Agent 这不是问题,但对服务化、平台化的场景就是致命伤。
1.3 为什么“Agent 扛并发”成为热词
社区里有个很实际的提问:“AI Agent 怎么扛并发”。我理解这个问题的根源就在 while 循环上。因为一个 Agent 实例就是一个循环,你要扛并发就只能开很多个循环:一个 Agent 一个线程,或者一个协程。线程多了,内存先吃紧;协程多了,事件循环被阻塞,延迟抖动。更麻烦的是,Agent 之间如果要协作,循环之间怎么通信?那又得引入消息队列,消息队列再回调进每个 Agent 的轮询循环,复杂度螺旋上升。
所以,与其在这个 while 循环外面加各种补丁,不如直接把循环拆掉,让“循环”这件事变成一个更底层的、可被调度和分发的东西。到这儿,为什么拆循环就很清楚了。下面讲我具体替换成的方案。
2. 拆掉 while 循环:我用“事件驱动 + 状态机”接替它
2.1 核心思路:控制权从 Agent 手里收走
拆循环的本质不是删掉“重复执行”这个动作,而是把“下一步干什么”的决策权从 Agent 内部搬到外部。传统循环里,Agent 自己决定:“我再观察一次、再让模型想一次”。拆掉之后,Agent 不再主动跑任何循环,它只等待消息:收到用户消息、收到工具返回、收到定时器触发,然后针对每种消息执行一个对应的处理函数。
听起来好像只是改了个写法,但这里有一个认知转变:Agent 从“一个主动执行的进程”变成了“一个被事件驱动的状态机”。这个转变带来的直接好处有三个。第一,没有任务时 Agent 的消耗趋近于零,不需要空转轮询;第二,外部事件可以随时介入,Agent 不需要完成当前轮次才能响应新消息;第三,整个系统的调度从“线性执行”变成了“消息分发”,并发能力不再受单个 Agent 内部循环的牵制。
2.2 状态机:把 Agent 的生命周期显式化
一个 Agent 实例的状态,我建模为这样几个状态:
class AgentState(str, Enum): IDLE = "idle" # 空闲,等待新任务 WAITING_INPUT = "waiting_input" # 已收到任务,等待用户补充 THINKING = "thinking" # 模型推理中 ACTING = "acting" # 执行工具调用 WAITING_TOOL = "waiting_tool" # 等待工具返回 DONE = "done" # 任务完成和 while 循环版最大的区别是:状态是显式的,而且状态机可以在任意一个节点上停下来等待外部事件。比如模型正在推理,这个 Agent 就是 THINKING,不会像循环那样往下一个动作冲;工具调用还没返回,它就在 WAITING_TOOL,这时候其他 Agent 的消息可以先处理。
每次状态转移都由一个事件触发,转移完成后,Agent 把自己新的状态和上下文持久化下来,然后继续等待下一条事件。说白了,Agent 控制权的粒度从“一整段循环”变成了“一条消息的处理”。如果你之前读过一些游戏引擎或者 GUI 框架的源码,会发现这个模型跟 UI 事件循环非常像:系统不主动跑你整个游戏逻辑,而是你有输入事件、定时事件、网络事件来了才触发对应的渲染和逻辑更新。
2.3 用事件总线连接所有 Agent
光有状态机还不够,Agent 之间、Agent 和外部服务之间怎么通信?我引入了事件总线。这里我用的是 Redis Stream,也可以用 Kafka、RabbitMQ,甚至一张数据库任务表,完全看场景。事件总线承担三个角色:
- 投递外部输入:用户消息、webhook、定时任务,都写成事件发布到对应 Agent 的队列。
- 承接工具回调:Agent 调一个工具,工具完成后不直接改 Agent 内部变量,而是把结果发布为
tool_result事件。 - Agent 之间协作:Agent A 完成一部分工作后,发布一个
task_handoff事件,Agent B 订阅后开始工作。
你可能会问:这不就是把原来的 while 循环里的流程拆散了吗?是的,但拆散之后,每个环节都是独立的消息,可以独立排队、重试、追踪。而且最关键的是:没有任何一个 Agent 在空转。没有事件就零消耗,这是和 while 循环最根本的差别。
3. 事件驱动 Agent 的具体架构:任务队列 + 状态快照 + 调度器
3.1 任务队列:每个 Agent 一个“专属邮箱”
架构上我按 Agent 实例拆分队列。每个 Agent 有一个agent_id,对应一个 Redis Stream key,或者一个 Kafka topic。外部系统要跟某个 Agent 说话,就是往它的邮箱里投递一条事件消息。这样设计有几个好处:
- 隔离性好:一个 Agent 的消息积压不会挤爆其他 Agent。
- 天然支持并发:多个 Agent 的不同队列可以并行消费。
- 消息可回溯:队列里的事件天然就是日志,出问题可以直接翻记录重放。
消息统一是一种结构化的 envelope。我用 JSON 序列化,字段大概长这样:
{ "event_id": "evt_12345", "agent_id": "agent_alpha", "event_type": "tool_result", "timestamp": 1734576000, "payload": { "task_id": "task_88", "tool_name": "search_web", "result": "……" } }这里event_type是关键,它决定了状态机下一步往哪儿走。payload是这次事件的实际内容,需要和 Agent 当前状态里的上下文拼起来才能推进。我在后来还加了trace_id字段,把一次完整任务的多个事件串成一条链路,排查问题的时候能顺着 trace 拉出完整时间线。
3.2 状态快照:Agent 记忆的“存档文件”
拆掉 while 循环后,Agent 不能靠进程内存记事了,因为下一次处理事件时进程可能已经换了。所以 Agent 的上下文必须被序列化成状态快照,随每个事件处理周期落盘。
一个完整的状态快照我定义为:
{ "agent_id": "agent_alpha", "state": "waiting_tool", "goal": "调研三篇关于事件驱动架构的论文并总结", "history": ["……"], "pending_tool_call": {"tool": "search_web", "args": {}}, "steps_done": 2, "max_steps": 20, "metadata": {"created_at": "..."} }状态快照解决了三个问题。一是暂停恢复:Agent 进程挂了没关系,从队列里把最后一个快照拉出来继续。二是横向迁移:快照存在 Redis 或数据库里,任何一个 worker 都可以加载它继续执行,Agent 从此不再绑定某台机器。三是审计:你能清楚看到 Agent 每一步的状态,不再是一锅粥。如果你把“Agent 记忆”当成一个工程问题来做,状态快照就是一个可落地的存档方案。
当然,快照也有成本,后面我会专门讲序列化和存储的坑。
3.3 调度器:驱动一切的无状态组件
现在回到“循环”这个概念。整个系统并不是没有循环了,而是循环被收敛到了一个非常薄的调度器里。调度器是唯一真正在“跑循环”的东西,但它跑的是一个很轻的循环:不停从队列里取事件,加载对应 Agent 的状态快照,根据事件类型调用 handler,handler 返回新的状态和事件,再把状态写回存储、把输出事件发布到其他队列。
调度器本身要做到完全无状态,这样才容易扩缩容。这里我放一个精简的伪代码,帮你看清它的职责边界:
def process_event(event): snapshot = state_store.load(event["agent_id"]) handler = registry.get_handler(event["event_type"]) new_snapshot, out_events = handler(snapshot, event["payload"]) state_store.save(event["agent_id"], new_snapshot) for out_event in out_events: event_bus.publish(out_event)整个系统里,调度器不持有任何 Agent 的运行状态,它只是把事件搬运到对应的处理器,处理器干完活把结果存回去。这个架构里的启发是:你把 Agent 跑在哪台机器上这件事彻底解耦了,任何一台有空余算力的机器都可以成为暂时的“宿主”。如果你研究过 harness 和 agent 的区别,会发现调度器承担的其实就是 harness 的控制逻辑:它负责流程推进和工具调用编排,而状态机负责记录 Agent 当前在哪个环节,两者配合得刚刚好。
4. 迁移实践:我从 while 循环改成事件驱动的四个步骤
讲完架构,说说实际的迁移过程。如果你的代码现在还是一个 while 循环,可以参考我这套步骤,风险控制得比较稳。
4.1 第一步:把循环体里的每个阶段拆成独立函数
先不要动行为,只改结构。原来的循环体:
while True: obs = observe() action = think(obs) result = act(action) if check_done(result): break改成下面这种函数集合:
def handle_observe(obs): ... def handle_think_action(obs): ... def handle_act_result(action_result): ...这一步的目标是让每个阶段都能独立调用,为后面的事件化铺路。这一步不会有行为变化,可以放心地在测试环境里跑一遍回归。
4.2 第二步:用事件类型替换循环分支
原来循环体内的条件分支,改成按事件类型路由。以 Python 为例,我建议用一个模块级的注册表,而不是一堆 if else,因为 Agent 的 handler 数量会越来越多:
HANDLERS = {} def on_event(event_type): def decorator(fn): HANDLERS[event_type] = fn return fn return decorator @on_event("new_task") def start_task(snapshot, payload): ... @on_event("tool_result") def continue_after_tool(snapshot, payload): ... @on_event("timer_fired") def wake_up(snapshot, payload): ...这一步之后,你的 Agent 已经可以“收到事件才干活”了。为了不一次改完,可以先只把某几个事件接到新路径。我当时是先跑了一个兼容层:旧的 while 循环照跑,但循环里的每次迭代改调用 handler,再把 handler 的行为改造成事件化。总之迁移原则是“小步快跑,每步都有回归验证”。
4.3 第三步:把 Agent 的上下文搬到持久化存储
这是最关键也最容易被忽略的一步。事件化之后,Agent 的状态不能凭空放着,因为它下一次收到事件时可能换了一台机器来执行。把history、goal、pending_tool_call这些都存进 Redis 或数据库。
这个阶段要特别小心:不要在每次事件都全量存一遍大上下文。先压缩再存;历史消息可以做摘要或只保留最近几轮;用户敏感信息单独放,别堆在快照里。还有一个容易踩的问题:快照要保持原子性。调度器加载快照、执行 handler、保存新快照,这三个步骤必须能在某个时刻被看成“一笔交易”,否则同时进来的两条事件可能互相覆盖。我的做法是给每个 Agent 加一把分布式锁,在事件处理期间锁住该 Agent,处理完释放。对单个 Agent 的吞吐要求不高,锁的开销可以接受。
4.4 第四步:接入可观测性
while 循环式 Agent 最大的痛点就是“不可观测”:你只知道它在跑,不知道它跑到哪。事件化之后,每个 event 天然是一条日志记录,每个 Agent 的状态快照天然是当前的进度。我的做法是:
- 每条事件打印一条结构化日志,包含
event_id、agent_id、event_type、耗时。 - 每隔一段时间记录 Agent 的状态分布(多少个 IDLE、THINKING、WAITING_TOOL)。
- 状态快照变更时记录 diff,方便定位是哪个 handler 把 Agent 推入了异常状态。
做完这四步,你的 Agent 代码形态已经和 while 循环时代完全不一样了。如果这个过程中你还纠结过“while 怎么退出循环”,那大概率说明你的 Agent 还停留在脚本期,迁移可以再等等;如果已经在为并发和状态恢复头疼,那这件事就值得排上日程。
5. 实测对比:并发、延迟、资源占用到底差多少
光说理论没用,我把自己项目的对比数据整理出来。项目背景:一个多 Agent 协同的调研助手,每个 Agent 负责一块任务(搜索、总结、交叉验证),整体由编排层调度。我们当时对比的是同一批任务(50 个调研任务),分别跑在旧的 while 循环实现和新的事件驱动实现上。机器是 8 核 16G 的开发机,服务端都是同一个 FastAPI 进程调同一个模型 API。
5.1 并发能力:从“一个循环一个实例”到“无限 Worker”
旧版实现,50 个任务意味着至少 50 个 Agent 实例并发跑,每开一个 Agent 多一个循环、一套上下文、一组线程绑定。在 8 核机器上跑到 20 个并发的时候,CPU 和内存已经不稳定,经常出现部分 Agent 卡死在等待。
事件驱动版,Agent 实例是“逻辑概念”,物理上只是队列里的一段事件流加一份状态快照。处理事件的 worker 数量和 Agent 数量解耦,可以按 CPU 核数开 worker,比如 8 个 worker 就能服务几百个 Agent 的待处理事件。实测下来,同样的任务量吞吐提升到原来的三倍左右,而且还没有达到新的瓶颈。
5.2 延迟:等待不阻塞、调度更均匀
旧版里最怕某个 Agent 在 while 循环里等待一个慢的工具调用,那个 Agent 的整条任务链都会卡住。事件驱动版里,WAITING_TOOL状态的 Agent 不占用任何 worker,只有工具返回事件到达时才重新被调度。这相当于把“阻塞等待”变成了“主动让出”,worker 利用率高了很多。整体平均完成时间大约缩短了四成。
5.3 资源占用:空闲时几乎零消耗
最有说服力的数据在空闲场景。旧版:保留 20 个 Agent 待命,进程一直有 10% 以上的 CPU 花在轮询消息和 sleep 循环上。事件驱动版:没有任何事件的时候,CPU 占用是 0.x%,内存只有 worker 基线的开销。对于要常驻的服务来说,这一点带来的成本节省非常直观。
5.4 可观测性:从黑盒变成日志流
这个没法量化,但对我来说是最值的改进。以前排查一个 Agent 为什么回复奇怪,得看它那一大坨日志输出,对着时间戳猜它哪一步出了问题。现在直接把某个agent_id的事件流拉出来看,每个状态转移都清清楚楚,问题定位时间从小时级降到分钟级。
5.5 一次直观的指标对照
把上面这些整理成一个表格,方便你直接对比:
| 指标 | while 循环实现 | 事件驱动实现 |
|---|---|---|
| 空闲 CPU 占用 | 10% 左右(轮询) | 0.x%(无事件零消耗) |
| 并发上限 | 受线程数/实例数限制 | worker 数与 Agent 数解耦 |
| 阻塞等待 | 占用整条执行链 | 释放 worker,事件唤醒 |
| 状态持久化 | 进程内存 | 状态快照落盘 |
| 问题定位 | 翻大日志猜流程 | 事件流 + 状态 diff |
当然,这个对比数据不绝对,不同业务场景差异不小。但流程型、工具调用多、需要长时间运行的 Agent,基本都能吃到事件驱动的红利。
6. 拆分过程中我踩过的四个坑
如果看完前面你就去动手改,大概率会遇到下面这些问题。每条都是我真金白银换来的教训,建议先记下来。
6.1 坑一:状态快照太大,序列化开销反而拖垮性能
我刚迁移的时候,把 Agent 的所有历史对话、工具返回全文、中间思考记录全都塞进快照,每次事件处理完完整序列化存进 Redis。结果发现,50 个 Agent 同时跑,Redis 的序列化和网络开销成了新瓶颈,比 while 循环时代还要卡。
解决办法:快照要做得“瘦”。历史只存最近 N 轮,更早的做摘要;长文本结果只存引用 ID,用的时候再去对象存储拉;系统消息和任务描述这种不变内容单独存,快照里只放指针。一句话,快照保存的是“当前需要推进的状态”,不是“所有的事实”。
6.2 坑二:事件风暴导致同一 Agent 的任务打架
事件驱动之后,并发性确实上来了,但同一个 Agent 如果同时收到多条事件,状态快照的读写就可能互相覆盖。我最初没给 Agent 加锁,结果出现过一个 Agent 的tool_result事件把另一个事件处理过程中保存的快照整个覆盖掉,导致任务凭空回退了好几步。
后来给每个 Agent 加了基于agent_id的分布式锁,同一时刻只处理该 Agent 的一条事件。如果业务要求某个 Agent 必须同时处理多个子任务,可以把子任务拆成独立的子 Agent,而不是让一个 Agent 内部并发处理。这个设计取舍在后面帮了大忙。
6.3 坑三:重试逻辑被事件“重复投递”放大了
消息队列有“至少一次投递”语义,所以在事件处理里如果写了工具调用,工具可能被重复执行。比如 Agent 调用了一个“发送通知”的工具,网络闪断导致事件重投,用户就可能收到两条通知。旧 while 循环里没有这个问题,因为重试是整段循环重跑,但事件化之后单条事件的重复性会放大。
对策是幂等设计。每个工具调用在事件里带上一个幂等键(比如task_id和工具名称的组合),工具端收到重复键直接返回缓存结果。这个一定要在设计阶段就想好,不然后面改起来很痛。顺带说一句,工具调用的权限边界也要在设计阶段定死,不能让 Agent 在重试时再把敏感操作执行一遍,这在事件驱动的场景里比循环式更容易被忽略。
6.4 坑四:没有了“主循环”之后,调试习惯必须变
说实话,刚改完那两周我很难受。以前遇到奇怪的问题,我直接在 while 循环里打 log 看流程对不对。现在整个 Agent 的执行是分布在不同事件里的,调试必须基于事件流和状态快照来思考,不能再线性地追代码。
我的办法是写了一个本地调试脚本:给定一个agent_id,拉取它最近 100 条事件,按时间顺序打印出来,再加上每次事件前后的状态快照 diff。这样一来,检查 Agent 行为就变成了看一段“事件时间线”,反而比原来更直观。如果你也决定迁移,提前准备这么个小工具,能省很多 Debug 时间。
7. 什么样的 Agent 适合拆掉 while 循环
最后说说边界。不是所有 Agent 都适合切成事件驱动,我在另一个项目里就保守处理了,没有盲目迁移。
7.1 适合拆的场景
- 需要长时间运行、任务可能有几步甚至几十步的 Agent。循环越长,事件驱动的收益越大。
- 有多个 Agent 协作、需要互相传递任务结果的场景。事件总线天然是协作的骨架。
- 需要把 Agent 服务化、平台化,应对不同用户并发提交任务的场景。
- 需要 Agent 支持暂停、恢复、重放、审计的场景,状态快照是刚需。
7.2 不适合拆的场景
- 一次问答就结束的轻量 Agent。比如“帮我把这句话翻译成英文”,这种任务走一个 while 循环反而是最直接的。
- 纯本地脚本式工具,Agent 只是串一串 API 调用,跑完就结束,没必要引入队列和存储。
- 团队刚接触 Agent 概念、还在快速迭代玩法阶段。这种阶段先让 while 循环跑通业务更重要,过早做架构治理会拖慢验证速度。
7.3 我的建议
拆 while 循环这件事,本质上是在问:你的 Agent 到底是“脚本”还是“服务”?脚本用一个循环就够了,跑完即走;服务则要考虑并发、可用性、可观测性,这时候“事件驱动 + 状态机”几乎是必然的选择。我现在的判断标准很简单:只要 Agent 不会在下一秒就结束任务,我就默认用事件驱动来搭骨架。
另外还想说,这种架构并不是我拍脑袋发明的,它本质上是把操作系统、消息中间件、游戏引擎里非常成熟的“事件循环”和“状态机”思想搬到了 LLM 应用层。大模型只是 Agent 的“大脑”,但它不应该是 Agent 唯一的“发动机”。
拆这个循环,前前后后花了我大半个月。现在回头看,当初那个while True真正的问题不在于“循环”,而在于我把 Agent 的整个生命周期都押在了循环上:它决定了 Agent 什么时候能收到消息、什么时候能继续、甚至什么时候崩溃。而事件驱动加状态机这个东西,其实在很多领域早就被验证过无数遍了——操作系统靠事件循环调度任务,游戏引擎靠状态机控制角色,消息中间件靠队列解耦生产者和消费者。LLM Agent 只是把同一套思想带进了新的场景。
如果你正被 Agent 的并发、稳定性、可调试性反复折磨,我建议你认真考虑一下这条路线。先小范围试一个工具调用多的 Agent,把事件流打出来看看,说不定你也会做出和我一样的决定——把 while 拆了。