1. 事故现场:一个协程跑掉 3000 万 Token 的完整链路
先说结论:这不是被攻击,也不是 Key 泄露,而是一个后台异步总结 Agent 在处理一篇格式错乱的 PDF 时,进入了递归推理,8 小时无人值守,单 Session 烧掉 3000 万 Token,隔夜账单直接多出上千美金。财务早上发邮件的时候,运维还在看 Prometheus 的 QPS 曲线,以为是流量突增。
我复盘的时候把调用链完整拉了一遍,问题出在三个叠加因素上。第一,Agent 的循环终止条件依赖模型自己输出DONE标记,但错乱 PDF 让模型反复输出「继续分析下一页」,终止条件永远不满足。第二,协程池没有对单个 Session 设置 Token 上限,context.WithTimeout只设了 30 分钟,但每次 LLM 调用都会重置超时,等于没有超时。第三,重试逻辑写成了for { call(); if err != nil { continue } },网络抖动触发的重试也在无限放大消耗。
这三个因素单独看都不致命,叠在一起就是灾难。传统后端我们习惯按 CPU、内存、连接数设限,但 LLM 的消耗单位是 Token,是钱,而且输出长度是非确定性的——同一个 prompt,模型这次返回 200 Token,下次可能返回 8000 Token。你没法用静态资源配额去约束一个概率性输出。
所以这篇文章不讲虚的,直接给你一套可复制的预算闸门方案:按协程维度、会话维度、用户维度三级限额,超限熔断加告警,再配合 TaoToken 统一 Key 通道做用量核对。目标很明确,把单个 Agent 的成本压回可控区间。适合谁看?正在跑多 Agent 协程并发、或者准备上生产 LLM 应用的团队,尤其是已经被账单吓过一次的。
2. 前置准备:用 TaoToken 统一 Key 通道收敛调用入口
事故复盘后我做的第一件事,不是改代码,而是把所有 Agent 的 LLM 调用入口收敛到一个统一通道。原因很简单:之前每个 Agent 各自持有不同的 Key,账单出来根本对不上是哪个协程烧的。你连「谁花的钱」都定位不了,谈何治理。
TaoToken 在这里的角色是统一 Key 通道加用量核对面板。你可以在官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册后,进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 创建 API Key,然后在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 管理你的密钥。所有 Agent 协程统一走这个 Key,Base URL 指向 https://taotoken.net/api,这样用量就集中在一个面板里,哪个时间段、哪个模型、消耗多少 Token,一目了然。
这里要强调一个认知:预算闸门是代码层的硬约束,用量核对是账单层的软验证,两者必须配合。只做代码熔断,你不知道熔断前已经花了多少;只做账单核对,等你发现的时候钱已经花完了。我试过的做法是,代码层熔断触发时,立刻把当前 Session 的累计 Token 数打到日志和监控,同时去 TaoToken 控制台核对这个时间段的实际消耗,两边对得上,说明闸门计数准确。
具体操作步骤:登录控制台后,在 API Keys 页面新建一个 Key,命名建议带上环境标识,比如prod-agent-pool。然后把这个 Key 配置到你的 Agent 服务环境变量里,不要硬编码。模型 ID 根据你的任务选,复杂推理用高配模型,简单摘要用低成本模型,这个后面动态路由会讲。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有完整的 Base URL 和鉴权格式说明。
如果你用的是 Claude Code 这类编码 Agent,配置方式略有不同,需要设置ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,具体参考 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。但核心逻辑一样:统一入口,集中计量。
3. 可复制配置:三级预算闸门与熔断中间件
这一节是核心,直接给你能跑的代码和配置。我按「单次调用 → 单 Session → 单用户单日」三级来设计,每级都有独立的计数器和熔断阈值。
先看配置文件,我用 JSON 格式定义阈值,方便不同环境覆盖:
{ "budget_gate": { "per_call_max_tokens": 8000, "per_session_max_tokens": 100000, "per_user_daily_max_tokens": 500000, "alert_threshold_ratio": 0.8, "circuit_break_on_exceed": true, "alert_webhook": "https://your-feishu-webhook.example.com/alert" }, "model_routing": { "complex_task_model": "gpt-4o", "simple_task_model": "gpt-4o-mini", "fallback_on_budget_low": true } }per_call_max_tokens是单次 LLM 调用的硬上限,防止单次返回超长内容。per_session_max_tokens是单个 Agent Session 的总预算,事故里那个协程就是缺了这一层。per_user_daily_max_tokens是用户维度的日限额,防止某个用户触发大量 Agent 任务。alert_threshold_ratio是告警触发比例,用到 80% 就发通知,别等熔断。
然后是 Go 实现的三级闸门,我把它写成了一个可复用的中间件:
package budget import ( "context" "errors" "fmt" "sync" "sync/atomic" "time" ) var ( ErrCallBudgetExceeded = errors.New("per-call token budget exceeded") ErrSessionBudgetExceeded = errors.New("session token budget exceeded") ErrUserBudgetExceeded = errors.New("user daily token budget exceeded") ) type GateConfig struct { PerCallMax int64 PerSessionMax int64 PerUserDailyMax int64 AlertRatio float64 } type SessionCounter struct { used int64 } type UserDailyCounter struct { used int64 resetDay string mu sync.Mutex } type BudgetGate struct { cfg GateConfig sessions sync.Map users sync.Map alertFn func(msg string) } func NewBudgetGate(cfg GateConfig, alertFn func(string)) *BudgetGate { return &BudgetGate{cfg: cfg, alertFn: alertFn} } func (g *BudgetGate) getSession(sid string) *SessionCounter { v, _ := g.sessions.LoadOrStore(sid, &SessionCounter{}) return v.(*SessionCounter) } func (g *BudgetGate) getUser(uid string) *UserDailyCounter { today := time.Now().Format("2006-01-02") v, _ := g.users.LoadOrStore(uid, &UserDailyCounter{resetDay: today}) uc := v.(*UserDailyCounter) uc.mu.Lock() if uc.resetDay != today { uc.used = 0 uc.resetDay = today } uc.mu.Unlock() return uc } func (g *BudgetGate) ConsumeCall(sid, uid string, tokens int64) error { if tokens > g.cfg.PerCallMax { return fmt.Errorf("%w: call=%d limit=%d", ErrCallBudgetExceeded, tokens, g.cfg.PerCallMax) } sc := g.getSession(sid) newSession := atomic.AddInt64(&sc.used, tokens) if newSession > g.cfg.PerSessionMax { g.alertFn(fmt.Sprintf("session %s exceeded: %d/%d", sid, newSession, g.cfg.PerSessionMax)) return fmt.Errorf("%w: session=%d limit=%d", ErrSessionBudgetExceeded, newSession, g.cfg.PerSessionMax) } if float64(newSession) >= float64(g.cfg.PerSessionMax)*g.cfg.AlertRatio { g.alertFn(fmt.Sprintf("session %s at %.0f%%: %d/%d", sid, g.cfg.AlertRatio*100, newSession, g.cfg.PerSessionMax)) } uc := g.getUser(uid) uc.mu.Lock() newUser := uc.used + tokens if newUser > g.cfg.PerUserDailyMax { uc.mu.Unlock() g.alertFn(fmt.Sprintf("user %s daily exceeded: %d/%d", uid, newUser, g.cfg.PerUserDailyMax)) return fmt.Errorf("%w: user=%d limit=%d", ErrUserBudgetExceeded, newUser, g.cfg.PerUserDailyMax) } uc.used = newUser uc.mu.Unlock() return nil }调用侧这样包装:
func CallLLMWithGate(ctx context.Context, gate *BudgetGate, sid, uid, prompt string) (string, error) { // 调用前预估 prompt token,保守估 500 if err := gate.ConsumeCall(sid, uid, 500); err != nil { return "", err } // 实际调用 LLM,拿到 usage resp, usage, err := doLLMCall(ctx, prompt) if err != nil { return "", err } // 调用后扣减实际 output token if err := gate.ConsumeCall(sid, uid, int64(usage.CompletionTokens)); err != nil { return "", err } return resp, nil }关键点:调用前预估扣一次,调用后按实际 usage 再扣一次。预估是为了在调用前就拦住明显超额的请求,实际扣减是为了精确计量。两次都走同一个ConsumeCall,任何一级超限都会立即返回错误,协程拿到错误后必须return,不能continue。
如果你用 Python,逻辑一样,用threading.Lock加dict实现即可。核心是原子操作和会话隔离,别让多个协程共享一个无锁计数器。
4. 验证请求:压测确认闸门真的会熔断
配置写完不验证,等于没写。我设计了一个压测脚本,模拟一个失控的 Agent 协程,看闸门是否在预算耗尽时准确熔断。
压测代码:
func TestBudgetGateCircuitBreak(t *testing.T) { cfg := GateConfig{ PerCallMax: 8000, PerSessionMax: 20000, PerUserDailyMax: 100000, AlertRatio: 0.8, } gate := NewBudgetGate(cfg, func(msg string) { t.Logf("[ALERT] %s", msg) }) sid := "test-session-001" uid := "test-user-001" callCount := 0 for i := 0; i < 100; i++ { err := gate.ConsumeCall(sid, uid, 3000) if err != nil { t.Logf("circuit break at call %d: %v", i, err) break } callCount++ } if callCount > 7 { t.Fatalf("expected break before 7 calls, got %d", callCount) } t.Logf("total calls before break: %d, session used: %d", callCount, 3000*callCount) }预期结果:PerSessionMax是 20000,每次扣 3000,第 7 次累计 21000 超过上限,熔断触发。实际跑下来,第 7 次返回ErrSessionBudgetExceeded,告警函数在累计到 16000(80%)时先发了一次预警,熔断时又发了一次。两次告警都打到了日志。
然后去 TaoToken 控制台核对。压测期间实际发起的 LLM 调用次数和 Token 消耗,应该和闸门计数一致。如果对不上,检查两个地方:一是你的预估 Token 和实际 Token 差异是否过大,二是是否有绕过闸门的调用路径。我在第一次核对时就发现有个定时任务直接调了 LLM,没走闸门,补上后两边就对齐了。
成功的结果长这样:压测日志显示circuit break at call 7,TaoToken 控制台显示该时间段消耗约 21000 Token,告警群收到两条消息,一条 80% 预警,一条熔断通知。整个链路闭环。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节列几个我在接入和压测过程中真实遇到的报错,以及排查路径。
401 Unauthorized:最常见。先检查 Key 是否复制完整,有没有多余空格。然后确认 Base URL 是不是https://taotoken.net/api,少写/api或者写成别的路径都会 401。如果你用的是 Claude Code,检查ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY是否配对,这两个必须同时设置。还有一种情况是 Key 被禁用或额度耗尽,去控制台 API Keys 页面确认状态。
local proxy failed:这个报错通常出现在你本地配了代理但代理没起来,或者环境变量HTTP_PROXY指向了一个不可用的地址。排查方法:先unset HTTP_PROXY HTTPS_PROXY再跑一次,如果通了,说明是代理配置问题。注意,这里说的是本地开发环境的代理配置,不是让你去搞什么网络工具,纯粹是环境变量清理。
reading choices 相关报错:这个一般出现在流式响应解析时,返回体里choices字段为空或者格式不符合预期。原因可能是模型返回了错误信息而不是正常 completion,或者你的 SDK 版本和 API 返回格式不匹配。排查:先把 stream 关掉,用非流式请求打一次,看原始返回体。如果返回体里有error字段,按错误信息处理。如果是 SDK 解析问题,升级 SDK 版本。
OAuth 相关报错:如果你用的是 Codex 或类似工具,认证走的是 OAuth 流程,报错通常是 token 过期或 scope 不对。检查你的auth.json配置,确认 Base URL、Key、Model ID 三件套都写对了。Codex 的auth.json路径一般在~/.codex/auth.json,内容格式参考接入文档。三件套缺一不可:Base URL 指向https://taotoken.net/api,Key 用控制台生成的,Model ID 按你实际要用的模型填。
排查通用原则:先看 HTTP 状态码,再看返回体原始内容,最后看你的配置三件套。90% 的问题出在配置上,不是代码逻辑。
6. 长期治理:动态路由与成本核对习惯
闸门是兜底,不是万能。真正把成本压下来,还得配合动态路由和定期核对。
动态路由的逻辑很简单:任务复杂度低的走低成本模型,复杂度高的走高配模型。我在闸门配置里留了model_routing字段,实际实现是在调用前根据任务类型选模型 ID。比如文章摘要、文本分类、格式转换,走gpt-4o-mini;多步推理、代码生成、复杂 Agent 协同,走gpt-4o。这一层降级配合闸门,整体成本能再降一截。
核对习惯:每周去 TaoToken 控制台看一次用量趋势,重点看有没有异常峰值。如果有,顺着时间点去查日志,定位是哪个 Session、哪个用户。我现在的做法是,闸门告警直接推到运维群,同时记录到监控面板,这样异常消耗在发生时就暴露,不用等账单。
如果你要长期跑编码 Agent 或复杂 Agent 协同,可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,配合闸门使用,成本更可控。模型对话验证在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 可以直接试。
最后说一个我踩过的坑:闸门的计数器是内存态的,服务重启就归零。生产环境必须把 Session 计数持久化到 Redis,否则重启后预算重置,等于没设。这个坑我在压测环境没遇到,上线后才发现的,补了 Redis 持久化才彻底闭环。