长对话不再炸:TuttiKit 的上下文压缩 + RAG 召回
写在前面:本文记录的是 TuttiKit(一个我自己写的多 Agent 聊天框架)从「长对话直接 400 context_length_exceeded」到「自动压缩 + RAG 召回,省 72% token」的全过程。先调研 Claude Code / Codex / Cursor / Manus / OpenHands 五个主流产品的做法,再选定方案落地,附完整代码 + 测试数据。
0. 起因:会话长了就炸
TuttiKit 的 Conductor 跑 ReAct 循环时,每一步都把整段 session.messages 喂给 LLM:
// apps/server/src/agents/conductor.ts
const session = await this.sessionManager.get(sessionId);
const messages = stripMeta(session.messages); // ← 这里把全量历史塞进去
const response = await this.llm.stream({ system, messages, tools }, ...);
短对话没事。但只要你和它聊到第 30 轮、或者跑过几次 plan-and-execute(每个 step 都会 append assistant + tool_result),上下文就会越过 provider 的窗口上限。
2026 年 5 月各家主流模型快照(按 context window 降序):
当前旗舰(1M 量级)
| Provider | 模型 | Context Window | 发布 |
|---|---|---|---|
| Anthropic | claude-opus-4-7 | 1,000,000 | 2026-04 |
| OpenAI | gpt-5-5(Codex CLI 默认) | 1,000,000 | 2026 |
| OpenAI | gpt-4.1 | 1,047,576 | 2025 |
| Alibaba | qwen-turbo / qwen3.5-plus | 1,000,000 | 2026-04 |
| DeepSeek | deepseek-v4-pro / -flash | 1,000,000 | 2026-04-24 |
当前旗舰(200K–262K 量级)
| Provider | 模型 | Context Window | 发布 |
|---|---|---|---|
| Moonshot | kimi-k2.6 | 262,144 | 2026-04-20 |
| ByteDance | doubao-seed-2.0 / -1.6 | 256,000 | 2026-02-14 |
| Tencent | hunyuan-turbos / t1 | 256,000 | 2026 主力(Mamba-Transformer) |
| Alibaba | qwen3.6-max-preview | 262,000 | 2026-04 |
| Anthropic | claude-opus-4 / sonnet-4 / haiku-4 | 200,000 | 2025 |
| Zhipu | glm-5.1 | 200,000 | 2026-04-07(开源 744B MoE) |
上一代仍在线但即将退役 / 小窗口分支
| Provider | 模型 | Context Window | 状态 |
|---|---|---|---|
| OpenAI | gpt-4o / gpt-4o-mini | 128,000 | 上一代 |
| DeepSeek | deepseek-chat(V3.2 API) | 128,000 | 2026-07-24 退役 |
| Moonshot | moonshot-v1-128k | 128,000 | K1 系列残留 |
| Alibaba | qwen-plus | 131,072 | |
| Zhipu | glm-4-plus | 128,000 | 上一代 |
| Moonshot | moonshot-v1-32k | 32,000 | K1 系列残留 |
| ByteDance | doubao-1-5-pro-32k | 32,000 | 上一代 |
注:表格数据截止 2026-05-21。模型迭代极快(Qwen 3.7-Max 就是今天刚发布的),半年内大概率全部刷新。
最大和最小差 31 倍(1M vs 32k)。这意味着三件事:
- 不能假设用户用的是大窗口模型。同一个 TuttiKit,今天用 Opus 4.7 跑得欢,明天用户切到豆包 32k 老版(业务系统迁移没那么快)就立刻撞墙。压缩策略必须默认开启、按当前 provider 动态算阈值。
- 就算是 1M 窗口也会撞墙。多 Agent + 工具调用的 trace 增长非常快——每次 ReAct step 都 append assistant + tool_result,加上附件 OCR 出的几千字……跑半小时就能进 100k token。1M 听起来很多,但乘上 plan-and-execute 的 N 个子 step,还是会满。
- 退役在即不等于已下线。比如
deepseek-chat要到 2026-07-24 才完全下线,绑了它的生产系统还会跑很久。框架不能只为旗舰服务。
一旦超了,provider 返回 400 context_length_exceeded。这个错误不在 withRetry 的可重试集合里(重试也没用,再发还是超),所以 Conductor 直接 emit turn:error,用户必须手动开新会话。我把它叫做项目的「唯一硬挂掉模式」。
我想干掉它。
1. 先看主流产品怎么做的
不想凭空拍方案。先调研。
1.1 Claude Code(Anthropic 官方 CLI)
/compact命令:手动触发上下文压缩。- 自动压缩:2026 年 5 月默认 ~83.5% 触发(接近窗口上限才压);已可通过环境变量
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=70等自定义阈值,社区共识 40-70% 更稳——越靠近上限模型质量越掉。 - 稳定前缀(KV cache 友好):system prompt 和工具定义永远不动,让 Anthropic 的 prompt cache 命中。Sonnet 4.6 的 cache 读只算 $0.30/M token(基础 $3 的 10%),cache 写比基础贵 25%——一旦前缀变,cache miss 不光退回原价、还多花 25% 重写缓存,账单两头都涨。
- cache TTL 的隐藏陷阱:Anthropic 在 2026 初把默认 prompt cache TTL 从 60 分钟悄悄降到 5 分钟。这意味着哪怕前缀完全稳定,只要两次请求间隔超 5 分钟,cache 就过期。「稳定前缀」从「锦上添花」变成「在 5 分钟窗口内连续打」的硬指标——长会话的"压缩频率"和"用户停顿时间"现在直接影响账单。
- 摘要里保留什么:任务状态 + 文件路径 + 已得出的结论。被压缩走的内容不可逆——这是 Claude Code 的一个明显不足,后面 Manus 给了改进思路。
1.2 Cursor
- Codebase Indexing:进项目时把代码切 chunk,做 embedding,存向量库。
- @ 引用 / 自动检索:用户提问时按当前编辑器位置 + query 做 RAG,拉相关 chunk 进 prompt。
- 团队复用:跨成员同 hash 的代码块复用同一份 embedding,省费用 + 加速冷启动。
- 启示:上下文不仅是「对话历史」,还可以包括「外部知识库」。压缩 = 选择性遗忘 + 选择性召回。
1.3 Manus(最有料的一份资料)
时效背景:Manus 在 2025-12 被 Meta 以 $20 亿+ 收购,现为 Meta 旗下产品。下面引用的 Context Engineering for AI Agents 工程博客仍在 manus.im 原址。
Manus 的工程博客详细写了「Context Engineering 五支柱」(有些资料归纳成 6 条,骨架一致),是这次调研里最实用的:
| 支柱 | 做法 |
|---|---|
| Offloading | 把长内容(文档、日志)写盘,prompt 里只保留路径/摘要 |
| Reduction | 压缩;但必须可逆——保留路径/URL,丢内容 |
| Retrieval | 按需召回,向量 + 关键词混合 |
| Isolation | 子 Agent 用独立上下文,不污染主对话 |
| Caching | 前缀稳定 → 命中 KV cache(10× 便宜) |
其中两条对我冲击最大:
- 「所有压缩必须可逆」:摘要时丢内容可以,但必须保留指向原文的指针。一旦用户后续追问到被压缩的细节,你要能拿回来。
- 「不要动态加/删工具」:很多人在 token 紧张时想动态裁工具列表。Manus 说别这么干——工具变了,KV cache 全失效;正确做法是用 logit masking 在采样阶段屏蔽不想让模型用的工具。
1.4 OpenHands(云编程 agent 平台,原"开源 Devin")
时效背景:OpenHands 已从早期的「Devin 开源替代」长成独立平台(2026-05 最新 v1.6.0,融资 $18.8M),主打云端 + 自部署编程 agent。
- Context Condenser:自动条件触发的摘要器(论文原始数据)。
- 数据:~2× 成本下降;上下文从二次方增长降到线性;SWE-bench 上 54% vs 不压缩的 53%——几乎不掉质量(注:这是论文 baseline 时代数据,当前 OpenHands SWE-bench SOTA 已远超此值,但「压缩本身不掉质量」的结论仍立得住)。
- 保留什么:用户目标 + 当前进度 + 剩余工作 + 关键文件。
- 启示:摘要本身的写法决定了质量。不要让 LLM 写「用户问了 X,AI 答了 Y」——直接保留 X 和 Y 本身。
1.5 Codex(OpenAI codex-cli)
Codex CLI 2026 年默认用 GPT-5.5(1M 窗口),公开资料里没有像 Claude Code 那样高调宣传压缩策略——很可能就是「窗口够大,先不压」。早期 codex-cli(GPT-4o 系列时代)是简单的滑窗 / 截断 baseline。
启示反着读:有钱有大窗口就不用压缩,但绝大多数自部署 / 国产模型场景不是这条路;对 TuttiKit 这种「我用什么 provider 都能跑」的框架,必须假设最坏情况。
2. 方案选型:混合摘要 + RAG 召回
调研完后,我把所有候选做法收敛成 4 个原子方案,编号 A / B / C / D:
| 编号 | 方案 | 一句话 | 代表产品 |
|---|---|---|---|
| A | 滑动窗口(drop oldest) | 老消息直接扔,只留最近 N 条 | Codex |
| B | 纯摘要替换 | 老消息合成一段摘要,原文丢弃 | Claude Code /compact(默认 ~83.5% 触发) |
| C | 混合摘要 | 最近 K 条原样保留 + 更老的批量摘要;摘要和原文双轨存(原文不丢) | Claude Code 配 AUTOCOMPACT_PCT_OVERRIDE=60 + OpenHands Condenser |
| D | RAG 召回 | 老消息全文 + embedding 进向量库,每轮按 user query 召回 top-K 拼回 prompt | Cursor codebase indexing、Manus retrieval pillar |
每个方案单独看都有短板:
| 方案 | 问题 |
|---|---|
| A 滑窗 | 直接丢信息,用户追问翻车——这就是 TuttiKit 现在的「硬挂掉模式」的更弱版本 |
| B 纯摘要 | 不可逆,被压的细节回不来;摘要写得不好整段对话就废了 |
| C 混合摘要 | 摘要本身丢细节;用户追问压缩前的具体内容时,摘要够不着 |
| D 纯 RAG | 没有摘要兜底,连贯性差;每轮都要全量召回,慢;老对话在 prompt 里像「断章」 |
C + D 互补:
- C 提供短期 + 中期记忆:最近 K 条原样(短期连贯)+ 摘要(中期概览)
- D 提供长期按需召回:archive 里的全文 + embedding 永远在那儿,用户追问到具体细节就召回
- C 的 archive 同时是 D 的向量库——一份数据两种用法,零重复存储
所以最终方案是 C 做主干 + D 做兜底召回,而不是「先选 C 再加个 D」。它俩共用 archive 这个数据结构,是同一件事的两个面。
设计原则(综合 4 个产品的精华):
- 稳定前缀(Manus + Claude Code):system prompt 永远不动
- 可逆压缩(Manus):被压消息全文 + embedding 都存盘
- 早触发(社区共识):60% 而非 80%
- 保留尾部(OpenHands):最近 K 条原样
3. 实现
3.1 数据结构
每个 session 加一个 archive 字段(写盘),保存被压缩的原始消息和它们的 embedding:
// apps/server/src/core/sessionCompact.ts
interface ArchivedMessage {
id: string;
role: 'user' | 'assistant' | 'tool' | 'system';
content: string;
originalIndex: number; // 在压缩前 session 里的位置
vec?: number[]; // embedding,给 RAG 用
vecModel?: string;
summaryId?: string; // 一同被合并进哪段 summary,方便前端「展开原文」反查
}
interface CompactSummary {
id: string;
rangeStart: number; // 覆盖的原始消息范围
rangeEnd: number;
text: string; // LLM 生成的摘要正文
vec?: number[];
createdAt: number;
}
session.messages 在压缩后重写为 [摘要消息们] + [最近 K 条原文]。
3.2 触发条件
export const COMPACT_TRIGGER_RATIO = 0.6; // 60% 触发
export const COMPACT_KEEP_RECENT_N = 8;
export const COMPACT_BATCH_SIZE = 8;
export const RECALL_TOP_K = 4;
export const RECALL_MIN_SIM = 0.30;
function estimateTokens(messages: Message[]): number {
// 英文 ~4 char/token、中文 ~1.5 char/token;混合按 3 char/token 折中
let chars = 0;
for (const m of messages) {
chars += (m.content || '').length;
chars += JSON.stringify(m.toolCalls ?? []).length;
}
return Math.ceil(chars / 3);
}
阈值通过 provider × model 查表算出,统一在 apps/server/src/llm/contextWindow.ts,前端 CtxMeter 和后端 compactor 共用一套数据。
3.3 摘要流程
// 老消息按 batchSize 切批,每批喂给 LLM 出一段 ~200 字摘要
for (let i = 0; i < oldMessages.length; i += batchN) {
const batch = oldMessages.slice(i, i + batchN);
const sumText = await summarizeBatch(llm, batch); // LLM 一次调用
const [sumVec] = await embedding.embed([sumText]); // 摘要本身也算 embedding
// 把这批原始消息每条单独算 embedding 进 archive
const vecs = await embedding.embed(batch.map(m => m.content || ''));
batch.forEach((m, j) => archive.push({
id, role: m.role, content: m.content || '',
originalIndex: cursor + i + j,
vec: vecs[j], summaryId: sumId,
}));
}
摘要 prompt 我特意写得比较「狠」:
把下面这段多轮对话压缩成一段不超过 200 字的中文摘要。
要求:
1. 保留事实性信息(决定了什么、写了什么文件、调用了什么工具的关键结果、用户的偏好)
2. 删除寒暄、确认、重复
3. 用一段话陈述,不要 1.2.3. 列表
4. **不要总结成"用户问了 X,AI 答了 Y"这种废话**,直接把内容写出来
最后那条「不要写废话」是踩过坑的经验——很多 LLM 默认会写「The user asked about X. The assistant explained Y.」这种没用的元描述,把 200 字摘要里 80 字浪费在自我介绍上。
3.4 RAG 召回(D)
每轮在 conductor 入口处,把最新 user query 拿去匹配 archive:
export async function recallRelevant({ sessionId, query, topK = 4, minSim = 0.30 }) {
const archive = getArchive(session);
const [qvec] = await embedding.embed([query]);
return archive.archive
.filter(m => m.vec && m.vecModel === ep.name)
.map(m => ({ m, sim: cosineSim(qvec, m.vec!) }))
.filter(r => r.sim >= minSim)
.sort((a, b) => b.sim - a.sim)
.slice(0, topK)
.map(c => c.m);
}
召回结果拼成一段 [相关历史 1 · 用户] xxx 的文本,注入到 system prompt 尾部(不是开头!前缀稳定让 prompt cache 命中)。
3.5 Hook 进 Conductor
整段逻辑在 _runReactSteps 之前只跑一次(不是每个 step 一次,避免重复烧 LLM):
// apps/server/src/agents/conductor.ts
drainer.enter();
// ────── 上下文管理(C+D) ──────
let recalledBlock = '';
try {
const ctxWindow = contextWindowOf(this.llm.name, modelName);
const result = await compactIfNeeded({ sessionId, contextWindow: ctxWindow, llm: this.llm });
if (result.triggered) {
await persistCompact(sessionId, result);
this.bus?.emit('context:compacted', {
sessionId,
beforeTokens: result.beforeTokens,
afterTokens: result.afterTokens,
archivedCount: result.archivedCount,
summariesCreated: result.summariesCreated,
});
}
const recalled = await recallRelevant({ sessionId, query: userMessage });
if (recalled.length > 0) {
recalledBlock = '\n\n' + formatRecalled(recalled);
this.bus?.emit('context:recalled', { sessionId, count: recalled.length });
}
} catch (err) {
// 失败不阻塞对话,回退到原始 messages
this.logger.warn({ err: (err as Error).message }, '[compact/recall] 失败,跳过');
}
let augmentedSystem = this.systemPrompt + recalledBlock;
// ... plan 和 react 都用这个 augmentedSystem
注意几个细节:
- 拼在 system 尾部而不是头部——前缀稳定
- 失败不挂掉对话——任何异常 fallback 到原始行为
- 只在 turn 开头跑一次——而不是每个 ReAct step 都跑
3.6 前端通知
SSE 总线加两个事件:
// apps/server/src/streaming/sse.ts
'context:compacted',
'context:recalled',
前端在 ChatNotices 里加一条吐司:
🗜 上下文已自动压缩:22 条老消息合成 3 段摘要,省下 72% 的 token(约 3,698)
用户能看到「框架在替我管理上下文」,而不是莫名其妙地丢历史。
4. 测试与数据
写了 apps/server/examples/test-compact.ts,5 个测试场景共 18 条断言:
✓ estimateTokens 单调:长消息 token 数更大
✓ 短消息估算合理(< 10)
✓ 少量消息 + 大窗口 → 不触发压缩
✓ 不触发时 archived/summaries=0
✓ 小窗口 + 多消息 → 触发压缩
✓ archive 数 = 总 30 - keep 8 = 22(实际 22)
✓ 生成 3 段摘要
✓ 压缩后 token 变少(5107 → 1409)
✓ messages 变短:30 → 11
✓ persist 后磁盘 session.messages 与 result 一致
✓ archive 中条数与 result 一致
✓ summaries 中条数与 result 一致
✓ archive[0] 保留原始全文(找到"第 0 条"标记)
✓ archive[0] role 保留为 user
✓ archive 条目关联到 summaryId
✓ t3 触发压缩
✓ 召回到 4 条相关历史
✓ 召回结果包含 React 相关消息(语义命中)
全部通过 ✅
关键数据:
| 指标 | 压缩前 | 压缩后 | 变化 |
|---|---|---|---|
| 消息数 | 30 | 11 | -63% |
| 估算 token | 5,107 | 1,409 | -72% |
| 信息丢失 | - | 0 | 全部进 archive,可召回 |
OpenHands 论文里给的 ~2× 成本下降,我这个场景达到 3.6×(5107 / 1409)。但要诚实声明:这是构造测试,30 条消息内容高度同质(每条都是「第 N 条」+ 500 个 x),压缩比天然很好看。真实多轮对话异质性强、摘要的边际收益没这么夸张,达到 OpenHands 的 2× 是更现实的预期。
可逆性测试:archive[0] 的原始 '第 0 条消息:xxxx...' 完整保留;role 字段没丢;summaryId 关联回它被合进哪段摘要。
5. 还没做的
不写「未来工作」凑字数。写真实欠的债:
- archive 太大时也要清理。现在 archive 只增不减。1000 条以后查询变慢。下一步:archive 也分层——再老的合成「超级摘要」(一段摘要的摘要)。
- 摘要质量没量化评测。我只跑了形式断言(条数、token 数),没让 LLM 判官评摘要保真度。需要建一个 eval:给定原始对话 + 摘要,问 LLM「能用摘要回答下列 5 个问题吗」。
- 召回 minSim 阈值是拍的(0.30)。在 MockEmbedding 下我把它设成 0.0 才能跑测试;OpenAI text-embedding-3-small 下需要重新校准。
- 没接 sqlite-vec。archive 现在还是 JSON 里的 array,线性扫描。条目少(< 几百)时无所谓;上 1000+ 需要换 sqlite-vec 或 hnswlib。
6. 反思
调研花了大半个小时,写代码花了 1 个小时多一点。但调研的价值在于:
- 我没走 Codex 的滑窗弯路
- 我直接采用了 Manus 的可逆性原则——把召回内容写进 archive 而不是直接覆盖,省了「等用户追问压缩前细节才发现回不来」的一次返工
- 我想清楚了召回应该拼在 system prompt 尾部而不是头部——前者保持前缀稳定让 prompt cache 命中、后者每轮 cache miss。这不是「美观问题」,是钱的问题
很多人写 AI 系统时倾向于「我先写一版,跑通再说」。但上下文管理这种架构性决策,等你跑通再改的代价是:所有 session 数据格式都得迁移、所有 prompt 都得重写。
先调研,再选型,再实现。 这是我从这次实践里最大的体会。
附录:相关文件
apps/server/src/core/sessionCompact.ts— C+D 核心实现apps/server/src/llm/contextWindow.ts— provider × model 窗口查表apps/server/src/agents/conductor.ts:106-151— hook 点(drainer.enter 之后到 augmentedSystem 拼装)apps/server/src/streaming/sse.ts:25-26— SSE 事件apps/web/src/hooks/useChat.ts:354-385— 前端 SSE 事件处理(compact / recall 两段)apps/web/src/components/ChatNotices.tsx— UI 提示(🗜 / 🔎 图标)apps/server/examples/test-compact.ts— 测试(5 场景 / 18 断言)