Blog /

你的 AI 对话超过 20 轮就崩?这是因为你没有这三道防线

从 OpenClaw 的上下文管理系统中,提炼出 LLM 应用的上下文溢出三级防线设计

用户跟你的 AI 助手聊到第 20 轮,突然收到一条”对话太长了,请开新会话”。他辛辛苦苦建立的上下文全没了。然后他卸载了你的 App。

问题

上一篇文章里,我从 OpenClaw 的 Agent 引擎中提炼出了 LLM 应用的容错层。其中有一个恢复策略我只用了一段话带过——上下文溢出恢复

但当我真正去读 OpenClaw 处理上下文的代码时,我发现它远比”压缩一下重试”复杂得多。

它是一个三级防线系统。每一级都有自己的触发条件、执行策略和退路。

大多数 LLM 应用对上下文溢出的处理:

try {
  await llm.chat(messages)
} catch (err) {
  if (err.message.includes('context')) {
    return "对话太长了,请开新会话 🙏"
  }
}

OpenClaw 的处理——三道防线,从轻到重:

每次 API 调用前


防线 1: 上下文预算检查(事前)
    → 估算当前 token 数,预判是否会溢出
    → 超过阈值?主动触发防线 2 或 3


防线 2: 工具结果截断(轻量)
    → 找到最大的工具结果,按阈值截断
    → 纯字符串操作,毫秒级完成


防线 3: 会话压缩(重量级)
    → 用便宜模型总结旧消息
    → 保留最近几轮 + 系统消息


全部失败 → 友好错误(而不是崩溃)

用户全程无感。对话继续。

关键区别:大多数人只在 API 报错后才处理溢出。OpenClaw 在发送前就知道会不会溢出。

防线 1:上下文预算——在溢出前就知道要出问题

其他两道防线是”出了问题怎么修”,这道防线是”提前知道要出问题”。

// 预留 4096 tokens 给模型输出
const RESERVE_OUTPUT_TOKENS = 4_096

// 启发式估算:4 个字符 ≈ 1 token
const CHARS_PER_TOKEN = 4

每次发送请求前,算一下当前消息总 token 数,减去输出预留,就知道还剩多少空间。利用率超过 70% 就主动触发截断或压缩——而不是等到 100% 崩溃。

这是整个防线系统的”雷达”。没有它,你只能在 API 报错之后才知道溢出了。有了它,你可以提前 30% 的余量就开始处理

防线 2:工具结果截断

最轻量的处理手段——不需要调用 LLM,纯字符串操作,毫秒级完成。

为什么工具结果是最大的溢出源?

在 Agent 场景里,一次 read_file 可以返回几十万字符的文件内容。一次数据库查询可以返回几百条记录。这些工具结果会直接被塞进上下文窗口。

OpenClaw 的解决方案:设定阈值,超过就截断。

// 单个工具结果最多占上下文的 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

为什么是 30%?因为上下文里还要放系统提示、对话历史、其他工具结果。一个工具结果占 30% 已经是很大的份额了。

截断的细节

截断不是简单的 .slice(0, n)。OpenClaw 有一个细节让我印象深刻:

// 尽量在换行处切断,不在一行的中间切
let cutPoint = keepChars
const lastNewline = text.lastIndexOf("\n", keepChars)
if (lastNewline > keepChars * 0.8) {  // 换行在 80% 位置之后
  cutPoint = lastNewline              // 就在换行处切
}

为什么?因为 LLM 读到被切断的半行 JSON 或代码时,可能会产生幻觉。在换行处切断,至少保证每一行都是完整的。

多个工具结果怎么办?

如果一条消息里有多个文本块(比如 Anthropic 格式的 tool_result),按比例分配截断预算:

文本块 A: 100K 字符 (50%)  → 分配 50% 的预算
文本块 B: 100K 字符 (50%)  → 分配 50% 的预算

公平分配,没有一个块被完全牺牲。

防线 3:会话压缩

工具截断不够时,就要动用更重的手段——用便宜的 LLM 总结旧对话

算法

  1. 保留系统消息(永不压缩)
  2. 保留最近 N 轮对话(默认 4 轮)
  3. 中间的旧消息 → 发给便宜模型(Haiku 级别)做摘要
  4. 摘要替换原始消息,插入一条 [Previous conversation compressed] 标记
压缩前:
  [system] 你是一个助手
  [user] 帮我看看这个 bug        ← 旧消息
  [assistant] 好的,我来看看     ← 旧消息
  [user] 这个文件呢?            ← 旧消息
  [assistant] 这里有个问题...    ← 旧消息
  [user] 那日志呢?              ← 保留(最近 4 轮)
  [assistant] 日志显示...        ← 保留
  [user] 怎么修?                ← 保留
  [assistant] 建议这样改...      ← 保留

压缩后:
  [system] 你是一个助手
  [user] [Previous conversation compressed]
         用户在调试一个 bug,发现文件中存在问题...
  [user] 那日志呢?
  [assistant] 日志显示...
  [user] 怎么修?
  [assistant] 建议这样改...

三个关键细节

1. Tool Use / Tool Result 必须成对处理

LLM 的 tool_use 消息和对应的 tool_result 消息是一对。如果你压缩了 tool_result 但留了 tool_use,LLM 会困惑:“我调用了工具,结果呢?”

所以压缩边界不能切在一对 tool_use/tool_result 的中间。

2. 图片直接丢弃

旧消息里的图片内容在压缩时被丢弃——你没法把一张图片”总结”成文字。摘要只保留文字上下文。

