我的 Agent 一晚上烧了 500 块,于是我把它的死循环拆成了四种形态

早上打开账单,多出来 500 块。

不是被攻击,也没有 bug 报错。日志里一切正常:Agent 在跑,工具在返回,每一步都合法。它只是在两个工具之间来回弹跳了一整夜------读一次文件,写一次文件,读同一个文件,写同一个文件。

这件事让我意识到一个问题:我对"Agent 出问题"的想象是错的。 我以为出问题是报错、是抛异常、是进程挂掉。但真正烧钱的失败模式是:Agent 觉得自己在正常工作,而它已经三个小时没有推进任何事情了。

后来我把这类"看起来在正常工作"的失败拆了一遍,发现至少有四种形态,而且只防其中一种的检测器到处都是。


一、为什么事后检查救不了你

最常见的防线是设一个预算上限,每次调用后累加,超了就抛异常。

python 复制代码
def call_and_check(client, messages, spent):
    response = client.chat.completions.create(model="gpt-4o", messages=messages)
    spent += cost_of(response)
    if spent > BUDGET:
        raise BudgetExceeded(spent)
    return response

这段代码没错,但它有一个结构性的问题:它只能报告已经花掉的钱,不能阻止将要花的钱。

算一笔具体的账。假设你的 Agent 每次调用都带上 18 万 token 的上下文(对代码库类的 Agent 很正常),并且允许最多 16k 的输出:

  • 输入:180,000 × 2.50/1M=∗∗2.50 / 1M = ** 2.50/1M=∗∗0.45**
  • 输出上限:16,000 × 10.00/1M=∗∗10.00 / 1M = ** 10.00/1M=∗∗0.16**
  • 单次最坏:$0.61

也就是说,单次调用就可能超出你设的任何小额预算。事后检查会发现它,但发现的时候钱已经付了。

500 块听起来要跑很久。按每次 $0.61 算,只需要一百多次调用------一个卡住的循环在夜里跑完这个量绰绰有余。

所以第一道防线应该是调用之前的检查:

python 复制代码
from agentguard import Guard, BudgetExceeded

guard = Guard(max_usd=1.00, max_steps=25)

# 假设已经花了 $0.42
try:
    # 最坏情况放不进剩余预算,就在请求发出去之前拒绝
    guard.preflight("gpt-4o", input_tokens=180_000, max_output_tokens=16_000)
except BudgetExceeded as exc:
    print(exc)
    # Budget exceeded: refused before spending: a gpt-4o call could reach
    # $1.03, over the $1 limit (already spent $0.42).

注意 $1.03 是怎么来的:已花的 0.42∗∗加上∗∗这次调用的最坏情况0.42 **加上**这次调用的最坏情况 0.42∗∗加上∗∗这次调用的最坏情况0.61。它不是"超了一点",它是"这次调用发出去就一定会超"。

这个区别是结构性的,不是优化。 事后检查是记账,事前检查才是控制。


二、死循环的四种形态

预检解决了"单次调用太贵",但解决不了"一万次便宜调用"。那是另一个问题:Agent 卡住了。

这里有个反直觉的地方:Agent 卡住的时候,它不是在报错,它是在非常努力地工作。

我把它拆成了四种形态。每一种,朴素的检测方式都会漏掉。

形态一:完全重复

同一个工具,同一份参数,反复调用。

python 复制代码
with guard.tool("search_web", {"query": "weather in oslo"}):
    ...

这是最容易防的一种,也是大部分库唯一防的一种 :把 (工具名, 参数) 哈希一下,出现次数超阈值就停。

形态二:A/B 弹跳

读文件 → 写文件 → 读同一个文件 → 写同一个文件......

erlang 复制代码
read_file({"path": "app.py"}) -> write_file({"path": "app.py", ...})

单看每一次调用,它都和上一次不同。一个只做"完全相同"检测的守卫,在这里永远不会触发。 你需要检测的是周期 ,不是重复。

形态三:换词重试

erlang 复制代码
search_web({"query": "how do i fix asyncio event loop is closed in python"})
search_web({"query": "how do i fix asyncio event loop is closed in python "})
search_web({"query": "how do i fix asyncio event loop is closed in python  "})

Agent 没有重复自己,它换了个说法。字节层面全不相同,语义层面一模一样。

这类循环靠精确匹配抓不到,需要的是相似度。

形态四:进度停滞

前面三种都假设"循环体现在调用上"。但还有一种:每次调用的参数都不一样,看起来完全正常,但实际状态没有推进。

比如一个分页抓取的 Agent,游标一直停在第一页;或者一个批处理,已写入行数一直没变。调用本身毫无异常,卡住的是它身后的世界。

这种只能由 Agent 自己上报------因为只有你的 Agent 知道"进度"是什么意思。

把它们放在一起

css 复制代码
exact repeat                     -> repeat
                                    the same call appeared 3 times in the last 3 steps: search_web({"query":"weather in oslo"})
