1. 我的 Agent 挂了三次,模型一次都没背锅
先说结论:AI Agent 生产环境稳定性这件事,模型能力大概只占两成权重。我跑了六周 OpenClaw,日均处理 200 条消息,中间出过三次严重故障,每次服务中断都超过一小时。复盘下来,没有一次是模型回答错了导致的。DeepSeek V4 该答对的还是答对,Opus 4.7 的 SWE-bench 再涨十个点也救不了这些 bug。
社区里天天在讨论哪个模型 MMLU 更高、哪个推理更准、Agent 准确率到了多少。好像只要模型够强,Agent 就能稳定运行。但真实体感完全相反:模型是整条链路里最稳定的部分。真正让 Agent 挂掉的,是 WebSocket 长连接断连、API 限流触发、熔断器误判、上下文管理失控这四类非模型故障。
这篇文章不讲模型选型,只讲链路归因。我会把三次故障的根因、可复制的熔断阈值配置、限流退避参数、WebSocket 心跳重连脚本全部摊开,再给出逐步验证动作。适合谁看:正在跑或打算跑生产级 AI Agent 的开发者,尤其是那种"模型没毛病但 Agent 就是挂"的场景。如果你还在 demo 阶段,这些坑迟早会踩,提前看能省几个通宵。
我试过最蠢的做法是每次挂了就换模型,换到第三个才发现日志里全是 429 和 close 事件,跟模型半毛钱关系没有。下面按故障类型拆,每类都给可跟做的配置和验证步骤。
2. TaoToken 统一 Key 通道:把限流和降级收口到网关层
第二次故障之后我意识到一个问题:我在代码里手写的降级链、重试逻辑、健康检查,可靠性远不如一个专门的 API 网关。原因很简单——业务代码里的容错逻辑会随着需求迭代被改乱,而网关层的限流处理、自动降级、健康检查是独立且经过验证的。
TaoToken 在这里扮演的角色就是统一 Key 通道。官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口 https://taotoken.net/api 。一个 Key 调 30+ 模型,限流和故障恢复在网关层处理,不用自己写熔断器。这不是说代码里就不用做容错了,而是把"模型级"的限流和降级交给网关,"业务级"的重试和幂等留给自己。
具体怎么分工,我列个对照:
| 故障类型 | 代码层负责 | TaoToken 网关层负责 |
|---|---|---|
| WebSocket 断连 | 心跳探针、重连脚本 | 不涉及(连接层) |
| 上下文 OOM | 内存裁剪、持久化 | 不涉及(进程层) |
| API 限流 | 业务重试、幂等 | 429 识别、自动降级、退避 |
| 熔断误判 | 业务熔断阈值 | 模型级熔断、冷却恢复 |
| 健康检查 | 进程存活 | 上游模型可用性 |
关键点在于:网关层的熔断是"跨请求记忆"的。我第三次故障的根因就是降级逻辑没有记忆,每条消息都先试 DeepSeek,被 429 后再降级,47 条消息产生 94 次调用。网关层一旦识别到某个模型在限流,后续请求直接走备用模型,不会重复撞墙。
接入方式很简单,把 Base URL 指向 https://taotoken.net/api ,Key 用统一通道的 Key,Model ID 按需切换。三件套配齐:
- Base URL:
https://taotoken.net/api - API Key: 在 https://taotoken.net/api-keys 生成
- Model ID: 如
deepseek-v4、qwen3.5-plus、gpt-4o等
如果你用 Claude Code 做编码类 Agent,接入文档在 https://taotoken.net/doc ,里面有完整的配置示例。Coding Plan 适合长期编码和 Agent 场景,地址 https://taotoken.net/coding-plan 。模型对话调试用 https://taotoken.net/chat ,控制台在 https://taotoken.net/console 。
这里要强调一点:TaoToken 是统一 Key 通道,不是让你绕过什么。它的价值在于把多模型的限流、降级、健康检查收口到一层,减少业务代码里的容错复杂度。我第三次故障如果一开始就用网关的熔断能力,根本不会发生。
3. 可复制配置:熔断阈值、退避参数与心跳重连脚本
这一节全是能直接抄的代码和配置。分三块:熔断器、限流退避、WebSocket 心跳重连。
3.1 熔断器配置(JSON + TypeScript)
先给一份熔断器的参数配置,放在config/circuit-breaker.json:
{ "breakers": { "deepseek-v4": { "failureThreshold": 3, "cooldownMs": 60000, "halfOpenMaxCalls": 1, "monitorWindowMs": 120000 }, "qwen3.5-plus": { "failureThreshold": 3, "cooldownMs": 60000, "halfOpenMaxCalls": 1, "monitorWindowMs": 120000 }, "gpt-4o": { "failureThreshold": 5, "cooldownMs": 120000, "halfOpenMaxCalls": 1, "monitorWindowMs": 180000 } }, "fallbackChain": ["deepseek-v4", "qwen3.5-plus", "gpt-4o"] }参数说明:failureThreshold是连续失败几次触发熔断,我设 3 次;cooldownMs是熔断后冷却多久,60 秒;halfOpenMaxCalls是半开状态允许试探几次,1 次;monitorWindowMs是失败计数的滑动窗口,避免偶发失败累积。
对应的 TypeScript 实现:
type BreakerState = "closed" | "open" | "half-open"; interface BreakerConfig { failureThreshold: number; cooldownMs: number; halfOpenMaxCalls: number; monitorWindowMs: number; } class CircuitBreaker { private failures: number[] = []; private state: BreakerState = "closed"; private lastFailure = 0; private halfOpenCalls = 0; constructor(private cfg: BreakerConfig) {} private pruneFailures() { const cutoff = Date.now() - this.cfg.monitorWindowMs; this.failures = this.failures.filter((t) => t > cutoff); } async call<T>(fn: () => Promise<T>): Promise<T> { if (this.state === "open") { if (Date.now() - this.lastFailure > this.cfg.cooldownMs) { this.state = "half-open"; this.halfOpenCalls = 0; } else { throw new Error("CIRCUIT_OPEN"); } } if (this.state === "half-open") { if (this.halfOpenCalls >= this.cfg.halfOpenMaxCalls) { throw new Error("CIRCUIT_HALF_OPEN_LIMIT"); } this.halfOpenCalls++; } try { const result = await fn(); this.failures = []; this.state = "closed"; return result; } catch (e) { this.pruneFailures(); this.failures.push(Date.now()); this.lastFailure = Date.now(); if (this.failures.length >= this.cfg.failureThreshold) { this.state = "open"; } throw e; } } }调用侧:
const breakers = new Map<string, CircuitBreaker>(); for (const [model, cfg] of Object.entries(config.breakers)) { breakers.set(model, new CircuitBreaker(cfg)); } async function callWithFallback(messages: any[]) { for (const model of config.fallbackChain) { const breaker = breakers.get(model)!; try { return await breaker.call(() => client.chat.completions.create({ model, messages }) ); } catch (e: any) { if (e.message === "CIRCUIT_OPEN") { console.warn(`${model} 熔断中,跳过`); continue; } console.warn(`${model} 调用失败: ${e.message}`); } } throw new Error("所有模型不可用"); }3.2 限流退避参数(TOML)
限流退避我放在config/rate-limit.toml:
[rate_limit] max_retries = 4 base_delay_ms = 500 max_delay_ms = 30000 jitter_ratio = 0.3 retry_on_status = [429, 503, 502, 504] respect_retry_after = true [rate_limit.per_model] deepseek-v4 = { rpm = 60, tpm = 100000 } qwen3.5-plus = { rpm = 120, tpm = 200000 } gpt-4o = { rpm = 500, tpm = 800000 }退避算法用指数退避加抖动:
function backoffDelay(attempt: number, cfg: RateLimitConfig): number { const exp = Math.min( cfg.base_delay_ms * Math.pow(2, attempt), cfg.max_delay_ms ); const jitter = exp * cfg.jitter_ratio * (Math.random() * 2 - 1); return Math.max(0, exp + jitter); } async function callWithRetry<T>( fn: () => Promise<T>, cfg: RateLimitConfig ): Promise<T> { let lastErr: any; for (let i = 0; i <= cfg.max_retries; i++) { try { return await fn(); } catch (e: any) { lastErr = e; const status = e?.status ?? e?.response?.status; if (!cfg.retry_on_status.includes(status)) throw e; if (i === cfg.max_retries) break; const delay = backoffDelay(i, cfg); console.warn(`第 ${i + 1} 次重试,等待 ${delay.toFixed(0)}ms`); await new Promise((r) => setTimeout(r, delay)); } } throw lastErr; }jitter_ratio是关键,没有抖动的话多个请求会同时重试,形成新的尖峰。0.3 的抖动比实测下来比较稳。
3.3 WebSocket 心跳重连脚本
第一次故障的根因是竞态:旧连接的 close 事件把新连接关掉了。修复方案是给每个连接分配唯一 ID,close 事件只能关闭对应 ID 的连接。完整脚本:
let activeConnectionId = 0; let currentWs: WebSocket | null = null; let heartbeatTimer: NodeJS.Timeout | null = null; function connect(url: string) { const myId = ++activeConnectionId; const ws = new WebSocket(url); ws.on("open", () => { if (myId !== activeConnectionId) { ws.close(); return; } currentWs = ws; startHeartbeat(myId); }); ws.on("close", () => { if (myId !== activeConnectionId) return; stopHeartbeat(); setTimeout(() => connect(url), 3000); }); ws.on("error", (err) => { console.error(`连接 ${myId} 错误:`, err.message); }); } function startHeartbeat(myId: number) { stopHeartbeat(); heartbeatTimer = setInterval(async () => { if (myId !== activeConnectionId) return; const probe = `__heartbeat_${Date.now()}`; const ok = await sendAndWaitForEcho(probe, 10000); if (!ok) { console.error("心跳超时,强制重连"); activeConnectionId++; currentWs?.close(); connect(currentWs?.url ?? ""); } }, 60000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }心跳间隔 60 秒,超时 10 秒。这两个值别乱调:太短会增加无效流量,太长检测不出假死。60/10 是我跑了两周比较稳的组合。
3.4 上下文内存裁剪
第二次故障是内存里上下文只增不减。裁剪策略:
const MAX_MEMORY_MESSAGES = 200; const PERSIST_INTERVAL = 300_000; function trimConversationMemory(conv: Conversation) { if (conv.messages.length <= MAX_MEMORY_MESSAGES) return; const overflow = conv.messages.splice( 0, conv.messages.length - MAX_MEMORY_MESSAGES ); db.prepare( `INSERT INTO message_archive (conv_id, messages, archived_at) VALUES (?, ?, datetime('now'))` ).run(conv.id, JSON.stringify(overflow)); } setInterval(() => { for (const conv of conversations.values()) { trimConversationMemory(conv); } }, PERSIST_INTERVAL);注意:发送给模型的上下文和进程内存里的上下文是两个东西。我优化了前者但忘了后者,结果 22 天积累到 14 万 token,光消息对象就占 800MB。两个都要管。
4. 验证请求:逐步确认链路真的通了
配置写完不算完,得验证。这一节给逐步验证动作,从单模型到全链路。
4.1 验证统一 Key 通道
先用 curl 确认 TaoToken 通道能通:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-v4", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 16 }'预期返回 200,body 里有choices[0].message.content。如果返回 401,检查 Key 是否在 https://taotoken.net/api-keys 正确生成;如果返回 429,说明当前模型在限流,换qwen3.5-plus再试。
4.2 验证熔断器行为
写个测试脚本,故意让某个模型连续失败,观察熔断是否触发:
async function testBreaker() { const breaker = new CircuitBreaker({ failureThreshold: 3, cooldownMs: 5000, halfOpenMaxCalls: 1, monitorWindowMs: 60000, }); for (let i = 0; i < 5; i++) { try { await breaker.call(async () => { throw new Error("模拟失败"); }); } catch (e: any) { console.log(`第 ${i + 1} 次: ${e.message}`); } } }预期输出:前 3 次是"模拟失败",第 4、5 次是"CIRCUIT_OPEN"。等 5 秒后再调一次,应该进入 half-open 试探。
4.3 验证心跳重连
模拟断连,观察是否自动重连且不丢消息:
setTimeout(() => { console.log("模拟断连"); currentWs?.close(); }, 10000); setTimeout(() => { console.log("检查连接状态:", currentWs?.readyState); }, 20000);预期:10 秒时断连,13 秒左右重连成功,20 秒时 readyState 为 1(OPEN)。如果 20 秒还是 3(CLOSED),检查重连计数器是否被旧 close 事件归零。
4.4 验证内存裁剪
跑一个高频会话,观察内存曲线:
node --expose-gc --max-old-space-size=2048 app.js每 30 秒打印一次 heap:
setInterval(() => { const usage = process.memoryUsage(); const heapMB = usage.heapUsed / 1024 / 1024; console.log(`heap: ${heapMB.toFixed(0)}MB`); if (heapMB > 2048) { console.error("内存告警,触发 GC"); global.gc?.(); } if (heapMB > 3072) { console.error("内存危险,主动重启"); process.exit(1); } }, 30000);预期:内存稳定在 1GB 以下,不会持续爬升。如果持续爬升,检查trimConversationMemory是否真的在执行。
4.5 验证限流退避
用压测工具打 100 个并发请求,观察 429 后的退避行为:
for i in $(seq 1 100); do curl -s -o /dev/null -w "%{http_code}\n" \ https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-v4","messages":[{"role":"user","content":"hi"}],"max_tokens":8}' & done wait预期:部分返回 429,但你的客户端应该按退避参数重试,最终成功率接近 100%。如果大量 429 且没有恢复,检查respect_retry_after是否开启。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节对照真实报错,逐个排查。每个报错都给现象、根因、修复。
5.1 401 Unauthorized
现象:请求返回 401,body 里error.message是 "Invalid API key"。
根因:Key 没配、配错、或者用了别的通道的 Key。常见于把 Base URL 指向 TaoToken 但 Key 还是旧通道的。
修复:确认三件套一致。Base URL 是https://taotoken.net/api,Key 在 https://taotoken.net/api-keys 生成,Model ID 用通道支持的。检查环境变量:
echo $TAOTOKEN_KEY | head -c 8如果输出为空或前缀不对,重新导出。注意别把 Key 硬编码进代码提交到仓库。
5.2 local proxy failed
现象:日志里出现 "local proxy failed" 或 "connect ECONNREFUSED 127.0.0.1:xxxx"。
根因:本地代理配置残留,或者某个 SDK 默认走了本地端口。常见于之前配过代理工具,环境变量没清干净。
修复:检查HTTP_PROXY、HTTPS_PROXY、ALL_PROXY环境变量,清掉:
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY然后确认 SDK 没有硬编码代理地址。如果用的是 OpenAI SDK,检查baseURL是否被覆盖。
5.3 reading 'choices'
现象:TypeError: Cannot read properties of undefined (reading 'choices')。
根因:响应体结构不对,通常是请求失败但代码没检查状态码,直接读response.choices。也可能是流式响应没处理完就解析。
修复:加防御性检查:
const resp = await client.chat.completions.create({ model, messages }); if (!resp?.choices?.[0]?.message?.content) { throw new Error(`响应异常: ${JSON.stringify(resp).slice(0, 200)}`); }如果是流式,确保for await循环完整消费,别中途 break 后还读 choices。
5.4 OAuth 相关报错
现象:Claude Code 或类似工具报 OAuth 失败、token 过期。
根因:OAuth token 和 API Key 是两套体系。如果你用 API Key 接入,就不该走 OAuth 流程。
修复:确认工具配置里用的是 API Key 模式,不是 OAuth 模式。Claude Code 接入参考 https://taotoken.net/doc 里的配置示例。如果工具同时支持两种,选 API Key 模式,Base URL 指向https://taotoken.net/api。
5.5 熔断器误判
现象:模型明明可用,但熔断器一直 open,请求全被跳过。
根因:monitorWindowMs太短,偶发失败累积到阈值;或者cooldownMs太长,恢复太慢。
修复:调大monitorWindowMs到 120 秒以上,cooldownMs降到 30-60 秒。同时检查失败计数是否把非限流错误(如参数错误)也算进去了——只对 429、503、502、504 计数。
5.6 心跳假死
现象:WebSocket readyState 是 OPEN,但消息发不出去也收不到。
根因:TCP 连接假死,底层没触发 close 事件。健康检查只看 readyState 会漏判。
修复:必须加主动心跳探针,发测试消息等 echo。光看 readyState 不够。心跳超时 10 秒就强制重连,别等系统 TCP keepalive。
5.7 上下文裁剪后模型失忆
现象:裁剪内存后,模型回复变得不连贯,忘了之前聊的内容。
根因:裁剪把近期消息也删了,或者摘要没生成。
修复:裁剪只删最老的,保留最近 N 条。同时把 overflow 写入 SQLite 后,生成摘要注入到发送给模型的上下文里。发送给模型的上下文 = 最近 3 轮 + 摘要,内存里保留最近 200 条,更老的归档。
6. 从统一 Key 通道到稳定 Agent:我的收口做法
三次故障之后,我加了 6 个核心监控指标,跟模型好不好没关系,但它们是 Agent 稳定性的生命线:
| 指标 | 告警阈值 | 为什么重要 |
|---|---|---|
| 心跳延迟 | > 10s | 检测连接是否真的活着 |
| 进程 heap 内存 | > 2GB | OOM 的前兆 |
| 会话上下文长度 | > 500 条 | 内存泄漏的前兆 |
| API 429 错误率 | > 5%/分钟 | 限流即将触发 |
| 模型调用延迟 P99 | > 15s | 上游服务可能在降级 |
| 消息处理积压量 | > 20 条 | 处理能力跟不上输入 |
这 6 个指标里,429 错误率和调用延迟直接跟 TaoToken 网关层相关。网关层识别到限流会自动降级,但你的监控还是要记录,因为降级本身也有成本。
我现在的工作流是:业务代码只管业务逻辑和幂等,模型调用统一走 TaoToken 通道,熔断和降级在网关层,代码里只保留业务级重试。WebSocket 心跳和内存裁剪留在代码层,因为这两块网关管不了。
如果你正在跑生产级 AI Agent,建议顺序是:先搞定基础设施,再优化模型。WebSocket 心跳、内存上限、熔断器这三样比选哪个模型重要得多。用统一 Key 通道别直连,网关层的限流处理、自动降级、健康检查比自己在代码里写的可靠。监控那些"无聊"的指标——内存、延迟、积压量,它们会在凌晨三点救你的命。
模型会越来越强,但基础设施的坑,每个新模型都救不了你。接入文档在 https://taotoken.net/doc ,API Key 在 https://taotoken.net/api-keys ,模型对话调试用 https://taotoken.net/chat ,长期编码和 Agent 场景看 https://taotoken.net/coding-plan 。先把通道配通,再按上面的验证步骤逐个确认,最后把监控挂上。这套跑下来,你的 Agent 至少能活过第一周。