早上打开账单,多出来 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=∗∗0.45**
- 输出上限:16,000 × 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.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,它确实触发了。测试验证的是"检测器对不对",没有人验证"它在默认配置下到底会不会被用到"。
这是一个非常隐蔽的问题:一份看起来在维护、测试覆盖率很高、但在生产里零触发的代码。
我们后来做了两件事:
- 把默认值改成"重复 2 次"(4 次观察)。这样它比"完全相同"检测器早一步,真正拥有自己的作用域。
- 加了一组专门的测试,守护默认配置下每个检测器的作用域都要可达:
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。