1. 一个让开发者半夜惊醒的账单故事
先说个真实场景。去年帮一个朋友排查他们线上客服 Agent 的问题,他给我看后台账单,一个晚上跑了将近四百万 Token,换算成钱够买一台不错的显示器了。问题是这个 Agent 的业务量根本没变,白天也就几十万 Token 的量级,怎么一到凌晨就失控?
我让他把调用日志拉出来,一眼就看出问题了:同一个用户会话 ID,在凌晨两点到四点之间,被反复触发了上千次工具调用,每次调用都带着完整的上下文历史,越滚越大。典型的循环调用——Agent 调工具拿到结果,发现结果不满足预期,又调一次,再不满意,再调,直到某个隐藏条件被满足或者超时。而那个"隐藏条件"写得有问题,几乎永远不会满足。
这就是标题里说的"莫名烧钱"。不是模型变贵了,不是有人恶意刷,而是你的 Agent 在某个边界条件下进入了死循环,每一次循环都在实打实地消耗 Token。这篇文章我想把这件事聊透:循环调用到底怎么产生的、怎么在架构层面兜底、Token 消耗怎么监控、网关这一层能做什么。适合正在做 Agent 开发、已经踩过或即将踩这个坑的同学。
2. 循环调用到底是怎么产生的
2.1 从 Agent 的执行模型说起
要理解循环调用,得先理解 Agent 的执行模型。一个典型的 Agent 循环是这样的:把用户输入和系统提示拼成消息发给大模型,模型返回一个响应,如果响应里包含工具调用请求,就执行工具,把工具结果塞回消息历史,再发给模型,如此往复,直到模型返回一个不再包含工具调用的最终答案。
这个循环本身是正常的,也是 Agent 能力的来源。问题在于,这个循环的终止条件是由模型自己决定的——模型认为任务完成了,才会停止调用工具。而模型对"任务完成"的判断,并不总是可靠的。
我见过几种典型的失控模式。第一种是工具返回结果不符合模型预期,模型反复重试同一个工具,期望得到不同结果,但工具是确定性的,结果永远一样。第二种是模型陷入"自我怀疑",它调了工具 A,拿到结果后又觉得应该先调工具 B 验证,调完 B 又觉得 A 的结果需要重新确认,来回横跳。第三种最隐蔽:多个工具之间存在依赖关系,模型在编排时形成了环,A 依赖 B 的输出,B 又依赖 A 的输出。
2.2 为什么模型自己停不下来
很多人第一反应是"那我在提示词里写清楚,让它别循环不就行了"。我试过,效果有限。原因在于,模型在每一轮决策时,看到的是当前的消息历史,它并没有一个全局的"我已经调了多少次"的计数器概念。它只是在做局部最优决策:当前这一步,调这个工具看起来是合理的。
这就像一个人在山里找路,每一步都选"看起来离山顶更近的方向",但如果地形是个环形山谷,他就会一直绕圈,而且每一步他都觉得自己在进步。模型没有"我已经绕了三圈"这个元认知。
更麻烦的是,随着消息历史越来越长,模型的判断反而可能越来越差。上下文里塞满了重复的工具调用记录,这些噪声会干扰模型对当前状态的判断,让它更难意识到自己陷入了循环。这就是为什么循环一旦开始,往往不会自己停下来,反而会加速。
2.3 循环调用和 Token 消耗的放大关系
这里有个容易被忽略的数学问题。假设单次工具调用的输入是 2000 Token,输出是 500 Token。第一次循环消耗 2500 Token。但第二次循环时,消息历史里多了上一轮的 2500 Token,所以输入变成 4500,消耗 5000。第三次输入 7000,消耗 7500。这是一个等差数列式的增长,第 N 次循环的消耗是 2500 + (N-1)×2500。
跑 100 次循环,总消耗是 2500 × (1+2+...+100) = 2500 × 5050 ≈ 1262 万 Token。这就是为什么账单会爆炸——不是线性增长,是平方级增长。循环次数翻倍,成本翻四倍。理解了这一点,你就明白为什么兜底方案必须尽早介入,越晚拦截,浪费越大。
3. 兜底方案的整体设计思路
3.1 分层防御而不是单点拦截
做兜底方案,我的核心思路是分层防御。不要指望在某一个地方加一个判断就能解决所有循环问题,因为循环的成因是多样的,单点拦截总有漏网之鱼。我一般会设计四层:提示词层、执行层、网关层、监控层。
提示词层是最外层的软约束,成本最低但最不可靠。执行层是硬性的循环次数和超时控制,这是主力。网关层负责 Token 预算和速率限制,是最后一道防线。监控层不拦截,但负责发现异常并告警,让你能持续优化前面的三层。
这四层的关系是递进的:提示词层尽量让模型别犯错,执行层在模型犯错时及时止损,网关层在执行层失效时兜底,监控层告诉你哪一层需要加强。任何一层单独用都不够,组合起来才稳。
3.2 为什么把网关放在关键位置
热词里"网关"出现频率很高,这不是偶然。在 Agent 架构里,网关是一个天然的收敛点——所有对模型的调用都要经过它。这意味着你可以在网关层做统一的 Token 计量、预算控制、速率限制,而不需要在每个 Agent 里重复实现。
我倾向于把网关设计成"透明但可干预"的角色。透明是指业务代码不需要感知它的存在,正常调用照常走。可干预是指当某个会话的 Token 消耗超过阈值时,网关可以主动拒绝后续请求,或者降级到更便宜的模型。这种设计的好处是,兜底逻辑和业务逻辑解耦,改兜底策略不用动业务代码。
3.3 兜底的目标不是零循环而是可控
这里要纠正一个认知:兜底的目标不是让循环完全不发生,而是让循环的代价可控。有些循环其实是合理的,比如模型确实需要多轮工具调用来完成一个复杂任务。你要做的是区分"合理的多轮调用"和"失控的循环"。
我的判断标准是三个维度:调用次数、Token 增速、结果收敛性。调用次数超过阈值、Token 增速呈平方级、连续几轮的工具调用结果高度相似——满足任意两条,基本可以判定为失控。兜底方案就是在这三个维度上设阈值,触发就干预。
4. 执行层的循环控制实操
4.1 最大循环次数怎么定
最直接的控制就是设最大循环次数。但定多少合适?我见过有人拍脑袋写 50,结果正常任务跑到 30 次就被截断了。也有人写 200,等于没设。
我的做法是分场景定。简单问答类 Agent,5 次足够。带工具调用的任务型 Agent,15 到 20 次。复杂的多步规划 Agent,可以到 30 次。定完之后,在监控里看正常任务的循环次数分布,如果 95 分位是 12 次,那阈值设 20 就有足够余量。
MAX_ITERATIONS = 20 def run_agent(messages, tools, max_iter=MAX_ITERATIONS): for i in range(max_iter): response = call_llm(messages, tools) if not response.tool_calls: return response.content for call in response.tool_calls: result = execute_tool(call) messages.append({"role": "tool", "content": result}) raise LoopLimitExceeded(f"Agent exceeded {max_iter} iterations")这段代码的关键在于,循环次数是硬编码的上限,模型再怎么想继续也突破不了。抛出的异常要能被上层捕获,记录日志,方便后续分析。
4.2 超时控制的两个层次
光有次数限制不够,因为单次调用可能很慢。我一般设两个超时:单次调用超时和整体任务超时。
单次调用超时针对的是模型响应慢或者工具执行慢,一般设 30 到 60 秒。整体任务超时针对的是整个 Agent 循环,一般设 5 到 10 分钟。两个超时都要设,因为单次快不代表整体快——20 次调用每次 20 秒,加起来也是 400 秒。
注意:超时时间不要设得太紧。我踩过的坑是把单次超时设成 10 秒,结果模型在生成长文本时经常被截断,反而触发了重试,变相增加了循环。超时值要根据你的模型和工具的实际 P99 延迟来定,留出 2 到 3 倍余量。
4.3 重复调用检测
次数和超时是粗粒度的控制,重复调用检测是细粒度的。核心思路是:如果连续几轮的工具调用参数高度相似,就判定为循环。
实现上,我会对每次工具调用的参数做哈希,维护一个最近 N 次调用的哈希列表。如果新调用的哈希在列表里出现过,就计数加一。连续出现 3 次相同哈希,直接中断。
from collections import deque import hashlib, json class RepeatDetector: def __init__(self, window=5, threshold=3): self.window = deque(maxlen=window) self.threshold = threshold def check(self, tool_name, args): key = hashlib.md5( json.dumps({"name": tool_name, "args": args}, sort_keys=True).encode() ).hexdigest() self.window.append(key) if list(self.window).count(key) >= self.threshold: return True return False这个检测器要小心误伤。有些工具本来就是幂等的、会被合理重复调用的,比如查询当前时间。所以我会给工具打标签,标记哪些工具允许重复调用,检测时跳过这些。
5. 网关层的 Token 预算与限流
5.1 会话级 Token 预算
网关层最有效的兜底是会话级 Token 预算。给每个会话分配一个 Token 上限,比如 10 万 Token,网关实时累计消耗,超过就拒绝后续请求。
这个预算要动态调整。新会话给基础预算,随着会话正常进行、消耗平稳,可以适当放宽。如果某个会话消耗速度异常,比如 1 分钟内消耗了预算的 50%,就提前预警甚至直接限流。
网关做这件事的优势是全局视角。它能看到所有会话的消耗情况,可以做横向对比。如果某个会话的消耗是同类会话的 10 倍,那大概率有问题,不需要等它跑完就能拦截。
5.2 速率限制的粒度选择
速率限制有好几种粒度:按用户、按会话、按 API Key、按 IP。我的建议是按会话为主,按用户为辅。
按会话限流能精准打击循环调用,因为循环通常发生在单个会话内。按用户限流能防止一个用户开多个会话绕过限制。两个一起用,效果最好。
限流的参数怎么定?我会先跑一周的监控,统计正常情况下的 QPS 和 Token 速率,然后取 P99 的 1.5 倍作为限流阈值。这样既能拦住异常,又不会误伤正常的高峰。
| 限流维度 | 建议阈值 | 触发动作 |
|---|---|---|
| 单会话 Token/分钟 | 正常 P99 的 1.5 倍 | 降级到小模型 |
| 单会话 Token/小时 | 正常 P99 的 2 倍 | 拒绝请求并告警 |
| 单用户并发会话数 | 5 到 10 | 拒绝新建会话 |
| 单 API Key QPS | 正常 P99 的 2 倍 | 排队或拒绝 |
5.3 降级策略的设计
限流触发后不是简单拒绝,而是降级。降级有几个层次:换更便宜的模型、缩短上下文、关闭工具调用。
换模型是最常用的。把 GPT-4 级别的调用降级到更小的模型,成本能降一个数量级。虽然效果会打折,但总比完全不可用好。缩短上下文是指丢弃早期的消息历史,只保留最近的几轮,这能显著降低单次调用的 Token 数。关闭工具调用是最后手段,让 Agent 只能基于已有信息回答,不再触发新的循环。
降级策略要能自动恢复。当会话的消耗速率回落到正常范围,就逐步恢复原来的配置。这个恢复过程要平滑,不要一下子全恢复,避免再次触发限流。
6. 监控与告警:让问题在烧钱前暴露
6.1 必须监控的几个指标
监控是兜底方案的"眼睛"。没有监控,前面三层都是盲打。我必看的指标有这么几个。
单会话 Token 消耗曲线:这是最直接的。正常会话的曲线是平缓上升的,循环会话的曲线是陡峭的、甚至指数上升的。看曲线形状比看绝对值更能发现问题。
循环次数分布:统计所有会话的循环次数,看分布。如果 P99 突然从 12 涨到 30,说明有新的循环模式出现了。
工具调用重复率:同一个会话内,相同工具相同参数的调用占比。这个比例超过 30% 就要警惕。
单次调用 Token 增速:相邻两次调用的 Token 数比值。正常情况这个比值接近 1,循环时会持续大于 1.5。
6.2 告警阈值的设置技巧
告警阈值设不好,要么天天误报,要么真出事不报。我的经验是用动态阈值而不是固定阈值。
具体做法是,用过去 7 天的同时段数据算一个基线,当前值超过基线的 3 倍就告警。这样能自动适应业务的周期性波动,比如白天高晚上低。
告警分级也很重要。轻微异常发到群里,严重异常打电话。我一般分三级:P3 是单会话消耗超基线 2 倍,记录不告警;P2 是超 3 倍,发群消息;P1 是超 5 倍或者触发限流,直接电话。
6.3 从告警到定位的链路
告警只是开始,能快速定位才是关键。我会在网关层给每个请求打上完整的标签:会话 ID、用户 ID、Agent 版本、模型版本、工具调用链。告警触发时,直接能拉到出问题的会话的完整调用链。
调用链的可视化很重要。我一般会做一个简单的时序图,横轴是时间,纵轴是调用层级,每个节点标注 Token 消耗。这样一眼就能看出是哪一步开始失控的。
提示:日志里一定要记录每次调用的完整消息历史哈希。这样当你想复现问题时,能精确还原当时的上下文,而不是靠猜。
7. 几个我踩过的坑和对应经验
7.1 提示词里写"不要循环"基本没用
我最早的做法是在系统提示里加一句"请不要重复调用同一个工具"。实测下来,模型在简单场景下会遵守,但一旦任务复杂、上下文变长,这句话就被淹没了。模型不是不听话,是它在做局部决策时,这句约束的权重太低了。
后来我改成在工具描述里加约束,比如"此工具返回结果确定,重复调用不会得到不同结果"。这个比系统提示有效,因为工具描述离决策点更近。但依然不能替代执行层的硬控制。
7.2 上下文截断反而可能加剧循环
有段时间我想通过截断上下文来省钱,结果发现有些会话的循环反而更严重了。原因是,截断后模型丢失了"我已经调过这个工具"的记忆,于是又调了一遍。
这个坑让我明白,上下文管理要配合循环检测一起做。截断的时候,要保留最近几轮的工具调用记录,或者至少在系统提示里注入一个"已调用工具摘要"。
7.3 流式响应下的 Token 计量
用流式响应时,Token 计量容易出错。因为响应是分块返回的,你需要在流结束时才能拿到准确的 Token 数。如果中途中断,已经消耗的 Token 可能统计不到。
我的做法是在网关层做"预扣费":请求发出时先按预估 Token 扣减预算,响应结束后按实际 Token 结算,多退少补。这样即使中途中断,预算也是准的。
7.4 多 Agent 协作时的循环放大
单个 Agent 的循环还好控制,多 Agent 协作时问题会放大。Agent A 调 Agent B,Agent B 又调 Agent A,形成跨 Agent 的循环。这种循环单看每个 Agent 的调用次数都不多,但合起来消耗巨大。
对付这种,我会在网关层做跨 Agent 的调用图分析。维护一个会话内的调用关系图,如果检测到环,直接中断。这个实现起来复杂一些,但对于多 Agent 系统是必须的。
8. 一套可以直接抄的兜底配置
把上面的东西整合一下,给一套我实际在用的配置。这套配置跑了大半年,拦住了好几次潜在的账单事故。
执行层:最大循环 20 次,单次超时 45 秒,整体超时 8 分钟,重复调用检测窗口 5 次阈值 3 次。
网关层:单会话预算 15 万 Token,单会话速率限制 8000 Token/分钟,单用户并发会话 8 个。触发限流后降级到小模型,5 分钟后尝试恢复。
监控层:Token 增速超过 1.5 持续 3 轮告警,循环次数超过 15 告警,工具重复率超过 30% 告警。
这套配置不是最优的,但足够稳。你可以根据自己的业务量调整数值,但结构建议保留。记住那个平方级增长的公式,你就知道为什么每一层都不能省。
最后分享一个我最近在用的技巧:给每个 Agent 会话加一个"预算心跳"。每消耗 10% 的预算,就往监控打一个点。这样你能看到预算消耗的节奏,如果某个会话在 1 分钟内消耗了 50% 的预算,不用等它跑完,直接就能介入。这个比事后看账单有用得多。