Agent 一夜烧穿钱包:自主 Agent 护栏避坑复盘

那天早上我是被一条账单告警短信叫醒的:调用大模型的 API 账户一夜之间烧掉了平时一整周的额度。爬起来打开后台一看愣住了——一个前不久才上线的自动化运营 Agent,从凌晨两点多开始就一直在疯狂调用大模型,几个小时发起了上万次请求,而它本该处理的任务其实十分钟就该结束。它没崩溃没报错,而是以一种更烧钱的方式活着:陷入了死循环,反复调用同一个工具、反复失败、又反复重试,像一只困在玻璃窗前的苍蝇对着同一个方向一遍遍撞,直到把钱包撞穿。根因是那个订单本就不存在工具每次都返回错误,而 ReAct 模式的 Agent 没把错误理解成此路不通该停下,反而当成姿势不对换个参数再试,加上我们没给循环设任何上限,于是一个本该几步完成或优雅放弃的任务变成持续数小时上万次调用的烧钱马拉松。这篇文章从这次事故出发,讲透构建自主 AI Agent 的护栏:循环必须有硬上限、如何检测并打断重复失败、用 token 成本超时预算给钱包上锁、为何要校验大模型报上来的工具调用、高危操作要人工审批、如何管理上下文别让它越滚越大,以及一个根本认知——自主性是把双刃剑,追求能力的同时必须同等地构建约束。

那天早上我是被一条账单告警短信叫醒的:我们调用大模型的 API 账户,一夜之间烧掉了平时一整周的额度。我睡意全无,爬起来打开后台一看,愣住了——一个我们前不久才上线的自动化运营 Agent,从凌晨两点多开始,就一直在疯狂地调用大模型,几个小时里发起了上万次请求,而它本该处理的任务,其实十分钟就该结束了。它没崩溃、没报错退出,而是以一种更烧钱的方式"活着":它陷入了一个死循环,反反复复地调用同一个工具、反反复复地失败、又反反复复地重试,像一只困在玻璃窗前的苍蝇,对着同一个方向一遍遍地撞,直到把我们的钱包撞穿。

这是我第一次如此真切地体会到,做 AI Agent 和做传统软件,踩的坑是多么不同。传统程序死循环,顶多是 CPU 跑满、服务卡住;可一个会自主调用大模型、自主决策下一步动作的 Agent 一旦失控,它烧的是真金白银,而且烧起来悄无声息——没有报错、没有崩溃,只有账单在你看不见的地方疯涨。这篇文章,就从这次"Agent 把我钱包烧穿"的事故讲起,聊聊构建 AI Agent 时那些和传统编程截然不同、却又最容易致命的坑:失控的循环、缺失的护栏、以及如何给一个"会自己思考、自己行动"的系统,套上必要的缰绳。

故障现场:一只撞玻璃的苍蝇

先交代一下这个 Agent 是干什么的。它是一个基于 ReAct 模式(Reasoning + Acting,即"思考-行动"循环)的自动化 Agent:我们给它一个运营任务(比如"汇总昨天异常订单并生成报告"),它会自己思考该做什么,然后从我们提供的一组工具里挑一个来调用(查数据库、查日志、调内部 API 等),拿到结果后再思考下一步,如此循环,直到它认为任务完成、给出最终答案。这套模式很强大,也是当下 Agent 的主流范式。

那天夜里出事的,是它调用的一个"查询订单详情"的工具。我后来翻日志,把它的死循环过程还原了出来,看得人哭笑不得:

轮次 Agent 的思考与动作 工具返回
第 1 轮 我需要查订单详情,调 queryOrder(id=123) 报错:订单不存在
第 2 轮 可能 id 格式不对,试试 queryOrder(id="123") 报错:订单不存在
第 3 轮 也许要加前缀,试试 queryOrder(id="ORD123") 报错:订单不存在
第 4 轮 让我换个思路,先查订单列表再定位… 报错:参数缺失
…… (就这样换着花样试了上万次) (一直在报错)

