我不想等到早上才发现调用没有上限
《ops-agent 建造笔记》第 7 篇 代码基线:v0.1.0-rc.1,提交
d897d2c95e70(2026-10-25 发布的候选版) 主要代码:agent/llm.py、agent/llm_anthropic.py、usage.py
0. 一个没有上限的钱包
我在设计定时巡检时推演过一个让自己不太舒服的场景。它不是已经发生的账单事故:
巡检任务每 5 分钟一轮。某天凌晨,provider 那边出了状况,开始大量返回 429 和 500。如果 LLM 客户端的处理是"失败就重试、重试不行再重试",那无人值守的 daemon 会做什么?它会在我睡觉的八个小时里,以每 5 分钟一轮的频率、每轮若干次重试的节奏,持续敲打一个已经出问题的 API。早上起来:一笔莫名其妙的 token 账单,外加几百次无效调用。
这个场景里有两个独立的问题,W7 各给了一个答案:
- 临时故障要怎么重试?→ provider 退避:区分瞬时错误和永久错误,前者有限退避,后者绝不重试。
- 总量要怎么封顶?→ 调用预算:按自然日滚动,超限立即拒绝新的 LLM 调用。
1. 哪些错误值得等
agent/llm.py 的 is_transient 先识别错误里的 HTTP 状态码,再对网络请求/读取失败做分类:
- HTTP 集合明确列出 408、409、425、429、500、502、503、504;加上识别出的网络瞬断,最多重试 2 次,也就是至多 3 次请求尝试。不是所有 5xx 都无条件重试。
- 400、401 等不在瞬时集合里,provider 这一层直接失败。
默认 _backoff 的两次等待分别是 3–4.5 秒、6–7.5 秒。指数部分有 30 秒的上限常量,默认只有两次重试,实际上还到不了这个上限;抖动是额外加的,不能把 30 秒写成任何配置下总等待的硬上限。
我更在意这份分类能说清楚,而不是尽可能替请求“救回来”。认证不对,继续重复并不能修好认证;能恢复的临时故障,也只能在剩余预算里有限地等。
三个裁剪规则,每一条都对应一个具体顾虑。
每次请求的 timeout 会取配置超时与剩余 deadline 的较小值。退避还留了一点余量:若剩余时间不够“计划等待 + 2 秒”,就直接结束这次失败,不为了再试一次睡到最后一刻。900 秒任务已经花了 890 秒,也不是无条件把剩下 10 秒全拿去等待。
流式回调已经输出内容后不重试;工具流没有完整结束标记时也要受控报错,不能拿半条工具调用去执行。这是防重复推送:流式响应已经吐了一半文本给我,然后连接断了,重试会让模型从头再生成一遍,我看到的就是前半段重复。文本重复比"这次请求失败"更难看,所以宁可不重试。这条和第 4 篇 unknown 是同一个思路,不能撤销的副作用一旦发生,自动重试这个选项就没了。
还有一层不能混淆:schedule._execute_started_run 对只读任务异常另有一次外层重试,使用新会话。provider 层不重试 401,并不意味着调度层绝不会再试这个任务一次;BudgetExhausted 才是外层明确不重试的类型。两层的所有实际 API 尝试都必须占用预算。
2. 先占名额,后记账
调用预算的核心实现在 usage.py,语义是"先占座、后结账":
- 每次请求尝试先调用
usage.reserve_call(),在同一把锁内检查预算、占用一次调用次数并持久化;保存失败就不发请求,耗尽则抛BudgetExhausted; - 完成后记账:
usage.jsonl追加一条 + 按日聚合; - 达到上限后拒绝新的 LLM 调用;按本机自然日选择新的计数桶。跨零点的在途请求仍记到预留名额时的那一天。
失败和重试也计入次数,这是我特意定的。一次 429 也是一次调用,一次超时的尝试也是一次尝试。如果只记成功,那 provider 故障期间的重试风暴就完全游离在预算之外,而那个场景恰恰是预算最该管的。
token 记账的口径:优先用 provider 返回的 usage;缺失时估算,估算值带"(估)"标记,且把工具定义和调用参数也算进去。工具 schema 和调用参数也会进入请求,只数消息正文会漏掉这部分输入成本。
token 上限在完成记账后阻断后续请求,不能预知在途请求的最终消耗。因此它不能保证只超一点点,一次很大的响应或者多个并发请求都可能跨过 token 上限。调用次数可以先占名额,实际 token 只能后结算;这是两个不同强度的边界。
预算耗尽之后谁还能干活,是这套设计里我考虑最久的部分。结论是确定性检查与通知链路不依赖 LLM 调用预算。命令行 inspect、场景的预检/后验可以独立运行;任务级 --checks 也在模型之前采集。若没有配置前置规则检查,而只是指望模型先调用 inspect,预算耗尽后那一步并不会自己发生。场景变更仍然有各自的人工审批要求,预算没有替它授权。
这条和第 2 篇的巡检架构接上了:通过 inspect 或任务级 --checks 明确触发的固定检查,不会因为 LLM 额度耗尽而取消。已经采集并保存的规则证据仍能用于判定与通知,新的模型解读则停下来。我把固定采样放在模型之前,预算闸门才可以收紧,不必再为“先让模型决定检查什么”留下豁免。
状态文件损坏的处理:备份成 .corrupt-*、阻断本次调用、写事件,随后重建计数。这是告警后的恢复策略,不是永久阻断。不能因为一个 JSON 坏了就把 LLM 调用永远禁掉,但本次调用必须停下来让我知道。
3. 两个相关的决定
预算按本机自然日滚动。budget set --calls 500 --tokens 500000。本地零点换到新的计数桶,比滑动窗口容易核对。代价是零点前后的两批请求可能连续消耗两个日预算;它不是任意连续 24 小时的滑动上限。
金额还没有做。budget.json 目前只接受 enabled、daily_calls、daily_tokens,没有单价字段,也没有人民币/美元支出上限或账单对账功能。路线图里的“配置单价后再估算金额”是后续方向,不能写成这一版已经提供了成本计算。
4. 现状
W7 复审补过失败/重试请求的计数和流式 usage 估算路径。候选版的测试还保留了真实回环 HTTP 503 → 200 的恢复与记账检查,但那是本地桩,不是真实付费 provider 的费用证明。现在 budget recent --tail 10 能看到每次调用的 token 和"(估)"标记。这点透明度对我挺重要,记账系统自己算的账要能被人翻出来核对。
默认预算是每日本机自然日 500 次调用、500000 token,可以用 ./opsagent budget status、./opsagent budget recent --tail 10 查看。budget set --calls 0 或 --tokens 0 表示对应项目不限,不能把 0 当成禁止调用。
还没做的:按任务/来源拆分预算、金额计算与账单对账、成本趋势可视化。当前还是同一个状态目录下共用一个计数池,定时任务、子代理和交互请求会共同消耗。本版 W8 的真实 provider 验收与修复后的连续 7 天观察仍待完成。
下一篇讲知识:技能库和长期记忆。为什么 playbook 是"先查目录、用时才载入",以及 remember 工具的写法约定为什么管得那么细。
这一版的代码落点
- 统一请求、重试与预算入口:agent/llm.py
- Anthropic 协议与流式 usage
- 调用预留、token 估算与账本:usage.py
- 预算并发/失败/跨日回归
- 真实回环慢 503 记账验证