ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

账单爆表事故复盘:单个 Agent 协程跑掉 3000 万 Token,用预算闸门治理非确定性 LLM

账单爆表事故复盘:单个 Agent 协程跑掉 3000 万 Token,用预算闸门治理非确定性 LLM 1. 事故现场一个协程跑掉 3000 万 Token 的账单爆表复盘先说结论这不是被攻击也不是密钥泄露而是一个后台异步总结文章的 Agent 协程在处理一篇格式错乱的 PDF 时陷入了递归推理8 小时无人值守单次 Session 烧掉 3000 万 Token隔夜账单直接飙到近 1500 美金。财务早上发邮件质问的时候运维还在查是不是有人刷接口。如果你正在做 Agent、异步任务、批量文档处理这类场景这篇复盘值得看完。核心问题不是模型太贵而是非确定性 LLM 调用链缺少确定性的预算闸门。CPU 和内存你能用 cgroup 限制Token 却常常是敞口信用卡——代码不主动切断它就能一直刷。我先把事故链路拆开你对照自己的项目看有没有同样的坑触发源一篇 PDF 解析后格式错乱文本里混入了大量重复片段和未闭合的标记。Agent 行为总结 Agent 发现内容不完整于是自我反思、重新读取、再次总结形成递归。失控点每一轮都调用 LLM每轮消耗几千到几万 Token循环没有次数上限也没有 Token 上限。放大点异步协程独立运行主流程早已返回没人盯着它。暴露点账单是 T1 出的等看到数字时钱已经花完了。这里最反直觉的一点是LLM 的输出是非确定性的但你的成本防线必须是确定性的。你不能指望模型自己停下来也不能指望这次应该不会循环。工程上唯一可靠的做法是在调用链上装一个硬编码的预算闸门Budget Gatekeeper超限就熔断不讲情面。下面这张是事故当时的调用链示意用文字描述方便你对照后台异步 Agent 任务 - Token 预算闸门缺失 - 检测 Session Token 消耗缺失 - 允许调用 LLM无限次 - 递归推理无上限 - 账单爆表我试过在事后回放这段逻辑把闸门补上之后同样的错乱 PDF 在第 3 轮就被拦下了消耗不到 5000 Token。差距就是这么直接。这一节想让你记住三件事第一异步 Agent 必须有独立的预算维度第二递归/反思类逻辑必须设最大轮次第三账单监控要能小时级告警而不是等日报。接下来我会先讲清楚为什么要在调用层做闸门再给可直接复制的配置和代码。2. 为什么预算闸门要建在 LLM 调用层TaoToken 前置与计量钩子设计很多人第一反应是我在业务层判断一下不就行了。问题是Agent 的调用链往往是多层嵌套的工具调用、反思、重试、子 Agent 委派业务层根本不知道底层到底发起了多少次 LLM 请求。所以闸门必须建在最靠近 LLM 调用的那一层也就是统一封装的 client 或网关层。这也是我把项目接入 TaoToken 的原因之一。它提供统一的 API 入口所有模型调用走同一个 Base URL计量和拦截只需要在一个地方做不用在每个业务模块里重复埋点。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 这个不加 UTM。统一入口带来的最大好处就是预算闸门可以做成中间件而不是散落各处的 if-else。设计上我分了四个维度你可以按需裁剪维度作用建议阈值示例单次调用防止单次 prompt 过大单次 ≤ 32k Token单 Session防止协程失控≤ 100k Token单用户单日防止单账号滥用≤ 2M Token系统单日兜底全局成本按预算倒推计量钩子的关键点是调用前预估 调用后校准。调用前用 tokenizer 估算 prompt 长度先扣一笔调用后用返回的 usage 字段校准实际消耗。这样即使预估有偏差也不会出现先花完再发现的情况。还有一个容易被忽略的点流式响应也要能打断。如果客户端断开或闸门触发要立刻向接口发送取消信号停止后续生成计费。很多事故就是因为流式连接挂着没人管模型一直在吐 Token。在 TaoToken 的控制台里可以创建和管理 API Key配合上面的四维阈值把闸门逻辑集中在一个 client 包装层。控制台入口 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。API Key 管理 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你用的是 Claude Code 这类编码 Agent接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content Claude Code 专用入口是 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这些入口的意义在于把调用统一收口闸门才有地方挂。一句话总结这一节闸门的位置比闸门的算法更重要。放在调用层你才能覆盖所有非确定性路径。3. 可复制配置预算阈值、协程级计量钩子与熔断代码这一节直接给能跑的东西。先给配置片段再给 Go 实现最后给一个 Python 版本你按自己的技术栈挑。3.1 预算阈值配置JSON把阈值外置成配置方便不同环境调整。路径建议放在config/budget.json{ budget: { per_call_max_tokens: 32000, per_session_max_tokens: 100000, per_user_daily_max_tokens: 2000000, system_daily_max_tokens: 50000000, warn_threshold_ratio: 0.8, max_agent_rounds: 8 }, llm: { base_url: https://taotoken.net/api, model_id: claude-3-5-sonnet, timeout_seconds: 60, stream_cancel_on_budget: true } }注意max_agent_rounds这个字段它是防递归的第二道锁。即使 Token 没超轮次超了也要停。3.2 协程级 Token 计量钩子Go这是事故后我实际用的版本线程安全用 atomic 保证并发下计数准确package budget import ( context errors fmt sync/atomic ) var ErrBudgetExceeded errors.New(session token budget exceeded) // Gatekeeper 协程级 Token 预算闸门 type Gatekeeper struct { maxSessionTokens int64 usedTokens int64 maxRounds int32 rounds int32 } func NewGatekeeper(maxSessionTokens int64, maxRounds int32) *Gatekeeper { return Gatekeeper{ maxSessionTokens: maxSessionTokens, maxRounds: maxRounds, } } // Consume 扣减预算超额立即返回错误触发熔断 func (g *Gatekeeper) Consume(tokens int64) error { newTotal : atomic.AddInt64(g.usedTokens, tokens) if newTotal g.maxSessionTokens { return fmt.Errorf(%w: used%d limit%d, ErrBudgetExceeded, newTotal, g.maxSessionTokens) } return nil } // NextRound 轮次闸门防止递归 func (g *Gatekeeper) NextRound() error { r : atomic.AddInt32(g.rounds, 1) if r g.maxRounds { return fmt.Errorf(agent rounds exceeded: %d, r) } return nil } func (g *Gatekeeper) Used() int64 { return atomic.LoadInt64(g.usedTokens) } // CallLLM 带预算拦截的调用包装 func CallLLM(ctx context.Context, g *Gatekeeper, promptTokens, outputTokens int64) (string, error) { if err : g.NextRound(); err ! nil { return , err } if err : g.Consume(promptTokens); err ! nil { return , err } // 这里替换为真实 LLM 调用返回后按 usage 校准 if err : g.Consume(outputTokens); err ! nil { return , err } return ok, nil }调用侧这样用g : budget.NewGatekeeper(100000, 8) for i : 1; i 20; i { if _, err : budget.CallLLM(ctx, g, 500, 1200); err ! nil { fmt.Printf([CUTOFF] round%d err%v used%d\n, i, err, g.Used()) break } fmt.Printf(round%d used%d\n, i, g.Used()) }跑起来你会看到它在第 8 轮或预算耗尽时被拦下而不是无限循环。3.3 Python 版本asyncio 场景如果你的 Agent 是 asyncio 协程用这个import asyncio from dataclasses import dataclass, field class BudgetExceeded(Exception): pass dataclass class Gatekeeper: max_session_tokens: int max_rounds: int used: int 0 rounds: int 0 _lock: asyncio.Lock field(default_factoryasyncio.Lock) async def consume(self, tokens: int): async with self._lock: self.used tokens if self.used self.max_session_tokens: raise BudgetExceeded( fused{self.used} limit{self.max_session_tokens} ) async def next_round(self): async with self._lock: self.rounds 1 if self.rounds self.max_rounds: raise BudgetExceeded(frounds{self.rounds}) async def call_llm(g: Gatekeeper, prompt_tokens: int, output_tokens: int): await g.next_round() await g.consume(prompt_tokens) await g.consume(output_tokens) return ok3.4 接入配置settings 片段如果你用 Cline / Claude Code 这类工具配置里必须写全三件套Base URL、Key、Model ID。以 settings 为例{ llmProvider: { baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, modelId: claude-3-5-sonnet }, budget: { perSessionMaxTokens: 100000, maxAgentRounds: 8 } }三件套缺一不可尤其是 Model ID写错会直接报模型不存在。Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 创建。4. 验证请求确认闸门真的会熔断配置写完不算完必须验证。验证分三步正常调用、触发熔断、观察告警。第一步正常调用。用 curl 打一次确认 Base URL 和 Key 通curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, messages: [{role: user, content: 只回复两个字收到}], max_tokens: 16 }返回里会有usage字段记下prompt_tokens和completion_tokens这是你校准计量钩子的依据。第二步触发熔断。把 Gatekeeper 的maxSessionTokens临时设成 2000跑上面的循环代码。预期输出类似round1 used1700 round2 used3400 [CUTOFF] round2 errsession token budget exceeded: used3400 limit2000看到CUTOFF就说明闸门生效了。这一步很关键很多人配了阈值但从没验证过真出事时才发现逻辑写反了。第三步观察告警。在 Prometheus 里加一个计数器每次熔断 1var budgetCutoffCounter prometheus.NewCounterVec( prometheus.CounterOpts{ Name: agent_budget_cutoff_total, Help: Total budget cutoff events, }, []string{session_id, reason}, )当agent_budget_cutoff_total在 5 分钟内增长超过阈值就推送到运维群。这样你不仅知道被拦了还知道拦了多少次能反推是不是有异常文档在批量触发。验证通过后把阈值调回生产值比如 100k再跑一次真实任务确认正常任务不会被误杀。误杀和漏杀都要避免前者影响业务后者等于没装闸门。5. 常见报错排查401、local proxy failed、reading choices、OAuth这一节按真实报错来都是我踩过的。401 Unauthorized九成是 Key 写错或没带Bearer前缀。检查Authorization: Bearer sk-xxx格式确认 Key 没有多余空格。如果 Key 是从控制台复制的注意别把换行带进去。还有一种情况是 Key 被禁用或额度耗尽去 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 确认状态。local proxy failed这个报错通常出现在本地工具链里意思是本地代理层连接失败。排查顺序先确认 Base URL 是否写成了https://taotoken.net/api不要多加/v1之外的路径再确认网络能通最后看工具本身的代理配置有没有冲突。如果是 Cline 或 Claude Code检查 settings 里的 baseUrl 字段是否被旧配置覆盖。reading choices 相关报错一般是响应结构解析失败。常见原因是流式和非流式混用或者返回体不是预期的 JSON。检查你的 client 是否按choices[0].message.content解析流式则要按 SSE 逐块拼接。如果模型返回了错误对象也会导致 reading choices 失败这时先打印原始响应体。OAuth 相关报错如果你用的是需要 OAuth 的工具比如某些编码 Agent报错通常是 token 过期或回调地址不匹配。处理方式是重新走一遍授权流程确认回调地址和工具配置一致。如果工具支持 API Key 模式直接切到 Key 模式更省事三件套配好即可。Codex auth.json 场景如果你用 Codex 类工具认证信息在auth.json里。确认里面的 base URL、key、model 三件套完整缺一个都会失败。改完记得重启工具很多工具是启动时读一次配置。CC Switch / Cline MCP 场景这类工具切换配置时容易残留旧值。切换后检查三件套是否同步更新尤其是 Model ID不同模型 ID 不通用。MCP 场景还要确认 MCP server 的启动参数里没有硬编码旧的 Base URL。排查通用思路先看 HTTP 状态码401/403 是认证404 是路径429 是限流5xx 是服务端。再看响应体里的 error 字段通常有明确原因。最后看本地配置八成问题出在配置而不是服务。6. 把闸门用起来从验证模型到长期编码 Agent 的接入路径闸门代码有了配置有了接下来是把它接到真实工作流里。不同场景接入方式不一样我按用途分三条路。验证模型阶段如果你只是想先确认某个模型能不能用、返回格式对不对直接用模型对话入口最快 https://taotoken.net/model?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。在这里发几条请求看 usage 字段确认计量口径再决定阈值怎么设。这一步不用写代码适合快速摸底。接入自有项目阶段把第 3 节的 Gatekeeper 包进你的 LLM client所有调用走它。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有 Base URL、鉴权、请求格式的完整说明。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 管理。这一步的重点是收口——所有调用必须经过同一个 client否则闸门形同虚设。长期编码 / Agent 阶段如果你跑的是 Claude Code 这类长期编码 Agent接入入口是 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这类 Agent 的特点是会话长、轮次多更容易失控所以预算闸门和轮次闸门都要开。如果预算需要按周期管理可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。最后给一个实操建议先把阈值设小跑一周看熔断日志再逐步调大。我一开始把单 Session 设成 50k结果正常的长文档总结被误杀后来调到 100k 才平衡。阈值不是拍脑袋定的是用真实数据调出来的。另外把熔断事件当成信号而不是噪音。每次熔断都值得看一眼是异常输入还是阈值太紧还是 Agent 逻辑本身有问题。事故复盘的价值不在于装了个闸门而在于通过闸门日志你能看见那些原本看不见的非确定性路径。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进