我们给客服系统接了个 AI Agent,本意是让它自动处理一部分简单工单:用户问"我的订单到哪了",它就调个查询工具去取物流、组织语言回复。Demo 演示时它聪明得像个真人,老板当场拍板上线。然后,上线的第一个晚上,它就给了我一个永生难忘的教训。
第二天一早我被报警短信叫醒:某第三方物流查询接口的调用量在一夜之间暴涨了几万倍,把对方接口打到限流;与此同时,我们的 LLM API 账单一夜之间多了好几百美金。我冲到电脑前扒日志,看到的景象让我头皮发麻——有那么几个工单,Agent 对着同一个查询工具,一晚上调用了上万次。
把其中一个工单的执行轨迹拉出来,死循环的样子触目惊心:Agent 调"查订单"工具 → 工具因为单号格式不对返回了个含糊的错误 → Agent 没看懂,觉得"那再查一次吧" → 又调一次 → 又是同样的错误 → 再查……就这样,它在一个它自己永远走不出去的圈里,一遍遍地空转,既烧着我们的 token,又锤着下游的接口,而整个过程没有任何机制踩下哪怕一脚刹车。
那一刻我才意识到:我们花了大力气调 prompt、选模型,却忘了 Agent 骨子里是一个"会自己决定下一步做什么"的循环——而任何一个能自主循环的系统,只要没有边界和刹车,就一定会在某个意想不到的地方失控。这篇文章,就是我把那次"烧钱死循环"事故复盘透彻之后,整理出的一份 AI Agent 工程化避坑指南。它不聊那些炫酷的 Agent 能力演示,只讲怎么让一个 Agent 在生产环境里"不闯祸"。
先纠正几个关于 AI Agent 的常见误解
动手之前,先把几个我曾经深信、后来被这次事故狠狠纠正的误解摆出来。如果你也在做 Agent,这几条大概率能帮你提前避开深坑。
| 常见误解 | 真相 |
|---|---|
| Agent 很聪明,会自己判断什么时候该停 | 它只会"贪婪地往下走",没有外部刹车,极易陷入死循环或反复重试 |
| prompt 写好了,Agent 行为就可控了 | prompt 影响倾向,但挡不住失控;真正的边界要靠代码层的步数/超时/预算限制 |
| 工具描述随便写写,模型能猜到怎么用 | 工具的 description 和返回的错误信息,本质是"写给模型读的文档",含糊就会误导它 |
| 把对话历史一股脑全塞进去,上下文越全越好 | 历史会无限膨胀,撑爆 context 窗口、推高成本、还稀释模型注意力 |
| Agent 能自动完成任务,就该让它全自动 | 高风险动作(扣款/删数据/对外发消息)必须留人类确认这道关 |
| 工具调失败了,让 Agent 重试就好 | 无脑重试正是死循环之源;失败要带可理解的原因,并配合重试次数上限 |
第一件事:看清本质——Agent 就是一个"带工具的 while 循环"
要防住失控,得先剥开 Agent 那层"智能"的外衣,看清它的骨架。所谓 AI Agent,本质上就是一个循环:模型思考下一步该干嘛 → 选一个工具并给出参数 → 系统执行这个工具 → 把结果(观察)喂回给模型 → 模型再思考……如此往复,直到它认为任务完成。这就是业界常说的 ReAct(Reason + Act)范式。
这个循环的威力在于"自主"——模型能根据每一步的结果动态决定下一步,不需要你写死流程。但它的风险也恰恰在于"自主":既然下一步由模型自己定,那么只要模型反复做出"再试一次"的判断,这个 while 循环就永远不会退出。我那次的死循环,本质就是模型在"工具失败"后一根筋地选择了"重试",而循环本身没有任何"你已经转了太多圈了,停下"的约束。把这个骨架画出来:
看懂这张图,治理思路就清晰了:这个循环的每一条边、每一个环节,都需要被装上约束。循环转了多少圈要有上限(防死循环),每个工具要让模型能正确使用、失败时能正确理解(防瞎转),"任务完成"要有明确信号(让它知道何时该退出),高风险的工具执行前要有闸门(防它闯祸)。下面就沿着这个循环,一处一处把刹车和护栏装上。
第二件事:给循环装刹车——最大步数、超时、预算三重上限
解决那次事故的第一刀,也是最关键的一刀:给 Agent 的循环装上硬性刹车。无论模型多么"想继续",这些刹车都是代码层面的铁律,踩到线就强制停。最基本的三道:最大迭代步数、整体超时、调用预算。
# 给 Agent 循环装上三重硬刹车,任何一个触发都强制终止
def run_agent(task, max_steps=10, deadline_s=60, max_tool_calls=20):
start = time.time()
tool_calls = 0
messages = [{"role": "user", "content": task}]
for step in range(max_steps): # 刹车1:步数上限
if time.time() - start > deadline_s: # 刹车2:总超时
return "任务超时,已终止"
if tool_calls >= max_tool_calls: # 刹车3:工具调用预算
return "工具调用次数超限,已终止"
resp = llm.chat(messages, tools=TOOLS)
if resp.finish: # 模型主动给出最终答案
return resp.content
# 否则执行工具调用
result = execute_tool(resp.tool, resp.args)
tool_calls += 1
messages.append({"role": "tool", "content": result})
return "已达最大步数,任务未完成" # 兜底:转够圈数也必须退出
这段代码的核心思想很朴素:永远不要相信循环会自己停下来,要让它"无论如何最多转 N 圈"。那次事故里,只要当初有一行 max_steps=10,死循环最多空转十次就会被掐断,根本烧不到几百美金、更打不爆下游。这三道刹车里,最大步数是底线中的底线——它是你和"无限循环"之间最后、也是最可靠的一道物理隔离。预算和超时则进一步从"花了多少钱""花了多少时间"两个维度兜底,三者叠加,失控的代价就被牢牢锁死在一个可接受的范围内。
第三件事:工具的描述和错误,是写给模型读的"文档"
刹车装上后,我开始反思一个更深的问题:那个工具为什么会让模型陷进重试的死胡同?扒开一看,根子在工具本身——它的描述含糊,失败时返回的错误信息也是一句模型读不懂的乱码。我这才意识到:对 Agent 来说,工具的 description 和它返回的内容(尤其是错误),本质上是"写给模型看的文档"。文档写得烂,再聪明的模型也会用错、会看不懂、会在原地打转。
# ❌ 反例:描述含糊,错误信息模型根本读不懂,只会让它瞎重试
def query_order(order_no):
if not valid(order_no):
raise Exception("err -1") # 模型:啥意思?那再试一次吧 → 死循环
...
# ✅ 正例:描述清晰,错误信息可读且给出"下一步该怎么办"
def query_order(order_no: str) -> dict:
"""根据订单号查询物流。order_no 必须是 'NO' 开头的 18 位字符串。"""
if not valid(order_no):
# 错误信息直接告诉模型:为什么错、该怎么纠正
return {"ok": False,
"error": "订单号格式错误,应为 NO 开头的 18 位字符串,"
"请向用户确认订单号,不要重复调用本工具。"}
return {"ok": True, "data": fetch_logistics(order_no)}
对比一下就明白差别:反例里抛出的 err -1,模型完全无法据此调整策略,只能盲目地"再试试";正例里的错误信息不仅说清了"哪里错了",还明确指引了"下一步该做什么(向用户确认、别再重试)"。一个好的 Agent 工具,它的返回值要像一个耐心的同事在跟模型对话:成功了给清晰的结果,失败了给可理解的原因和建议。这是从"瞎转"变成"会纠错"的关键——很多死循环,根上其实是"工具没把话说清楚"。
第四件事:给"任务完成"一个明确信号,别让模型自己悟
死循环的另一半成因是:循环不知道什么时候该退出。如果你只是模糊地期望"模型答完了自然就停",那它在遇到困难时,很容易把"停下来说我搞不定"误判成"我应该继续想办法"。正确的做法是给它一个明确的、结构化的"收工"动作——通常是专门设计一个 finish 工具,让"结束"变成一次显式的、模型必须主动做出的选择。
# 把"完成/放弃"都做成显式工具,让退出成为模型的一个明确动作
FINISH_TOOLS = [
{"name": "finish", "desc": "任务已成功完成时调用,附上给用户的最终回复。"},
{"name": "give_up", "desc": "确认无法完成(如信息不足、超出能力)时调用,说明原因。"},
]
def step(resp):
if resp.tool == "finish":
return ("done", resp.args["reply"]) # 正常收工
if resp.tool == "give_up":
return ("failed", resp.args["reason"]) # 体面地放弃,也是一种退出!
# ...执行其他业务工具
关键在那个 give_up:"放弃"必须和"完成"一样,是一条被明确允许、甚至被鼓励的退出路径。我那次事故里,模型其实早该"放弃"——订单号格式根本不对,它再查一万次也查不到。但因为系统从没告诉它"查不到时你可以体面地停下、转人工",它就只剩"继续重试"这一条路可走。给 Agent 一个"认输并退出"的出口,往往比让它"更努力地尝试"更重要。
第五件事:管好上下文,别让对话历史无限膨胀
Agent 每转一圈,都会把新的"思考-工具调用-观察结果"追加进消息历史。任务一长、工具调用一多,这个历史就会急剧膨胀,带来三个问题:撑爆模型的 context 窗口直接报错、每轮都把全部历史重发一遍导致 token 成本飙升、过长的上下文还会稀释模型注意力让它"忘了"最初的任务。
# 控制上下文规模:保留系统提示 + 最近 N 轮,更早的历史压缩成摘要
def trim_context(messages, keep_recent=6):
system = messages[0]
history = messages[1:]
if len(history) <= keep_recent:
return messages
old, recent = history[:-keep_recent], history[-keep_recent:]
summary = llm.summarize(old) # 把久远历史浓缩成一段摘要
return [system,
{"role": "system", "content": f"早期步骤摘要:{summary}"},
*recent] # 只保留摘要 + 最近几轮原文
常见的策略有几种:滑动窗口(只保留最近 N 轮)、摘要压缩(把久远的历史交给模型浓缩成一段)、以及只保留关键观察(丢掉中间冗长的原始工具输出,只留提炼后的结论)。核心原则是:喂给模型的不该是"全部历史",而该是"完成当前这步所需要的恰当上下文"。上下文不是越多越好,精准比完整更重要。
第六件事:高风险动作,必须留一道人类确认的闸门
最后一道、也是底线级别的护栏:凡是高风险、不可逆的动作,Agent 不能自己拍板,必须停下来等人类确认(human-in-the-loop)。查询类工具失控,顶多是烧钱、打爆接口;可如果失控的是"退款""删除数据""给用户群发消息"这类动作,后果就是真金白银的损失或无法挽回的事故。
# 工具分级:只读工具自动执行,高风险写操作必须人工确认
HIGH_RISK = {"refund", "delete_order", "send_sms_to_all"}
def execute_tool(name, args):
if name in HIGH_RISK:
# 不直接执行,而是挂起、生成一条待审批,等人点"同意"
ticket = create_approval(name, args)
return {"ok": False, "pending": ticket,
"error": f"该操作需人工审批(单号 {ticket}),已暂停,请等待确认。"}
return _do_execute(name, args) # 只读/低风险:照常自动执行
这里的设计哲学是按风险分级授权:查物流、查订单这类只读操作,放手让 Agent 自动干;而一旦涉及金钱、删除、对外通讯,就强制插入一道人工闸门。把这次事故的所有护栏收个尾,下面这张决策树是我沉淀出的"上线一个 Agent 前,该检查什么"速查图:
几条可以直接抄走的铁律
- Agent 本质是个能自主循环的系统,默认它不会自己停。失控是常态,边界要靠你来设。
- 循环必须有硬刹车:最大步数 + 总超时 + 调用预算,三者叠加,最大步数是底线。
- 工具的描述和错误信息是"写给模型的文档",要清晰、可读、并指引下一步,否则它会瞎转。
- 给"完成"和"放弃"都设计显式的退出动作,让 Agent 能体面地认输,而不是死磕到底。
- 主动管理上下文,用滑动窗口或摘要压缩,精准比完整更重要,别让历史撑爆窗口。
- 按风险给工具分级,只读的自动跑,高风险不可逆的(扣款/删除/群发)强制人工确认。
- 给 Agent 配全链路可观测,每一步思考、每次工具调用都要能回溯,出事才查得清。
顺手补上的一课:可观测性,是事后能不能查清的唯一依凭
那次事故能被快速定位,其实有点侥幸——我们恰好打了比较全的日志,才能把"调用了上万次"的轨迹原原本本地复原出来。复盘后我们立刻把可观测性当成 Agent 的标配来建。因为 Agent 的行为是模型动态决定的,你无法像传统程序那样靠读代码预判它会怎么走;唯一能搞清"它当时到底在想什么、干了什么"的办法,就是把每一步都记下来。
| 该记录的维度 | 为什么重要 |
|---|---|
| 每一步的思考(reasoning) | 复盘时能看懂它"为什么"做这个决定,定位逻辑跑偏的根因 |
| 每次工具调用的入参与返回 | 死循环、错误调用一目了然,是排查的第一现场 |
| 每一步的 token 消耗与累计成本 | 成本是 Agent 最容易失控的指标,要能实时看、能告警 |
| 循环步数与最终退出原因 | 区分"正常完成 / 放弃 / 撞上刹车",评估 Agent 健康度 |
| 整条轨迹的唯一 trace id | 把一次任务的所有步骤串起来,出问题能整条拎出来看 |
有了这套记录,Agent 才从一个"凭感觉调 prompt 的黑盒",变成一个"可量化、可排查、可优化"的工程系统。没有可观测性的 Agent,出了事你只能干瞪眼;有了它,每一次失控都会变成一次能复盘、能改进的具体案例。尤其是成本和步数这两个指标,强烈建议接入实时告警——它们一旦异常飙升,往往就是失控的最早信号,比等到账单出来或下游限流要早得多。
这次死循环,我们最后是怎么收尾的
定位到根因后,修复同样分了几层。最先做的是急刹:连夜给 Agent 循环加上 max_steps、超时和调用预算这三道硬刹车,先确保"无论如何不会再无限烧下去",这是止血。接着是治本:把所有工具的描述和错误返回逐个重写,让它们"说人话"、能指引模型纠错;再补上 finish/give_up 这对显式退出动作,给模型一条体面认输的路。
最后是加固:把退款等高风险动作全部改成需人工审批,并接入了完整的轨迹日志和成本告警。这一套打完,那个曾经一晚上能调用上万次的 Agent,现在最多转十步就会给出结果或干净利落地转人工——既没再烧过钱,也没再打爆过任何下游。从"聪明但危险"到"可靠且可控",中间隔的不是更强的模型,而正是这一层层并不性感、却至关重要的工程护栏。
别一上来就堆复杂度:能用工作流,就别用全自主 Agent
复盘到最后,我还想到一个更前置的问题:那个工单场景,真的需要一个"全自主决策"的 Agent 吗?坦白说,未必。很多被包装成"Agent"的需求,内核其实是一条步骤相对固定的工作流,根本不需要把"下一步做什么"完全交给模型去自由发挥。而我们一上来就给了它最大的自主权,失控的风险也随之拉满。
这里有个很实用的判断尺度:能用固定流程(workflow)解决的,就别用自主 Agent;非要 Agent 的,也优先给它最小的自主空间。所谓 workflow,就是你把步骤和分支用代码写死——比如"先解析订单号 → 校验格式 → 查物流 → 套模板回复",模型只在每一步内部做它擅长的"理解和生成",而不负责决定流程怎么走。这样既享受了大模型的语言能力,又把"乱跳步、死循环"的风险从根上消除了。
自主 Agent 真正的用武之地,是那些步骤无法预先确定、必须根据中间结果动态规划的开放性任务。这类场景确实存在,也确实强大,但它对工程护栏的要求,比固定工作流高出一个量级。一个成熟的判断是:自主性不是越高越好,而是"刚好够用就好"——你给模型的每一分额外自由,都要用对应的一分额外护栏去对冲。当初要是先问一句"这事真需要全自主吗",或许那个烧钱的夜晚根本就不会发生。
写在最后
这次事故给我最深的一课,是它纠正了我对 AI Agent 的一个根本性误解。在那之前,我下意识地把 Agent 当成一个"很聪明、会自己看着办"的智能体,潜意识里指望它能像个靠谱的人那样,遇到死胡同自己就退出来了。可现实是:模型再聪明,它也只是在每一步做局部最优的判断,它没有"全局的自我约束",不会数着自己转了多少圈,也不会心疼你的账单。"会自己停下来"这件事,从来不是模型的本能,而是工程师必须替它装上的能力。
所以做 Agent 这件事,最考验功力的部分,恰恰不在那些光鲜的能力演示里,而在这些"扫兴"的边界上:刹车、超时、预算、退出条件、上下文裁剪、人工闸门、全链路日志。这些护栏,决定了你的 Agent 究竟是一个能放心交给生产环境的工具,还是一个随时可能在某个深夜替你烧钱闯祸的定时炸弹。
如果要把这篇浓缩成一句能刻进脑子里的话,那就是:给 Agent 自主权之前,先给它装好刹车——你赋予它多大的行动自由,就必须为它设下多硬的安全边界。那个被报警短信叫醒、对着上万次工具调用日志倒吸凉气的早晨,大概是我做 AI 应用以来最深刻的一堂工程课。
如果你正准备把第一个 Agent 推上生产,不妨在上线前对照那张决策树逐条过一遍:刹车装了没、工具的话说清了没、退出信号有没有、上下文会不会爆、高风险动作拦了没、日志全不全。这六个问题答完,你就已经避开了我那晚踩中的几乎所有坑。把这些枯燥的护栏当成 Agent 的安全带——平时你感觉不到它的存在,真出事的那一刻,它就是把你和那份天价账单隔开的唯一一道东西。
—— 别看了 · 2026