看明白这只"苍蝇"是怎么撞玻璃的了吗?那个订单本来就不存在(数据问题),工具老老实实地每次都返回"订单不存在"。可我们的 Agent 没有把这个错误理解成"此路不通,该停下来或换条大路了",反而把它当成了"我这次姿势不对,换个姿势再试一次"的提示。于是它一遍遍地微调参数、变换思路,执着地想"调通"这个根本调不通的工具。而最致命的是:我们没有给这个循环设任何上限——它可以永永远远地试下去,只要我们的 API 余额还够。于是一个本该几步完成、或者优雅放弃的任务,变成了一场持续数小时、消耗上万次 API 调用的烧钱马拉松。

第一件事:任何 Agent 循环,都必须有硬性上限

这次事故给我上的第一课,也是最重要、最该立刻落地的一课:任何让大模型自主循环的 Agent,都必须有一个硬性的循环次数上限(max iterations)。这是一道无条件的安全护栏,不是可选项。不管你的 Agent 设计得多聪明、多大概率能正常收敛,你都必须假设"它有可能陷入死循环",并为这个假设兜底。

# 最基本、也最不该省的护栏: 循环次数硬上限
MAX_ITERATIONS = 10   # 根据任务复杂度设, 但必须有

def run_agent(task):
    for i in range(MAX_ITERATIONS):          # ← 用 for 限定上限, 而非 while True
        thought, action = llm_decide(history)
        if action.is_final_answer:
            return action.answer             # 正常完成, 提前返回
        result = call_tool(action)
        history.append((thought, action, result))
    # 跑满上限还没结束 → 强制终止, 别让它无限跑下去
    raise AgentMaxIterationsError(
        f"Agent 在 {MAX_ITERATIONS} 轮内未能完成任务, 已强制终止")

这段代码的核心,就是那个不起眼的 for i in range(MAX_ITERATIONS)——它把一个理论上可以无限跑的 while True 循环,变成了一个最多跑 N 次就一定会停下来的有界循环。这一行的价值,在那次事故里可以直接换算成钱:如果当时有这道上限设成 10,那 Agent 撞 10 次墙就会被强制叫停,我们损失的就是 10 次调用、几毛钱;而没有它,我们损失的是上万次调用、几小时和一大笔账单。这道护栏的成本几乎为零,收益却是"防止破产级别的失控",是性价比最高的一行代码。

第二件事:别让 Agent 在同一个错误上反复撞墙

循环上限是"兜底",但它治标不治本——它只是限定了 Agent 最多浪费多少次,并没有解决"它为什么会反复撞同一面墙"。要治本,得回到那个死循环的本质:Agent 把"工具返回的错误"当成了"再试一次"的鼓励,而不是"此路不通"的警告。所以第二件事,是要在循环里加入"循环检测"——识别出"它正在重复同样的失败动作",并主动打断它。

# 检测重复失败: 同一个动作连续失败 N 次, 就别让它继续撞了
def run_agent(task):
    recent = []   # 记录最近的 (动作, 结果) 指纹
    for i in range(MAX_ITERATIONS):
        thought, action = llm_decide(history)
        if action.is_final_answer:
            return action.answer
        result = call_tool(action)
        history.append((thought, action, result))

        # 关键: 检测"重复的失败动作"
        fingerprint = (action.tool_name, normalize(result))
        recent.append(fingerprint)
        if recent.count(fingerprint) >= 3:   # 同样的调用+同样的错, 撞了3次
            # 别再让它换着花样试同一件事了, 主动给它"换路"或终止的信号
            history.append(("系统提示",
                "你已多次以类似方式调用该工具均失败, 请换一种完全不同的"
                "思路, 或判断该任务无法完成并如实说明, 不要重复尝试。"))
            recent.clear()

这里的思路是:给每次"动作 + 结果"算一个指纹,如果发现某个"失败的动作"在短时间内重复出现了好几次,就说明 Agent 大概率卡进了"撞墙循环"。这时候,与其放任它继续,不如主动往它的上下文里注入一条系统提示,明确告诉它"这条路你已经试过好几次都不通了,要么彻底换个思路,要么就承认这个任务做不了"。这相当于在它撞得头破血流时,有人拍拍它的肩膀说:"别撞了,这是堵墙。"

