拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

账单爆表事故复盘:给单个 Agent 协程装上预算闸门,拦住 3000 万 Token 的非确定性 LLM 消耗

账单爆表事故复盘:给单个 Agent 协程装上预算闸门,拦住 3000 万 Token 的非确定性 LLM 消耗 1. 事故现场一个协程跑掉 3000 万 Token 的账单是怎么来的先说结论这不是被攻击也不是密钥泄露而是一个后台异步 Agent 协程在处理一篇格式错乱的 PDF 时陷入了递归推理在无人值守的情况下独自跑了 8 个小时单次 Session 消耗了 3000 万 Token隔夜账单直接飙到上千美金。财务早上发邮件质问的时候我们才意识到——代码里给 CPU 和内存都设了 limit唯独没给 Token 设上限。如果你正在写 Agent、写异步任务、写后台批处理并且调用的是按 Token 计费的 LLM API那这篇文章就是写给你的。它不聊虚的架构图只交付一套可以复制粘贴的「预算闸门」骨架单协程 Token 上限、超限熔断、告警触发以及怎么验证拦截真的生效。适合谁适合所有把 LLM 调用放进循环、放进递归、放进 while True 的人——也就是几乎所有 Agent 开发者。事故的根因其实很朴素。传统后端里一个失控的循环最多把 CPU 打满K8s 会 OOMKill 它成本可控。但 LLM 调用不一样每一次调用都是一次真实的、按量计费的网络请求循环不会崩它只会安静地、一笔一笔地花钱。更麻烦的是 LLM 输出具有非确定性模型可能因为一段乱码 PDF 反复「再想想」Agent 的 planner 看到「还没完成」就再调一次于是递归停不下来。没有预算闸门就等于给这段代码开了一张没有额度上限的信用卡。我试过最直接的止血方式在调用 LLM 的那一层包一个计数器超过阈值就抛错。听起来简单但要做得线程安全、要区分 prompt 和 completion 的 Token、要在流式返回时也能实时打断还是有几个坑。下面从接入层开始一步步把这套闸门搭起来。2. 前置准备用 TaoToken 统一接入让预算闸门有地方挂预算闸门要生效前提是你能在一个统一的入口观测和拦截所有 LLM 调用。如果团队里每个 Agent 各写各的 SDK、各配各的 key那闸门只能挂在某一个协程里漏掉的调用照样烧钱。所以第一步是把调用收敛到一个兼容 OpenAI 协议的入口。TaoToken 在这里的角色是统一接入层它提供 OpenAI 兼容的 API你现有的openaiSDK 或httpx调用几乎不用改只需要换base_url和api_key。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数直接用它做 base_url 即可。为什么统一入口对预算治理这么关键因为闸门需要拿到每次调用的 usage 字段prompt_tokens / completion_tokens。如果调用散落在十几个地方你没法保证每个地方都上报。收敛到一个 base_url 之后你可以在自己的封装层里统一读取 usage统一扣减预算统一触发熔断。这跟「把数据库连接收敛到连接池」是一个道理——治理的前提是收口。拿到 Key 的路径是登录后进入控制台在 API Keys 页面创建一个新 key。控制台入口是 https://taotoken.net/console 创建 key 的页面是 https://taotoken.net/api-keys 。建议给后台 Agent 单独建一个 key不要和线上核心业务共用这样即使某个 Agent 失控你也能在控制台按 key 维度看到它的消耗曲线快速定位是哪个协程在烧钱。如果你只是想先验证模型通不通、usage 字段长什么样可以直接用模型对话页面手动发一条请求看看返回结构 https://taotoken.net/models 。这一步不用写代码先确认返回体里有 usage 对象后面闸门才有数据可扣。3. 可复制配置线程安全的 Token 预算闸门骨架下面这套代码是 Go 版本核心是一个线程安全的计数器加一个熔断判断。为什么用atomic而不是mutex因为 Agent 协程可能并发调用锁竞争会拖慢推理链路而预算扣减本身是「加一个数、比一个数」的简单操作原子操作足够且更快。package budget import ( context errors fmt sync/atomic ) // ErrBudgetExceeded 预算超限错误调用方据此熔断 var ErrBudgetExceeded errors.New(session token budget exceeded) // Gatekeeper 单协程/单 Session 的 Token 预算闸门 type Gatekeeper struct { maxTokens int64 // 硬上限 usedTokens int64 // 已消耗原子读写 warnTokens int64 // 预警线触发告警 warned int32 // 是否已告警避免重复推送 } func NewGatekeeper(maxTokens, warnTokens int64) *Gatekeeper { return Gatekeeper{ maxTokens: maxTokens, warnTokens: warnTokens, } } // Consume 扣减预算超额立即返回错误触发熔断 func (g *Gatekeeper) Consume(tokens int64) error { newTotal : atomic.AddInt64(g.usedTokens, tokens) if newTotal g.maxTokens { return fmt.Errorf(%w: used%d limit%d, ErrBudgetExceeded, newTotal, g.maxTokens) } // 预警线只触发一次 if newTotal g.warnTokens atomic.CompareAndSwapInt32(g.warned, 0, 1) { go sendAlert(fmt.Sprintf(session token warning: used%d, newTotal)) } return nil } func (g *Gatekeeper) Used() int64 { return atomic.LoadInt64(g.usedTokens) }关键点有三个。第一Consume在调用前扣 prompt 预估、调用后扣实际 completion两次都判断避免「先花后算」的窗口期。第二预警线用CompareAndSwap保证只告警一次否则一个失控协程会刷爆你的告警群。第三maxTokens是硬上限一旦超过直接返回错误调用方必须break或return不能吞掉错误继续循环。接下来是带预算拦截的 LLM 调用包装。这里用 OpenAI 兼容的 HTTP 调用演示你可以换成任意 SDKpackage budget import ( bytes context encoding/json net/http ) type chatReq struct { Model string json:model Messages []message json:messages } type message struct { Role string json:role Content string json:content } type chatResp struct { Choices []struct { Message message json:message } json:choices Usage struct { PromptTokens int64 json:prompt_tokens CompletionTokens int64 json:completion_tokens TotalTokens int64 json:total_tokens } json:usage } // CallLLMWithBudget 带预算闸门的调用 func CallLLMWithBudget(ctx context.Context, g *Gatekeeper, prompt string) (string, error) { // 1. 调用前按 prompt 长度预估先扣一笔防止并发穿透 estimatedPrompt : int64(len(prompt) / 3) // 粗略估算中文约 1 token/字 if err : g.Consume(estimatedPrompt); err ! nil { return , err } body, _ : json.Marshal(chatReq{ Model: gpt-4o-mini, Messages: []message{{Role: user, Content: prompt}}, }) req, _ : http.NewRequestWithContext(ctx, POST, https://taotoken.net/api/v1/chat/completions, bytes.NewReader(body)) req.Header.Set(Content-Type, application/json) req.Header.Set(Authorization, Bearer apiKey) resp, err : http.DefaultClient.Do(req) if err ! nil { return , err } defer resp.Body.Close() var out chatResp if err : json.NewDecoder(resp.Body).Decode(out); err ! nil { return , err } // 2. 调用后按真实 usage 补扣修正预估误差 if err : g.Consume(out.Usage.TotalTokens); err ! nil { return , err } return out.Choices[0].Message.Content, nil }注意base_url用的是https://taotoken.net/api/v1/chat/completions这是 OpenAI 兼容路径。如果你用官方openai-goSDK把BaseURL设成https://taotoken.net/api/v1即可其余代码不用动。然后是主循环也就是事故里那个「递归推理」的位置。给它套上闸门func RunAgentSession(ctx context.Context, task string) error { // 单 Session 硬上限 100k预警线 50k g : NewGatekeeper(100_000, 50_000) for step : 1; step 100; step { out, err : CallLLMWithBudget(ctx, g, task 继续推理下一步) if err ! nil { if errors.Is(err, ErrBudgetExceeded) { // 熔断记录现场退出循环绝不重试 log.Printf([BUDGET CUTOFF] step%d used%d, step, g.Used()) return err } return err } _ out } return nil }这段代码里最重要的不是计数器而是errors.Is(err, ErrBudgetExceeded)之后直接 return不重试。事故之所以烧到 3000 万就是因为外层有个retry逻辑把「模型没返回有效结果」当成可重试错误于是无限重试。预算熔断必须被识别为「不可重试错误」这是纪律问题不是技术问题。4. 验证请求怎么确认闸门真的拦住了写完代码不验证等于没写。验证分三步从单元测试到真实请求。第一步单元测试直接打满预算确认熔断触发func TestGatekeeperCutoff(t *testing.T) { g : NewGatekeeper(2000, 1500) // 模拟三轮调用每轮 1200 token for i : 1; i 3; i { err : g.Consume(1200) if err ! nil { t.Logf(第 %d 轮被拦截: %v, used%d, i, err, g.Used()) if !errors.Is(err, ErrBudgetExceeded) { t.Fatalf(期望预算超限错误实际: %v, err) } return } } t.Fatal(三轮后仍未触发熔断闸门失效) }预期输出是第 2 轮就被拦截120012002400 2000used停在 2400。如果三轮都过了说明你的maxTokens设太大或者判断写反了。第二步发一条真实请求确认 usage 字段能读到。用 curl 直接打curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: gpt-4o-mini, messages: [{role: user, content: 用一句话解释什么是 Token 预算闸门}] }返回体里会带usage.total_tokens把这个值打印出来确认它和你代码里扣减的数字一致。如果 usage 是 0 或者缺失检查是不是用了流式stream模式——流式返回默认不带 usage需要额外参数或在最后一个 chunk 里取。第三步做一次「失控模拟」。故意写一个不设退出条件的循环把maxTokens设成 5000跑起来看它是不是在消耗到 5000 左右就抛错退出。这一步是端到端验证能同时验证计数器、熔断、日志三件事。跑通之后你会看到类似这样的日志[BUDGET CUTOFF] step4 used5120看到这行闸门就算装好了。5. 本篇常见错排查错误一ErrBudgetExceeded被外层 retry 吞掉。现象是闸门明明触发了账单还在涨。排查方法在熔断分支打日志看它有没有被retry包住。修复方式是给错误加类型判断预算超限直接return不进入重试队列。错误二并发协程共享一个 Gatekeeper但用了普通 int 而非 atomic。现象是used数字对不上偶尔超过上限。原因是usedTokens tokens在并发下会丢更新。修复所有读写走atomic.AddInt64/atomic.LoadInt64不要用mutex包一层再读裸变量。错误三只扣 completion 不扣 prompt。现象是实际消耗比闸门统计高 30% 以上。prompt 在长上下文 Agent 里往往比 completion 还大必须两次都扣。修复调用前按 prompt 预估扣一次调用后按 usage 补扣。错误四流式返回时闸门失效。现象是开了 stream 之后usage拿不到扣减为 0。修复流式场景下按 chunk 数量或字符数实时估算扣减或者请求时带上stream_options: {include_usage: true}在最后一个 chunk 取 usage。错误五预警线告警刷屏。现象是失控协程每秒推一条告警运维群被淹没。修复用CompareAndSwap保证预警只触发一次或者加时间窗口限流。错误六base_url 写错导致请求 404。现象是调用直接失败闸门根本没机会工作。检查点OpenAI 兼容路径是https://taotoken.net/api/v1不要漏掉/v1也不要带 UTM 参数。接入文档在 https://taotoken.net/doc 里面有完整的路径说明和示例。6. 把闸门装进你的 Agent下一步做什么预算闸门不是一次性工程它需要跟着你的 Agent 一起演进。单协程上限只是第一层往上还有单用户单日、单任务类型、系统全局三层。建议的阈值骨架是单次调用 8k、单 Session 100k、单用户单日 2M、系统单日按预算倒推。每一层都用同样的Consume模式只是作用域不同。如果你还在频繁调模型、反复试 prompt可以先用模型对话页面手动验证返回结构和 usage https://taotoken.net/models 。等 Agent 逻辑稳定了需要长期跑编码类任务或常驻 Agent可以看 Coding Plan https://taotoken.net/coding-plan 它更适合有持续调用需求的场景。所有调用的 Key 统一在 https://taotoken.net/api-keys 管理接入细节查 https://taotoken.net/doc 。最后留一个我踩过的坑闸门的阈值不要拍脑袋定先用一周的真实数据跑出 P99 消耗再乘以 1.5 作为上限。定太低会误杀正常任务定太高等于没装。装好之后你会发现自己终于敢让 Agent 在夜里自己跑了——因为你知道最坏情况下它也只能花掉你预设的那点预算。
返回列表