返回文章列表

长对话不再炸:TuttiKit 的上下文压缩 + RAG 召回

·11 分钟阅读·#Agent#LLM#TuttiKit#RAG#上下文管理
目录 (19)

长对话不再炸: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)。这意味着三件事:

  1. 不能假设用户用的是大窗口模型。同一个 TuttiKit,今天用 Opus 4.7 跑得欢,明天用户切到豆包 32k 老版(业务系统迁移没那么快)就立刻撞墙。压缩策略必须默认开启、按当前 provider 动态算阈值。
  2. 就算是 1M 窗口也会撞墙。多 Agent + 工具调用的 trace 增长非常快——每次 ReAct step 都 append assistant + tool_result,加上附件 OCR 出的几千字……跑半小时就能进 100k token。1M 听起来很多,但乘上 plan-and-execute 的 N 个子 step,还是会满。
  3. 退役在即不等于已下线。比如 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× 便宜)

其中两条对我冲击最大:

  1. 「所有压缩必须可逆」:摘要时丢内容可以,但必须保留指向原文的指针。一旦用户后续追问到被压缩的细节,你要能拿回来。
  2. 「不要动态加/删工具」:很多人在 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 个产品的精华):

  1. 稳定前缀(Manus + Claude Code):system prompt 永远不动
  2. 可逆压缩(Manus):被压消息全文 + embedding 都存盘
  3. 早触发(社区共识):60% 而非 80%
  4. 保留尾部(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

注意几个细节:

  1. 拼在 system 尾部而不是头部——前缀稳定
  2. 失败不挂掉对话——任何异常 fallback 到原始行为
  3. 只在 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. 还没做的

不写「未来工作」凑字数。写真实欠的债:

  1. archive 太大时也要清理。现在 archive 只增不减。1000 条以后查询变慢。下一步:archive 也分层——再老的合成「超级摘要」(一段摘要的摘要)。
  2. 摘要质量没量化评测。我只跑了形式断言(条数、token 数),没让 LLM 判官评摘要保真度。需要建一个 eval:给定原始对话 + 摘要,问 LLM「能用摘要回答下列 5 个问题吗」。
  3. 召回 minSim 阈值是拍的(0.30)。在 MockEmbedding 下我把它设成 0.0 才能跑测试;OpenAI text-embedding-3-small 下需要重新校准。
  4. 没接 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 断言)