ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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.883+08: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 = 3000,d = 45,n = 423,得到:

S = 423 * 3000 + 45 * 423 * 422 / 2

S ≈ 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是0,msg是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_points=2000): 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_iterations=8、max_session_cost=2000、tool_fail_limit=3。改提示词可以随便改,但这三个参数要改必须走代码评审。这个习惯在后来好几个项目里都救过我,也让“智能体烧钱”这个事从意外事故变成了可预期、可控制的工程问题。如果你正在做类似的Agent应用,我建议你今晚就去翻一下当前项目的循环限制和预算熔断配置——别等账单教做人。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进