2024 年我在产品里做一个 AI Agent——给它一组工具,让它自己跑多步任务:用户提一个稍复杂的需求,它自己想一步、调一个工具、看看结果、再想下一步,直到把事办完。这个"让 Agent 自己一步步跑"的循环,我压根没多想。第一版我做得很省事:让 Agent 自己跑多步任务,不就是写个 while 循环?让它一轮轮调工具,等它说一句"完成"就退出。本地开发时——真不错:我自己发几条指令测,Agent 调三五个工具就把事办完了,干完利落地说一句"完成",循环干净退出,几十行代码搞定。我心里很踏实:"Agent 跑任务嘛,不就是个循环、等它说完成?"可等这个功能真正上线、被真实用户用起来,一串问题冒了出来。第一种最先把我打懵:有个用户提了个稍微复杂点的请求,Agent 自己跟自己来回调工具,调了一两百轮还不停——一次请求烧掉平时几十倍的 token,当天的额度哗一下就见了底。第二种最难缠:Agent 卡在两三个工具之间反复横跳——查一下、改一下、再查一下、再改一下,同样的几步循环往复、永远不收敛,它既不出错、也永远不说"完成"。第三种最隐蔽:Agent 明明已经把事办完了,却不肯停——它又"想"了好几轮、多调了几次工具,把本来已经对的结果又改坏了。第四种最莫名其妙:有的任务 Agent 跑到一半就停了,回一句模棱两可的话,既没说完成、也没说失败,我那个只认"完成"二字的循环条件没覆盖到这种话,它就那么停在了任务的中间。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"让 Agent 自己跑多步任务,就是写个循环、等它说完成就退出"。这句话把"Agent 的循环"和"我自己代码里那种普通的 while 循环",当成了同一种东西。可它们不是。一个普通的 while 循环,它会停下来,是因为退出条件是一个确定的、由你写死的判断——计数器到了、队列空了、标志位翻了,这个判断每一轮都被严格地、可预测地执行,所以循环必然收敛。而一个由大模型驱动的 Agent 循环,它的"退出",靠的是模型在每一轮自己判断"任务办完了没、要不要继续"——这是一个不确定的、由模型即兴生成的判断:它可能永远觉得"还差一点、再调一个工具看看",于是永不收敛;它可能在两三个工具之间反复横跳,陷进一个它自己意识不到的死循环;它也可能在任务只做了一半时,就提前觉得"差不多了"而草草收尾。换句话说,你把这个循环的"刹车",完全交给了一个不保证会踩刹车、甚至不保证踩得准的东西。普通 while 循环的收敛性,是代码逻辑给你的硬保证;Agent 循环的收敛性,根本不存在这种保证——它必须由你从循环外部,用一套硬约束强制施加上去。真正做对 Agent 的多步循环,核心不是"写个循环等它说完成",而是给循环装上步数预算、把终止条件显式枚举、用循环检测识别原地打转、用成本上限按 token 和花费独立刹车、再让任何一种退出都走统一的收尾。这篇文章就把 Agent 的循环控制梳理一遍:为什么"等它说完成就退出"是错的、步数预算怎么定、终止条件怎么枚举、原地打转怎么检测、成本上限怎么设,以及单步超时、可观测、人工介入、退出收尾这些把 Agent 循环真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"写个 while 循环、等 Agent 说完成就退出"的多步 Agent,在被真实用户用起来后冒出一串问题:遇到稍复杂的请求,Agent 自己跟自己调工具调上百轮、一次请求烧光当天额度;它卡在几个工具之间反复横跳、永不收敛、也永不出错;它明明办完了却不肯停、又多走几轮把对的结果改坏;它也会跑到一半含糊地停下、循环条件没认出这是"没干完"。
我当时的错误认知:"让 Agent 自己跑多步任务,就是写个循环,让它一步步调工具,等它说一句'完成'就退出。"
真相:这个认知错在它对"循环为什么会停"这件事想当然了。我们写普通循环时,从不操心"它会不会停"——因为它一定会停:退出条件是一段确定的代码,i < n 也好、queue.empty() 也好,这个判断由 CPU 不打折扣地执行,所以循环的收敛性,是程序逻辑给你的硬保证。我把这份"循环一定会停"的安心感,想当然地搬到了 Agent 循环上。可 Agent 循环的退出条件,根本不是一段确定的代码——它是"模型每一轮自己判断要不要继续"。模型这个判断有三个要命的特性:它不确定(同样的局面,这次它说继续、下次它说完成);它可能不收敛(它永远能再想出一个"还可以再查查"的理由);它可能误判(没干完却说干完了、或干完了还觉得没干完)。这三个特性,正好对上开头那四个问题:调上百轮,是模型永远不收敛、循环没有硬上限去截断它;反复横跳,是模型陷进了自己意识不到的死循环、而我没有循环检测去发现;办完了不停、改坏结果,是模型对"已完成"误判、而我没有别的退出信号去替它兜底;含糊地半路停,是我把退出窄化成了只认"完成"二字的字符串匹配。问题的根子清楚了:这不是"循环条件多写几个关键字"的小修补,而是要换一个根本的认知——Agent 循环的收敛性不是天生的,它必须由你从外部,用步数、成本、进展这几把硬尺子强制框出来。
要把 Agent 的循环控制做对,需要几块认知:
- 为什么"等它说完成就退出"是错的——模型循环没有"会停下来"的内建保证;
- 步数预算——用
for循环给迭代轮数装一个谁也突破不了的硬上限; - 终止条件——把"为什么停"显式枚举,别让循环只有"停了"一种含糊状态;
- 循环检测——给每一步的动作算指纹,识别"原地打转"和"毫无进展";
- 成本上限——按累计 token 和花费记账,这是独立于步数的另一道闸;
- 单步超时、可观测、人工介入、退出收尾这些工程坑怎么处理。
一、为什么"等它说完成就退出"是错的
先把这件最根本的事钉死:"等它说完成就退出"错在它默认了一件根本不成立的事——它默认 Agent 这个循环,和普通循环一样,"自己会收敛"。一个普通的 while 循环为什么让人放心?因为它的每一轮之间,有一个确定的、单调推进的东西在变:计数器加一、指针后移、队列变短。这个"单调推进"加上一个"固定的终点",共同保证了循环必然在有限步内停下——这是一种数学意义上的收敛性保证。可 Agent 循环里,根本没有这样一个"单调推进的量"。它每一轮在变的,是一段对话历史在变长、模型的"想法"在变——而模型的想法不是单调推进的,它可能进两步退一步,可能绕回原地,可能越想越多。它唯一的"终点",是模型某一轮主动说出"完成"——可模型说不说这个词,是它即兴的、不确定的输出,你没有任何机制能保证它"迟早会说"。于是这个循环就成了一个没有下界、没有单调量、终点全凭运气的循环。它在本地不出事,纯粹是因为你的测试任务都简单——简单到模型三五步就能想明白、痛痛快快说出"完成"。一旦任务变复杂,模型在多步推理里迷了路,这个循环就露出它的真面目:它本来就不保证会停。
下面这段代码,就是我那个"本地几步就退、上线却收不住"的第一版:
# 反面教材:把循环的"刹车"完全交给模型自己判断
def run_agent(user_request):
messages = [{"role": "user", "content": user_request}]
while True: # 破绽 1:循环没有任何硬上限,纯靠模型自觉
reply = llm.chat(messages) # 破绽 2:每一轮都在烧 token,却没人记账
messages.append({"role": "assistant", "content": reply})
if "完成" in reply: # 破绽 3:靠关键字判断退出,模型不说就永不退
return reply
tool_result = call_tool(reply) # 模型要调工具就调,调多少次没人拦
messages.append({"role": "user", "content": tool_result})
这段代码在本地开发时表现不错,因为本地我自己发的指令都简单——任务简单到模型三五轮就想清楚了,干完爽快地回一句带"完成"的话,while 循环恰好在第三五轮被那个 if 接住、干净退出。它的问题不在某一行语法上——while True、if "完成" in reply,语法都对——而在它把整个循环能不能停,完全押在了"模型每一轮的即兴判断"上:这个 while True 自己没有任何下界,它什么时候停,百分之百取决于模型哪一轮愿意说出"完成";而模型在复杂任务里很可能永远不说、或在几个工具间打转着说不出、或半路说点别的含糊话。这三个破绽对应的,正是开头那几类事故。问题的根子清楚了:做对 Agent 循环,第一步不是把退出关键字写得更全,而是承认"这个循环不会自己收敛",然后从循环外面,给它强行装上几道一定会触发的硬刹车。下面五节,就是这几道刹车怎么装。
二、步数预算:给循环装一个硬上限
既然循环不会自己收敛,那第一道、也是最不可绕过的一道刹车就是:给迭代轮数装一个硬上限。最直接的做法,是把 while True 换成一个 for 循环——for 循环的轮数是写死的,模型再怎么不收敛,也突破不了这个上限。先把一次运行的所有硬约束,集中定义清楚:
from dataclasses import dataclass
@dataclass
class AgentLimits:
"""一次 Agent 运行的硬约束:循环能不能停,不靠模型自觉,靠这几个上限。"""
max_steps: int = 12 # 最多迭代多少轮,到顶强制停
max_tokens: int = 60000 # 累计 token 预算,烧光强制停
max_seconds: float = 90.0 # 整个任务的墙钟时间上限
no_progress_limit: int = 3 # 连续多少轮没有新进展,就判定卡死
max_repeated_action: int = 2 # 同一个动作最多重复几次
有了这个上限,主循环就不再是 while True,而是一个轮数封顶的 for:
def run_agent(user_request, limits=AgentLimits()):
"""Agent 主循环:每一轮开始前,先过一遍所有"该不该停"的检查。"""
state = AgentState(messages=[{"role": "user", "content": user_request}])
for step in range(limits.max_steps): # for 而非 while:步数上限是一道硬墙
stop = check_stop(state, limits, step) # 每轮先问:现在该停了吗?
if stop is not None:
return finalize(state, stop) # 任何一种停因,都走统一的收尾
reply = llm_step(state) # 这一轮真正让模型走一步
state.record(reply)
return finalize(state, StopReason.STEP_BUDGET) # for 自然跑完 = 撞上了步数上限
这里的认知要点是:把 while True 换成 for range(max_steps),这一个改动,看着只是语法上的小调整,实质上却是把循环的"收敛性"从模型手里夺了回来。while True 的收敛,依赖一个"模型迟早会说完成"的乐观假设;而 for range(max_steps) 的收敛,是 Python 语言给你的、谁也改不了的硬保证——这个循环最多走 max_steps 轮,这是写死在代码里的事实,和模型说什么、想什么完全无关。这道步数上限,是所有刹车里最该第一个装、也最不该被绕过的一道。它的意义不在于"max_steps 这个数字定得多准"——它定成 12 还是 20,可以再调;它的意义在于"循环存在一个上限"这件事本身:有了它,你的 Agent 在最坏情况下的开销,从一个"无法预估、可能无限大"的数,变成了一个"max_steps 乘以单步开销"的、有限的、可以预估的数。这是一条工程底线:任何一个会循环调用大模型的地方,都必须有一个步数硬上限。还要注意主循环的一个细节——check_stop 放在每一轮的开头,而不是结尾。放开头,意味着"该不该停"这个判断,先于"让模型再走一步"发生:一旦该停了,这一轮的模型调用就根本不会发出去,省下的正是最贵的那部分开销。步数上限框住了"最多走几轮",可"走到第几轮该正常停",还需要把终止条件理清楚——这是下一节。
三、终止条件:别只认"模型说完成"
步数上限是兜底的硬刹车,但一个 Agent 绝大多数时候不该是"撞上步数上限才停"的——它该正常地、因为把任务办完了而停。问题是,"停"这件事不止一种原因:有正常办完,有撞上各种上限,有卡死。第一步,要把这些原因显式地枚举出来:
from enum import Enum
class StopReason(Enum):
"""循环退出的原因 —— 把"为什么停"显式枚举,而不是只有"停了"这一种含糊状态。"""
FINAL_ANSWER = "final_answer" # 模型给出了最终答案,任务正常完成
STEP_BUDGET = "step_budget" # 撞上步数上限,强制停
TOKEN_BUDGET = "token_budget" # 撞上 token 预算,强制停
TIMEOUT = "timeout" # 撞上时间上限,强制停
STUCK = "stuck" # 原地打转 / 毫无进展,判定卡死
ERROR = "error" # 出现无法恢复的错误
第二步,要可靠地判断模型这一步到底是不是"最终答案"。绝不能像第一版那样去文本里搜"完成"二字——而要让模型用结构化的字段明确表态:
def parse_step(reply):
"""解析模型一步的输出:它要么是一个工具调用,要么是一个最终答案。"""
# 约定:模型用结构化字段表态,而不是靠我们去文本里猜"完成"二字
if reply.get("type") == "final":
return ("final", reply.get("answer", ""))
if reply.get("type") == "tool_call":
return ("tool", reply.get("tool"), reply.get("args", {}))
# 两种都不是:模型输出不合约定,当作一次无效步处理,而不是无限地等下去
raise InvalidStepError("模型输出既不是工具调用、也不是最终答案")
第三步,把所有的停因集中在一处判断——这就是主循环里那个 check_stop:
def check_stop(state, limits, step):
"""把所有"该停了"的条件集中在一处判断,返回一个停因或 None。"""
if state.final_answer is not None:
return StopReason.FINAL_ANSWER # 正常完成,优先级最高
if state.total_tokens >= limits.max_tokens:
return StopReason.TOKEN_BUDGET
if state.elapsed() >= limits.max_seconds:
return StopReason.TIMEOUT
if state.no_progress_streak >= limits.no_progress_limit:
return StopReason.STUCK
return None # 一个停因都不满足,循环继续
下面这张图,把 Agent 每一轮循环要过的关卡 画出来:
这里的认知要点是:终止条件这件事,要扭转两个根深蒂固的想当然。第一个,是"停就是停,有什么好分类的"。不对——"为什么停"恰恰是整件事里信息量最大的东西。一个 Agent 因为 FINAL_ANSWER 而停,和因为 STEP_BUDGET、STUCK 而停,是性质完全相反的两件事:前者是成功,后者是失败。如果你的循环退出时只留下一个"它停了",不带原因,那么调用方就无从知道这个结果该不该信、该不该展示给用户、该不该触发重试或告警。把 StopReason 显式枚举出来,本质上是逼着系统在每一次退出时都回答"这次到底是成了还是没成"——这个回答,是后面一切处理的前提。第二个想当然,是"判断模型完没完成,就在它的话里找'完成'这个词"。这是第一版那个含糊半路停问题的直接根源:自然语言里表达"我做完了"有无穷多种说法,你列不全;反过来,模型在解释、在引用用户原话时,也可能无意中带出"完成"二字,造成误判。可靠的做法只有一个——和模型约定一套结构化的输出协议:它要调工具,就回一个 type 为 tool_call 的结构;它认为任务结束了,就回一个 type 为 final 的结构。是否终止,由这个结构化的 type 字段决定,而不由自然语言的措辞决定。parse_step 里那个 InvalidStepError 也很关键:当模型的输出两种结构都不符合时,你要把它当成一次明确的"无效步"抛出来处理,而不能默默忽略、让循环空转着等下去。终止条件能识别"正常完成"和"撞上上限",可还有一种最难缠的情况它认不出来——原地打转。
四、循环检测:识别"原地打转"
开头那个最难缠的问题——Agent 在几个工具之间反复横跳、永不收敛——它的特殊之处在于:它不出错(每一步单看都正常),它不超时(每一步都跑得挺快),它在撞上步数上限前一直"忙忙碌碌"。光靠步数上限和终止条件,识别不出它正在空转。要专门加一道循环检测:给每一步的动作算个指纹,同一个动作反复出现,就是在原地打转:
import hashlib, json
class LoopDetector:
"""循环检测:给每一步的动作算指纹,同一个动作反复出现就是在原地打转。"""
def __init__(self, max_repeat=2):
self.max_repeat = max_repeat
self.counts = {}
def fingerprint(self, tool, args):
# 工具名 + 参数 一起算指纹:调同一个工具、且传一样的参数,才算"重复"
raw = json.dumps([tool, args], sort_keys=True, ensure_ascii=False)
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def check(self, tool, args):
fp = self.fingerprint(tool, args)
self.counts[fp] = self.counts.get(fp, 0) + 1
if self.counts[fp] > self.max_repeat:
raise StuckError(f"动作 {tool} 已重复 {self.counts[fp]} 次,判定原地打转")
return self.counts[fp]
"重复同一个动作"是一种打转;还有一种更隐蔽的——动作每一步都不一样,但系统状态毫无推进。这要靠无进展检测:每一步比对一下任务状态的快照,连续多步没有任何变化,就是卡死了:
class ProgressTracker:
"""无进展检测:每步比对任务状态的快照,连续多步毫无变化就是卡死了。"""
def __init__(self):
self.last_snapshot = None
self.streak = 0
def update(self, snapshot):
# snapshot 是当前任务状态的摘要:已完成的子目标、已拿到的关键数据
if snapshot == self.last_snapshot:
self.streak += 1 # 和上一步一模一样 —— 这一步白走了
else:
self.streak = 0 # 状态变了 —— 有真实进展,计数归零
self.last_snapshot = snapshot
return self.streak
这里的认知要点是:循环检测要解决的,是一类步数上限拦不住、终止条件也认不出的"假忙碌"。一个 Agent 卡在两个工具之间反复横跳,从外面看它每一步都在调工具、都有返回、都不报错,它显得非常勤奋——可它其实一寸都没往前走。步数上限确实能最终把它截断,但那要白白烧掉十几轮的 token 才轮得到;循环检测的价值,就是在它打转的第二、第三轮就把它当场抓住,而不是等步数耗尽。这里有两个层次要分清。第一层是"动作重复":同一个工具、同一组参数,被调了一遍又一遍——LoopDetector 给"工具名加参数"算一个指纹,指纹重复超过阈值就判定打转。注意指纹必须把参数也算进去:调同一个查询工具去查不同的东西,是正常的探索;调同一个工具去查同一个东西,才是打转。第二层更隐蔽,是"动作不重复、但状态不前进":模型每一步调的工具都不一样,看着花样百出,可任务状态——已完成哪些子目标、已掌握哪些关键信息——却一连几步纹丝不动。ProgressTracker 抓的就是这一层:它不看动作,只看结果,只要连续若干步状态快照完全相同,就判定卡死。把这两层合起来,你才算真正能识别"原地打转"——一个判它"在重复做事",一个判它"做了事却没结果"。步数、终止、循环都管住了,可还有一种开销维度,它们都框不住——花掉的钱。
五、成本上限:按 token 和钱刹车
前面几道刹车,都是按"轮数"或"状态"来刹的。但开头那个最先把我打懵的问题——一次请求烧光当天额度——它提醒了一件事:步数少,不代表花钱少。一个 Agent 走 10 步,如果每一步都带着一段越来越长的对话历史,那这 10 步烧的 token,可能比另一个走 30 步的任务还多。所以必须有一道独立按 token 和花费来记账的闸:
class CostTracker:
"""成本上限:按累计 token 与累计花费记账,任何一项触顶就叫停。"""
def __init__(self, max_tokens, max_usd):
self.max_tokens = max_tokens
self.max_usd = max_usd
self.tokens = 0
self.usd = 0.0
def add(self, prompt_tokens, completion_tokens, price_per_1k):
# 每调用一次模型,就把这一次的 token 和花费记进总账
self.tokens += prompt_tokens + completion_tokens
self.usd += (prompt_tokens + completion_tokens) / 1000 * price_per_1k
def over_budget(self):
# 步数没到上限,不代表还能继续 —— 钱和 token 是另一道独立的闸
return self.tokens >= self.max_tokens or self.usd >= self.max_usd
这里的认知要点是:成本上限要扭转的想当然,是"我已经限制了步数,开销自然就被限制住了"。这个推理是错的,因为它默认"每一步的开销是个常数"。Agent 循环恰恰不满足这个前提:它的对话历史是逐轮累积的,第 10 步要带的上下文,远比第 1 步长,而模型按 token 收费——于是越往后,每一步越贵。步数是线性增长的,单步成本却是递增的,两者一乘,总成本是超线性增长的。这就是为什么"步数没爆、钱先爆了"会发生。所以成本必须有一道独立于步数的闸:CostTracker 不关心你走了几步,它只盯着一个累计的总账——累计 token、累计花费,任何一项触顶,立刻叫停。这道闸还有一个步数闸给不了的好处:可预测性与可控性。当你的刹车标准是"累计花费不超过 X",你就能对每一次 Agent 运行的成本上界给出一个确定的承诺——这个承诺对一个要上线、要面对真实流量、要控制预算的产品来说,是必须的。把这道理推到极致:一个成熟的 Agent 系统,它的成本上限往往不只在"单次运行"这一层,还会叠加"单用户每日累计""全系统每分钟累计"这些更大范围的闸——但无论哪一层,内核都是同一个:开销必须有一个用钱本身度量的、独立的硬上限,绝不能指望"限制了步数"去顺带把它管住。主干的几道刹车都齐了,最后是几个把 Agent 循环真正用到生产里才会撞见的工程坑。
六、工程坑:单步超时、可观测、人工介入
主干之外,还有几个工程坑,不处理就会让你的循环控制在边角上漏掉。坑 1:单步执行也要带超时。前面的时间上限管的是"整个任务"的墙钟时间,可如果某一步(一次模型调用、一次工具调用)卡死了,整个循环会一直干等在那一步上。每一步都要套一个单步超时:
import concurrent.futures
def llm_step_with_timeout(state, timeout_s):
"""单步执行也要带超时:一步卡住,不能拖垮整个任务的时间预算。"""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(_do_one_step, state)
try:
return future.result(timeout=timeout_s)
except concurrent.futures.TimeoutError:
# 这一步超时:当作一次失败步,交回主循环按停因处理,而不是干等
raise StepTimeoutError("单步执行超时")
坑 2:任何一种退出,都要走统一的收尾。循环可能因为六种 StopReason 里的任意一种而退出。无论哪一种,都必须给调用方一个明确的结果——尤其是,非正常退出时,绝不能返回一个"假装成功"的结果:
def finalize(state, reason):
"""收尾:无论哪种原因退出循环,都必须给调用方一个明确的结果。"""
audit_log(task_id=state.task_id, steps=state.step_count,
tokens=state.total_tokens, stop_reason=reason.value)
if reason == StopReason.FINAL_ANSWER:
return {"ok": True, "answer": state.final_answer, "reason": reason.value}
# 非正常停因:绝不返回一个"假装成功"的结果,要如实告知任务并未完成
return {
"ok": False,
"reason": reason.value,
"partial": state.best_effort_summary(), # 把已完成的部分如实交出来
}
坑 3:循环过程必须可观测。Agent 在循环里每一步调了什么工具、传了什么参数、烧了多少 token、当前进展如何,都要结构化地记下来。出了"烧光额度""原地打转"这类事故,你得能翻日志看清楚它到底在那一两百轮里干了什么——没有这份记录,这类问题根本无从复盘。坑 4:留一个人工介入的口子。对高风险或开销特别大的任务,别让 Agent 一口气从头跑到尾。可以在关键步骤前(比如它要调一个有副作用的工具时)或步数过半时暂停,把当前进展交给人确认,人点头了再继续。坑 5:max_steps 不是越大越安全。有人觉得"那我把 max_steps 设成 1000,不就不会被它截断了"——这恰恰想反了。max_steps 是一道保护闸,不是一个要尽量满足的目标。它该设成"一个正常任务合理情况下用不到的轮数"——正常任务 5 步搞定,它设 12、15 就够;真撞上了上限,本身就说明这个任务不正常、该停下来报告,而不是该把闸放得更宽。坑 6:并发跑多个 Agent 时,预算要算总账。单个 Agent 的成本上限管住了,可如果你同时跑着几十个 Agent,几十份"单个上限"加起来依然能把总预算撑爆。要有一个全局的、跨任务的配额,在调度层就把"同时在跑的 Agent 总开销"限制住。坑 7:终止条件的优先级要想清楚。check_stop 里,FINAL_ANSWER 的判断放在最前面是有意的:如果模型这一轮既给出了最终答案、又恰好撞上了步数上限,你应当判它正常完成,而不是强制停——已经办成的事,要认。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 等模型说完成的错 | 模型循环没有"会收敛"的内建保证,刹车不能交给它 |
| 步数预算 | 用 for 循环给迭代轮数装一道写死的硬上限,到顶强制停 |
| 停因显式枚举 | 把"为什么停"列成 StopReason,而非只有"停了"一种状态 |
| 结构化表态 | 模型用字段说明是工具调用还是最终答案,不靠文本搜词 |
| check_stop 放在轮首 | 先判该不该停,再决定要不要发这一轮的模型调用 |
| 循环检测 | 给每步动作算指纹,同一动作重复超阈值即判原地打转 |
| 无进展检测 | 比对状态快照,连续多步无变化即判卡死 |
| 成本上限 | 按累计 token 与花费记账,是独立于步数的另一道闸 |
| 单步超时 | 整体超时之外,每一步也要带超时,防一步卡死拖垮全局 |
| 统一收尾不假装成功 | 任何停因都走 finalize,非正常退出如实告知未完成 |
避坑清单
- 别用 while True 等模型说完成,模型循环没有"会收敛"的保证。
- 用 for 加 max_steps 给迭代轮数装一道硬上限,到顶强制停。
- 把退出原因显式枚举成 StopReason,别让循环只有"停了"一种含糊状态。
- 让模型用结构化字段表态,别靠在文本里搜"完成"二字判断终止。
- check_stop 放在每一轮开头,先判该不该停,再发模型调用。
- 加循环检测,同一动作重复超阈值就判原地打转、当场强制停。
- 加无进展检测,连续多步状态快照无变化就判卡死、强制停。
- 成本上限按 token 和花费独立记账,别指望限制步数顺带管住开销。
- 单步执行也要带超时,一步卡住不能拖垮整个任务的时间预算。
- 任何原因退出都走统一收尾,非正常停因绝不返回假装成功的结果。
总结
回头看那串"烧光额度、反复横跳、办完不停改坏结果、半路含糊地停"的问题,以及我后来在 Agent 循环上接连踩的坑,最该记住的不是某一个检测函数的写法,而是我动手前那个想当然的判断——"让 Agent 自己跑多步任务,就是写个循环、等它说完成就退出"。这句话错在它把"Agent 的循环"和"普通的 while 循环",当成了同一种东西。我以为写个循环、让它一轮轮调工具、等它说完成,这件事就办成了。可我忽略了一件最要紧的事:一个普通 while 循环之所以让人放心,是因为它的退出条件是一段确定的、单调推进的代码,语言本身就保证了它必然收敛。而 Agent 循环的退出,靠的是模型每一轮即兴判断"要不要继续"——这个判断不确定、可能永不收敛、可能误判。我把"循环一定会停"这份本属于普通循环的安心感,想当然地搬到了一个根本不具备这种保证的东西上。这个错配,本地开发时根本看不出来——因为本地的测试任务都简单,模型三五步就想明白、痛快地说出"完成";它只会在真实的、复杂的任务里,在模型于多步推理中迷路时,以烧光额度、原地打转这些方式爆出来。
所以做对 Agent 的循环控制,真正的功夫不在"写一个判断完成的函数"那几行上。判断完成本身不难。真正的功夫,在于你要从一开始就承认"这个循环不会自己收敛",然后从循环外面,给它强行装上一整套一定会触发的硬刹车:你不能指望模型自觉,就用 for 加 max_steps 装一道谁也突破不了的步数硬上限;你要分清成功和失败,就把退出原因显式枚举成 StopReason;你怕它原地打转,就加循环检测和无进展检测、当场把空转抓住;你怕它步数没爆钱先爆,就用一道独立的成本上限按 token 和花费记账;而到了单步超时、退出收尾、可观测这些边角上,你还要处处守住,别留一处能让循环失控的缺口。这篇文章的几节,其实就是顺着这套刹车展开的:先想清楚"等它说完成就退出"为什么错,再讲步数预算怎么定、终止条件怎么枚举、循环检测怎么做、成本上限怎么设,最后是单步超时、收尾、人工介入这几个把循环守扎实的工程细节。
你会发现,Agent 的循环控制这件事,和现实里"一个主持人怎么开好一场会"完全相通。一个不靠谱的主持人会怎么开会?他不定议程、不设时长,把会开成什么样,全凭参会的人"自己觉得聊得差不多了"。于是会议要么没完没了——大家从一个话题扯到另一个话题,谁也不喊停,几个钟头过去了还在开;要么原地兜圈子——同样几个观点,这个人说一遍、那个人再说一遍,反复拉锯、毫无结论;要么没等正事谈完,大家就稀稀拉拉散了,留下一个谁也说不清"到底定没定下来"的烂尾。而一个靠谱的主持人怎么开会?他开会前就把议程和每一项的目标定死——议程走完,会就该结束,而不是等大家"感觉聊够了"(这就是显式的终止条件);他给整场会和每一项议题都卡死了时长,时间一到,有没有结论都得往下走、或者收尾(这就是步数预算和单步超时);他盯着会场,一旦发现大家在同一个点上反复绕、毫无推进,立刻喊停、换话题或直接拍板(这就是循环检测);散会时,他一定给一个明确的交代——哪些定了、哪些没定、没定的为什么没定,绝不让会议不明不白地烂尾(这就是统一收尾、绝不假装成功)。同样是开一场会,不靠谱的主持人把会议能不能结束、有没有结论,全交给参会者的随性,靠谱的主持人用议程、时长、节奏这几把硬尺子,从外面把整场会牢牢框住——差别不在"开会这件事本身难不难",只在主持人心里有没有"一场会必须有人从外面控住它的边界"这根弦。
最后想说,Agent 的循环控制做没做对,差距永远不会在"本地开发、自己发几条简单指令"时暴露——本地你测的任务都简单,模型三五轮就想明白、爽快地说一句"完成",你那个 while True 恰好在第三五轮被接住、干净退出,你自然觉得"Agent 跑任务嘛,不就是个循环、等它说完成"一点问题都没有。它只在真实的、复杂的、会让模型在多步推理里迷路的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为循环没有硬上限,眼睁睁看着一次请求烧光一整天的额度,会因为没有循环检测,让 Agent 在几个工具间空转上百轮、忙忙碌碌却一寸没动,会因为只认"完成"二字,让任务半路含糊地烂尾、你还以为它办成了;而做对了,你的每一次 Agent 运行都有一个写死的步数上限、一道独立的成本闸、一套能识别打转的检测,最坏情况下的开销是有限的、可预估的,每一次退出都带着明确的原因、绝不假装成功。所以别等"一次请求烧光额度"那一刻找上门,在你写下每一个驱动 Agent 的循环时就该想清楚:这个循环有步数硬上限吗、有成本闸吗、能识别原地打转吗、退出时分得清成功和失败吗、会不会半路假装成功地烂尾,这一道道刹车,我是不是都替这个循环装上了?这些问题有了答案,你交付的才不只是一个"本地几步就退"的循环,而是一个无论任务多复杂、模型多迷路,都收得住、停得明白、烧不爆的、让人放心的 Agent。
—— 别看了 · 2026