那天早上我是被一条账单告警短信叫醒的:我们一个跑在生产环境的 AI Agent,在一夜之间烧掉了平时一个月的 API 费用。这个 Agent 的活儿很简单——帮用户查订单状态、必要时调几个内部接口、然后用自然语言回复。可监控面板上的曲线触目惊心:区区几十个对话,却产生了四千多次大模型调用,而且整夜都没停过。我点开其中一个对话的日志,头皮发麻——同一个工具,被这个 Agent 一遍又一遍地调用了几百次,每次都失败,可它从不放弃,像鬼打墙一样无限重试到天亮。
根因后来查清了,简单得让人哭笑不得:那个 query_order 工具因为下游抖动,开始返回一句没头没脑的 "internal error"。模型看不懂这句话到底是"参数错了""稍后重试"还是"这事干不成了",于是它最擅长的事就上演了——换个姿势接着试。而我们的 Agent 循环既没有步数上限、也没有成本预算、更没有"该收手了"的终止判断,于是这场无意义的重试就一路烧到了天亮。这篇就从这个"停不下来的 Agent"讲起,把 AI Agent 的设计真正讲透:循环到底在循环什么、它为什么会失控、刹车该装在哪、工具该怎么设计,以及一个最反直觉的真相——让 Agent 跑起来很容易,让它该停的时候停下来才是真功夫。
先认清:Agent 失控有哪几种典型姿势
在拆解机制前,先把 Agent 跑飞的几种典型模式摆出来。我那次中的是第一种,但其余几种我后来也都踩过,它们背后是同一类设计缺失:
| Agent 失控的姿势 | 根因 | 后果 |
|---|---|---|
| 同一工具无限重试 | 工具错误反馈含糊,模型分不清"重试"还是"放弃";循环无步数上限 | API 费用爆炸、对话永不结束 |
| 工具参数是模型瞎编的 | 直接信任模型输出的参数,不做 schema 校验 | 传入非法参数、误操作数据 |
| 上下文越滚越大 | 每轮把全部历史无脑塞回 prompt | token 暴涨、超窗报错、成本飙升 |
| 每一步都"成功",任务却失败 | 只看单步结果,没有任务级的成功判定 | Agent 自我感觉良好地交付错误答案 |
| 调了有副作用的工具还反复调 | 工具不幂等,又没有人工确认环节 | 重复下单、重复发消息等真实损失 |
这张表里每一条的共同点是:Agent 的"自主性"是把双刃剑——你给它自己决定下一步做什么的权力,就必须同时给它"什么时候不该做、该停"的边界。没有边界的自主,就是失控。下面先把 Agent 循环这台发动机讲清楚,再逐个把刹车和护栏装上去。
第一件事:看懂 Agent 循环到底在循环什么
很多人觉得 Agent 很玄,其实它的内核朴素得很——就是一个"思考→行动→观察"的循环(ReAct 范式):模型想一下该干嘛、决定调用某个工具、拿到工具返回的结果、再把结果喂回去接着想,如此往复,直到它认为可以给出最终答案。画成图是这样:
注意那条虚线——它就是我那次事故的全部祸根:F → B 这个回环本身没有任何"该停了"的判断,只要模型一直选"调工具",这个圈就能永远转下去。用最小的代码把这个循环写出来,你会发现它确实简单到危险:
# 一个最小的 Agent 循环 —— 注意它有多容易失控
def run_agent(user_input, tools):
messages = [{"role": "user", "content": user_input}]
while True: # ← 致命:没有任何退出上限
resp = llm.chat(messages, tools=tools) # 模型决定下一步
if resp.tool_call: # 模型想调工具
result = exec_tool(resp.tool_call) # 执行它
messages.append(tool_result(result)) # 把结果喂回去
continue # ← 接着转,永不停歇
else:
return resp.content # 只有模型主动给答案才结束
看清楚这个 while True 没有:整个循环的"是否继续",百分之百交给了模型的一念之间。模型状态好,它会在合适的时候返回答案、跳出循环;模型一旦因为看不懂工具报错而陷入"再试一次"的执念,这个 while True 就成了一台烧钱永动机。第一课因此无比清晰:绝不能让循环的终止权只掌握在模型手里,你必须从外部给它套上硬性的刹车。下一节就装这个刹车。
第二件事:给循环装上硬刹车——步数预算与终止条件
治本的第一步,是把"什么时候停"从模型手里抢回来,变成循环外部的硬性约束。最基本的两道刹车:一个最大步数上限(转够 N 圈无论如何都退出),一个成本预算(累计 token / 调用次数超阈值就熔断)。再加上对"同一工具连续失败"的检测,我那场通宵烧钱本可以在第 5 次就被掐断:
# 给 Agent 循环装上多重刹车
def run_agent(user_input, tools, max_steps=10, max_tokens=20000):
messages = [{"role": "user", "content": user_input}]
total_tokens = 0
consecutive_fail = 0 # 连续失败计数
for step in range(max_steps): # ← 刹车1:硬性步数上限
resp = llm.chat(messages, tools=tools)
total_tokens += resp.usage.total_tokens
if total_tokens > max_tokens: # ← 刹车2:成本预算熔断
return "任务过于复杂,已达资源上限,转人工处理"
if not resp.tool_call:
return resp.content # 正常给出答案,退出
result = exec_tool(resp.tool_call)
if result.is_error:
consecutive_fail += 1
if consecutive_fail >= 3: # ← 刹车3:同一类失败别死磕
return "依赖的服务暂时不可用,稍后再试"
else:
consecutive_fail = 0
messages.append(tool_result(result))
# ← 刹车4:转够 max_steps 还没结论,主动收尾而不是无限转
return "处理步骤过多,已停止;请把问题描述得更具体些"
这四道刹车里,最大步数上限是底线中的底线——它是你跟"无限循环烧钱"之间唯一的硬墙。哪怕其它逻辑全写错了,只要有这堵墙,损失也被锁死在 max_steps 次调用之内。成本预算则是给"步数少但每步巨贵"的情况兜底。记住:Agent 循环的默认形态不该是 while True,而该是 for step in range(max_steps)——把"无限"换成"有限",是 Agent 工程化的第一条纪律。
第三件事:工具的错误反馈,要让模型"看得懂、知道下一步"
装好刹车只是止损,真正的根因还在那句 "internal error" 上。模型决定下一步全靠工具返回的那段文字——你给它一句含糊的报错,它就只能瞎猜;你给它一句明确的指引,它才能做对决策。这是 Agent 工具设计里最被低估、却最致命的一环:
# 反例:含糊的错误,模型根本不知道该重试、改参数、还是放弃
def query_order_bad(order_id):
try:
return db.get_order(order_id)
except Exception:
return {"error": "internal error"} # ← 灾难之源:模型只会一遍遍重试
# 正解:结构化、可执行的错误,明确告诉模型"这是什么错、下一步该怎么办"
def query_order_good(order_id):
if not is_valid_id(order_id):
return {"ok": False, "error_type": "INVALID_ARG",
"message": f"order_id 格式不对: {order_id}",
"action": "请检查并修正 order_id,不要重试原值"}
try:
order = db.get_order(order_id)
if order is None:
return {"ok": False, "error_type": "NOT_FOUND",
"message": f"订单 {order_id} 不存在",
"action": "不要重试,直接告知用户该订单不存在"}
return {"ok": True, "data": order}
except TimeoutError:
return {"ok": False, "error_type": "RETRYABLE",
"message": "查询超时", "action": "可稍后重试,最多 1 次"}
except Exception as e:
return {"ok": False, "error_type": "FATAL",
"message": str(e), "action": "这是不可恢复错误,停止重试并上报"}
区别就在于:好的错误返回里有一个 action 字段,明确区分了"参数错(改了再试)""不存在(别试了,直接答复)""可重试(限次)""致命(立刻停)"。模型读到 "不要重试,直接告知用户",就不会再傻乎乎地循环;读到 "最多重试 1 次",就有了次数概念。一条原则:工具的每一个返回——尤其是错误——都要写得像是在对一个只看文字做决策的下属交代清楚,而不是甩一句 "error" 让它自己悟。我那次事故,只要 query_order 早返回 error_type: FATAL, action: 停止重试,模型大概率第一次就收手了。
第四件事:工具参数别全信模型,执行前先按 schema 校验
Agent 调工具时,工具名和参数都是模型"生成"出来的——而生成,就意味着可能幻觉:它可能编出一个不存在的参数、把日期写成 "明天" 这种自然语言、或者给一个本该是正整数的字段塞个负数。把模型吐出来的参数直接拿去执行,等于让一个偶尔会说胡话的实习生不经审核就动生产数据。正确做法是:执行前,先用 schema 校一遍:
# 用 schema 给每个工具的参数定规矩,执行前强制校验
from pydantic import BaseModel, field_validator
class RefundArgs(BaseModel):
order_id: str
amount: float
@field_validator("amount")
@classmethod
def amount_must_be_positive(cls, v):
if v <= 0:
raise ValueError("退款金额必须为正")
return v
def exec_tool(tool_call):
try:
# 模型给的参数先过 schema:类型、范围、必填一律校验
args = RefundArgs(**tool_call.arguments)
except ValidationError as e:
# 校验失败不是崩溃,而是作为"可纠正的错误"反馈给模型
return {"ok": False, "error_type": "INVALID_ARG",
"message": str(e), "action": "请按要求修正参数后再调用"}
return do_refund(args.order_id, args.amount) # 校验通过才真正执行
这里有个一举两得的细节:校验失败时,不要直接抛异常崩掉,而要把校验错误当成一条结构化反馈喂回给模型——模型读到"金额必须为正",下一轮就会自己改对。这样 schema 既是"防止脏参数落地"的护栏,又是"引导模型自我纠正"的反馈通道。原则:凡是有副作用、会动数据的工具,参数必须经过 schema 校验才能执行;模型的输出是"建议",不是"命令"。
第五件事:管好上下文窗口,别让历史无脑膨胀
Agent 转的圈越多,对话历史越长。如果每一轮都把全部历史原封不动塞回 prompt,token 会随步数线性甚至更快地膨胀——既烧钱,又迟早撞上上下文窗口上限直接报错。尤其那些工具返回的大段 JSON、长文档,留着原文意义不大,却最占地方:
# 控制喂回模型的上下文:保头、保尾、压缩中间的冗长观察
def build_context(messages, max_turns=6):
system = messages[0] # 系统提示始终保留
recent = messages[-max_turns:] # 只保留最近几轮的完整内容
# 对更早的、冗长的工具观察结果,做摘要而非全文塞回
older = messages[1:-max_turns]
if older:
summary = summarize(older) # 压成一段"前情提要"
return [system, {"role": "system",
"content": f"早期步骤摘要:{summary}"}] + recent
return [system] + recent
# 工具返回的超长结果,落库存原文,只把精简版给模型
def tool_result(result):
full = json.dumps(result, ensure_ascii=False)
if len(full) > 2000:
ref = store_blob(full) # 原文存起来,留个引用
return {"summary": truncate(full, 500), "blob_ref": ref}
return result
核心思路是"保头保尾、压缩中间":系统提示和最近几轮要原样保留(它们最影响下一步决策),更早的历史则摘要成一段"前情提要";工具吐出的超长结果,原文落库、只把精简版喂给模型。一条原则:上下文是 Agent 最贵的资源,要像管理内存一样主动管理它——该截断截断、该摘要摘要,别让它无限增长。
第六件事:给每一步留痕,失控了能复盘
我那次能在几小时内定位根因,全靠日志里完整记着每一步"模型想调什么、传了什么参、工具返回了什么"。Agent 是个会自己做决策的黑盒,没有结构化的留痕,出了事你只能干瞪眼。每一步都应记录成一条可检索、可聚合的结构化日志:
# 每一步都留下结构化痕迹:谁、第几步、调了什么、结果如何、花了多少
def log_step(trace_id, step, resp, result, tokens):
logger.info({
"trace_id": trace_id, # 一次完整任务的唯一ID,把所有步骤串起来
"step": step, # 第几步
"tool": resp.tool_call.name if resp.tool_call else None,
"args": resp.tool_call.arguments if resp.tool_call else None,
"result_type": result.error_type if result else "final_answer",
"tokens": tokens, # 这一步的 token 消耗
"latency_ms": resp.latency,
})
有了 trace_id 把一次任务的所有步骤串起来,你就能轻松回答这些救命的问题:哪个工具失败率最高?哪类任务平均要转多少圈?哪个对话的 token 消耗异常?我那次正是靠"按 trace_id 聚合后发现某几个对话步数高达几百"才一眼锁定问题。原则:可观测性不是 Agent 上线后的奢侈品,而是它失控时你唯一的救生索——从第一天就把每步留痕做进去。
把整套设计收成一棵决策树
把前面六件事串起来,下次你设计或排查一个 Agent,照着这棵树过一遍,基本能避开最坑的那几个雷:
这棵树的总开关,就是开头那个烧钱教训:设计 Agent 的第一反应,不是"它能干多少事",而是"它会不会停、停得下来吗"。这一个分叉,就避开了本文最大的那个无限循环烧钱的坑。
收口成几条 AI Agent 的铁律
- 循环默认有限,不要 while True:用
for step in range(max_steps),再叠加成本预算熔断——这是你和"无限烧钱"之间唯一的硬墙。 - 工具错误必须结构化、可执行:带
error_type和action,明确区分"改参重试 / 别试了 / 限次重试 / 立刻停",别甩一句"error"让模型瞎猜。 - 模型输出的参数是"建议"不是"命令":有副作用的工具,参数执行前一律 schema 校验,失败就作为反馈让模型自我纠正。
- 像管内存一样管上下文:保头保尾、摘要中间、长结果落库,别让历史随步数无限膨胀。
- 每一步都结构化留痕:带
trace_id串起整条链路,失控时这是你唯一的复盘依据。 - 区分单步成功与任务成功:每步都"成功"不等于任务做对了,要有任务级的结果校验。
- 有副作用的工具要幂等或加确认:下单、发消息、转账这类操作,要么设计成可去重的幂等调用,要么插入人工确认环节。
几个特别容易踩的认知误区
这套经验讲给同事时,有几个误区几乎人人都有,值得专门点破。
第一个、也是最致命的:"模型足够聪明,它自己知道什么时候该停。" 这正是我那次烧钱的认知根源。模型擅长的是"在当前上下文里生成一个看起来合理的下一步",但它没有全局的"我已经试了 300 次、该放弃了"这种意识——每一轮对它都像是"第一次尝试"。终止判断必须由你在循环外部用代码强制实现,把它寄托在模型的自觉上,就是把账单的安全交给运气。
第二个误区:"工具返回什么不重要,模型能看懂。" 恰恰相反,工具返回是模型决策的唯一输入,它的质量直接决定 Agent 的行为质量。一句 "error" 和一句 "NOT_FOUND,别重试,直接告知用户",会导出天差地别的后续行为。设计工具返回时,要时刻想着"模型读到这句话,会做出什么决策"——你是在给它写决策依据,不是在写给人看的日志。
第三个误区:"参数是模型生成的,应该没问题,直接用就行。" 模型会幻觉,会把不存在的字段、不合法的值一本正经地生成出来。对只读查询,坏参数顶多查不到;但对会动数据、有副作用的工具,一个幻觉参数可能就是一次错误退款、一条误发的消息。副作用工具的参数,执行前必须校验,这是不可省略的安全门。
第四个误区:"每一步工具都调成功了,任务肯定就完成了。" 不一定。Agent 可能每步都"成功",却把整件事做歪了——查对了订单却答非所问,调通了接口却用错了结果。单步成功只是局部正确,任务是否真的达成,需要一个独立的、任务级的校验(比如最终答案是否回应了用户的真实问题),而不是看"工具有没有报错"。
再补一刀:有副作用的工具,要么幂等,要么加人工确认
前面的刹车和校验,挡住的主要是"读"和"瞎试"的失控。但还有一类风险更可怕:当 Agent 调用的是会真实改变世界的工具——下单、退款、发消息、删数据——一次失控就不再只是烧 token,而是真金白银的损失。想象一下,如果开头那个无限重试的工具不是 query_order(只读),而是 create_refund(退款),那一夜烧掉的就不是 API 费用,而是几百笔重复退款了。
对付这类工具,有两道必须的护栏。第一道是幂等性:让"重复调用"和"调用一次"产生相同的结果,这样即便 Agent 因为看不懂返回而重试,也不会真的执行第二次:
# 用幂等键让重复调用安全:同一个 idempotency_key 只会真正执行一次
def create_refund(order_id, amount, idempotency_key):
# 先查这个键是否已处理过,处理过就直接返回上次的结果
existing = refund_store.get(idempotency_key)
if existing:
return {"ok": True, "data": existing, "note": "幂等命中,未重复退款"}
result = payment.refund(order_id, amount)
refund_store.save(idempotency_key, result) # 记录,供下次重试时去重
return {"ok": True, "data": result}
幂等键通常可以由"订单号 + 操作类型"这类业务语义稳定地生成,这样模型即使把同一个退款请求发了五次,真正执行的也只有第一次,后四次都会命中去重、安全返回。
第二道护栏是人工确认(human-in-the-loop):对金额大、不可逆、影响面广的操作,不让 Agent 自主执行,而是让它"提议",由人点头后再落地:
# 高风险操作:Agent 只能"提议",必须人工确认后才执行
def exec_tool(tool_call):
if tool_call.name in HIGH_RISK_TOOLS: # 退款、删除等高风险操作
if tool_call.arguments.get("amount", 0) > 1000:
# 不直接执行,而是挂起等待人工审批
return {"ok": False, "error_type": "NEED_APPROVAL",
"message": "金额超过 1000,需人工审批",
"action": "已提交审批,请告知用户稍候,不要重复提交"}
return do_exec(tool_call)
这两道护栏的取舍很清楚:能做成幂等的,就用幂等键兜住重试风险;无法幂等或后果严重的,就插入人工确认这个断点。判断标准是看操作的"可逆性"和"代价"——查询随便重试,发消息要幂等,大额退款则必须有人按下确认键。一条原则:给 Agent 的工具权限,要和它失控时可能造成的损失成反比——越是不可逆、代价越大的操作,越要收紧它的自主权。
写在最后
回到开头那个烧光预算的夜晚。最终的修复,是给 Agent 循环加上了 max_steps 和成本熔断,把所有工具的错误返回改成了带 action 的结构化格式,再给副作用工具补上了参数校验——重新上线后,即便下游再抖动,Agent 顶多试个两三次就会带着明确的说明体面退出,再没出现过那种通宵打转的惨剧。改动算不上大,可它逼着我把"Agent 的自主性到底意味着什么"从头到尾想明白了一遍。
这件事给我最深的体会是:我们太容易被"让 AI 自己决定下一步"的强大迷住,以至于忘了——自主性的另一面,是它同样会自主地犯错、自主地停不下来。一个能干活的 Agent,Demo 里跑通就行;一个能上生产的 Agent,八成的工程量其实都花在"它失控时怎么办"上:刹车、护栏、可观测、人工兜底。它不是"写个 prompt、挂几个工具"那么表面的事;它是一套你必须时刻假设"模型会做最蠢的决策"来设计的系统。下次你又准备给 Agent 放开手脚的时候,想想我那个烧了一整夜的账单——那个停不下来的循环,可能就是你下个月那张吓人账单背后的原因。
—— 别看了 · 2026