ARTICLE DETAIL

资讯详情

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

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

账单爆表事故复盘:给 Agent 协程装上 TaoToken 预算闸门,拦住 3000 万 Token 的非确定性消耗 1. 事故现场一个 Agent 协程如何跑掉 3000 万 Token先说结论这不是被攻击也不是密钥泄露而是一个后台异步总结文章的 Agent 协程在处理一篇格式错乱的 PDF 时陷入了递归推理在无人值守的情况下独自跑了整整 8 个小时单次 Session 消耗了 3000 万 Token。财务早上发来邮件的时候我们还在排查是不是有人刷了接口。如果你正在写 Agent、写异步任务、写后台批处理这篇复盘值得你花十分钟看完。因为 LLM 调用和传统函数调用最大的区别在于它的消耗是非确定性的。同样的输入模型可能返回 200 Token也可能因为陷入循环返回 20 万 Token。你没法用静态代码去预估一次调用的成本这就意味着你必须用确定性的工程手段去兜底。我先把事故链路还原一下方便你对照自己的代码。这个 Agent 的职责很简单接收一篇文档切块逐块调用 LLM 做摘要最后合并。问题出在切块环节——那篇 PDF 的排版彻底乱了切块逻辑产出了一个几乎为空的 chunk模型对这个空 chunk 返回了「请提供更多上下文」而 Agent 的循环判断逻辑是「只要模型没给出有效摘要就重试」。于是它重试、重试、再重试每一轮都带着完整的历史对话上下文Token 消耗呈指数级增长。关键点在于没有任何一层拦得住它。没有单次调用上限没有单 Session 上限没有用户维度配额没有系统级熔断。代码里只有一句for { callLLM() }而callLLM内部对返回内容不做任何长度和次数约束。这就是典型的「给代码开了一张没有额度上限的信用卡」。我后来复盘时列了一张表把这次事故的四个放大因子写清楚了放大因子具体表现后果上下文累积每轮重试都带上完整历史Token 随轮次线性叠加无重试上限循环条件依赖模型输出模型不配合就无限跑无 Session 预算单次任务无 Token 硬上限单协程可无限消耗无告警熔断消耗异常无监控触发8 小时后才被发现这四个因子单独存在都不致命叠在一起就是账单爆表。所以治理思路也很清晰不是去改模型也不是去改业务逻辑而是在调用链路上插入一道预算闸门Budget Gatekeeper让确定性的配额去约束非确定性的消耗。下面我会从接入准备、可复制的闸门配置、压测验证、常见报错排查四个部分展开每一步都能直接跟做。你不需要重构整个 Agent只需要在 LLM 调用外面包一层。2. 前置准备用 TaoToken 统一 LLM 调用入口与预算观测要让预算闸门真正生效前提是你的 LLM 调用有一个统一入口。如果 Agent 里散落着十几处直接requests.post到不同厂商的代码你根本没法在中间插一层计数器。所以第一步是把调用收敛到一个网关。我这边用的是 TaoToken 作为统一入口官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它的价值不在于「多一个中转」而在于它把模型调用统一成 OpenAI 兼容协议这样我的预算闸门只需要对接一种请求格式不用为每个厂商写适配。具体操作上你需要在控制台创建一个 API Key。入口在这里https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。创建时建议按「环境 用途」命名比如agent-prod-summarize这样后面做配额统计时能直接按 Key 维度聚合。拿到 Key 之后先别急着写闸门先用最小请求确认链路通。你可以打开模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 手动发一条消息确认返回正常。这一步看起来多余但能帮你排除掉「Key 没生效」「模型 ID 写错」这类低级问题避免后面把链路问题和闸门问题混在一起排查。然后配置环境变量我习惯用.env管理export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export AGENT_SESSION_TOKEN_BUDGET100000 export AGENT_MAX_RETRY5这里AGENT_SESSION_TOKEN_BUDGET就是我们要装的闸门阈值。100000 是我给单个 Agent Session 设的硬上限你可以按业务调整但一定要设哪怕先设一个宽松的值也比没有强。关于模型 IDTaoToken 的模型列表和文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 建议你按文档里的模型 ID 填写不要凭记忆写gpt-4这种模糊名字否则请求会直接 404。我踩过的坑就是模型名写错结果闸门统计到的全是失败请求Token 消耗为 0看起来「很省钱」实际是根本没调通。如果你后面要做长期编码或 Agent 编排可以考虑 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它更适合持续性的开发场景。但本文的重点是预算闸门所以接入部分到此为止接下来进入核心配置。3. 可复制配置Token 预算闸门与熔断中间件这一节是全文的核心我会给出可以直接复制的配置片段和代码。先说设计原则闸门必须在线程/协程安全的前提下做原子扣减并且要在调用前预估、调用后校正。因为 LLM 的 Token 消耗是调用完成后才知道的你不能等结果回来再判断那样已经花出去了。先看配置文件。我用 JSON 管理多级预算路径放在config/budget_gate.json{ gatekeeper: { session_token_budget: 100000, single_call_token_budget: 8000, user_daily_token_budget: 500000, system_daily_token_budget: 5000000, warn_threshold_ratio: 0.8, hard_cutoff: true }, retry_policy: { max_retry: 5, backoff_base_ms: 500, on_budget_exceeded: abort }, model_routing: { complex_task_model: claude-3-5-sonnet, simple_task_model: qwen-14b, fallback_on_budget_low: true } }这份配置定义了四级预算单次调用、单 Session、用户单日、系统单日。warn_threshold_ratio是预警线到 80% 就告警hard_cutoff为 true 表示超额直接熔断不做「软放行」。on_budget_exceeded设为abort意思是预算耗尽时终止整个 Agent 任务而不是降级继续跑——这一点很重要降级继续跑往往会让循环更隐蔽。然后是闸门实现。我用 Go 写因为原子操作方便你用 Python 的话把atomic换成threading.Lock即可package budget import ( errors fmt sync ) var ErrBudgetExceeded errors.New(token budget exceeded) type Gatekeeper struct { mu sync.Mutex sessionBudget int64 sessionUsed int64 singleCallBudget int64 warnRatio float64 warned bool } func NewGatekeeper(sessionBudget, singleCallBudget int64, warnRatio float64) *Gatekeeper { return Gatekeeper{ sessionBudget: sessionBudget, singleCallBudget: singleCallBudget, warnRatio: warnRatio, } } // PreCheck 调用前预估扣减防止超支请求发出 func (g *Gatekeeper) PreCheck(estimatedTokens int64) error { g.mu.Lock() defer g.mu.Unlock() if estimatedTokens g.singleCallBudget { return fmt.Errorf(%w: 单次预估 %d 超过单次上限 %d, ErrBudgetExceeded, estimatedTokens, g.singleCallBudget) } if g.sessionUsedestimatedTokens g.sessionBudget { return fmt.Errorf(%w: 已用 %d 预估 %d 超过 Session 上限 %d, ErrBudgetExceeded, g.sessionUsed, estimatedTokens, g.sessionBudget) } g.sessionUsed estimatedTokens return nil } // PostAdjust 调用后按实际用量校正 func (g *Gatekeeper) PostAdjust(estimated, actual int64) { g.mu.Lock() defer g.mu.Unlock() g.sessionUsed actual - estimated if !g.warned float64(g.sessionUsed) float64(g.sessionBudget)*g.warnRatio { g.warned true fmt.Printf([BUDGET WARN] Session 已用 %d / %d触发预警\n, g.sessionUsed, g.sessionBudget) } } func (g *Gatekeeper) Used() int64 { g.mu.Lock() defer g.mu.Unlock() return g.sessionUsed }注意PreCheck和PostAdjust的配合调用前先按预估量扣调用后按实际量补差。这样即使预估不准也不会出现「先花后算」的漏洞。warned标志保证预警只打一次避免日志刷屏。接下来是带闸门的调用包装这里对接 TaoToken 的 OpenAI 兼容接口func CallLLMWithBudget(ctx context.Context, gk *budget.Gatekeeper, prompt string) (string, error) { estimated : int64(len(prompt)/4 500) // 粗略预估4 字符约 1 Token if err : gk.PreCheck(estimated); err ! nil { return , err } reqBody : map[string]interface{}{ model: claude-3-5-sonnet, messages: []map[string]string{ {role: user, content: prompt}, }, max_tokens: 2000, } // 实际请求发往 https://taotoken.net/api/v1/chat/completions // 此处省略 HTTP 细节重点看闸门逻辑 resp, actualTokens, err : doRequest(ctx, reqBody) if err ! nil { return , err } gk.PostAdjust(estimated, actualTokens) return resp, nil }这里有个细节max_tokens一定要设。它是模型单次输出的物理上限是闸门之外的第二道保险。很多人只设闸门不设max_tokens结果模型一次吐 3 万 Token闸门虽然拦住了后续调用但这一发已经打出去了。最后是 Agent 主循环把重试上限和闸门结合起来func RunAgent(ctx context.Context, gk *budget.Gatekeeper, chunks []string) error { for i, chunk : range chunks { for retry : 0; retry 5; retry { result, err : CallLLMWithBudget(ctx, gk, chunk) if errors.Is(err, budget.ErrBudgetExceeded) { return fmt.Errorf(第 %d 块触发预算熔断任务终止: %w, i, err) } if err ! nil { time.Sleep(time.Duration(retry) * 500 * time.Millisecond) continue } if isValidSummary(result) { break } } } return nil }关键在errors.Is(err, budget.ErrBudgetExceeded)这一句预算熔断和普通网络错误要区别对待。网络错误可以重试预算熔断必须直接终止整个任务否则重试逻辑会把熔断当成「临时失败」继续跑闸门就白装了。4. 压测验证复现拦截效果并确认熔断生效配置写完不代表生效必须压测验证。我设计了一个最小复现实验故意构造一个会触发递归的 Agent看闸门是否在预算耗尽时准确熔断。压测脚本用 Go 写模拟一个「永远返回无效摘要」的模型响应func TestBudgetCutoff(t *testing.T) { gk : budget.NewGatekeeper(10000, 2000, 0.8) ctx : context.Background() callCount : 0 for { callCount _, err : CallLLMWithBudget(ctx, gk, 请总结这段内容...) if errors.Is(err, budget.ErrBudgetExceeded) { t.Logf(第 %d 次调用触发熔断已用 Token: %d, callCount, gk.Used()) break } if callCount 100 { t.Fatal(闸门未生效调用次数超过 100 仍未熔断) } } }预期结果是在 Session 预算 10000、单次预估约 500 的情况下大约第 15 到 20 次调用就会触发熔断gk.Used()停在 10000 附近不会超过太多。如果跑出来 callCount 超过 100 还没熔断说明PreCheck没生效回去检查是不是漏了gk.PreCheck调用。实测下来第一次跑的时候我确实遇到了没熔断的情况原因是PostAdjust里用了但PreCheck里已经加过一次导致重复计数——这个 bug 反而让闸门更早触发属于「错得比较安全」。修正后计数准确熔断点稳定在预算线附近。除了单元测试我还做了一次真实链路压测。用 50 个并发协程每个协程跑一个独立的 Agent SessionSession 预算设为 50000观察整体表现go test -run TestConcurrentBudget -v -count1结果输出大致如下[Session-07] 触发熔断已用 50120 Token调用 23 次 [Session-13] 触发熔断已用 50080 Token调用 22 次 [Session-31] 触发熔断已用 50240 Token调用 24 次 ... 50 个 Session 全部在预算线附近熔断无一个超支超过 1% 总消耗: 2,506,000 Token理论上限 2,500,000这个结果说明闸门在并发场景下也是安全的sync.Mutex保证了计数不串。如果你用 Python记得用threading.Lock或者asyncio.Lock别用普通变量自增否则并发下计数会丢。压测还有一个必做动作验证告警是否触发。我在PostAdjust里打了预警日志压测时应该能看到每个 Session 在 80% 处打印一次[BUDGET WARN]。如果没看到检查warnRatio是不是设成了 1.0 或者warned标志逻辑写反了。最后把闸门指标接到监控。我用 Prometheus 暴露两个指标var ( sessionTokensUsed prometheus.NewGaugeVec( prometheus.GaugeOpts{Name: agent_session_tokens_used}, []string{session_id}, ) budgetCutoffTotal prometheus.NewCounterVec( prometheus.CounterOpts{Name: agent_budget_cutoff_total}, []string{reason}, ) )agent_budget_cutoff_total这个计数器特别有用它能告诉你「今天有多少次任务因为预算被拦下来了」。如果这个数字突然飙升说明有新的异常输入在触发循环需要去看具体是哪些文档导致的。5. 常见报错排查401、local proxy failed 与 choices 解析失败闸门上线后我陆续遇到几类报错这里按出现频率排一下方便你对照排查。第一类401 Unauthorized。这个最常见通常是 Key 没配对或者环境变量没加载。检查顺序是先确认TAOTOKEN_API_KEY是否真的注入到进程里echo $TAOTOKEN_API_KEY再确认请求头是不是Authorization: Bearer sk-xxx最后确认 Key 有没有被误删或过期。如果用的是 TaoToken去控制台 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 看一眼 Key 状态。注意401 是认证失败和预算无关别往闸门方向排查。第二类local proxy failed。这个报错通常出现在你本地配了转发但目标地址写错的时候。检查TAOTOKEN_BASE_URL是不是https://taotoken.net/api注意结尾不要多加/v1或者斜杠具体路径以文档为准。我见过有人写成https://taotoken.net/api/v1/v1/chat/completions多了一层/v1直接连不上。另外确认你的 HTTP 客户端没有走系统里残留的转发配置这类配置会干扰请求。第三类reading choices 解析失败。报错长这样failed to parse response: reading choices: unexpected end of JSON input。这通常意味着返回体不是标准 OpenAI 格式可能是请求打到了错误的端点或者模型 ID 不存在导致返回了错误页。排查方法把原始响应体打印出来看如果是 HTML 或者空 body说明端点错了如果是{error: ...}看 error 字段的具体信息。还有一种可能是流式和非流式混用stream: true时返回的是 SSE 分片不能按完整 JSON 解析。第四类OAuth 相关报错。如果你用的是 Claude Code 这类工具可能会遇到 OAuth token 过期的问题。这类工具建议参考官方文档重新授权或者改用 API Key 方式接入。Claude Code 的接入说明在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有按文档走一遍即可。注意 OAuth 报错和预算闸门是两套体系别混在一起调。第五类闸门误熔断。这个不是报错但很烦人明明预算还够却提前熔断了。原因通常是预估量偏大。len(prompt)/4这个估算对中文偏保守中文一个字约 1.5 Token如果你全是中文可以把除数改成 2。另外PostAdjust里如果actual estimated会退还差额所以长期看不会累积偏差但单次可能误触发。解决办法是把single_call_token_budget设得比实际最大输出大一些留出缓冲。排查时有个通用技巧在闸门里加一行 debug 日志打印每次 PreCheck 的预估量和当前已用量。这样任何异常都能一眼看出是预估问题还是计数问题。日志别用fmt.Println打太多用结构化日志按 session_id 聚合。6. 从闸门到成本工程把预算控制做成默认能力事故复盘到这里技术方案已经完整了。但我想再往前推一步预算闸门不应该是一个「出了事才补」的补丁而应该是 Agent 框架的默认能力。我现在的做法是任何新建的 Agent 协程模板里默认就带三样东西一个 Gatekeeper 实例、一个max_retry上限、一个max_tokens参数。新人写代码时不需要理解成本控制只要用模板闸门就自动生效。这比事后治理有效得多。另外闸门的阈值不是拍脑袋定的。我的做法是先用宽松阈值跑一周收集每个 Session 的实际 Token 分布然后取 P99 作为硬上限。这样既能拦住异常又不会误伤正常任务。TaoToken 的用量统计页面可以按 Key 和时间段看消耗配合自己的监控指标能很快定位到异常 Session。如果你还在用「一个 Key 跑所有任务」的模式建议尽早拆分。按业务线、按环境、按优先级拆成多个 Key每个 Key 配独立的预算。这样即使某个业务出问题也不会拖垮整个系统。Coding Plan 里对这类多项目场景有更细的配额管理长期做 Agent 开发的话值得看一下https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。最后说一个心态上的转变。传统后端开发里我们习惯「先跑通再优化」因为 CPU 和内存是固定的跑不爆。但 LLM 调用不是它的成本是弹性的、非确定性的你不控制它它就会失控。所以在大模型应用里成本控制必须前置到架构设计阶段而不是等账单来了再补。预算闸门就是这条防线的第一块砖把它砌好后面的动态路由、模型降级、缓存复用才有意义。你现在就可以做一件事打开你的 Agent 代码找到那个for循环看看它有没有 Token 上限。如果没有把本文第 3 节的配置复制过去跑一遍第 4 节的压测。十分钟的事可能帮你省下一张天价账单。
返回列表