笔者所在小团队维护着一个内部文档平台,沉淀了大量的产品介绍、技术文档、规范手册、FAQ 等知识资产。boss 有样学样,要求我们实现一个智能问答功能,以提高销售同学工作效率。
经过后端同学的努力(据说是用了开源 RAG 框架实现文档的 Embedding),将其文档知识化存入向量数据库,再搭配司内部署的 DeepSeek 进行预训练,基本实现了 boss 要求的「智能问答」功能。
然而,上线一段时间后,用户开始反馈一个奇怪的现象:
用户: 刚开始对话挺准的,但是聊了几轮之后,回答就开始"跑偏"了,有时候甚至答非所问。
这就是本文要探讨的核心问题——上下文腐烂(Context Rot)。
什么是上下文腐烂?
先来看一个真实的对话场景:
[第1轮] 用户:我们的 API 网关支持哪些协议?
AI:支持 HTTP/HTTPS、gRPC、WebSocket 三种协议...(准确)
[第2轮] 用户:gRPC 怎么配置?
AI:gRPC 配置需要在 config.yaml 中添加...(准确)
[第3轮] 用户:有没有示例代码?
AI:这是一个 gRPC 客户端示例...(准确)
... 经过多轮工具调用、代码检索、文档查询 ...
[第15轮] 用户:刚才说的超时配置在哪个文件?
AI:超时配置通常在 nginx.conf 中设置...(跑偏了!明明在聊 gRPC)
问题出在哪?
上下文窗口是有限资源,且边际收益递减。
随着对话轮次增加,上下文窗口被大量的工具调用结果、历史消息、思维链(thinking)等内容填满。模型在处理如此庞大的上下文时,注意力被稀释,早期的关键信息被"淹没",回答质量自然下降。
业界给这个现象起了个形象的名字:Context Rot(上下文腐烂)。
问题分析
笔者仔细分析了我们的对话日志,发现几个典型的"腐烂"模式:
1. 工具结果堆积
每次调用 RAG 检索、联网搜索等工具,都会返回大量文本。10 轮对话下来,工具结果可能占据 80% 以上的上下文空间:
[Tool: search_docs] → 返回 3000 tokens
[Tool: fetch_url] → 返回 2000 tokens
[Tool: read_file] → 返回 1500 tokens
...
这些工具结果大多是"一次性"的——用完就没用了,但却一直霸占着宝贵的上下文空间。
2. 思维链膨胀
知识问答支持用户切换模型,比如切换到 DeepSeek-R1 这类推理模型后,模型会输出大量的推理过程。这些 thinking 块在当时有助于提升回答质量,但对后续对话来说就是"噪音":
{
"type": "thinking",
"thinking": "让我分析一下用户的问题...首先...其次...综上所述..." // 动辄几千 tokens
}
3. 关键信息被稀释
用户在第 3 轮说的 "我用的是 Java 技术栈",到了第 15 轮,这个关键上下文可能已经被后续的海量信息"冲淡",模型"忘记"了这个前提。
Token 用量可视化
笔者做了一个简单的统计,一个典型的 15 轮技术问答对话:
| 类别 | Token 占比 |
|---|---|
| 工具调用结果 | 65% |
| Thinking 块 | 20% |
| 用户消息 | 8% |
| 助手回复 | 7% |
难怪会"跑偏"——真正有价值的用户意图和助手回复,只占 15%!
业界最佳实践:上下文工程四大支柱
带着问题去调研,笔者发现 Anthropic 在2025年的一篇文档 effective-context-engineering-for-ai-agent 中提出了一套系统化的方法论。结合 OpenAI、Google 等厂商的实践,可以总结为四大支柱:
| 支柱 | 核心思想 | 解决的问题 |
|---|---|---|
| Select(选择) | 按需检索,即时拉取 | 避免一次性加载过多信息 |
| Write(写入) | 持久化到上下文窗口之外 | 关键信息不会丢失 |
| Compress(压缩) | 缩减冗余,保留精华 | 释放上下文空间 |
| Isolate(隔离) | 分发给子 Agent | 并行处理,互不干扰 |
这四个支柱,正好对应了我们遇到的问题!于是笔者决定将其工具化,形成一套可复用的解决方案。
context-kit:极简上下文工程工具包
说干就干,笔者参考业界最佳实践,用 TypeScript 实现了一个轻量级工具包:context-kit-nodejs。
核心设计理念:
- 极简:纯函数 + 轻量核心,零重依赖
- 可组合:各模块独立,按需组合
- 框架无关:兼容任意 Agent 框架
- 不可变:所有操作返回新实例,避免副作用
技术选型
| 类别 | 选型 | 理由 |
|---|---|---|
| 语言 | TypeScript 5.x | 类型安全,IDE 友好 |
| 运行时 | Node.js 18+ | ES Modules 原生支持 |
| 构建 | tsc | 简单够用,不引入额外复杂度 |
| 测试 | Vitest | 快,且与 Jest 兼容 |
| LLM SDK | OpenAI / Anthropic(可选) | peer dependencies,不强制安装 |
项目结构
context-kit/
├── src/
│ ├── index.ts # 公共 API 导出
│ ├── context.ts # Context 核心类(压缩操作)
│ ├── message.ts # Message 类 + 多厂商格式互转
│ ├── memory.ts # Memory CRUD(Claude Memory Tool 接口)
│ ├── select.ts # 即时文件检索(listDir/grep/readFile)
│ ├── llm.ts # LLM 适配器(OpenAI/Anthropic)
│ ├── tools.ts # Agent 工具封装
│ └── util.ts # Token 估算工具
├── examples/ # 5 个示例脚本
└── tests/ # 单元测试
下面逐一介绍各模块的实现。
支柱一:Select(选择)—— 渐进式披露
之前的做法是把相关文档一股脑塞进上下文,这太粗暴了。更优雅的方式是渐进式披露:
- 先看目录(低成本):大致了解有哪些知识文档
- 再搜关键词(中成本):定位到具体文档地址
- 最后读内容(高成本):按需加载
import { listDir, grep, readFile } from "context-kit";
// 第一步:了解全貌(约 200 tokens)
const entries = listDir("./src", { maxDepth: 2 });
// 返回:src/auth.ts, src/api/gateway.ts, src/config/...
// 第二步:缩小范围(约 500 tokens)
const matches = grep(/timeout/, "./src", { filePattern: "*.ts" });
// 返回:src/config/grpc.ts:42: timeout: 30000
// 第三步:按需加载(约 300 tokens)
const content = readFile("./src/config/grpc.ts", {
startLine: 40,
endLine: 50
});
对比一下 Token 消耗:
| 方式 | Token 消耗 |
|---|---|
| 传统:加载所有文件 | ~15000 |
| 渐进式披露 | ~1000 |
节省 93%! 这就是 Select 的威力。
支柱二:Write(写入)—— 持久化关键信息
有些信息虽然当前用不到,但后续可能需要。与其让它占着上下文空间,不如写出去,需要时再取回来。
context-kit 实现了与 Claude Memory Tool 兼容的接口:
import { initMemory, create, view, strReplace } from "context-kit";
// 初始化存储目录
initMemory("./agent_data");
// 保存关键发现
create("/memories/findings.md", `
# 用户环境
- 语言:Java
- 框架:Spring Boot 3.x
- gRPC 版本:1.54.0
# 关键配置
- 超时:30s
- 重试:3次
`);
// 后续需要时取回
const context = view("/memories/findings.md");
// 支持增量更新
strReplace("/memories/findings.md", "超时:30s", "超时:60s");
这样,即使对话进行到第 100 轮,关键信息也不会丢失。
支柱三:Compress(压缩)—— 释放上下文空间
这是解决「上下文腐烂」的核心武器。context-kit 提供两种压缩策略:
策略一:规则压缩
基于规则清除冗余内容,快速且确定性强:
import { Context } from "context-kit";
const ctx = Context.fromOpenAI(messages);
// 只保留最近 3 条工具结果,其余清除
const compressed = ctx.compressByRule({
keepToolUses: 3,
excludeTools: ["readFile"], // 某些工具结果永远保留
});
// 同时清除 thinking 块
const moreCompressed = ctx.compressByRule({
keepToolUses: 3,
clearThinking: true,
keepThinkingTurns: 1, // 只保留最近 1 轮的 thinking
});
清除后的内容会被替换为占位符:
[Tool result cleared. Use memory_read('/memories/tool_001_grep.md') to retrieve.]
配合 memoryPath 选项,还能自动归档到 Memory,需要时再取回:
ctx.compressByRule({
keepToolUses: 3,
memoryPath: "./agent_data", // 自动归档
});
策略二:模型压缩
对于更复杂的场景,可以用 LLM 做摘要:
import { Context, fromOpenAI } from "context-kit";
const llm = fromOpenAI(openaiClient, "gpt-4o-mini");
const compressed = await ctx.compressByModel(llm, {
instruction: "保留:关键决策、未解决问题、用户偏好。丢弃:探索性尝试、重复内容。",
keepRecent: 3, // 最近 3 轮不压缩
});
压缩后,旧对话被替换为一条摘要消息:
[Previous conversation summary]
用户咨询 gRPC 配置问题,使用 Java + Spring Boot 环境。
已确认:超时设为 60s,重试 3 次。
待解决:负载均衡策略尚未确定。
压缩效果对比
笔者在真实对话数据上做了测试:
| 压缩策略 | 压缩前 | 压缩后 | 压缩率 |
|---|---|---|---|
| 规则压缩(keepToolUses=3) | 45000 tokens | 12000 tokens | 73% |
| 模型压缩(keepRecent=3) | 45000 tokens | 8000 tokens | 82% |
| 组合使用 | 45000 tokens | 6000 tokens | 87% |
压缩 87% 后,模型的回答质量明显提升——它终于能"专注"于关键信息了。
支柱四:Isolate(隔离)—— 分而治之
对于复杂任务,可以将子任务分发给独立的 Agent,每个 Agent 有自己的上下文空间,互不干扰。
这部分 context-kit 没有直接实现(属于框架层面),但提供了工具封装,方便集成:
import { getMemoryTools, getSelectTools, getAllTools } from "context-kit";
// 返回标准化的工具函数,可直接注入 Agent
const tools = getAllTools("./agent_data", "./src");
// [memoryRead, memoryWrite, memoryUpdate, memoryDelete, fileList, fileSearch, fileRead]
// 每个工具函数签名统一:(params) => string
// 可无缝对接 LangChain、AutoGen 等框架
多模型支持
做 AI 应用绑死一家厂商可不行。context-kit 内部使用统一的 Message 格式,支持与主流 LLM API 互转:
import { Context } from "context-kit";
// 从 OpenAI 格式创建
const ctx = Context.fromOpenAI(openaiMessages);
// 导出为 Anthropic 格式
const [anthropicMsgs, system] = ctx.toAnthropic();
// 导出为 Google/Gemini 格式
const [googleContents, sysInst] = ctx.toGoogle();
支持的特性:
| 特性 | OpenAI | Anthropic | |
|---|---|---|---|
| 多模态(图片) | ✅ | ✅ | ✅ |
| Thinking 块 | ✅ (reasoning_content) | ✅ (thinking) | ✅ (thought: true) |
| Tool Use | ✅ (tool_calls) | ✅ (tool_use) | ✅ (function_call) |
| Tool Result | ✅ (tool role) | ✅ (tool_result) | ✅ (function_response) |
这意味着你可以在 OpenAI (DeepSeek 完全遵守 OpenAI 的规范)上开发调试,部署时切换到 Claude 或 Gemini,零改动。
Token 可视化
上下文管理的前提是知道 Token 花在哪了。context-kit 提供了详细的统计功能(也是为了向 boss 展示压缩后的量化数据^_^):
const ctx = Context.fromOpenAI(messages);
// 估算总 Token
console.log(ctx.estimateTokens()); // ~15000
// 按类型分类
const breakdown = ctx.getTokenBreakdown();
// { text: 3000, thinking: 5000, toolCalls: 1000, toolResults: 6000, images: 0 }
// 可视化输出
ctx.displayTokenBreakdown(128000);
// Context Usage: 15000 / 128000 tokens (11.7%)
// text: 3000
// thinking: 5000
// tool_calls: 1000
// tool_results: 6000
有了这个,优化就有的放矢了。
最终效果
集成 context-kit 后,我们的文档问答系统表现大幅提升:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 15轮对话后回答准确率 | 62% | 89% |
| 平均 Token 消耗/轮 | 8500 | 2200 |
| 用户满意度 | 3.2/5 | 4.5/5 |
用户反馈最多的一句话:"终于不会聊着聊着就跑偏了!"
小结
-
上下文腐烂是多轮对话的通病,根源在于上下文窗口是有限资源,边际收益递减;
-
四大支柱是系统化的解决方案:
- Select:渐进式披露,按需加载
- Write:持久化关键信息到外部存储
- Compress:规则压缩 + 模型压缩,释放空间
- Isolate:子任务隔离,互不干扰
-
context-kit 将这些最佳实践工具化,提供:
- 轻量、框架无关的 API
- 多 LLM 格式互转
- Token 可视化统计
不足与展望
当然,这个工具包还有改进空间:
- Token 估算不够精确:目前采用简单的字符数/4 估算,不同模型的 tokenizer 差异未考虑
- 模型压缩有延迟:需要调用 LLM 做摘要,增加了响应时间
- Isolate 模块未实现:目前只提供工具封装,完整的子 Agent 编排需要框架层面支持
- 缺少持久化层抽象:Memory 模块目前只支持文件系统,未来可扩展 Redis、数据库等
欢迎感兴趣的同学一起完善!
附录:快速上手
# 克隆仓库
git clone https://github.com/GuangMingZ/context-kit-nodejs
cd context-kit-nodejs
# 安装依赖
npm install
# 运行示例
npm run example:minimal # Context 核心用法
npm run example:select # Select 渐进式检索
npm run example:memory # Memory 持久化
npm run example:compress-rules # 规则压缩
npm run example:compress-model # 模型压缩(需配置 API Key)
- 项目地址 context-kit-nodejs
- 完整文档见 README.md