Context Compact

压缩让对话保持可用,即使上下文窗口已经拥挤。

token 压力检测 + 摘要替换。

70 行压缩器

问题

长会话运行 2 小时后,上下文窗口满了。直接截断旧消息?模型会忘记任务的来龙去脉。把旧消息全留着?请求直接失败。

上下文窗口是硬边界,会话长度不是。 需要一个机制:当压力到达阈值时,把早期历史压缩成摘要,用摘要替换原文——保留"发生了什么的要点",丢弃逐字细节。

解决方案

四步压缩流程:

  1. 度量:估算当前上下文的 token 数;
  2. 触发:超过阈值(如窗口的 70%)时启动压缩;
  3. 摘要:把要压缩的早期消息发给模型,生成结构化摘要;
  4. 替换:摘要作为一条 system 消息放入历史,被压缩的原文移除。
function estimateTokens(text) { return Math.ceil(text.length / 3); } // 粗略估算

async function compact(messages, maxTokens) {
  const total = messages.reduce((n, m) => n + estimateTokens(JSON.stringify(m)), 0);
  if (total <= maxTokens * 0.7) return messages; // 未达压力阈值

  const head = messages.slice(0, Math.floor(messages.length * 0.6)); // 压缩最早的 60%
  const tail = messages.slice(Math.floor(messages.length * 0.6));
  const summary = await summarize(head); // 见下
  return [{ role: "system", content: `[历史摘要] ${summary}` }, ...tail];
}

async function summarize(history) {
  const res = await fetch(API, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}` },
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: [
        { role: "system", content: "把对话压缩为要点摘要,保留:任务目标、已完成的决策、待办事项、重要事实。" },
        { role: "user", content: JSON.stringify(history) },
      ],
    }),
  });
  return (await res.json()).choices[0].message.content;
}

工作原理

第 1 步:压缩必须发生在请求前——每次调用模型前检查一次压力。

async function step(messages, tools) {
  messages = await compact(messages, CONTEXT_WINDOW); // 先压缩,再请求
  // ... 然后才发请求
}

第 2 步:摘要的质量决定压缩的成败。好的摘要提示词要显式要求保留任务状态,而不是逐条复述。

第 3 步:压缩不是删除,是换一种表示。模型仍能回答"我们最初为什么做这个",只是不再记得中间某个 cat 的原始输出。

压缩的代价:摘要会丢失细节。所以生产系统要回答两个问题——什么时候该压缩(压力阈值、可配置)、什么内容不该被压缩掉(用户明确要求保留的、关键决策)。这两个问题分别对应 DSH 的 token 压力检测和压缩策略配置。

试一下

运行 s08,构造一个长会话:

  • "写一段 100 行的文本文件"(让 bash 输出撑大上下文)
  • 重复几轮后观察:哪一轮触发了压缩?压缩后问"我们之前的目标是什么",模型还能答对吗?

观察重点:估算 token 的误差有多大?如果摘要太短,模型丢失了什么?如果摘要太长,压缩的意义何在?

以下内容基于 packages/compactiondocs/subsystems/compaction.md 的核查。DSH 的压缩是标准的"能力缝"三件套:

角色
compaction 压缩缝与事件词汇表(ctx.compactioncompaction/* 事件)
compaction-basic token 压力 + 摘要后端
compaction-tool-result-pruner 模型无关的工具结果裁剪(大段工具输出直接删,不经过模型)
command-compact 人类触发的 /compact 命令

一、两个关键设计

  1. 工具结果裁剪先行:在调用模型摘要之前,先裁剪掉巨大的工具输出(比如 cat 一个 10 万行的文件)——这类内容通常不需要模型参与即可安全删除,省一次昂贵的摘要调用。你的实现里 summarize(head) 会把所有原文都塞给模型,DSH 会先做无模型裁剪。
  2. 可配置的压力阈值:压缩在什么 token 压力下触发、摘要策略如何,都是配置项,不是写死的常量。

二、与会话日志的关系

压缩不是"把 messages 数组改短"——DSH 的会话日志(s09)是唯一事实源,压缩是从日志派生消息历史时的一个环节:deriveMessages() 在投影时应用压缩策略。这保证了压缩后的历史依然能从日志重建(Model-visible ⟺ logged 不变量)。

一句话:你的 compact() 是压缩缝的最小实现。DSH 把"度量、裁剪、摘要、替换"拆成可组合的部件,并让压缩发生在派生层而非存储层。