我读了100万行代码,发现大多数 LLM 应用缺了最关键的一层
从 OpenClaw 开源项目的 Agent 引擎中,提炼出 LLM 应用的生产级容错层设计
你的 AI 应用上线第一天,就会遇到这 5 种崩溃。大多数人的解决方案是
try/catch+ 弹个错误提示。这篇文章告诉你,还有一种更好的做法。
问题
我最近在读一个叫 OpenClaw 的开源项目。它是一个”AI 助手网关”——把 AI 接入你的 WhatsApp、Slack、Telegram 等聊天工具,让 AI 直接出现在你已有的通讯环境里。
105 万行 TypeScript,3000+ 个文件,713 个贡献者,3 个月内积累了近 12000 个 commit。
在读它的 Agent 执行引擎核心文件(run.ts,997 行)时,我发现了一个让我震撼的设计模式。
大多数 LLM 应用的架构是这样的:
用户输入 → 调用 LLM API → 返回结果
而 OpenClaw 的架构多了一层:
用户输入 → 弹性层(容错 + 恢复) → 调用 LLM API → 返回结果
这一层看起来不起眼,但它决定了用户体验的天花板。
5 种必然会遇到的崩溃
只要你的 AI 应用接入了 LLM API,这 5 种错误一定会发生:
| # | 错误 | 频率 | 用户看到的 |
|---|---|---|---|
| 1 | Rate limit (429) | 高峰期每天 | ”请求太频繁,请稍后重试” |
| 2 | Auth 失败 (401/403) | Key 过期时 | ”服务暂时不可用” |
| 3 | 上下文溢出 | 长对话时 | ”对话太长了,请开新会话” |
| 4 | Thinking 模式不支持 | 切换模型时 | ”该模型不支持此功能” |
| 5 | 账单问题 (402) | 额度用完时 | ”服务暂时不可用” |
大多数开发者的处理方式:
try {
const response = await anthropic.messages.create({ ... });
return response;
} catch (err) {
return "出错了,请重试"; // 😅
}
用户看到的是”坏了”。然后他们关掉你的 App,去用 ChatGPT。
OpenClaw 的做法:用户什么都不知道
OpenClaw 的 Agent 引擎核心是一个 while(true) 循环。每种错误都有对应的自动恢复策略:
while (true) {
result = await callLLM(...)
if (context overflow) → 自动压缩会话,重试
if (rate limit) → 冷却当前 key,静默切到备用 key
if (auth failure) → 标记失败,切到下一个 auth profile
if (thinking unsupported) → 自动降级 extended → deep → off
if (billing error) → 长冷却,切到下一个 provider
if (success) → break
}
用户感知到的是”它就是能用”。背后可能已经换了 2 个 key、降了 1 级 thinking、压缩了一次会话。
5 种恢复策略详解
策略 1:Key 轮换 + 指数退避冷却
不是只存一个 API key,而是维护一个有序的候选列表:
const keys = ["sk-key1", "sk-key2", "sk-key3"];
let keyIndex = 0;
// 某个 key 被 rate limit 了
// → 标记冷却(1分钟 → 5分钟 → 25分钟 → 1小时,指数退避)
// → 切到下一个 key
// → 用户无感
OpenClaw 的冷却时间表:
| 连续失败次数 | 冷却时间 |
|---|---|
| 1 | 1 分钟 |
| 2 | 5 分钟 |
| 3 | 25 分钟 |
| 4+ | 1 小时(封顶) |
对于 billing 错误(账单问题),冷却更长:5 小时起步,最长 24 小时。因为用户充值需要时间。
策略 2:多 Provider 降级
当 Anthropic 的所有 key 都不能用了:
Anthropic (所有 key 冷却中)
↓ 自动降级
OpenAI (尝试 GPT-4o)
↓ 也不行
Google (尝试 Gemini)
用户可能发现”回答风格变了”,但至少没有中断。
策略 3:上下文溢出的三级防线
这是最精妙的设计。当 LLM 报”context too long”时,不是直接失败,而是三级恢复:
Level 1: SDK 已经自动压缩了?→ 直接重试(零成本)
Level 2: 主动调用压缩函数 → 用便宜模型总结旧对话 → 重试
Level 3: 截断超大的工具结果 → 重试
Level 4: 全部失败 → 返回友好提示
工具结果截断的阈值也很有讲究:
// 单个工具结果最多占上下文的 30%
const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;
// 硬上限 400K 字符(≈100K tokens)
const HARD_MAX_TOOL_RESULT_CHARS = 400_000;
// 截断后至少保留 2000 字符(让 LLM 理解内容是什么)
const MIN_KEEP_CHARS = 2_000;
截断时还有一个细节:尽量在换行处切断,而不是切在一行的中间。
let cutPoint = keepChars;
const lastNewline = text.lastIndexOf("\n", keepChars);
if (lastNewline > keepChars * 0.8) { // 换行在 80% 位置之后
cutPoint = lastNewline; // 就在换行处切
}
策略 4:Thinking 模式自动降级
不同模型支持的 thinking level 不同。OpenClaw 用一个 Set 追踪已尝试的级别,避免循环:
const attempted = new Set<ThinkingLevel>();
// extended → deep → off
// 已试过的不再试,避免死循环
策略 5:Token 计费不虚报
这个细节值 1000 块钱。
在工具调用循环中,每次 API 调用 LLM 都会报告完整上下文的 token 数。如果有 5 次工具调用,简单累加 = 5 倍虚报。
// ❌ 错误做法
totalInputTokens += response.usage.input_tokens; // 5 次 = 5 倍
// ✅ 正确做法(OpenClaw 的方案)
// Output tokens:累加(总共生成了多少)
// Prompt tokens:只取最后一次调用的值(反映真实上下文大小)
你的 LLM 应用今天就能加上这一层
看到这里你可能在想:这个 while(true) 循环我自己也能写。没错,但魔鬼在细节里——错误分类的 40+ 种正则、指数退避的冷却计算、token 计费的 last-call 模式、thinking 降级的死循环防护……这些边界情况,每一个都是线上踩过的坑。
所以我把 OpenClaw 这 997 行 run.ts 的精华提炼成了一个开箱即用的库:
npm install @yuyuqueen/resilient-llm
GitHub: github.com/yuyuqueen/llm-toolkit — 欢迎 Star
5 分钟接入
上面讲的 5 种恢复策略,用这个库只需要这样写:
import { createResilientLLM } from '@yuyuqueen/resilient-llm'
import Anthropic from '@anthropic-ai/sdk'
const resilient = createResilientLLM({
providers: [
{
name: 'anthropic',
model: 'claude-sonnet-4-20250514',
keys: [
{ id: 'key-1', value: process.env.ANTHROPIC_KEY_1! },
{ id: 'key-2', value: process.env.ANTHROPIC_KEY_2! },
],
},
{
name: 'openai',
model: 'gpt-4o',
keys: [{ id: 'openai-1', value: process.env.OPENAI_KEY! }],
},
],
})
const result = await resilient.call(async (ctx) => {
const client = new Anthropic({ apiKey: ctx.apiKey.value })
return {
response: await client.messages.create({
model: ctx.model,
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello!' }],
}),
}
})
// 用户不需要知道底层换了几次 key、降了几次级
console.log(result.response.content[0].text)
对比一下改造前后的区别:
| 之前 | 之后 | |
|---|---|---|
| Rate limit | 崩溃,弹错误 | 静默换 key,用户无感 |
| Key 过期 | 服务中断 | 自动切备用 key |
| 上下文溢出 | ”请开新会话” | 自动压缩,继续对话 |
| Provider 宕机 | 整个应用不可用 | 降级到备用 provider |
| Token 计费 | 5 倍虚报 | 精确统计 |
核心设计
- Provider-agnostic:不绑定任何 LLM SDK,你传回调,库管编排
- 零依赖:纯 TypeScript,无运行时依赖
- 5 种自动恢复:Key 轮换、Provider 降级、上下文压缩、Thinking 降级、指数退避
上下文压缩
const result = await resilient.call(
callFn,
{
thinkingLevel: 'high',
contextCompressor: async () => {
const removed = trimOldMessages(messages)
return removed > 0
? { compressed: true, description: `Removed ${removed} messages` }
: { compressed: false }
},
},
)
Key 健康监控
const health = resilient.getKeyHealth()
// → { keys: [{ id: 'key-1', status: 'cooldown', errorCount: 2 }, ...] }
随时知道每个 key 的状态,再也不用半夜被 alert 叫起来排查”哪个 key 又挂了”。
结论
LLM 应用的用户体验天花板,不是由 AI 模型的能力决定的,而是由你的容错层决定的。
模型都一样(大家都在调同样的 API),真正的差异化在于:当出错时,用户看到的是”出错了”还是”什么都没发生”。
这就是那”最关键的一层”。而你现在可以用一行 npm install 把它加到你的项目里。
→ @yuyuqueen/resilient-llm on npm → GitHub 源码
这篇文章是「从开源项目中提炼工具库」系列的第一篇。后续计划:
- 上下文窗口管理的三级防线(
@yuyuqueen/llm-context-kit) - 别再写死你的系统提示了(
@yuyuqueen/prompt-assembler)
关注我获取更新 → Twitter @YuYuQueen_ · GitHub