3. 超时保护

压缩本身需要调用 LLM,也可能失败。OpenClaw 设了 5 分钟的安全超时,防止压缩过程本身变成问题。

你的 LLM 应用今天就能加上这三道防线

看到这里你可能觉得:道理我都懂,但实现起来边界情况太多。工具结果截断要处理 Anthropic 和 OpenAI 两种消息格式,会话压缩要处理 tool_use/tool_result 配对和图片剥离,预算估算的启发式参数需要调优……

我把 OpenClaw 的 1000+ 行代码提炼成了一个开箱即用的库——零依赖,不绑定任何 LLM 提供商:

npm install @yuyuqueen/llm-context-kit

GitHub: github.com/yuyuqueen/llm-toolkit — 欢迎 Star

上下文预算检查

import { createToolResultTruncator } from '@yuyuqueen/llm-context-kit'

const truncator = createToolResultTruncator()

// 自动找到超大的工具结果并截断
const { messages: safeMessages, truncatedCount } =
  truncator.truncate(messages, 200_000) // context window tokens

console.log(`截断了 ${truncatedCount} 个工具结果`)

会话压缩

import { createContextCompressor } from '@yuyuqueen/llm-context-kit'

const compressor = createContextCompressor({
  summarize: async ({ messages, systemPrompt }) => {
    // 用便宜模型做摘要
    const response = await anthropic.messages.create({
      model: 'claude-haiku-4-5-20251001',
      max_tokens: 4096,
      system: systemPrompt,
      messages: messages.map(m => ({
        role: m.role as 'user' | 'assistant',
        content: m.content,
      })),
    })
    return response.content[0].text
  },
  preserveRecentTurns: 4,  // 保留最近 4 轮
})

const result = await compressor.compress(messages)
if (result.compressed) {
  messages = result.messages
  console.log(result.description)
  // → "Compressed 12 messages into summary"
}

上下文预算检查

import { createContextBudget } from '@yuyuqueen/llm-context-kit'

const budget = createContextBudget({
  contextWindowTokens: 200_000,
  reserveOutputTokens: 4_096,
})

const status = budget.check(messages)
console.log(status)
// → {
//     withinBudget: true,
//     estimatedTokens: 45000,
//     availableTokens: 150904,
//     utilizationPercent: 23
//   }

三道防线串起来

async function chat(messages) {
  // 防线 1: 检查预算
  const status = budget.check(messages)

  // 防线 2: 预算紧张?先截断工具结果
  if (status.utilizationPercent > 70) {
    messages = truncator.truncate(messages, 200_000).messages
  }

  // 防线 3: 还是不够?压缩旧对话
  if (status.utilizationPercent > 85) {
    const compressed = await compressor.compress(messages)
    if (compressed.compressed) messages = compressed.messages
  }

  return callLLM({ messages })
}

改造前后

场景改造前改造后
工具返回 50KB JSON上下文直接溢出自动截断到安全范围,换行处切割
对话 30 轮模型丢失早期指令,变蠢旧消息压缩成摘要,最近 4 轮完整保留
上下文即将满毫无预警,突然崩溃提前感知,主动触发截断/压缩
工具调用配对压缩后 tool_use/result 错位成对处理,永不分割
处理耗时不处理(直接崩)截断毫秒级,压缩有 5 分钟超时保护

配合 resilient-llm 使用

这个库可以和上一篇文章介绍的 @yuyuqueen/resilient-llm 无缝配合:

import { createResilientLLM } from '@yuyuqueen/resilient-llm'
import { createContextCompressor } from '@yuyuqueen/llm-context-kit'

const compressor = createContextCompressor({
  summarize: async ({ messages, systemPrompt }) => { /* ... */ },
})

let messages = [/* ... */]

const resilient = createResilientLLM({
  providers: [/* ... */],
  contextCompressor: async () => {
    const result = await compressor.compress(messages)
    if (result.compressed) {
      messages = result.messages  // 更新外部消息数组
    }
    return {
      compressed: result.compressed,
      description: result.description,
    }
  },
})

// 上下文溢出时自动触发压缩,用户无感
const result = await resilient.call(async (ctx) => {
  return {
    response: await anthropic.messages.create({
      model: ctx.model,
      max_tokens: 1024,
      messages,
    }),
  }
})

两个库配合,就是完整的 LLM 生产级防护:

API 调用失败

    ├─ Rate limit → resilient-llm 自动换 key
    ├─ Auth 错误 → resilient-llm 切 provider
    ├─ 上下文溢出 → llm-context-kit 截断/压缩
    └─ 其他错误 → resilient-llm 指数退避重试

设计原则

这个库遵循和 resilient-llm 相同的设计哲学:

  • Provider-agnostic — 不绑定任何 LLM SDK。压缩需要调 LLM?你传回调,库管编排
  • 零依赖 — 纯 TypeScript,无运行时依赖
  • 不可变 — 所有操作返回新数组,不修改你的原始数据
  • 兼容双格式 — 同时支持 Anthropic 和 OpenAI 的消息格式

结论

上下文窗口不是无限的,但用户的对话可以是。

200K tokens 听起来很多,但在 Agent 场景里,几次 read_file + 几轮工具调用就能吃掉大半。没有防线的 App 会在用户最投入的时候崩溃。有了三级防线,用户可以一直聊下去。

@yuyuqueen/llm-context-kit on npm@yuyuqueen/resilient-llm on npmGitHub 源码


这篇文章是「从开源项目中提炼工具库」系列的第二篇。

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

评论