two-step ping-pong               -> cycle
                                    a 2-step pattern repeated 2 times: read_file({"path":"app.py"}) -> write_file({"body":"print('hi')"...
paraphrased calls                -> similarity
                                    4 near-identical calls (>= 95% similar) in the last 4 steps: search_web({"query":"how do i fix asyncio...
no progress                      -> no-progress
                                    the progress marker did not change for 6 consecutive observations: {"rows_written":0}

(这是在本地跑 python examples/loop_detection.py 的真实输出,不需要 API key。)

四种形态对应四个检测器,默认阈值分别是第 3 次完全相同、第 2 个完整周期、第 4 次近似调用、第 6 次未变化的进度标记。


三、一个我踩过的坑:测试全绿,但生产里一次都没触发过

这部分是本文我最想讲的,因为它跟这个库本身没多大关系。

我们最初给周期检测器定的默认值是"一个周期重复 3 次才触发"。看起来很稳妥------避免误杀嘛。

问题是:一个 A/B 周期重复 3 次,需要 6 次观察。而"完全相同"检测器在第 3 次相同的调用就触发了------也就是第 5 次观察。

所以周期检测器永远轮不到。 在任何一个真实场景里,"完全相同"都会先一步拦下来,周期检测器拿到的判定机会是零。

而它的单元测试是全绿的。因为测试直接调用了检测器本身,喂给它六次 A/B,它确实触发了。测试验证的是"检测器对不对",没有人验证"它在默认配置下到底会不会被用到"。

这是一个非常隐蔽的问题:一份看起来在维护、测试覆盖率很高、但在生产里零触发的代码。

我们后来做了两件事:

  1. 把默认值改成"重复 2 次"(4 次观察)。这样它比"完全相同"检测器早一步,真正拥有自己的作用域。
  2. 加了一组专门的测试,守护默认配置下每个检测器的作用域都要可达:
python 复制代码
def test_defaults_report_a_two_step_cycle_as_a_cycle(self):
    # RepeatDetector 需要 "read()" 出现第三次,也就是第 5 次观察,
    # 所以默认的 CycleDetector 必须在第 4 次先触发。
    verdict = feed(LoopMonitor(default_detectors()), ["read()", "write()"] * 2)
    assert verdict.kind == "cycle"

推广一下:任何"默认开启的检测/防护机制",都必须有一份测试证明它在默认配置下真的会被触发。 否则你维护的是一份安慰剂。阈值本身调错不是最可怕的,阈值让一个组件永远不可达才是------因为它不会报错,不会掉覆盖率,只会安静地什么都不做。


四、另一个决策:拒绝猜价格

预算守卫要工作,前提是它知道每次调用花了多少。而它必须把 token 数换成钱,这就需要一张价格表。

价格表一定会过期。新模型每周都在出,你的表不可能同步。

市面上常见的做法是:遇到不认识的模型,按一个"保守估算"的费率计费。

我理解这个设计的出发点------总比算成 0 好。但它有一个更隐蔽的坏处:

它让你以为自己在被保护,实际上没有。

如果估算费率低于真实费率,预算会悄无声息地被突破,而且报告上看起来一切正常。你损失的不只是钱,还有"我的预算守卫在盯着"这个错觉。

所以我们的选择是不猜 :不认识的模型记为 unpriced,从预算计算里排除,并且在报告里单独列出来:

sql 复制代码
  by model
    gpt-4o           6 calls      $0.309     108,000 in / 8,400 out
    acme-rerank-v3    1 call    unpriced      40,000 in / 0 out

  ! 1 call(s) had no known price and are excluded from the budget:
      acme-rerank-v3
    Pass Guard(pricing={...}) to include them.

一个安静地按 $0 计算的安全组件,比没有安全组件更危险。

一个真实的坑:NaN

顺着"校验"这条线,我们还发现过一个更极端的情况。

价格是浮点数,而浮点数里有 NaN。如果某个环节让一个费率变成了 NaN,那么:

python 复制代码
>>> float("nan") > 1.0
False

NaN 和任何数比较都是 False。 所以 spent > budget 这个判断会恒为假------预算检查不会报错,它会彻底静默失效,而且看起来完全正常。

我们现在在构造 Price 的时候就拒绝负数、NaN 和无穷大,也拒绝 bool(因为 float(True) 会拿到 1.0,一个不小心传错的 True 就变成了"每百万 token 一块钱")。

一个安全组件最重要的性质,不是它覆盖了多少场景,而是它在不确定的时候是否诚实地表达不确定。


五、最后一个:仓库里的配置文件,本身就是一个攻击面

这一条是最近才意识到的,也是我觉得最值得单独拿出来讲的。

Agent 的定价配置很实用------你可能有自己的 fine-tune、网关别名、谈下来的费率,这些都写不进公开价格表。所以我们支持一个 JSON 配置:

json 复制代码
{
  "models": { "my-finetune-v3": { "input": 3.0, "output": 12.0 } },
  "aliases": { "acme/fast": "claude-3-5-haiku" },
  "disable": ["gpt-4"]
}

自然的实现是:从当前目录开始向上找 agentguard.json,找到就读。

但请想一下这意味着什么。

如果这个配置默认被读取,那么"clone 一个仓库,在里面运行你的 Agent"就成了一条文档化的账单失控路径。仓库作者可以:

  • 给模型标一个极低的价格,让你的预算守卫形同虚设
  • 或者直接 disable 掉贵的模型
  • 或者把 gpt-4o 别名到一个便宜模型上,让你以为在用好的

这是一次用数据文件完成的预算绕过。 不需要一行恶意代码,一次代码审查都不会发现它------谁会去审查一个 JSON 配置呢?

所以我们现在的规则是:

  • 项目树里的配置文件默认不信任。 只有显式设置环境变量才读。
  • 被跳过的文件每个进程报一次,不是静默忽略。
  • agentguard config path 会告诉你跳过了什么、为什么跳过。
bash 复制代码
$ agentguard config path
config locations

  env     AGENTGUARD_CONFIG=(not set)
  trust   AGENTGUARD_TRUST_PROJECT_CONFIG=(not set)
  user    ~/.config/agentguard/pricing.json  (not found)
  project ~/work/my-repo/agentguard.json  (ignored: not trusted)

  reading: bundled prices only

  ! ~/work/my-repo/agentguard.json was not read: a config file inside a source tree
    travels with that repository, so it could reprice models or
    disable them without you noticing. Ask for it explicitly:
      set AGENTGUARD_TRUST_PROJECT_CONFIG=1   (or pass Guard(config_path=...))

而用户目录下的配置文件、或者通过环境变量/参数显式指定的路径,不需要信任------因为那些路径是你自己决定的,不是跟着仓库来的。

推广一下:任何随代码一起传播的配置都是输入,而输入默认不可信。 这条在 Agent 时代会越来越重要,因为 Agent 的"配置"能直接影响它花多少钱、调用什么模型、访问哪些工具------它已经不是传统意义上"改个超时时间"的配置了。


六、一些数字

如果你在做类似的事情,这几个数字可以参考:

测试 473 个,不需要安装任何东西 就能跑(python -m unittest discover -s tests -t .)
运行时依赖 0 ------ 纯标准库,不 import 任何厂商 SDK
磁盘 I/O 除了你显式调用的 save(),不写任何文件
平台 Linux / macOS / Windows × Python 3.10--3.13
代码 pip install agent-budget-guard-py,import agentguard

用它大概是这样:

python 复制代码
from agentguard import Guard, BudgetExceeded

guard = Guard(max_usd=1.00, max_steps=25, name="research-agent")

try:
    with guard:
        while True:
            with guard.step() as step:
                response = call_llm(...)
                step.record(response)                     # 自动提取 token 和费用
                with step.tool("search", {"q": query}):   # 生成指纹,用于循环检测
                    results = search(query)
                step.progress(len(results))               # 上报进度标记
except BudgetExceeded as exc:
    print(exc)
finally:
    print(guard.report())

仓库在这里:github.com/yaoyuxiang-...

最后说一句诚实的:这个方向上已经有几个项目在做了(agent-fuse、agentbudget、agent-governor),它们的功能都比我们多。我们选的取舍是零依赖、不写磁盘、以及前面说的"不猜数字"------如果你需要的是完整的可观测平台或者多语言 SDK,它们更合适。


上面所有代码片段都可以离线运行,不需要 API key。

相关推荐
tellmewhoisi1 小时前
机器学习:集成学习4(XGBoost前置知识泰勒展开式1)
人工智能·机器学习·集成学习
An独行者1 小时前
融合生长阶段信息的深度学习:冬小麦叶片氮含量无人机高光谱估算新方法
人工智能·深度学习·无人机
gnsnswa1 小时前
回归岗位价值:CAIE 认证适合人群与能力定位
人工智能·网络协议·职场和发展·产品经理·创业创新·信息与通信·ai写作
ZaferLiu1 小时前
我给自家 RAG 装了一道 Jev 闸门:从设计到上线的完整实录
人工智能
爱签AI电子合同1 小时前
电子合同大批量怎么测?并发与批量处理维度专项测评
服务器·数据库·人工智能·企业微信·电子签名
johnsong1 小时前
当AI Agent遇见治理革命
人工智能
AI酒旅实战开发1 小时前
拆解一个AI旅游Agent的完整技术栈:从前端对话到MCP到支付
人工智能·旅游
土星云SaturnCloud1 小时前
HRNet-pose 算法全解析:从高分辨率网络原理到土星云 SE110S 边缘部署实战
服务器·人工智能·算法·ai·边缘计算
一水鉴天1 小时前
家庭AI公约与思考能力培养:a / every / some 20260921(元宝)
人工智能