Blog /

我读了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 种错误一定会发生

#错误频率用户看到的
1Rate limit (429)高峰期每天”请求太频繁,请稍后重试”
2Auth 失败 (401/403)Key 过期时”服务暂时不可用”
3上下文溢出长对话时”对话太长了,请开新会话”
4Thinking 模式不支持切换模型时”该模型不支持此功能”
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 的冷却时间表:

连续失败次数冷却时间
11 分钟
25 分钟
325 分钟
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 npmGitHub 源码


这篇文章是「从开源项目中提炼工具库」系列的第一篇。后续计划:

关注我获取更新 → Twitter @YuYuQueen_ · GitHub

评论