前几天帮一个朋友排查他们某 AI 代理项目半夜跑崩的问题。一个自动整理行业资讯的小程序,从下午四点多开始重复执行同一组搜索动作,到第二天早上,账单涨了近千块。日志拉出来一看:同一个关键词搜了 47 次,每次生成的摘要几乎一模一样,前端页面还在傻等结果。这不是什么罕见事故。AI 代理本质上是一个自主决策进程,每做一个动作都可能产生模型调用费用;一旦失控,它不是浪费算力,是在直接烧钱。
所以我现在做 agent 项目,一定会加一层“实时监控 + 毫秒级叫停”的护栏。这篇文章不聊复杂理论,就讲清楚这套护栏怎么设计、怎么实现、有哪些坑。核心就一句话:让 AI 代理自由奔跑,但给它装个油门刹车一体的安全阀。
1. 先搞清楚钱是怎么“烧”掉的:代理失控的几种典型场景
要设计叫停机制,得先弄明白失控长什么样。我见过的大多数“天价账单”都不是模型本身贵,而是代理的运行路径出了问题。
1.1 场景一:低价值循环——同一个动作反复执行
这是最常见、也最烧钱的一种。代理在某个决策点失去有效进展,反复调用同一个工具。比如上面提到的资讯整理程序,陷入循环的原因很简单:提示词里没有“如果已经拿到该信息就立即停止”的约束,工具响应的结果也没有做去重。于是模型每次都以为“这次应该能搜到新内容”,结果每次都拿到同样结果,再生成一遍几乎相同的摘要。
这种循环的特征是:工具调用序列里出现大量重复项。正常人的工作模式是A→B→C→D推进,失控代理的模式是B→B→B→B。而且这类循环很难靠“设置 max_tokens”拦住,因为每次单独调用都是合法的小请求,问题出在调用次数上。
1.2 场景二:任务膨胀——单步请求越写越长
聊天型 agent 有另一个隐蔽花钱点:上下文滚动累加。每一轮对话都把历史记录原样重发给模型,输入 token 随着轮数增长。一个 5 轮任务可能每轮只需几百 token,跑到 20 轮的时候,单次输入可能膨胀到几万 token,费用翻几倍。
更糟的是很多 agent 框架的“记忆管理”做得很粗糙,把工具返回的完整 HTML、异常堆栈、中间日志一股脑塞进上下文。我见过一个案例,代理只是做产品比价,却把 3 万字的商品详情页原样带进下一轮 prompt,一次调用的输入 token 直接顶别人十次。
1.3 场景三:级联失败的放大效应
第三个典型失控路径是重试机制被滥用。某个工具调用超时 → 捕获异常 → 整体重试 → 又超时 → 再重试。如果重试次数不设上限,或者退避策略变成“失败后升级到更大的模型再试一次”,一个小故障就会变成账单放大器。
更隐蔽的是“半成功”状态:任务已经失败,但 agent 还在继续生成解释性文本,把错误信息反复总结、道歉、再尝试,每一步都是成本。
1.4 成本组成拆解:一次性账单 vs 持续风险
| 成本来源 | 什么时候产生 | 失控风险 |
|---|---|---|
| 输入 token | 每次请求的 prompt + 上下文 | 上下文膨胀导致持续走高 |
| 输出 token | 每次响应的内容 | 话痨模式、重复生成 |
| 工具调用附加费用 | 调用外部服务 / API | 循环调用时成倍累积 |
| 重试与降级 | 失败后的再次请求 | 指数级放大风险 |
| 记忆/缓存写入 | 长时任务中间态存储 | 容易被忽略 |
理解这张表之后,再看实时监控:不能只盯一个指标,必须同时覆盖“量的膨胀”和“路径的异常”。
2. 实时监控的四个核心信号:没有指标就谈不上叫停
监控不是写一句“盯紧点”就行,要落到具体可计算的信号上。我在生产环境里长期用的信号主要有四个,它们组合起来能覆盖绝大多数失控场景。
2.1 信号一:Token 消耗速率 / 预算消耗曲线
这是最基础的信号,监控的是“花钱的速度”。所有模型服务都会返回 usage 信息,记录下来后,可以维护两个指标:
- 累计成本:整个任务从开始到现在的总花费,单位是“元”或“美金”。
- 滑动窗口速率:过去 60 秒内花了多少。这个指标比累计值更灵敏,能在流量刚抬头的时候就发现异常。
为什么要用滑动窗口而不是固定窗口?举个例子:固定窗口从 0 秒到 60 秒统计,如果任务正好从 59 秒开始爆发,头一秒的消耗不会被计入,等进入下一个窗口时已经晚了。滑动窗口每时每刻都在滚动统计,能平滑掉这种边界割裂问题。
2.2 信号二:工具调用序列的“重复度”
针对循环型失控,我习惯给代理的每一次工具调用生成一个“指纹”:工具名 + 参数摘要的哈希值。然后维护一个最近 N 步的调用队列,计算当前动作在最近 N 步里出现了多少次。
def is_repetition(recent_calls, threshold=5): latest = recent_calls[-1] count = sum(1 for c in recent_calls[-threshold:] if c == latest) return count >= threshold比如最近 10 步里有 5 次是同一个搜索动作,那基本可以判定进入了低价值循环。这个计算是 O(N) 的,毫秒级完成,完全不会拖慢代理主流程。阈值不需要写死,批处理场景可以严格一点,交互式场景可以松一点。
2.3 信号三:单步请求体积与响应长度突变
这个信号专门用来抓“任务膨胀”和“话痨模式”。正常任务的单次输入长度和输出长度通常在一个稳定区间内。一旦出现以下突变,就要引起注意:
- 输入上下文突然比上一轮翻倍:大概率是记忆管理出了问题,把超大文本塞进了上下文。
- 单次响应输出 token 远超该任务的合理长度:模型可能进入了“车轱辘话”模式。
- 某个工具返回的内容异常巨大:后续所有轮次都会背着这个包袱,费用会被反复放大。
监控这个信号不需要机器学习,设定一个“单次请求体积上限”和“单次响应长度上限”,超出就告警,就是有效方案。
2.4 信号四:执行路径偏离度
最后这个信号适合流程相对固定的 agent。比如你预定义了一个五步执行流程,每一步只能调用指定工具。执行路径偏离度就是实时比对“当前正在做的动作”和“预设动作清单”,发现模型突然执行了清单之外的操作,立刻标记风险。
简单实现:维护一个所有允许动作的集合,以及一个可选的顺序期望。如果代理调用了不在集合内的方法,或者跳过了必经步骤,触发警告。这个判断规则简单直接,误报率低,适合合规要求高、流程标准化的任务。
3. 毫秒级叫停的实现路径:拦截、熔断与流式中断
有了信号,接下来解决最关键的落地问题:怎么在毫秒级别叫停正在烧钱的代理。拖延半秒钟,可能就多烧上千 token。
3.1 在哪个环节叫停:三层拦截架构
我在工程上把叫停点分成三层,各有各的用途:
- 接入层:在 agent 与模型 API 之间加一层代理或网关,统一记录所有请求和响应。这里能做全局限流,比如总体预算耗尽后,拒绝一切新请求。适合做账户级别的“总闸”。
- 运行层:在 agent 框架的执行循环里挂回调,每次开始执行一步、每次工具调用返回时都触发检查。这一层能拿到完整的上下文,适合做精准的路径判断。
- 输出层:在流式读取模型响应时逐块检查。这里能最快发现单次生成里的异常膨胀,适合做“半路掐断”。
实际项目里我推荐“运行层为主、接入层兜底、输出层补漏”。运行层负责判断“该不该停”,接入层负责“绝对不超支”,输出层负责“一旦开跑,尽可能快速止血”。
3.2 拦截器与回调钩子:最小侵入方案
大多数 agent 框架都支持回调或钩子机制。你不需要改框架源码,只要注册几个事件处理器就行。伪代码大概是这样的:
agent.on("tool_call_start", check_tool_repetition) agent.on("step_end", check_budget) agent.on("response_chunk", check_stream_anomaly)关键注意点:这些回调函数必须是轻量、同步、无阻塞的。不要在拦截器里发起新的网络请求、不要写大文件、不要调用另一个模型来判断“当前状况如何”。那会带来两倍延迟和两倍费用。正确的做法是:所有要判断的指标都已经在内存里实时更新,回调只是查一下计数器、比一下阈值,立刻返回。
3.3 预算熔断器:阈值、窗口与状态机
熔断器是叫停机制的核心组件。它不是简单的一次性判断,而是一个带状态的状态机,通常有三态:
- 关闭(closed):一切正常,所有请求放行。
- 打开(open):检测到预算超限或异常,直接拒绝所有请求,进入保护状态。
- 半开(half-open):过一段时间后放开少量请求试探,如果恢复正常则回到关闭,否则继续保持打开。
下面是一个简化的双阈值熔断器示例,同时统计累计预算和窗口速率:
class BudgetGuard: def __init__(self, total_budget, rate_budget, window_seconds=60): self.total_budget = total_budget self.rate_budget = rate_budget self.window_seconds = window_seconds self.spent = 0.0 self.window_start = time.monotonic() self.window_spend = 0.0 self.state = "closed" def check(self, cost: float) -> None: if self.state == "open": raise BudgetExceeded("circuit open, task stopped") now = time.monotonic() if now - self.window_start >= self.window_seconds: self.window_start = now self.window_spend = 0.0 self.spent += cost self.window_spend += cost if self.spent > self.total_budget or self.window_spend > self.rate_budget: self.state = "open" raise BudgetExceeded("budget or rate limit exceeded")为什么要双阈值?累计预算解决的是“绝对不能超过总花费”的底线问题;窗口速率解决的是“短时间异常爆发”的灵敏问题。两个结合,既能防长期超支,又能防瞬时爆炸。
3.4 流式输出的“半路掐断”:SSE 中断与资源回收
大模型响应普遍走流式输出。很多人的监控只做在“整个响应返回之后”,这是不够的。一个响应如果设定 max_tokens 为 8000,模型可能真的生成 8000 token 才会停下来。如果中间第 3000 token 时已经能判定异常,剩下 5000 token 的钱就白烧了。
流式场景的正确做法是边收边查:
def guarded_stream(prompt, max_output=2048): collected = "" try: for chunk in model_api.stream(prompt): # 1. 单步输出检查:超长立即掐断 if current_output_tokens + chunk.token_count > max_output: break # 2. 内容重复性检查:连续 chunk 高度重复则掐断 if is_repetitive_chunk(chunk, recent_chunks): break # 3. 动态预算检查:累计花费超预算则掐断 guard.check(estimate_chunk_cost(chunk)) collected += chunk.text yield chunk finally: close_stream()这里的 finally 很重要。流式连接是一个长期占用的资源,掐断后必须显式关闭连接、取消任务状态,否则会出现连接泄漏,导致后续任务排队阻塞,反而衍生出新的问题。
4. 一套可落地的实时监控原型:从埋点到叫停
光有概念不够,下面给出一套我自己实际跑过的原型方案。它适合单机、中小并发的 agent 应用,核心思路能平滑迁移到生产环境。
4.1 原型架构与数据结构
整个原型分四个模块:
| 模块 | 职责 | 存储 |
|---|---|---|
| 状态存储 | 保存每个任务的累计花费、调用序列、最近响应块 | 内存 dict + deque |
| 指标收集 | 从模型响应中解析 token 用量、工具调用信息 | 在回调里实时更新 |
| 规则引擎 | 执行四个信号的判断,返回 stop / continue / warn | 纯函数式判断 |
| 执行器 | 根据规则结果决定中断、降级或继续 | 调用框架的 stop 接口 |
单机原型不需要引入消息队列和时序数据库,一个defaultdict加一个deque就能跑起来。只要规则判断逻辑是纯内存计算,毫秒级叫停就没有瓶颈。
数据结构建议用任务 ID 作为维度:
task_state = { "task_id": "task_001", "total_cost": 0.0, "window_start": 0.0, "window_cost": 0.0, "calls": deque(maxlen=20), "recent_chunks": deque(maxlen=10), "context_tokens": 0, "output_tokens": 0, }4.2 核心实现:规则引擎与流式中断的组合
在上一节的 BudgetGuard 基础上,我做了第二个关键实现——把四个信号统一收敛到一个should_stop()方法里:
def should_stop(state, event) -> tuple[bool, str]: # 信号一:预算与速率 if event.type == "cost": guard.check(event.cost) # 信号二:调用序列重复 if event.type == "tool_call": recent = list(state["calls"]) if is_repetition(recent + [event.fingerprint], threshold=5): return True, "tool_call_loop" # 信号三:单步响应膨胀 if event.type == "chunk": if state["output_tokens"] > MAX_OUTPUT_TOKENS: return True, "output_too_long" # 信号四:路径偏离 if event.type == "action": if event.action not in allowed_actions: return True, "path_deviation" return False, ""把这个方法挂到回调里之后,代理每走一步都会经过检查。调用这个方法是毫秒级开销,相对于一次动辄几百毫秒甚至几秒的模型调用,完全可以忽略。
4.3 多任务并发下的状态共享与锁
一旦同时跑多个 agent 任务,就不能用全局共享 dict 裸写。任务之间会互相污染,还会出现竞态:两个并发任务同时更新同一个计数器,导致预算统计失真。
原型里我建议按任务 ID 分片加锁:
- 每个任务一个独立的
Lock对象,更新状态前先获取锁。 - 跨任务的全局预算用
threading.Lock保护,每次更新时做一次原子加操作。 - 如果将来上生产,用集中式存储(比如 Redis)存预算状态,配
INCRBY和EXPIRE做原子操作,天然支持多进程。
这里容易踩的坑是:把锁粒度放得太大,导致并发任务互相阻塞。合理做法是锁只保护“状态更新”那几行代码,规则判断本身可以无锁执行,因为判断只读当前快照。
5. 实战中容易踩的坑:误杀、漏放与监控本身的开销
这章是血泪经验总结。一个监控系统做得不好,比不做的危害更大——它会误杀正常任务,或者给你虚假的安全感。
5.1 误杀:把正常任务当成失控
最典型的是长报告生成任务。代理确实要连续输出大量内容,比如生成一份万字行业分析,输出 token 天生就大。如果阈值设得太紧,系统会在任务进行到一半时误判为“异常膨胀”并强制叫停,整个任务前功尽弃。
解决办法是分级动作,而不是一把梭:
- 第一级:黄色预警。命中速率阈值后,只记录日志、发送提醒,不中断任务。
- 第二级:橙色限制。持续超过阈值后,开始降低模型规格或压缩上下文,让任务继续但消耗放缓。
- 第三级:红色熔断。超过更高级别的阈值(比如预算耗尽、循环判定成立),才强制叫停。
这样既避免了误杀,又保证了失控时能收住。你不能指望一个规则在所有场景都完美准确,你只能让规则的分级足够合理。
5.2 漏放:监控滞后与采样盲区
有些监控只在“完整响应返回后”执行。这等于在钱已经烧完后再看账单,完全失去了叫停的意义。流式输出必须边收边查;工具调用循环必须在每次调用返回时立刻检查,而不是攒够十次再算。
还有一类漏放来自“采样盲区”:如果你只读取流的前 N 个 chunk 做异常检测,后面的内容可能完全不可控。所以检查逻辑要贯穿整个流生命周期,不能只查开头。
5.3 监控本身的开销:别把监控做成新的瓶颈
监控代码写在回调里,它不能反过来拖慢主流程。我在早期版本里犯过错误:在回调里用正则深度解析完整的工具返回内容,结果一个回调花了 80 毫秒,代理每走一步都慢得离谱。
经验做法:
- 用哈希指纹代替全文比对,重复检测只比较哈希。
- 用累计计数代替复杂统计,除非必要不做概率计算。
- 不许在回调里发起同步网络请求,所有数据都放内存。
- 日志异步写入,或者只在触发告警时写详细日志。
实测下来,一个纯内存的规则检查基本在 1 毫秒以内,对比模型调用本身动辄几百毫秒以上,这个开销不值得担心。
5.4 叫停之后怎么办:审计、重启与善后
叫停不是终点,是救援的开始。每次触发熔断后,至少要留下完整的审计信息:
- 触发时间、触发规则、当时的累计花费。
- 最近 N 步的工具调用序列。
- 被中断时的上下文快照和中间结果。
- 当前断点位置,以便后续恢复。
对于有状态任务,建议把中间结果持久化到磁盘或对象存储,这样下一次可以从断点重启,而不是从零开始。我的典型做法是:触发熔断后先把任务标记为“interrupted”,然后走一个自动恢复流程——如果任务可以降级,就用小模型继续;如果不行,就通知人工接管。
6. 不同场景下的叫停策略配置参考
不同任务的失控形态不一样,监控规则也要跟着调。下面是我在几个典型场景里用的配置参考,你可以按需调整。
6.1 场景对照表
| 场景 | 主要失控形态 | 建议监控信号 | 参考阈值 | 叫停动作 |
|---|---|---|---|---|
| 批处理数据处理 | 重试放大、循环调用 | 重试次数、单任务成本 | 重试≥3次,成本超预算80% | 熔断并人工复核 |
| 客服问答代理 | 长话痨、上下文膨胀 | 单次输出 token、输入长度 | 单次输出超5000,上下文超限 | 截断重写、降级小模型 |
| 代码生成代理 | 相同错误反复修复 | 相同报错出现次数 | 同一报错≥3次 | 暂停、换修复策略 |
| 长时研究型任务 | 累计烧钱、路径漂移 | 每分钟成本、调用重复度 | 每10步≥5次重复动作 | 断点暂停 + 通知人工 |
这个表只是起点。更精确的阈值要基于你自己任务的数据来定。建议先跑几天不做自动叫停,只把监控数据打点记录下来,看到正常任务的实际消耗分布后,再反过来设置阈值。
6.2 如何根据预算反推叫停阈值
假设你接到一个需求:某个批处理任务,单次预算上限是 5 元。模型服务报价大约是输入 token 1 元/百万,输出 token 3 元/百万。根据运行历史,平均每一步输入约 4000 token,输出约 1500 token。
那每一步的成本估算为:
- 输入成本:4000 × 1 / 1000000 = 0.004 元
- 输出成本:1500 × 3 / 1000000 = 0.0045 元
- 单步合计:约 0.0085 元
5 元预算大约能支撑 588 步。这时候不要把 588 直接设为硬阈值,因为正常任务可能有上下波动。更稳的做法是:
- 累计预算硬上限设为 4 元,留 1 元作为熔断后处理余量。
- 窗口速率阈值设为 60 秒内不超过约 0.5 元,折算下来大约每分钟不超过 58 步,正常任务很难达到这个速率,失控任务却能轻松撞线。
这样配置的逻辑是:累计上限兜底,速率上限抓异常。两条线同时起作用,比单纯设一个步数限制可靠得多。
7. 最后分享一点我的实际体会
做实时监控这套东西,真正让我受益最深的一点,是把“叫停”从简单的 break 升级成带断点恢复的 cancel 语义。第一次做的时候我只会在预算超限后粗暴终止,结果任务丢失上下文,重跑一次又花了大半预算。后来改成记录断点、序列化中间状态,恢复效率高了很多。如果你也在做类似系统,建议不要一上来就追求复杂的模型判断,先用一个计数器加一个预算阈值,把“发现异常→中断→留痕→复盘”这条链路跑通,再逐步加入路径偏离、重复检测这些高级规则。监控的目标不是把 agent 管死,而是让它敢跑、跑不丢。