更深一层的教训是:大模型并不会天然地从"失败"中学会"放弃"。它的本能是"完成任务",所以面对失败,它的默认反应往往是"再想想别的办法继续试",而不是"判断这事是不是根本做不成"。"知难而退""适可而止"这种判断,对人类是常识,对 Agent 却需要我们显式地、通过提示和机制去教它。所以,在设计 Agent 时,一定要专门考虑"失败路径":工具失败了怎么办?连续失败该不该停?什么情况下应该如实告诉用户'我做不到'?——把这些"优雅放弃"的能力显式地设计进去,而不是默认它会自己想明白。

第三件事:给钱包上锁——成本与超时护栏

循环次数管住了"逻辑上的失控",但还有一个维度需要单独上锁:成本。因为同样是 10 轮循环,每轮塞进去的上下文有大有小,烧的钱可能差出几十倍;而且除了循环,还有别的失控方式(比如单次请求塞了超大上下文)。所以,直接针对"钱"和"时间"再加一道护栏,是必要的双保险。

# 直接给"钱"和"时间"上锁, 与循环次数护栏并存
class Budget:
    def __init__(self, max_tokens=100_000, max_seconds=120, max_cost=1.0):
        self.max_tokens, self.max_seconds, self.max_cost = \
            max_tokens, max_seconds, max_cost
        self.used_tokens, self.cost, self.start = 0, 0.0, time.time()

    def check(self):   # 每轮循环开头调一次
        if self.used_tokens > self.max_tokens:
            raise BudgetExceeded("token 预算超了")
        if time.time() - self.start > self.max_seconds:
            raise BudgetExceeded("执行超时")
        if self.cost > self.max_cost:        # 按消耗的 token 折算成钱
            raise BudgetExceeded("成本超了, 强制刹车")

这道护栏的意义在于:它直接守住了你最终真正在乎的那条底线——花了多少钱、跑了多久。循环次数是"间接"指标(10 轮可能很便宜也可能很贵),而 token 用量、耗时、折算成本是"直接"指标。给每个 Agent 任务设一个明确的预算上限(比如"这个任务最多花 1 块钱、最多跑 2 分钟"),一旦触顶就强制刹车,你就再也不会在早上被一条天价账单短信叫醒了。对任何会消耗付费 API 的自主系统,"预算护栏"都应该和"循环上限"一样,被当成默认必备的基础设施,而不是事后补救。

把前面这几道护栏叠在一起,一个 Agent 的执行循环就从"裸奔"变成了"全程有缰绳牵着"。我画成一张图,这是我们事故后重构的 Agent 主循环骨架:

这张图的精髓,是在每一轮循环的开头,都先过两道"刹车检查"(循环上限、预算超时),确认安全了才让大模型去思考和行动。这就保证了无论 Agent 内部的决策多么离谱、多么想一条道走到黑,它都被牢牢地框在"最多 N 轮、最多 X 钱、最多 Y 秒"这个安全笼子里。自主性越强的系统,越需要这种"外层的、强制的、不依赖它自觉的"边界约束。

第四件事:别信 Agent 报上来的工具调用,要校验

护栏装好之后,我又顺着这次事故往深里查,发现 Agent 还有一类隐患和这次直接相关:大模型"决定"调用的工具和参数,本质上是它"生成"出来的,而生成就可能出错——它会调用根本不存在的工具、传错参数名、传不合法的值,甚至凭空"幻觉"出一个工具。那次死循环里,后面好几轮的"参数缺失"报错,就是它自己把参数名拼错、或者漏传了必填参数导致的。如果你不校验就直接执行它报上来的调用,轻则报错喂回去引发新一轮乱试,重则执行了危险的操作。

# 大模型报上来的工具调用, 必须先校验再执行 (别盲目信任)
def call_tool(action):
    # 1. 工具必须真实存在 (防幻觉出不存在的工具)
    if action.tool_name not in TOOL_REGISTRY:
        return f"错误: 不存在名为 {action.tool_name} 的工具, 可用工具: {list(TOOL_REGISTRY)}"
    tool = TOOL_REGISTRY[action.tool_name]
    # 2. 参数必须符合该工具的 schema (防参数名/类型错误)
    try:
        validated = tool.schema.validate(action.args)   # 用 schema 校验
    except ValidationError as e:
        return f"错误: 参数不合法 - {e}, 请按 {tool.schema} 重新调用"
    # 3. 高风险工具 (删除/支付/外发) 要二次确认或加白名单
    if tool.is_dangerous:
        return require_human_approval(tool, validated)
    return tool.run(validated)   # 校验通过, 才真正执行

