资讯动态

智能体积分烧爆事故:循环调用与上下文膨胀的代价与防治

发布时间:2026/10/20 7:13:03 来源:尧图企业网站定制
1. 半夜的积分告警一次平凡提问烧掉四万七千积分1.1 事故复现从“帮我查订单”到余额告警先交代一下背景。我手上有一个智能体Demo是做订单助手的接了大模型、订单查询工具和物流查询工具然后在某大模型平台上开了个测试账号。功能不复杂用户问“订单为什么没发货”智能体就去查订单表、物流表再组织一段回答。之前测试一直很正常一个问题通常几十积分就够系统提示词再长也就一百多积分。那天晚上我随手丢了个问题进去“帮我查一下上周三的订单为什么没有发货。”然后去倒水回来发现平台弹出余额告警一个会话消耗了四万七千多积分。第一反应是账号被盗了或者是某个测试脚本在死循环调用。但查了会话ID和调用方IP确实是我本地联调时发起的那个请求全程没有任何外部参与。我立刻把这次会话的完整调用流水导了出来看到数据的那一刻人才算彻底清醒这个问题在4分多钟内触发了423次底层模型调用其中工具调用失败重试占了312次。也就是说智能体不是“回答不了”而是“停不下来”。如果你也在做智能体应用尤其是接了工具调用、跑多轮任务的场景这起事故值得花十分钟看完——因为它暴露的问题不是一个prompt写错了那么简单。1.2 先把账单拆明白积分到底去哪了在排查代码之前我先做了一件事确认平台计费口径。不同平台的积分算法差异很大有的是按输入token和输出token的总和折算有的会给不同模型套不同倍率还有的会把工具调用结果里的图片或附件单独计费。我们这次用的模型是纯文本接口账单明细里很明确消耗的大头是输入token输出token反而只占很小比例。我把会话日志里的token字段加了一下数据非常惊人指标数值底层模型调用次数423次工具调用失败重试次数312次累计输入tokens约421万累计输出tokens约3.9万折算积分47,xxx看见没输出才3.9万token输入居然有421万。这说明问题的核心不是模型“想太多写太多”而是每一次循环都把已经膨胀的对话历史整包发送给了模型模型每次都要从头读一遍这一大坨内容。换句话说积分不是一次性烧光的是四百多次循环一点一点磨光的。这是我复现事故后第一个明确结论智能体烧积分绝大多数不是单次请求贵而是请求次数多、且每次请求的上下文越来越大。2. 顺着调用日志把四百多次模型调用追了出来2.1 我的可观测设计每次Agent调用都留下了“黑匣子”事故发生后我能还原整个链条靠的不是平台自带的可视化界面而是我在智能体外面自己加的一套结构化日志。我的做法很朴素在Agent入口、工具调用前、工具调用后、模型返回后这四个位置各打一条JSON日志记录会话ID、序列号、当前循环次数、输入token数、输出token数、被调用的工具名、工具返回状态、耗时。这里放一条当时第17次模型调用的日志格式大概长这样{ session_id: demo-order-0802, seq: 17, ts: 2025-07-14T23:41:12.88308:00, input_tokens: 12340, output_tokens: 86, tool_calls: [query_order], tool_result_status: empty, loop_count: 12, request_id: req_00fce2 }很多人做Agent原型时不记这种日志觉得“能跑就行”。但真出问题没日志就只能对着一个莫名其妙的高额账单瞎猜。我这次是因为日志字段足够全才能在15分钟内定位到问题不是模型异常不是工具接口异常而是Agent流程没有终止条件。2.2 调用树里的异常形状同一个工具反复被点名打开日志后我先按工具名做了聚合发现一个非常刺眼的特征query_order这一个工具就被调用了187次而且大部分参数几乎一模一样都是同一个订单号。紧接着是query_logistics也调用了125次其中89次是在query_order返回空结果之后发生的。这就形成了一个典型的“循环怪圈”用户问为什么没发货 - 智能体调用订单查询 - 返回空 - 模型认为可能是参数不对换了个同义参数再查 - 还是空 - 继续查物流 - 物流返回空 - 模型又觉得是不是订单号看错了回头再查订单。每一步之间间隔只有0.3到0.6秒看起来像是系统在“努力解决问题”实际上是在原地空转反复把同一个问题丢给模型裁决。如果你只看最后一条日志会觉得“哦就是上下文太长导致积分高”。但把423条请求按时间顺序排开真正的高消耗模式就出来了几百次几乎无意义的工具调用以及伴随每次调用被重发的、越来越长的消息列表。所以我在复盘时给自己定了个规矩智能体的可观测性不能只看成本总量必须能看到“调用链的形状”。形状一旦出现高频重复调用大概率是循环。3. 第一个元凶工具失败后模型不会“认输”只会死磕3.1 大模型如何陷入自问自答的循环先解释一个底层机制。大模型本身是“根据当前对话历史生成下一步动作”的系统它没有“这个任务已经彻底失败”的本能判断。Agent框架给了它调用工具的能力之后模型在每一步都会基于已有的全部消息决定下一步是再调工具还是直接回答用户。关键就在这里如果工具返回的结果不够明确比如返回了一个空数组模型会倾向于认为“数据暂时没取到再试一次”。试一次没有结果它不会像人一样意识到“这可能查不到”反而会因为上下文中已经出现“上一次也是空的”这一事实继续尝试别的参数。当时我的系统提示词里还有一句火上浇油的话“如果查询结果为空请用不同的关键词或参数再次查询务必给用户一个明确的答复。”这句话的本意是让Agent更勤快实际上等于给模型下了一个“不允许失败”的死命令。模型一看到“务必给出明确答复”就把“认输”当作错误选项宁可反复调用工具也不愿意直接说“查不到”。这种自问自答的循环一旦开始除非触达模型的最大步数限制或者用户主动叫停否则会一直耗下去。我后来在其他项目里也见过类似情况有的团队把最大迭代次数调到了100结果一个错误问题能让整个对话上下文长到超过窗口上限然后直接报错那也算一种“终止”但积分已经烧完了。3.2 现在就能复现的循环代码长什么样很多Agent框架会把循环封装得很深让你看不到“死循环”的存在。剥开来看它本质上就是我下面写的这段伪代码的执行过程for i in range(max_iterations): response llm.chat(messages) tool_call parse_tool_call(response) if tool_call: result invoke_tool(tool_call.name, tool_call.args) messages.append({role: tool, content: result}) continue return response.content这个循环看起来逻辑是对的模型说调用工具就调工具结果放回消息列表模型最后不再请求工具时返回。但问题出在两个地方。第一max_iterations被我设得过大当时配置的是50第二invoke_tool返回的任何内容包括“查无此单”“参数非法”都会被当作普通消息追加到上下文中然后交给模型判断。模型判断来判断去永远觉得“还需要再试”。正常的设计里循环至少要有一个“连续失败计数”当同一个工具连续返回失败达到阈值就应该直接跳出循环而不是把决定权再次交给模型。顺带说一句测试的时候我还发现模型在循环里会不断输出一些“过渡性废话”比如“好的让我再查一次”“看起来仍然没有结果我会再核实”。这些输出虽然单条只有几十个token但在423次循环里也累计了几万token进一步放大了成本。这只是小头真正的大头是下面要说的上下文膨胀。4. 第二个元凶上下文无限膨胀累计计费翻着跟头涨4.1 每次循环都在把老账本背在身上大模型接口的计费规则重点不在“最后那一次请求有多长”而在“每一次请求都要重新读一遍前面的历史”。比如第一次调用时消息列表里可能只有用户问题、系统提示词和一些示例假设总共3000 token。第一次工具调用结束后我们把工具返回追加进去第二次调用时模型就需要阅读3000加上新追加的内容。第三次又要读第二次追加后的内容。只要循环不退出来消息列表就越变越长每轮需要处理的输入也就越来越大。我当时粗略估算过初始消息大约3000多token每发生一次工具调用和模型回复至少新增40到80个token。到第423次调用时单次输入已经涨到约22000 token。这个数字其实还没爆因为模型窗口支持足够长但乘上调用次数就非常可怕了。这也解释了为什么单看某一条日志会觉得“还好”但账单却是天价。Agent系统是顺序累计的每一分钱都不是凭空消失的而是被那一千多轮重复阅读吃掉的。4.2 用等差数列算算四万七千积分是怎么来的我们可以做一个简化推导。假设第1次调用的输入token数是a每次循环新增d个token那么第n次调用的输入token数大约是T_n a d * (n - 1)累计输入token数就是等差数列求和S_n n * a d * n * (n - 1) / 2代入事故数据a 3000d 45n 423得到S 423 * 3000 45 * 423 * 422 / 2S ≈ 1,269,000 4,012,000 5,281,000也就是说累计输入tokens超过了五百万。按积分折算系数粗略计算和账单上的四万七千多积分基本吻合。而如果循环能提前在第20次终止累计输入只有20 * 3000 45 * 20 * 19 / 2 60,000 8,550 68,550tokens还不到现在的七十分之一。所以结论非常清楚真正的问题不是单次提示词太啰嗦而是循环次数N太大。解决循环次数是节省成本的最有效手段。光是加一句“最多重试三次”就能把上面这个天文数字压到一万以内。4.3 上下文瘦身的工程做法到了这个份上光靠“少调几次工具”还不够必须在架构上限制历史无限膨胀。我现在用的方案是三层第一只保留最近几轮对话和工具结果超过一定轮数的内容统一压缩成一段摘要第二工具返回里明显无关的字段在写入上下文前直接过滤掉绝不让模型看到一长串它根本不需要的数据第三对同一个工具同一参数的重复失败结果做去重合并处理后面再出现时只保留一条“已尝试过且失败”的记录。有一类人会觉得“上下文窗口很大省这点干嘛”但在Agent场景下这个想法非常危险。因为Agent天然会调用很多次只要循环不控制再大的窗口也会被撑爆积分也会跟着线性甚至平方级上涨。5. 第三个元凶工具接口设计得越“自由”模型越爱空转5.1 失败信号不明确模型只能靠猜第三个问题出在我自己写的工具接口上。当时的query_order返回结构很简单大致是{ code: 0, data: [], msg: success }从程序角度看这是一个“成功响应但数据为空”的结果。可对模型来说它看不到数据库里没有这个订单它只看到“code是0msg是success但data是空的”。模型会怎么想大概率是认为“查询条件不够准确”于是换个订单号格式再查或者换时间范围再查甚至去查物流接口碰运气。每一步看起来都有理有据实际上都是在猜测。更麻烦的是空结果没有给出“是否应该重试”的线索。模型只能根据历史自己推断而我们都知道大模型的推断在这个场景下并不稳定。同一个空结果它有时候会判断成“订单不存在”有时候会判断成“系统临时故障”完全看上下文里其他内容怎么引导。5.2 一个“会终止循环”的工具返回长什么样后来我把工具返回改成了结构化失败信号让模型一眼就能判断“还要不要继续”。下面是改完之后的返回示例{ status: NOT_FOUND, code: 20001, message: 根据订单号 ORDER-2081 未查询到订单记录, reason: ORDER_ID_NOT_EXIST, suggest: no_retry }关键点在于新增了status、reason和suggest三个字段。status明确告诉模型这次查询是“有明确结论的失败”不是普通网络错误reason用机器可读的枚举写清原因suggest则直接给出建议no_retry代表不要重试这是最有效的刹车信号。改完之后我用同样的问题重新测了50次没有再出现循环。模型看到NOT_FOUND和suggest: no_retry之后会直接组织回答“未找到该订单记录”而不是继续死磕。所以我要强调工具设计不好模型很容易空转工具返回的信息越可决策模型越不会陷入循环。这个收益不光是省积分还能让用户更快得到明确答复。同时我还加了工具级熔断。在Agent框架里维护一个连续失败计数器同一个工具连续失败三次就将它临时从可用工具列表里移除不再让模型选。这属于工程兜底即使模型没有理解返回结构框架也能强制刹车。6. 止损与防再爆我给智能体加的五道保险6.1 预算熔断在积分爆掉之前主动掐断第一道保险是预算熔断。我不会再让一个会话无限花下去而是在Agent会话层接了一个简单的配额保护器。每次调用模型前预估一下本次输入会有多少token加上当前会话已消耗的token数一旦超过预设上限立刻中止会话并返回兜底话术。class BudgetGuard: def __init__(self, max_points2000): self.max_points max_points self.spent 0 def check_before_call(self, estimated_tokens: int) - bool: return self.spent estimated_tokens self.max_points def charge(self, actual_tokens: int): self.spent actual_tokens这个类虽然简单但作用非常大。它相当于给会话加了一个“余额红线”无论模型发什么疯最多烧掉2000积分就强制喊停。以前我会把这个阈值设得很高觉得“反正开发环境便宜”经过这次事故后我改成了2000测试环境中足够覆盖正常任务又能兜住异常循环。6.2 迭代上限与连续失败退出第二道保险是迭代次数限制。不管业务怎么设计一个会话内的工具调用轮次绝对不应该超过某个硬值。我现在默认设8轮。这里多说一句8轮听起来不多但对绝大多数订单查询、信息整合类任务已经足够因为一次工具调用就能拿到主要结果最多再加一两次辅助查询。第三道保险是连续失败退出。和预算熔断不同这个机制关注的是“同一件事反复失败”。我现在的规则是同一个工具连续失败3次或者同一个错误类型出现3次立即终止流程。这比单纯限制总轮次更精确——它能判断这3次失败是不是同一个原因如果是就能断定继续尝试没有意义。6.3 上下文瘦身保留关键信息丢掉废话第四道保险是上下文瘦身。在长任务里我不会让对话历史无限增长。具体做法是维护一个滑动窗口只保留最近四轮原始消息更早的内容全部交给一个轻量摘要模型压成两行要点。对工具返回结果同样只保留结构化字段把冗长的原始日志、无关字段挡在上下文之外。这里要注意一个细节摘要本身也会消耗token所以不能每轮都做。我的策略是每5轮或者消息总量超过15000 token时才触发一次摘要压缩。这样既能控制上下文长度又不会因为频繁摘要反而增加成本。6.4 并发、缓存与告警让问题在十分钟内暴露第五道保险是可观测告警。我给会话消耗设置了三个级别的告警阈值单次会话消耗超过500积分告警提醒超过1000积分立刻停止并把Trace导出超过2000积分直接熔断。另外我还给重复工具调用加了专门的监控指标比如“同一工具同一参数在30秒内调用超过5次”就标记为异常循环。缓存这类问题则更适合测试场景。同一个订单号在短期内被反复查询可以直接把第一次的NOT_FOUND结果缓存下来后续命中缓存就不再调用模型和工具。这不能解决循环问题但能减少存量测试带来的无效消耗搭配熔断机制效果更好。7. 这起事故真正暴露了什么别把成本安全交给模型自觉复盘到这里技术层面的原因已经很清楚工具失败后缺少终止条件上下文无限膨胀工具返回没有可决策的失败信号三层问题叠加在一起让一个普通问题滚成了四万七千积分的雪球。但如果是作为一篇踩坑复盘我更想说的是另一件事这类问题为什么难发现以及它在提醒我们什么。难发现的原因在于开发环境的单次请求成本很低一个两个问题根本看不出异常。只有当智能体真正进入长对话、多轮工具调用场景时循环和膨胀才会被放大到“一夜烧掉几万积分”的程度。很多人会误以为“模型知道什么时候该停”但这次事故告诉我模型不知道Agent框架的默认行为也不知道只有设计者用硬约束去兜底。我现在新起任何智能体项目第一件事就是在基座里写死三行配置max_iterations8、max_session_cost2000、tool_fail_limit3。改提示词可以随便改但这三个参数要改必须走代码评审。这个习惯在后来好几个项目里都救过我也让“智能体烧钱”这个事从意外事故变成了可预期、可控制的工程问题。如果你正在做类似的Agent应用我建议你今晚就去翻一下当前项目的循环限制和预算熔断配置——别等账单教做人。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