你的 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 总结旧对话。
算法
- 保留系统消息(永不压缩)
- 保留最近 N 轮对话(默认 4 轮)
- 中间的旧消息 → 发给便宜模型(Haiku 级别)做摘要
- 摘要替换原始消息,插入一条
[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 npm → GitHub 源码
这篇文章是「从开源项目中提炼工具库」系列的第二篇。
- 第一篇:我读了100万行代码,发现大多数 LLM 应用缺了最关键的一层
- 第三篇:别再写死你的系统提示了(
@yuyuqueen/prompt-assembler)
关注我获取更新 → Twitter @YuYuQueen_ · GitHub