这段代码的核心理念是:把 Agent 报上来的工具调用,当成"来自一个不可信来源的输入"来对待,执行前必须经过三道关——工具是否存在、参数是否合法、是否高危需人工确认。这其实和传统后端"永远不要信任客户端输入"是一模一样的道理,只不过这里"不可信的客户端"换成了"会犯错、会幻觉的大模型"。注意校验失败时,我们返回的是一句"该怎么改"的清晰提示(而不是一个晦涩的堆栈),这能帮 Agent 在下一轮里纠正自己——前提是配合第二件事的"重复失败检测",别让它纠正不过来还无限纠。

尤其要强调第三关:对那些会产生真实副作用的高危工具(删数据、发钱、发邮件、调用外部不可逆接口),一定要加额外的关卡——要么人工二次确认,要么严格的白名单和权限控制。因为 Agent 是自主决策的,你无法 100% 预测它会调用什么;万一它"灵机一动"决定调用一个删除工具,后果不堪设想。给高危操作上人工审批这道锁,是自主 Agent 安全的底线。

第五件事:管好上下文,别让它越滚越大

最后一个和"烧钱"密切相关的坑是上下文管理。ReAct 这种循环,每一轮都会把"思考 + 动作 + 工具结果"追加进历史,再把整个历史喂给大模型做下一轮决策。这意味着:循环跑得越久,喂给模型的上下文就越长,每一轮的花费也越来越贵——这是一条会"越滚越大"的雪球。那次死循环之所以那么烧钱,除了次数多,还因为到后期每次请求都拖着前面几千轮积累的巨长历史。更别提工具返回的结果本身可能就很大(比如查出几千行数据),一股脑塞进上下文,几下就把窗口撑爆、把钱烧光。

# 控制上下文膨胀: 截断超大工具结果 + 压缩老历史
def add_tool_result(history, result):
    # 1. 工具结果太大就截断/摘要, 别整坨塞进上下文
    if len(result) > MAX_RESULT_LEN:
        result = result[:MAX_RESULT_LEN] + "...(结果过长已截断)"
    history.append(result)
    # 2. 历史太长时, 把早期轮次压缩成摘要, 只保留近几轮原文
    if count_tokens(history) > CONTEXT_SOFT_LIMIT:
        old, recent = history[:-RECENT_KEEP], history[-RECENT_KEEP:]
        summary = llm_summarize(old)          # 把老历史浓缩成一段摘要
        history[:] = [summary] + recent       # 摘要 + 最近几轮原文
    return history

这里有两个关键动作:一是截断超大的工具返回——工具查出一大坨数据时,别原样塞给模型(它也消化不了那么多),截断或先摘要;二是压缩历史——当累积的上下文超过一个软上限时,把早期的轮次浓缩成一段摘要,只保留最近几轮的原文。这两招能有效遏制上下文这个雪球,让长循环的成本不至于失控。我把 Agent 的几类典型失控方式和对应护栏整理成一张表:

失控方式 后果 护栏
死循环 / 反复重试 烧钱、跑不停 循环次数上限 + 重复失败检测
成本 / 耗时失控 天价账单、长时间占用 token/成本/超时预算护栏
幻觉工具 / 错误参数 报错连锁、执行异常 执行前校验工具与参数 schema
调用高危工具 误删、误发、不可逆损失 高危操作人工审批 + 白名单
上下文雪球膨胀 越跑越贵、撑爆窗口 截断大结果 + 压缩历史摘要
无法判断完成 / 放弃 该停不停、该退不退 显式设计失败路径与终止条件

一张"上线 Agent 前必查"的决策图

把这次踩坑的所有护栏拧成一条检查线,做成一张"任何自主 Agent 上线前都过一遍"的决策图。下次你准备把一个会自己调工具、自己循环的 Agent 放到生产、尤其放到无人值守的定时任务里时,照着它逐项确认:

这张图的态度很明确:上面任何一道护栏没有,就先别让 Agent 上生产——尤其是无人值守的场景。因为自主 Agent 失控时,往往是在没人盯着的深夜(就像我那次),等你发现,损失已经造成。这些护栏不是锦上添花,而是放手让一个"自己会思考、会行动、会花钱"的系统独立运行的前提条件

