去年冬天凌晨3点,我被一条线上告警叫醒。一个调研型Agent从晚上7点开始就没停过,它在一个while循环里反复调用搜索工具,一会觉得“信息还不够”,一会又觉得“再验证一下这个数据”,直到上下文窗口被撑爆、token预算烧穿,整个任务以失败告终。我盯着日志里那几十条高度重复的搜索记录,脑子里蹦出一个问题:为什么Agent系统里一定要有一个while循环?如果把它拆掉,任务还能跑吗?这篇东西就是我后来把循环拆掉、换了一种Agent架构之后想好好说清楚的事。它不是什么论文级别的理论,就是一次真实的架构调整,但那次调整之后,我对“Agent到底该怎么被组织”的理解彻底变了。
1. 复盘那次深夜跑飞的Agent:while循环到底把人坑在哪
1.1 Agent框架里的while循环,长得几乎一模一样
只要是做过Agent开发的人,大概率写过或者见过这样一段代码:
async def run_agent(task: str, tools: list[BaseTool]) -> str: state = { "task": task, "messages": [], "tool_results": [], } done = False while not done: resp = await llm.complete( messages=build_prompt(state), tools=[t.schema for t in tools], ) if resp.tool_calls: for call in resp.tool_calls: result = await execute_tool(call) state["messages"].append( tool_result_message(call, result) ) elif resp.final_answer: done = True return resp.final_answer else: # 防空转 await asyncio.sleep(0.5)不管是ReAct、Function-Calling还是市面上大多数Agent框架,核心骨架基本都是这个形态:外部循环里调用大模型,模型决定是调用工具还是给出最终回答,直到遇到“final_answer”才跳出循环。这个设计太自然了,自然到没有人会去质疑它。Agent嘛,本来就要“反复思考、不断行动”,while循环简直就是为它量身定做的。
但恰恰是这个“天经地义”,埋下了我那天夜里所有问题的根源。
1.2 一次真实的失控时间线
那天跑飞的任务本身不复杂,要让Agent产出一份“RAG在金融场景中的落地调研报告”。我给了它搜索工具、网页解析工具,也写了提示词,要求它“收集足够的信息后输出报告”。看起来没什么问题,对吧?可实际执行过程是这样的:
| 时间 | 动作 | 上下文变化 |
|---|---|---|
| 19:02 | 任务启动,LLM第一次调用搜索工具 | 上下文约3k token |
| 19:05 | 搜索“RAG 金融 落地案例” | 约12k token |
| 19:10 | 又搜索“RAG 风控 合规” | 约25k token |
| 19:21 | 搜索“RAG 金融 落地案例”(重复) | 约41k token |
| 20:18 | 开始写报告初稿 | 约60k token |
| 20:35 | 模型觉得案例太少,又重新搜索 | 约75k token |
| 23:47 | 继续补采,搜索关键词与之前高度重叠 | 约110k token |
| 03:12 | 上下文溢出,任务直接失败 | token耗尽 |
凌晨3点的时候我看到的是:40多次工具调用,其中至少三分之一是重复搜索,累计烧掉了近百万token,最后任务没有完成,也没有任何可用的中间结果落地。最讽刺的是,它搜索过的页面,我后来用脚本去重之后发现,真正有价值的信息只来自不到10个页面。
我不怪LLM。模型只是在不断做它认为“正确”的事——信息不够就继续找。真正的问题在架构层面:我把“这个任务应该什么时候停下来”的判断权,交给了概率模型。
1.3 问题的本质:让LLM给while循环踩刹车,等于把方向盘交给概率
你可能觉得,在提示词里写清楚“收集足够信息后必须结束”不就行了吗?我那天也是这么想的,而且写得更严格,甚至加了“如果你觉得信息不足,最多再补充两次搜索”这样的限制。结果呢?模型在长上下文里渐渐丢失了约束,它每轮看到的都只有“我还可以再做好一点”,于是不断继续。
这件事让我想明白一个关键点:循环式Agent的退出条件,本质上是一个语义判断。什么叫“信息足够了”?这个问题没有数值解。相比之下,普通程序里的while循环几乎都有确定性的终止条件。比如初学者最常见的题目:用while循环计算常数e的级数,e = 1 + 1/1! + 1/2! + 1/3! ...,直到最后一项小于10^-4为止。程序什么时候停?当某一项的值真正小于10^-4,这是一个可以计算的量,是确定性的。
Agent的循环却没有这样的“数值判据”。“任务完成”是一个模糊的、依赖模型状态和上下文理解的判断。你让一个概率模型去决定无限循环的出口,出问题的概率天然就不低。那个凌晨的一切,在架构设计出来的那一刻就已经注定了。
2. 藏在while循环背后的五个工程代价
2.1 行为空间不可枚举,测试调试像盲人摸象
写普通代码时,我们能对一个函数的行为做穷举测试,因为输入空间和输出空间是可枚举的。但循环式Agent不是。每一轮循环,模型可能选择调用工具A、工具B、或者直接输出答案;工具返回的结果可能成功、失败、部分失败;上下文的长度还在不断变化。这些因素组合起来,Agent的行为空间是一个接近无限深的树。
这意味着什么?意味着你没法通过写测试用例来保证Agent的行为边界。你只能靠“观察几次跑得还行”来获得信心。我那个Agent在测试环境跑了十几轮都正常,一到线上面对真实搜索结果就失控了。因为测试集永远覆盖不了真实世界的多样性。一个没有可枚举行为空间的系统,本质上是一个不可测试的系统。
2.2 状态全部堆在上下文里,Agent记忆变成历史包袱
循环式Agent最省事的做法,是把所有中间状态都追加到messages里:用户的任务、模型的历史思考、每次工具调用的原始返回、中间结果、甚至失败的报错,全部堆在一起。最初这样确实简单,但问题很快出现。
首先是成本。每多一轮循环,下一轮请求的input token就越大,成本非线性上涨。其次是效果。当上下文长度超过某个阈值,模型对早期信息的“注意力”会迅速衰减。我做过一个实验,把前面10轮的思考过程丢弃,只保留工具结果,模型对任务的理解反而更清晰。这说明上下文里的很多东西不是记忆,而是噪声。
循环式架构把“记忆”和“过程日志”混为一谈。真正优秀的Agent记忆,应该是只保留有价值的结论和状态,而不是把所有历史过程都背在身上。
2.3 并发改造极难,扛并发时要推倒重来
做Agent最常被问的问题就是“ai agent怎么扛并发”。老实说,循环式Agent在这个问题上是天生被动的。因为整个循环是一个串行的自我演化过程,状态都藏在run_agent函数的局部变量里。你没法把“任务执行到一半”的状态保存下来,分发给另一个worker去继续。每个任务只能在一个进程里从头跑到尾。
如果你打算用“一个任务起一个进程”的方式来扛并发,那其实就是把并发问题往后推,并没有真正解决。一旦某个Agent在循环里偶发卡住,那个worker就会被占住直到超时,资源利用率非常难看。阶段化架构最大的好处之一,就是中间状态可以序列化、可以分发,分布式执行的可能性被打开了。
2.4 终止条件脆弱,线上事故的根源
循环式Agent的退出条件,通常由模型产出的final_answer标志来决定。但这个标志本身并不可靠。模型可能在一轮认为任务完成了,给出答案;如果在下一轮重新询问,它又可能推翻自己,觉得“还需要补充”。这种判断的不稳定性,加上长上下文的干扰,让终止条件变成了整个系统里最脆弱的部分。
工程系统的核心原则之一,是每一层都要有确定性的兜底。循环式Agent把“是否终止”这个最关键的兜底逻辑交给了概率模型,这是架构性的脆弱。
2.5 成本方差巨大,预算控制形同虚设
循环式Agent的token消耗方差极大。同一个任务,状态好的时候10万token就结束了,状态差的时候能跑出100万token。这种方差让成本控制变成一句空话:你按80万token做预算,它跑飞的时候照样超;你按20万做预算,正常情况又浪费。在toB生产环境里,这种不可预测性是非常致命的,因为客户要的是稳定的成本报价。
五个代价叠加在一起,已经足够说明:循环不是实现细节,而是影响整个系统可靠性的架构决策。
3. 拆掉循环之后:事件驱动的阶段化Agent架构核心设计
3.1 一个根本性的换位:确定性编排器接管“要不要继续”
我拆掉while循环之后想明白的第一个道理,不是“让Agent别循环了”,而是“把要不要继续的决策,从LLM手里拿回来”。新架构叫它“阶段化流水线”也好、“事件驱动Agent”也好,本质都是一回事:先把任务拆成确定的阶段,每个阶段有明确的输入、输出和出口条件,LLM只在阶段内部工作,阶段之间的流转由确定性的编排器控制。
可以这样理解:以前的Agent是一个实习生,老板只给了个大目标,实习生每做完一点就跑去问“我接下来该干嘛?我是不是可以停了?”——每一个“该干嘛”都让LLM回答。新的架构里,实习生拿到的是一张任务卡,上面写着“第一件事做什么,做完了交给谁”。他自己不需要不断决定“下一步是什么”,只需要专注于把当前这一步做到位。
这种换位的核心意义在于:把系统的不可控空间从无限缩小到了有限。循环式Agent的行为空间是无限深的树,阶段化Agent的行为空间是一个固定深度的DAG,每个节点的执行规则是确定的。这让测试、调试、并发、成本估算都重新变得可能。
3.2 阶段化骨架到底长什么样
我当时搭的骨架非常简单,一个阶段枚举、一张流转表、一个执行器:
from enum import Enum class Stage(str, Enum): GATHER = "gather" # 信息收集 SYNTHESIZE = "synthesize" # 综合归纳 VERIFY = "verify" # 完整性校验 REPORT = "report" # 输出报告 # 阶段流转表:每个阶段结束后确定性地进入下一阶段 STAGE_FLOW = { Stage.GATHER: Stage.SYNTHESIZE, Stage.SYNTHESIZE: Stage.VERIFY, Stage.VERIFY: Stage.REPORT, Stage.REPORT: None, }注意这里我依然写了Stage.REPORT映射到None,也就是没有下一个阶段,任务自然结束。整个流程不是靠LLM说“我完成了”来结束,而是编排器确定性地走完所有阶段。LLM在GATHER阶段负责决定搜索哪些关键词,在SYNTHESIZE阶段负责归纳总结,在VERIFY阶段负责检查遗漏,但它在任何阶段都不能决定“任务到此为止”。这个决定权,被我从模型手里拿走了。
可能会有朋友说:这不还是一个while循环吗?外层确实是循环,但它的步数等于阶段数量,是一个常量。我们拆掉的不是“结构上的循环”,而是“语义上的无限循环”。这个差别至关重要:外层循环的每一步都对应一个预先定义好的阶段处理器,没有哪一步会“意外地多执行一次”。
3.3 LLM从无限循环的司机变成阶段内的执行者
架构调整后,LLM的角色发生了微妙但重要的变化。在循环式架构里,LLM是“司机”,方向盘、刹车、油门全在它手里,它既能决定去哪个工具,也能决定何时停车,但车可能失控。在阶段化架构里,LLM更像“高铁列车员”,列车在哪一站停、什么时候停,由轨道系统决定;列车员负责在指定站点把服务做到位。
实际上这并不会让Agent变笨,反而让它在自己的职责范围内更加专注。它不再需要花心智去纠结“我是不是该结束了”,而是可以把全部能力用在“怎么在这个阶段把任务做好”。我后来观察到一个有意思的现象:同样的模型,在阶段里输出的思考质量明显比在无限循环里更高,因为不需要分心做元决策。
架构层面的“降权”反而带来了模型输出质量的“升维”,这是当时没有预料到的额外收获。
4. 一次完整重构:调研类Agent从循环式改成阶段流水线
4.1 先把任务边界和验收标准定死
任何重构的第一步都不是写代码,而是把任务和目标定清楚。我选了一个非常典型的场景:技术调研Agent,任务描述是“产出一份《RAG在金融场景中的落地调研报告》”。重构前我定义了四个硬性验收标准:
- 报告必须包含四部分:场景现状、真实案例、潜在风险、结论建议;
- token总预算不超过30万;
- 端到端耗时不超过10分钟;
- 任何一步失败,必须有明确的日志定位,而不是整个任务黑盒消失。
这四个约束直接倒逼架构设计。token预算意味着不能做无限制的探索式搜索;耗时意味着搜索阶段必须支持并发;可定位意味着每一步都要有独立的可观测节点。
4.2 重构前的循环式实现差在哪
重构前,Agent拿到任务后在while循环里自由发挥,结果就是我在第一章复盘的那种局面:重复搜索、上下文膨胀、无法按预算收敛。更深层的麻烦是,循环式实现里“搜索关键词”是模型每一轮临时生成的,它不记得上一轮搜过什么,也没有一个全局的去重机制。这不是某个参数的问题,而是循环式架构天然缺失“计划”和“执行”的分层。
我当时给Agent的能力是足够的,搜索工具、网页解析工具、评分能力都有,但它把这些能力用成了一把完全随机打出去的霰弹枪。
4.3 重构后的阶段划分与并发改造
新架构把任务拆成四个阶段:
GATHER阶段负责信息收集。进入阶段后,先用确定性规则生成一组初始搜索词,而不是让LLM在循环里逐个临时想词。我让LLM一次性生成6个覆盖不同子主题的搜索关键词,然后用协程并发执行。
SYNTHESIZE阶段负责综合归纳。等所有搜索结果回来,把6组结果一次性交给LLM,要求它输出结构化摘要。因为输入在这一阶段是固定的,token消耗是可控的。
VERIFY阶段负责完整性检查。用规则判断摘要是否覆盖了四个必备部分,缺哪一部分就单独补充搜索,但只补一轮,补完立即合成,不再二次延展。
REPORT阶段负责格式化输出。把校验过的内容渲染成最终报告。
这里特别要注意:补采阶段虽然带了点循环的味道,但它是有限循环,最多执行一次。它不是while,而是if——缺哪补哪,补完就停。
并发化改造的收益很大。GATHER里6个搜索用协程并发执行,从串行的十来分钟压缩到两分钟左右。这正好回应了“ai agent怎么扛并发”的问题:当你把任务拆成独立阶段后,阶段内部的并行改造就变得非常自然,而循环式架构想做到这一点就非常困难。
4.4 一版能直接落地的代码骨架
下面这个版本我做了一定简化,但保留了全部关键结构,可以直接在此基础上扩展:
import asyncio from dataclasses import dataclass, field @dataclass class TaskContext: task: str queries: list[str] = field(default_factory=list) gather_results: dict = field(default_factory=dict) synthesis: str = "" missing_parts: list = field(default_factory=list) report: str = "" async def stage_gather(ctx: TaskContext) -> TaskContext: # 第一阶段:一次性生成搜索计划,而不是在循环里临时想 prompt = f"针对以下任务,给出6个独立的搜索关键词:{ctx.task}" resp = await llm.complete(prompt) ctx.queries = parse_queries(resp) # 并发执行搜索,每个查询互相独立 async def search_one(q: str) -> tuple[str, str]: result = await search_tool.search(q) return q, result results = await asyncio.gather( *[search_one(q) for q in ctx.queries], return_exceptions=True, ) ctx.gather_results = dict(results) return ctx async def stage_synthesize(ctx: TaskContext) -> TaskContext: # 第二阶段:把所有结果一次性交给LLM综合归纳 content = json.dumps(ctx.gather_results, ensure_ascii=False) prompt = f""" 请根据以下调研材料,输出一份结构化摘要。 必须包含:场景现状、真实案例、潜在风险、结论建议。 材料如下: {content} """ ctx.synthesis = await llm.complete(prompt) return ctx async def stage_verify(ctx: TaskContext) -> TaskContext: # 第三阶段:规则校验,缺什么补什么,最多补一轮 ctx.missing_parts = check_required_sections(ctx.synthesis) if ctx.missing_parts: supplement_queries = [f"RAG金融 {p}" for p in ctx.missing_parts] supplements = await asyncio.gather( *[search_tool.search(q) for q in supplement_queries] ) ctx.gather_results.update(supplements) ctx.synthesis = await llm.complete( f"请在上文摘要基础上补充以下缺失部分并重新汇总:{ctx.missing_parts}\n" f"补充材料:{json.dumps(supplements, ensure_ascii=False)}" ) return ctx async def stage_report(ctx: TaskContext) -> TaskContext: # 第四阶段:格式化输出最终报告 ctx.report = render_markdown_report(ctx.synthesis) return ctx然后编排器把这些阶段串联起来:
STAGE_HANDLERS = { Stage.GATHER: stage_gather, Stage.SYNTHESIZE: stage_synthesize, Stage.VERIFY: stage_verify, Stage.REPORT: stage_report, } async def run_pipeline(task: str) -> str: ctx = TaskContext(task=task) stage = Stage.GATHER while stage is not None: handler = STAGE_HANDLERS[stage] ctx = await handler(ctx) stage = STAGE_FLOW[stage] return ctx.report注意这里依然有一个while,但它最多循环4次,每次执行哪个函数完全是确定的。你甚至可以把这个循环直接展开成顺序调用,效果没有任何区别。这正是“拆掉语义循环”的含义:不是消灭循环语法,而是让执行路径变得完全可预判。
4.5 重构前后的实测数字
重构完成之后,我在同一批任务上跑了十轮,取了典型数据:
| 维度 | 循环式 | 阶段化 |
|---|---|---|
| 平均耗时 | 23分钟 | 6分钟 |
| 平均token消耗 | 56万 | 18万 |
| 重复搜索次数 | 约5~12次 | 0次 |
| 失败定位难度 | 需要翻全部日志 | 直接定位到阶段 |
| 单次成本方差 | 极大 | 可控 |
| 并发利用 | 基本串行 | 阶段内并行 |
| 任务结果稳定性 | 忽高忽低 | 稳定达验收标准 |
这套数字不算惊艳,但已经足够让我在线上环境放心替换。更重要的是,“失败定位”从玄学变成了日常操作。哪一步挂了、挂在哪一阶段、是搜索超时还是校验没过,一眼就能找到。
5. 拆完之后踩到的三个坑:隐性循环、工具超时与阶段粒度
5.1 坑一:阶段内的隐性循环,差点把架构改回原样
我在第一版重构里,VALIDATE阶段写得比较随意。原计划是“如果校验没过,就重新生成报告”,结果写着写着变成了这样:
while not passed: ctx.synthesis = await llm.complete(...) passed = check(ctx.synthesis)这就是典型的隐形循环迁移。你以为拆掉了主循环,结果把循环搬进了阶段内部。只要这个循环不断让LLM重新生成,它照样可以无限转下去。而且因为嵌套更深,反而更难发现。
教训是:阶段内任何重试都必须有明确的次数上限。要么是固定重试一次,要么是补充一轮后无论如何都进入下一阶段,要么是把问题抛给人工审核通道。绝不能留着无界循环。我当时甚至是刻意在每个阶段处理器里加了一个重试计数,超过阈值直接跳出整个pipeline,保证“任何路径都是有限步数”。
5.2 坑二:阶段化之后,工具超时和重试必须先于LLM解决
循环式架构有一个隐藏的“缓冲”作用:模型每轮只调一两次工具,工具超时对整个任务的影响不大,大不了下一轮再调。阶段化之后,GATHER阶段要并发跑6个搜索,任何一个工具迟迟不返回,整个阶段都会被拖住。
所以你在做阶段化改造时,工具层的健壮性必须前置处理。我给每个工具调用挂了独立的超时控制,3秒没有任何返回就标记为失败,把失败原因写进结果集,后续阶段依然可以基于“部分缺失的数据”工作,而不是整体崩溃。这个细节看起来小,但真正撑起了并发阶段的高成功率。
async def safe_search(query: str) -> tuple[str, str | None]: try: result = await asyncio.wait_for( search_tool.search(query), timeout=3.0 ) return query, result except Exception as exc: return query, f"[ERROR] {exc}"5.3 坑三:阶段粒度过粗或过细,都会让Agent“变笨”
阶段太粗,比如把“综合归纳+校验+补采”合并成一个大阶段,结果就是你又在阶段里偷偷造了一个小循环。阶段太细,比如把“搜索关键词生成”拆成独立阶段、“解析搜索结果”又拆一个阶段,你会发现大多数阶段本身不完整,反而要频繁切换状态,开销大,收益低。
我实际跑下来的经验是:一个阶段内应该只包含一到两个LLM调用和一到两个工具调用,并且要有一个明确的产出物。产出物明确,阶段边界才清晰;动作数量少,阶段内部才不需要复杂的自控制逻辑。如果你发现自己写的一个阶段处理器超过50行,多半是该拆了。
这个“粒度”原则,有点像是厨房切菜。切得太粗,菜不入味;切得太细,浪费时间还容易碎。合适的标准就是:“下一道工序可以直接使用,不需要再加工。”
6. 循环式还是阶段化:Agent主循环的选型边界与决策顺序
6.1 三种模式的适用场景
写完上面的重构,我并不建议所有人无脑抛弃循环式Agent。它们在各自的场景里都成立。我现在的判断方式是画一张表:
| 维度 | 循环式Agent | 阶段化Agent | 混合式Agent |
|---|---|---|---|
| 典型任务 | 开放式探索、头脑风暴 | 有明确产出物的任务 | 探索+产出物混合 |
| 定义任务边界 | 基本不定义 | 必须提前拆清 | 只定义主边界 |
| 成本稳定性 | 差 | 好 | 中 |
| 并发优势 | 弱 | 强 | 中 |
| 调试难度 | 高 | 低 | 中 |
| 适合阶段 | 原型验证 | 生产环境 | 复杂生产任务 |
| 失败兜底 | 依赖LLM判断 | 确定性编排器兜底 | 阶段内部有限循环+编排器兜底 |
我的经验是:只要任务能提前映射出阶段(一个能产出的结果、一组固定动作),阶段化就是更省心的选项。凡是把自己封闭在while循环里的Agent,上线前必须回答三个问题:
- 如果模型连续三轮判断不一致,系统会怎样?
- 如果任务做了40轮还没完成,用户是什么体验?
- 你能承受最坏情况下的token成本吗?
这三个问题但凡有一个让你心里发虚,就说明循环式架构的生产级能力不足。
6.2 我现在的默认选择:阶段化外壳,循环化内核
上面说了这么多,其实我最推荐的既不是纯循环也不是纯阶段化,而是混合式:外部用确定性编排器控制阶段流转,LLM的探索能力被限制在阶段内部,并且任何内部探索都必须是有限次数的。这相当于把while循环从“系统主循环”降级为“阶段附属工具”。
比如在一个“写代码Agent”里,主流程可以分成“需求理解、方案设计、代码生成、测试验证、修复”五个阶段;在“测试验证”阶段内部,让Agent循环执行测试、定位、修复的动作,但设定最多重试5次,超过5次就请求人工介入。这样一来,Agent的探索性还在,但系统永远不会失控。
6.3 下次写Agent时,先画阶段流转图
我现在开发Agent的第一件事,就是在一张纸上画这个任务从输入到输出要经过哪些阶段、每个阶段产出的物化结果是什么、阶段之间如何流转。如果这张图画不出来,说明你对任务本身的理解还不够透,也不该急着写代码。如果能画出来,哪怕阶段画粗一点,也一定要按照阶段化的框架搭骨架。
很多朋友一开始觉得“阶段化”是在额外增加工程量,我承认前期多花一小时拆阶段确实费事。但它的回报会在线上运行的第一天就体现出来:不再有凌晨的告警,不再有烧穿的预算,不再有翻不完的日志。
我最后一次半夜爬起来维护Agent,不是因为它聪明过头跑飞了,而是因为我当初给了它无限的自由。拆掉那个主循环之后,它反而在一套确定的轨道里,把每一件小事都做得比之前更好。如果你也在为Agent跑飞、token失控、难以排错而头疼,我的建议很简单:试着把它的主循环拆掉看看,先从一个明确的任务开始,设计出它的第一阶段,剩下的自然就有了方向。