做 AI Agent 的人大概都经历过这种时刻:深夜本地跑一个多智能体协作任务,临睡前瞄了一眼监控面板,API 调用量像心电图一样狂跳,短短二十分钟烧掉了几百块钱。我当时第一反应是“哪个环节崩了”,打开日志一看,什么都没崩——恰恰是什么都“正常”,一个带着“修复报告格式”指令的 Agent 在自我修正循环里转了几十圈,每圈都在消费 token。这种事故靠超时和重试根本拦不住,从那以后我给提示流编排器专门加了一套运行时看门狗和死循环熔断器。这篇是开源系列的第十三篇,前面十二篇从提示词模板一路写到了编排引擎,这篇只聊一个主题:如何不让 Agent 在运行时把你的账单刷爆。
1. Agent 失控前的那 3 秒:先把“烧钱事故现场”还原一遍
1.1 一条看似人畜无害的 Agent 指令是如何跑成死循环的
很多人以为 Agent 死循环是代码 bug 导致的,比如while True忘记 break。但真实场景里最常见的情况是:每一轮逻辑都成功执行了,但整体永远无法收敛。
我用一个实际案例拆给你看。任务是这样的:让 Agent 写一份市场分析报告,初稿完成后追加一句“你检查一下数字有没有问题,有问题就继续改”。这句话听起来毫无杀伤力,但执行链会变成这样:
- Agent 调用 LLM 生成了初稿。
- Agent 检查时发现引用格式不统一,于是调用一个格式化工具。
- 格式化工具返回了新内容,同时给报告加了一个新的时间戳。
- Agent 对比新旧版本,发现“内容有变化”,于是判定“还需要再检查一轮”。
- 下一轮检查又触发了格式化,时间戳再次更新,循环继续。
从程序角度看,每一步都返回成功,没有任何异常可以捕获。从业务角度看,输出内容每一版都在“变化”,所以 Agent 认为自己一直在“推进”。但从成本角度看,每一轮都是 5k-10k token 的调用,循环 20 次就是一两万 token,再转到 50 次,账单就开始让人肉疼了。
这类问题的本质是:Agent 缺少一个明确的“退出条件”信号,而编排器层又没有对“是否真的有进展”做判断。两者缺一,都会失控。
1.2 四类高发失控模式与各自识别特征
我梳理了实际项目中撞到过的失控模式,大致可以归成四类。每一类都有比较明显的特征,看门狗判定是否“原地打转”时,主要就是靠这些特征说话:
| 失控模式 | 典型场景 | 识别特征 |
|---|---|---|
| 自我修正循环 | Agent 反复修改同一段输出,每次只改格式或措辞 | 动作签名高度重复,相邻版本文本相似度超过 95% |
| 工具重试风暴 | 下游工具持续返回可重试错误,Agent 陷入重试 | 同一工具调用次数激增,错误类型相同,间隔越来越短 |
| 多 Agent 互相追尾 | Agent A 调用 B,B 的输出又触发 A 重新执行 | 调用对 (A→B) 和 (B→A) 交替出现,没有终止事件 |
| 上下文污染失忆 | 会话压缩后关键指令丢失,Agent 每轮从头开始 | 上下文摘要指纹大幅变化,但输出质量评分不提升 |
第一类和第三类最隐蔽,因为每一轮都“成功”。第二类相对好抓,因为错误率高。第四类在长时任务里很常见,尤其当你做了 context summarize 之后,Agent 可能忘了自己已经做过什么,又重新执行一遍。
识别这些模式,靠的不是看单个请求的返回码,而是看一段时间窗口内的执行轨迹。这也是为什么传统的 try-catch 和超时机制完全没法兜底。
1.3 为什么超时重试和指数退避救不了场
先把这个说透,不然很多人会误以为“加了超时不就完了”。超时解决的是“单次调用卡住”的问题,比如 LLM API 长时间无响应。但死循环里的每一次调用都是正常返回的,每次 3 秒到 5 秒,永远不超时,所以超时机制根本不会被触发。
指数退避解决的是“瞬时故障”的问题,比如限流或者网络抖动。设计思路是:等一下再试,通常能恢复。但死循环不是瞬时故障,它是系统性的逻辑无法收敛,多等几秒只会把每一次失败的间隔拉长,让账单晚一点到,但不会让循环停止。
所以你必须在一个更高的层面去回答三个问题:
- 这个任务的总预算还剩多少?
- 这一轮执行和前面几轮相比,是否有实质进展?
- 如果再放一次 LLM 调用,风险是可控的吗?
这三个问题,就是看门狗和熔断器要回答的。
2. 看门狗不是“看门大爷”:运行时监控的真正职责边界
2.1 监控为什么必须放在编排器层
先说一个我踩过的坑。早期我给 OpenAI SDK 客户端做了个 wrapper,在每个请求前后打点,统计 token 消耗和延迟。这样做了之后确实能看到每次调用的成本,但它对“死循环”基本无感。
因为 SDK 层看到的是一个个孤立的请求,它看不到这些请求属于哪个节点、依赖什么上游结果、产出了什么新事件。按下单次请求看,每一条都“很健康”,只有把彼此串起来看,才会发现它们是在原地绕圈。
所以看门狗必须放在编排器层,也就是你的提示流执行引擎这一层。这一层天然能看到:
- DAG 里每个节点的执行序列和执行次数
- 每个节点的输入和输出摘要(指纹)
- 每次 LLM 调用的 token 消耗(从响应里拿 usage 字段)
- 当前执行到第几轮、任务跑了多长时间
换句话说,编排器就是那个唯一能看到“全局地图”的位置。你在这里做监控,花很小的代价就能拿到最有判断价值的信息。
2.2 核心监控指标与成本估算模型
看门狗至少要盯四个指标,缺一不可:
1. 累计消耗预算
不要只记 token 数,要记折算后的金额。不同模型的单价差异很大,一个常用做法是把每次响应的usage.prompt_tokens和usage.completion_tokens分别乘上输入单价和输出单价,再累加起来。
class BudgetTracker: def __init__(self, task_budget_usd: float): self.task_budget_usd = task_budget_usd self.spent_usd = 0.0 self.total_prompt_tokens = 0 self.total_completion_tokens = 0 def record(self, prompt_tokens: int, completion_tokens: int, price_per_prompt: float, price_per_completion: float): prompt_cost = prompt_tokens * price_per_prompt / 1000 completion_cost = completion_tokens * price_per_completion / 1000 self.spent_usd += prompt_cost + completion_cost self.total_prompt_tokens += prompt_tokens self.total_completion_tokens += completion_tokens @property def remaining_usd(self) -> float: return self.task_budget_usd - self.spent_usd价格这里我用“每千 token 美元”作单位,方便直接乘。如果你跑的是开源模型,单价可以填 0,但依然要监控 token 数,防止上下文窗口被打满。
2. 动作签名重复度
这是判断“原地打转”的核心信号。每执行完一轮,把(节点 ID、工具名、参数关键信息、输出指纹)拼成一个签名,然后在滑动窗口内统计重复情况。如果连续 7/10 轮都是高度相似的动作,说明任务很大可能在循环。
3. 心跳时间
每个任务在执行过程中会周期上报心跳。如果心跳超过 N 分钟没更新,说明任务可能卡在某个 IO 或者模型调用上。这是对“单次调用超时”的补充,防止整体任务静默挂起。
4. 单轮无进展标记
如果当前轮次产出的结果与上一轮相比没有任何新的信息(比如重复的文本、相同函数调用、等同的工具输入),就给这一轮打上“无进展”标记。无进展轮次的占比一旦超过阈值,就进入熔断评估。
这四个指标各管一段,组合起来才能区分“慢”和“卡死”。
2.3 监控事件要与熔断器形成闭环
只做指标采集没有意义,重要的是把这些指标转成事件,然后把事件送进熔断器里做决策。我在项目里定义的几类事件大概是这样的:
budget_warning:消耗超过任务预算的 50%,提醒但不断loop_high_repetition:动作重复度超过阈值,准备触发熔断heartbeat_timeout:任务心跳超时,准备触发熔断circuit_break:正式熔断,暂停当前任务并保存现场
事件要带上上下文:任务 ID、节点链、各指标当前值。熔断器消费这些事件,根据状态决定是忽略、预警、还是直接断开。没有闭环的话,看门狗就只是个高级计数器,无法真正阻止资金流失。
3. 熔断器为什么必须做成状态机:三态设计与阈值反推
3.1 熔断不是“杀掉任务”,而是“保护现场再试探”
很多人的第一反应是:检测到死循环,直接把这个 Agent 任务 kill 掉不就行了?听起来干脆,但实际很坑。
第一个问题是:你怎么区分“真死循环”和“还在出结果的慢任务”?误杀一个正常任务,用户丢掉的可能是十分钟的计算结果。第二个问题是:杀掉任务之后,所有中间状态都没了,用户想恢复就得从头开始,体验很差。
所以我的做法借鉴了电气系统的熔断器思想:检测到异常先把这条线路断开,不让它继续消耗资源,但保留现场。等冷却期过去,再放一个试探性的小请求,看链路是否恢复了,恢复即重新接通,未恢复则继续保持断开。
这样设计的精髓在于:先止损,后试探,最后才决定是否完全放弃。
3.2 从预算数据反向推导熔断阈值
阈值设置靠拍脑袋是不行的,我建议用你实际任务的 P99 耗量来反推。
具体做法分三步:
- 选 10-20 个有代表性的历史任务样本,跑一遍,记录每个任务在正常完成时的 LLM 调用次数、token 消耗、总耗时。
- 算出 P95 和 P99 基线。比如正常任务最多调用 30 次 LLM,正常预算消耗 2 美元。
- 熔断阈值在 P99 基础上放宽 1.5-2 倍。比如 P99 是 30 次,阈值定在 50 到 60 次。太紧了会误伤,太松了起不到保护作用。
阈值要根据任务重要度分级:
- 实验性任务(本地调试、玩具项目):预算 0.5 美元以下,调用 20 次就熔断。
- 常规任务(普通生成、数据分析):按 P99 的 1.5 倍来。
- 生产级任务(用户付费流程):预算可以宽,但必须设置硬顶,比如单任务最多 100 次 LLM 调用。
3.3 CLOSED / OPEN / HALF-OPEN 三态细节与恢复策略
熔断器核心是状态机,三个状态各司其职:
- CLOSED(关闭):正常状态,放行所有调用。持续监控各项指标。
- OPEN(打开):触发条件满足后进入,此时新的 LLM 调用直接拒绝,不再发起请求。必须等待冷却时间。
- HALF-OPEN(半开):冷却结束后进入,只放一个探测请求。探测请求成功,说明链路恢复,转回 CLOSED;探测请求失败,说明下游还是有问题,回到 OPEN 并重置冷却时间。
我之前用了一个简单的类来管理它:
import time from enum import Enum class CircuitState(str, Enum): CLOSED = "closed" OPEN = "open" HALF_OPEN = "half_open" class CircuitBreaker: def __init__(self, failure_threshold: int, cooldown_seconds: int): self.failure_threshold = failure_threshold self.cooldown_seconds = cooldown_seconds self.state = CircuitState.CLOSED self.failure_count = 0 self.last_open_at = 0.0 def trip(self): self.state = CircuitState.OPEN self.last_open_at = time.monotonic() def allow_call(self) -> bool: if self.state == CircuitState.CLOSED: return True if self.state == CircuitState.OPEN: if time.monotonic() - self.last_open_at >= self.cooldown_seconds: self.state = CircuitState.HALF_OPEN return True return False if self.state == CircuitState.HALF_OPEN: return True def on_probe_success(self): self.failure_count = 0 self.state = CircuitState.CLOSED def on_probe_failure(self): self.failure_count += 1 self.state = CircuitState.OPEN self.last_open_at = time.monotonic()这里故意把 HALF_OPEN 也允许一次调用,是因为后面要接探测请求。千万注意:HALF_OPEN 状态下同一时刻只能放一个探测请求,否则会引发恢复风暴,这个坑我放到第五部分细说。
冷却时间不能写死。我一般根据异常类型动态调整:如果是预算超支,冷却 60 秒就够了,用户加点预算就能恢复;如果是下游工具持续 50X,冷却 5-10 分钟更合适,给下游恢复时间。
4. 核心实现:watchdog + circuit_breaker 最小可运行模块
4.1 执行轨迹指纹:如何判断 Agent 在原地打转
现在进入动手环节。先说“原地打转”的判定实现,这是整个看门狗最核心的部分。
我的思路是给每一轮执行生成一个签名。签名由四个维度组成:
node_id:当前执行的节点名称tool_name:调用的工具名,如果没有工具调用就是Nonearg_signature:工具参数的规范化哈希output_fingerprint:模型输出的归一化指纹
import hashlib import json from collections import deque def _stable_hash(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] def build_action_signature(node_id, tool_name, tool_args, output_text): arg_sig = _stable_hash(json.dumps(tool_args, sort_keys=True, ensure_ascii=False)) output_fingerprint = _stable_hash(output_text.strip()[:2000]) raw = f"{node_id}|{tool_name}|{arg_sig}|{output_fingerprint}" return _stable_hash(raw) class LoopDetector: def __init__(self, window_size: int = 10, repeat_threshold: int = 7): self.window = deque(maxlen=window_size) self.repeat_threshold = repeat_threshold def push_signature(self, sig: str) -> bool: self.window.append(sig) if len(self.window) < self.repeat_threshold: return False counter = {} for s in self.window: counter[s] = counter.get(s, 0) + 1 max_count = max(counter.values()) return max_count >= self.repeat_threshold几个实现细节要提醒你:
- output_fingerprint 不要取全文。取前 2000 字符做归一化就够了,因为大模型输出后半段经常是格式变化的噪音,取全文会导致签名差异过大,误判率飙升。
- tool_args 要做 sort_keys 序列化。否则同一个参数在字典里顺序一变,哈希就变了,同样会被误判。
- 滑动窗口比计数累加更稳。用
deque(maxlen=10)的方式,只看最近 10 轮,防止任务前期正常、后期循环时被“平均”掉。
4.2 预算追踪与熔断器的协作主循环
看门狗不是独立运行的,它要和预算追踪器、熔断器组成一个协作循环。我把这个循环放在一个Watchdog类里,直接包装编排器的执行器。
class Watchdog: def __init__(self, executor, budget_tracker, breaker, loop_detector): self.executor = executor self.budget = budget_tracker self.breaker = breaker self.loop_detector = loop_detector def run(self, task): while True: if not self.breaker.allow_call(): self._save_checkpoint(task) raise CircuitOpenError("运行预算或循环检测触发熔断") result = self.executor.step(task) if result.finished: return result self.budget.record( result.prompt_tokens, result.completion_tokens, result.price_per_prompt, result.price_per_completion, ) sig = build_action_signature( result.node_id, result.tool_name, result.tool_args, result.output_text, ) looped = self.loop_detector.push_signature(sig) if self.budget.remaining_usd <= 0: self.breaker.trip() self._save_checkpoint(task, reason="budget_exhausted") raise CircuitOpenError("任务预算已耗尽") if looped: self.breaker.trip() self._save_checkpoint(task, reason="loop_detected") raise CircuitOpenError("检测到高重复执行轨迹")这里的关键点在executor.step(task):它只执行一轮,而不是执行整个任务。这是看门狗能够插手的基础。如果你的执行器是executor.run(task)一把梭,那就没有检查的机会了,必须在设计编排器时就把执行粒度拆到“轮”。
_save_checkpoint要保存什么?至少包含:任务 ID、当前节点链、已产出的中间结果、各指标快照、触发原因。这样即使熔断了,用户也能基于 checkpoint 做人工恢复,而不是从零开始。
4.3 关键配置项与默认建议值
我把自己项目里的配置项整理成了一张表,这些值是经过几轮误伤之后修正过的,可以参考但不建议照抄:
| 配置项 | 默认值 | 说明 |
|---|---|---|
task_budget_usd | 0.5 美元 | 单任务预算,实验性项目建议不超过 1 美元 |
max_llm_calls_per_task | 50 | 单任务 LLM 调用硬顶,防止超长任务失控 |
loop_window_size | 10 | 滑动窗口大小,记录最近 10 轮签名 |
loop_repeat_threshold | 7 | 窗口内相同签名达到 7 次即触发熔断 |
budget_warning_ratio | 0.5 | 消耗达到 50% 时发出预警,不熔断 |
cooldown_seconds | 60 | OPEN 到 HALF-OPEN 的冷却时间 |
probe_max_tokens | 256 | 探测请求的最大输出 token,越小越好 |
预算的默认值 0.5 美元,是我基于中等规模模型单次调用 1-3 美分、正常任务 10-20 轮出的结果反推的。如果你的任务经常在 30 轮以上,预算要跟着抬高,否则熔断器会频繁误伤。
4.4 接入现有提示流编排器的方式
如果你和我一样是自己写的编排器,直接在executor.step()上下打点就行。如果用的是更成熟的编排框架,一般会在节点级别提供回调钩子,你可以在这些钩子里集成看门狗。
我提供一个最朴素的接入思路:把执行流程拆成“开始一轮 → 构建请求 → 调用模型 → 更新指标 → 检查是否熔断”这几步,给执行器加一个可选的 watchdog 回调。核心逻辑仍然是:
- 每一轮执行后,先更新预算跟踪器
- 再计算动作签名并推入滑动窗口
- 最后检查熔断条件
这样无论底层用的是哪种模型、哪个框架,接入都只是“多套一层循环控制”的问题,不需要改业务代码。
5. 上线之后踩过的坑:误伤、放走、恢复风暴
5.1 误伤正常长任务:签名重复不等于死循环
第一次上线时我遇到最大的问题是误伤。一个数据清洗任务,处理 2 万行 CSV,每一批数据的处理逻辑完全一样——读取、清洗、写回。动作签名高度重复,第 8 轮就被熔断器拦下来了,任务中断,用户炸了。
排查后才发现,我把“动作相同”和“没有进展”画了等号,但实际上输入数据每一批都不同,只是处理函数一样而已。后来我在签名里加重了“输入量”的权重:如果每轮处理的输入数据量在变化,说明任务在前进,不算循环。
修复方式是对工具的输入参数做分类,部分参数参与签名计算(如数据分片范围、查询条件),部分参数不参与(如固定格式化选项)。这样既能识别真正的重复动作,又能放行正常的分批任务。
这个坑提醒我:光靠“动作签名重复”不能下结论,必须同时观察“输入是否有实质变化”和“输出结果是否有增量”。
5.2 阈值定太宽:预算拦不住“大额单笔调用”
另一个坑是预算阈值定得太宽,放走了一个真正烧钱的任务。当时某个 Agent 的任务预算是 0.5 美元,单次模型调用却要 0.4 美元——因为输出配置了很长的 max_tokens,模型每次生成一大段。结果就是第一次调用就消耗了 80% 预算,但熔断器没有触发,因为它是在调用完成后才检查的。
之后我把预算检查改成了两段式:
- 调用前前瞻预算:预估当前调用可能消耗的 token(用请求里 max_tokens + 平均输入长度估算),如果预估消耗会让累计超过预算,直接在下一次循环时熔断。
- 调用后结算:用真实 usage 数据更新预算。
前瞻预算不精确,但它的价值在于“在真的花钱之前拦一下”。对于单笔调用就可能吃掉一半预算的任务,必须在发出请求前就知道风险。
5.3 HALF-OPEN 阶段的恢复风暴
熔断器进入 HALF_OPEN 之后,我差点又给自己挖了个坑。当时设计是:冷却结束,所有排队任务都允许发一个探测请求。看起来合理,但在同一时刻如果有 20 个任务一起进入 HALF_OPEN,就会同时发出 20 个探测请求。
结果有两个问题:
- 下游模型 API 被瞬间打成了限流,探测请求大面积失败,熔断器全部回到 OPEN。
- 每次探测请求都在消耗 token,恢复了但也花了一笔额外费用。
修复方案很简单:所有 HALF_OPEN 探测请求全局串行化。同一时刻只允许一个任务处于探测状态,其余任务继续等待。这样每次只有一个 LLM 调用,成本和限流风险都降下来了。
然后还要注意,探测请求用的 prompt 尽量短,max_tokens 压到 256 以内。目的只是确认“调用链是否恢复”,不是让 Agent 真正完成业务。如果探测请求也能成功返回,再让完整任务恢复执行。
5.4 熔断后的用户侧体验:给用户一个可操作的出口
最后说一个容易被忽视的点:熔断之后,用户看到什么。
我之前只在控制台抛一个CircuitOpenError,日志里写满红色堆栈。用户反馈说“我知道坏了,但我不知道能做什么”。后来我把熔断改成了“暂停-恢复”模型:
- 触发熔断时,保存 checkpoint,并向控制面板推送一条事件:“任务已因为预算耗尽暂停,你可以增加预算并继续,或者查看执行轨迹。”
- 用户点击“增加预算”,任务从 checkpoint 恢复,而不是从头跑。
- 用户点击“查看轨迹”,可以看到每一轮的调用记录和 token 消耗明细。
这样熔断器就不是一个“failure”,而是一个“可对话的暂停”。对用户来说,体验差异非常大——前者是事故,后者是一次有掌控感的收手。
对我来说,熔断器不是目的,目的是在 Agent 失控时给用户一个从容喊停的机会。真正让 Agent 不烧钱的办法,还是要在提示词层面把退出条件、终止信号写清楚。看门狗和熔断器是最后一道防线,而不是唯一一道防线。我在处理了几次熔断事件之后,回头去看那些当时死活调不完的任务,发现大多数只要在 prompt 里加上一句“当输出与上一轮没有实质差异时,直接返回当前结果”,就能避免整个循环。先优化提示词,再上防线,两者结合着来,预算才能真正守得住。