我们立下的 Agent 工程铁律

这次"钱包被烧穿"的事故后,我们把这些用真金白银换来的教训,沉淀成了团队做 AI Agent 的几条铁律:

  1. 循环必有硬上限:用有界的 for 循环,绝不用裸 while True;跑满上限强制终止并返回已有进展。
  2. 预算护栏不可省:每个 Agent 任务都设 token / 成本 / 超时上限,触顶即刹车,守住"花多少钱"这条终极底线。
  3. 检测并打断重复失败:识别"反复撞同一面墙",主动注入提示让它换思路或如实放弃。
  4. 工具调用先校验后执行:把模型报的调用当不可信输入,校验工具存在性、参数合法性,失败返回清晰的纠正提示。
  5. 高危操作必须人工把关:删除、支付、外发等不可逆动作,加人工审批或严格白名单,绝不让 Agent 独自拍板。
  6. 主动管理上下文:截断超大工具结果、压缩老历史,遏制上下文雪球,控制长循环成本。
  7. 全程可观测 + 告警:记录每一步思考、动作、花费;给账户和单任务成本设实时告警,别等月底看账单才知道出事。

这几条里,第一、二条是保命的,只要做了,就绝不会再有"一夜烧穿钱包"这种事;而最后一条"可观测 + 告警"是我尤其想强调的——那次事故,我们之所以损失那么大,不光是因为没护栏,更是因为它失控了好几个小时,我们却毫不知情。如果当时有一条"单账户每小时成本超过阈值"的实时告警,哪怕没有任何护栏,我也能在第一个小时就被叫醒、手动掐掉它,损失能减少大半。对自主系统,"快速感知到它出问题了",和"防止它出问题"同样重要。

写在最后:自主性是把双刃剑

这场"被账单叫醒"的事故,彻底改变了我做 AI Agent 的心态。在那之前,我满脑子想的都是怎么让 Agent "更聪明、更自主、能力更强"——给它更多工具、更大的决策空间,让它能独立搞定更复杂的任务。我把"自主性"当成了纯粹的优点,一味地追求。可这次事故让我看到了硬币的另一面:自主性是一把双刃剑。你给一个系统多大的自主决策空间,就给了它多大的失控空间;它能自己想办法把事办成,也就能自己想办法把事办砸、办到失控。而和传统程序不同的是,Agent 的失控带着大模型特有的"创造力"——它会用你完全想不到的方式跑偏,会执着地撞一面你以为没人会去撞的墙。

所以我现在做 Agent,心态成熟了:追求能力的同时,必须同等地、甚至优先地,去构建约束。能力决定了 Agent 的上限有多高,而约束决定了它的下限有多安全;一个只有能力、没有约束的 Agent,就像一辆只有油门、没有刹车和护栏的赛车,跑得越快,翻车时摔得越惨。真正成熟的 Agent 工程,不是比谁的 Agent 更"放飞自我",而是比谁能在"给足自主空间"和"套牢安全缰绳"之间,找到那个让人放心的平衡——既让它自由地发挥智能,又让它始终跑在你画好的安全边界之内。

这个道理,其实和人类社会管理"权力"何其相似:我们既希望能干的人有充分施展的空间,又必须用制度和规则去约束权力、防止它失控滥用——对越是强大、越是自主的力量,这种约束就越不可或缺。AI Agent 是一种崭新的、正在快速变强的"数字力量",我们在惊叹它能力的同时,千万别忘了同步为它建立起配套的"约束机制"。那次被烧穿的钱包提醒我:让 Agent 学会"做事"只是第一步,让它在我们可控的边界内"安全地做事",才是把它真正放心地用到生产里的前提。愿你在拥抱 Agent 强大能力的路上,永远别忘了先给它系好那根安全带——别像我一样,用一笔天价账单,才买来这份本该提前就有的清醒。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

tsc 全绿生产却白屏:TypeScript as 断言避坑复盘

2026-6-1 12:21:17

技术教程

用户数据莫名串味:Python 可变默认参数避坑复盘

2026-6-1 12:31:08

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索