换模型之后,Chatbot 为什么要自己做 compact?

笔者所在小团队维护着星悟,是司内用的一款 chatbot。用户拿它查资料、追问、把一轮对话拉得很长。

开头那段日子,模型基本只有 GPT。长了就在输入框打 /compact,服务端把当前消息转成 Responses 的 input,再调用模型网关的 POST /v1/responses/compact。使用官网API进行上下文的压缩。

后来候选列表里进来了 Claude、Deepseek、Gemini、通义。换模型成了常规操作。官方 compact 还在,却只认 gpt、o 系列、chatgpt。换到别的模型,指令直接被关掉。

好吧,我就知道没那么简单。

于是乎无脑 compact 就变成必须自己做的事。怎么压缩?没经验啊,因此实现之前先过 OpenAI 的 Compaction 指南、Anthropic 的 Compaction 文档、LangChain 的 summarizationMiddleware,也把仓库里现在的 lib/chat/compact.ts 和旧的官方 compact 路由对了一遍,才得到适合当前场景的压缩算法,算法本身不新鲜。难的是网关、跨模型和无状态历史叠在一起以后,官方那块加密上下文带不过去。

背景

星悟的会话除了在服务端存一份 User Conversation。还活在前端的 UIMessage[] 里,每一轮连同当前模型一起 POST 到 /api/chat。这两份 Conversation 是不一样的,服务端哪份是原始数据、完整数据,前端的这份是 compact 后的数据。

对 OpenAI Responses,模型网关还不能老实开 store: true。上游会把请求打到不同的模型资源,上一轮留下的 itemId 到下一轮经常对不上,报的是 item 在另一个资源上创建。所以语言层被我们写成显式 store: false,也不再回放 item_reference

在这种形态里,官方 compact 一开始还能用,前提是人一直待在同一个 GPT 模型上。模型网关把 /v1/responses/compact 透传出去,返回值里会出现 type: "compaction" 的加密块。按 OpenAI 的要求,这个窗口要原样送回下一次 /responses,不能再裁。文档写得很清楚,compaction item 是 opaque 的,不给人读。

多模型一进来,这条假设就碎了。

换模型时,星悟已经选定了一套更狠的策略。prepareMessageHistory 发现当前模型和会话原模型不一致,就只留用户和助手的纯文本,reasoning、工具结果、厂商 metadata 全部丢掉。同模型才按协议白名单回放,OpenAI 的 itemIdresponseId 即使同模型也会剥掉。Gemini 的 thoughtSignature、Claude 的 signature 只在同厂商续写时才有意义。

于是出现一个很具体的冲突。官方 compact 最想保住的东西,正好是星悟跨模型时必须扔掉的东西。加密块给 Claude 看不懂,给 Gemini 也看不懂,即便人还停在 GPT 上,网关跨资源时也不稳。/compact 如果继续绑在网关那一个官方 compact 端点上,多模型切换就等于没有这条指令。

还有一层产品形状。星悟的 compact 是用户主动打斜杠,不是 agent 在后台偷偷压。用户能看见压缩前后的窗口。官方那种不给人读的 item,放到界面上只能显示一句「上下文已压缩」,后面空空的,人不知道丢掉了什么。

所以要做的不是再包一层 OpenAI compact。要做的是,任意当前模型都能执行同一条 /compact,压完仍是可展示、可继续发给当前模型、换模型后也能按纯文本回放的 UIMessage

方案调研

目前社区里管理长对话的办法,大致就这么几类。我按星悟能不能吃来看。

首先想到的便是厂商自己压。OpenAI 有服务端 context_management,也有独立的 POST /responses/compact。你把当前窗口送进去,拿回一个更小的窗口,里面带着加密 compaction item。官方还特别写了,不要再修剪 compact 的输出,那就是下一轮的规范窗口。Claude 这边是 Messages API 的 compact_20260112,默认大约 15 万 input tokens 触发,下限 5 万。API 会写一个 compaction 块,下次请求自动丢掉这个块之前的内容。两家都把压缩做成自己协议里的私有状态。星悟同时跑 GPT、Claude、Gemini、通义,私有块过不了厂商边界。网关上即便透传成功,也解决不了换模型。

OpenAI 文档里的独立 compact 用法大致如下。你先正常聊,窗口大了再把整段 input 送去 compact,然后把返回的 output 原样接到下一轮。

js 复制代码
const compacted = await client.responses.compact({
  model: "gpt-5.6",
  input: conversation,
});

const nextInput = [
  ...compacted.output,
  { role: "user", content: "Add two more days to the itinerary." },
];

返回窗口里会出现人读不懂的块。结构接近这样,encrypted_content 必须原样回传,换模型或换上游资源就失效。

json 复制代码
{
  "output": [
    {
      "type": "message",
      "role": "user",
      "content": [{ "type": "input_text", "text": "Plan a trip to Kyoto." }]
    },
    {
      "type": "compaction",
      "id": "cmp_xxx",
      "encrypted_content": "<opaque>"
    }
  ]
}

Claude 的 compaction 块里带的是模型写的摘要,人能看,但仍是 Anthropic 协议里的私有类型。下次请求会忽略这个块之前的所有内容。丢到 Gemini 或通义的消息历史上,同样没有定义。

只留最近几轮。实现最便宜,切数组就结束。短任务够用。星悟这种内部问答,前面很容易出现人名、预算、饮食禁忌、已经否掉的方案。滑动窗口会把这些连根拔掉。人回头问「我刚说不吃香菜」,模型会一脸无辜。

整段历史压成一篇摘要。早期 LangChain 的 ConversationSummaryMemory 就是这条。近几轮细节会糊。摘要再拿去摘要,错误还会叠。星悟的 /compact 之后,人通常马上追问刚才那句,近窗必须还在。

啊哈,还真找着了一个轻量且合适的形态。LangChain 现在的 summarizationMiddleware 就是这个。旧上下文生成一段文本表示,最近若干条原始消息留下。文档示例里常见的是 keep: { messages: 20 },触发可以按 token 数或消息数。这是生产里最常见的平衡。质量、成本和延迟都还能看。星悟后来选的就是它的语义,没有把整套 Agent middleware 引进来。

只裁不压。Vercel AI SDK 的 pruneMessages 能丢掉 reasoning、过旧的 tool 结果和空消息。对 agent 读大文件很有用。它不负责把长对话文本本身压短。星悟搜索结果可以先按这个思路裁,对话正文还是要摘要。

向量记忆、MemGPT 那一类。按当前问题再把旧片段捞回来。能力更强,也更重。星悟现在是点一下压缩当前会话,不是每轮检索。为这一条指令上记忆库,过了。

对照下来,值得抄的是 keep-recent 这条社区共识。不值得抄的是各家加密块和自动触发阈值。Claude 默认 15 万才压,星悟的斜杠指令等不到那个数。用户已经开口了,有东西可压就该压。

技术方案

说干就干。所有模型走同一套客户端压缩。输出必须是普通助手文本加近窗原文。不要厂商私有item

压缩后的窗口长这样。

text 复制代码
[assistant] 压缩说明 + 结构化摘要
[user / assistant ...] 最近若干条原文

近窗默认留 8 条,大约三四轮。旧段拿当前对话模型做一次 generateText。提示词放在 config/prompt.jsoncompact.system 里,要求覆盖目标、未完成事项、已确认事实、偏好、约束、结论和待办。原文没出现的,必须写明没提到,不许编。界面上那句压缩完成提示也在配置里,叫 compact.notice。代码只负责读和拼。

有几条是从踩坑里长出来的,不是审美偏好。

先裁再摘要。reasoning 和 source-url 一律丢掉。旧段只留文本,搜索工具那堆大结果不进摘要稿。近窗可以留工具结果,方便人紧接着问刚才搜到的那条链接。厂商 metadata 全部剥掉。这样压完的窗口,换 Gemini 也不会因为缺 thoughtSignature 炸掉。

失败就失败。摘要调用空了、模型报错了,历史保持原样,把错误抛回界面。静默丢掉用户刚聊完的内容,比压不了更糟。

OpenAI 原生 compact 不做默认路径。即便网关某一天把官方 compact 跑稳了,它也只适合人始终停在同一条 Responses 模型上。星悟的产品动作是换模型,统一算法才能让 /compact 的行为可预期。

门槛很轻。有效文本消息不足 4 条,就提示没有多少可压缩的内容。不必上 5 万 token。自动压缩以后再说。先把指令做稳。

联调用过一组十条消息的旅行对话。压缩前是五轮原文。

text 复制代码
用户  我叫李明,准备去京都旅行三天。
助手  好的李明,京都三日行程可以从清水寺、伏见稻荷开始。
用户  预算每人八千人民币,不要太赶。
助手  那就每天安排两个核心景点,晚上吃京料理。
用户  第二天想看寺庙,不要太热门。
助手  可以去大德寺和高台寺,人会少一些。
用户  第三天想买伴手礼。
助手  锦市场和中京区的和果子店比较合适。
用户  请记住我不吃香菜。
助手  已记下,不吃香菜,安排餐厅时会避开。

近窗留 8 条,旧段只剩第一轮。Claude Sonnet 压完后,窗口变成 1 条摘要加 8 条近窗原文。摘要只讲被切走的那一轮,近窗里的预算、寺庙、伴手礼、不吃香菜还是原话。

text 复制代码
助手  上下文已压缩,后续对话将使用压缩后的窗口。

      用户李明计划去京都旅行三天。助手建议可以从清水寺、伏见稻荷开始。
      预算、同行人数、住宿和饮食偏好,旧段里都没出现。

用户  预算每人八千人民币,不要太赶。
助手  那就每天安排两个核心景点,晚上吃京料理。
用户  第二天想看寺庙,不要太热门。
助手  可以去大德寺和高台寺,人会少一些。
用户  第三天想买伴手礼。
助手  锦市场和中京区的和果子店比较合适。
用户  请记住我不吃香菜。
助手  已记下,不吃香菜,安排餐厅时会避开。

通义 qwen3.8-flash-next 也是压成同样的 1+8 结构,摘要措辞不同,近窗原文相同。这只说明跨协议能跑通,还不能当成摘要质量评测。和官方加密块比,用户能看见丢掉了什么,换模型也能继续发。

实现

修改点不多:

app/api/chat/compact/route.ts 不再判断是不是 OpenAI 直连,只校验消息和非空模型,然后调用 compactConversation

提示词从 getCompactPrompt() 读。核心都在 packages/chatbot/lib/chat/compact.ts

切窗很短。至少留 1 条给旧段,避免 4 条消息时近窗把历史全吃掉。

ts 复制代码
const MIN_TEXT_MESSAGES = 4;
const KEEP_RECENT_MESSAGES = 8;
const MIN_OLD_MESSAGES = 1;

function splitCompactWindow(messages: UIMessage[]) {
  const recentCount = Math.min(
    KEEP_RECENT_MESSAGES,
    Math.max(0, messages.length - MIN_OLD_MESSAGES),
  );
  return {
    oldMessages: messages.slice(0, messages.length - recentCount),
    recentMessages: messages.slice(-recentCount),
  };
}

主流程是先把消息裁成可移植形态,旧段再剥掉工具结果,只把文本记录交给当前模型。

ts 复制代码
export async function compactConversation(
  messages: UIMessage[],
  modelId?: string,
): Promise<UIMessage[]> {
  const pruned = messages
    .map((message) => pruneMessage(message, true))
    .filter((message): message is UIMessage => message !== undefined);

  if (pruned.length < MIN_TEXT_MESSAGES) {
    throw new CompactError("没有多少可压缩的内容。");
  }

  const { oldMessages, recentMessages } = splitCompactWindow(pruned);
  const transcript = formatTranscript(
    oldMessages
      .map((message) => pruneMessage(message, false))
      .filter((message): message is UIMessage => message !== undefined),
  );
  if (!transcript) {
    throw new CompactError("没有多少可压缩的内容。");
  }

  const resolvedModel = resolveModel(modelId);
  const { protocol } = await resolveRoute(resolvedModel);
  const { notice, system } = getCompactPrompt();
  const { text } = await generateText({
    model: createLanguageModel(resolvedModel, protocol),
    prompt: transcript,
    system,
  });
  const summary = text.trim();
  if (!summary) {
    throw new CompactError("压缩接口未返回可用的上下文。", 502);
  }

  return [
    {
      id: `compact-summary-${nanoid(8)}`,
      parts: [{ text: `${notice}\n\n${summary}`, type: "text" as const }],
      role: "assistant",
    },
    ...recentMessages,
  ];
}

pruneMessage 做三件事。reasoning 和来源链接不要。文本去掉供应商 metadata。近窗才保留 tool-*。旧段格式化成用户一行、助手一行,避免把 UI part 结构喂给不认这套协议的模型。

返回值没有 providerMetadata,没有 item id,没有 thoughtSignature。下一轮聊天走现有 prepareMessageHistory 即可。压缩失败抛 CompactError,路由把 4xx 和模型 502 分开,前端不改消息列表。

界面上压缩过程会插一条正在压缩的虚拟助手消息。指令选完 /compact 以后候选菜单会收起。这些不影响算法,但决定用户敢不敢在长会话里真的去点它。

小结

  1. 会话压缩在社区里已经有成熟做法,旧段摘要、近窗留原文就能用。特殊约束出现时才需要自己写一层。星悟这边的特殊约束是多模型切换加网关无状态转发。
  2. 多模型网关上,压缩结果也是一种历史。历史不能携带下一任模型读不懂的私有状态。现在还没做每轮自动压,也没有把 OpenAI 或 Claude 的服务端 compact 做成默认路径。以后若有人始终钉在同一条 GPT Responses 上,官方 compact 可以当隐藏优化。默认路径继续走可移植的 UIMessage
  3. 还有一个方向这次没试。把一部分知识查询和总结拆给子 Agent,主会话只收回短结论 。上下文体积会自己变小,不必等整段历史再 compact。星悟现在的 /compact 仍是压当前这一条会话。

总之关于自定义 compact 有一个重要原则:对于多模型网关场景,压缩结果也是一种历史。历史不能携带下一任模型读不懂的私有状态。即:要保证历史能被任意下一任模式正确读取!

附录

compact 完整代码:

ts 复制代码
/**
 * 跨模型 /compact:先裁掉推理与过旧工具载荷,再把更早轮次摘要,近窗原文保留。
 */
import { getCompactPrompt } from "@/lib/chat/prompt";
import { createLLMLanguageModel } from "@/lib/model";
import { resolveLLMRoute } from "@/lib/probe";
import { resolveLLMModel } from "@/lib/protocol";
import { generateText, type UIMessage } from "ai";
import { nanoid } from "nanoid";

/** 少于此条有效消息则认为没有压缩价值。 */
const MIN_TEXT_MESSAGES = 4;
/** 默认近窗保留的消息条数,约 3~5 轮。 */
const KEEP_RECENT_MESSAGES = 8;
/** 短会话也至少留出 1 条给摘要,避免近窗吞掉全部历史。 */
const MIN_OLD_MESSAGES = 1;

type CompactPart = UIMessage["parts"][number] & {
  callProviderMetadata?: unknown;
  providerMetadata?: unknown;
  resultProviderMetadata?: unknown;
  text?: unknown;
};

/**
 * compact 校验失败,应返回 4xx 而不静默丢弃历史。
 */
export class CompactError extends Error {
  constructor(
    message: string,
    readonly status = 400,
  ) {
    super(message);
    this.name = "CompactError";
  }
}

/**
 * 判断 part 是否为工具调用或工具结果。
 */
function isToolPart(part: CompactPart): boolean {
  return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}

/**
 * 从 UI 消息中提取可展示的纯文本。
 */
function getMessageText(message: UIMessage): string {
  return message.parts
    .map((part) =>
      part.type === "text" && "text" in part && typeof part.text === "string"
        ? part.text
        : "",
    )
    .join("")
    .trim();
}

/**
 * 去掉不能跨模型回放的供应商元数据。
 */
function withoutProviderMetadata(part: CompactPart): CompactPart {
  const next = { ...part };
  delete next.callProviderMetadata;
  delete next.providerMetadata;
  delete next.resultProviderMetadata;
  return next;
}

/**
 * 将消息裁成可写入压缩窗口的形态:始终去掉推理与来源链接;
 * 旧段只留文本,近窗额外保留工具结果便于紧接着追问。
 */
function pruneMessage(
  message: UIMessage,
  keepToolResults: boolean,
): UIMessage | undefined {
  const parts = message.parts.flatMap((part) => {
    const record = part as CompactPart;
    if (record.type === "reasoning" || record.type === "source-url") {
      return [];
    }
    if (record.type === "text") {
      const text = typeof record.text === "string" ? record.text.trim() : "";
      return text ? [withoutProviderMetadata({ ...record, text })] : [];
    }
    if (keepToolResults && isToolPart(record)) {
      return [withoutProviderMetadata(record)];
    }
    return [];
  });

  if (parts.length === 0) return undefined;
  return {
    id: message.id,
    parts: parts as UIMessage["parts"],
    role: message.role,
  };
}

/**
 * 将旧段格式化为供摘要模型阅读的纯文本记录。
 */
function formatTranscript(messages: UIMessage[]): string {
  return messages
    .map((message) => {
      const text = getMessageText(message);
      if (!text) return "";
      const speaker = message.role === "assistant" ? "助手" : "用户";
      return `${speaker}:${text}`;
    })
    .filter(Boolean)
    .join("\n\n");
}

/**
 * 按近窗策略切开旧段与最近原文。
 */
function splitCompactWindow(messages: UIMessage[]): {
  oldMessages: UIMessage[];
  recentMessages: UIMessage[];
} {
  const recentCount = Math.min(
    KEEP_RECENT_MESSAGES,
    Math.max(0, messages.length - MIN_OLD_MESSAGES),
  );
  return {
    oldMessages: messages.slice(0, messages.length - recentCount),
    recentMessages: messages.slice(-recentCount),
  };
}

/**
 * 用当前对话模型把旧段压成一条可跨模型回放的助手摘要,并拼上近窗原文。
 */
export async function compactConversation(
  messages: UIMessage[],
  modelId?: string,
): Promise<UIMessage[]> {
  const pruned = messages
    .map((message) => pruneMessage(message, true))
    .filter((message): message is UIMessage => message !== undefined);

  if (pruned.length < MIN_TEXT_MESSAGES) {
    throw new CompactError("没有多少可压缩的内容。");
  }

  const { oldMessages, recentMessages } = splitCompactWindow(pruned);
  const transcript = formatTranscript(
    oldMessages
      .map((message) => pruneMessage(message, false))
      .filter((message): message is UIMessage => message !== undefined),
  );
  if (!transcript) {
    throw new CompactError("没有多少可压缩的内容。");
  }

  const resolvedModel = resolveLLMModel(modelId);
  const { protocol } = await resolveLLMRoute(resolvedModel);
  const { notice, system } = getCompactPrompt();
  const { text } = await generateText({
    model: createLLMLanguageModel(resolvedModel, protocol),
    prompt: transcript,
    system,
  });
  const summary = text.trim();
  if (!summary) {
    throw new CompactError("压缩接口未返回可用的上下文。", 502);
  }

  return [
    {
      id: `compact-summary-${nanoid(8)}`,
      parts: [{ text: `${notice}\n\n${summary}`, type: "text" as const }],
      role: "assistant" as const,
    } satisfies UIMessage,
    ...recentMessages,
  ];
}

参考

相关推荐
星栈29 分钟前
自定义 UI-UX-Pro-Max 的 CSV 知识库,把 AI 生成的后台拉回行业该有的样子
前端·agent·weui
张彦峰ZYF35 分钟前
MCP 从“能连工具”到“像 Web 一样部署”——无状态核心、扩展框架与企业级 Agent 基础设施的真正分水岭
人工智能·llm·agent·mcp
研☆香42 分钟前
简单的图片上传 删除 预览
前端
ITresearchGuest1 小时前
我用 AI 一小时写了一个世界杯数据可视化平台|前端 VibeCoding 初体验
前端·人工智能·信息可视化
枫叶丹42 小时前
实时语音 Agent:从语音机器人到连续协作界面
人工智能·chatgpt·机器人·语音识别·agent·codex
慧一居士2 小时前
Vite项目中使用Less步骤,详细使用示例
前端·css
花间相见2 小时前
【LangChain组件03】—— LangChain Agents 执行与状态:工作流程与状态管理实战
java·前端·langchain
kyriewen3 小时前
面试官让我用 AI 重构一个 8 年陈的 React 组件——他说他不看代码,只看我会不会拆
前端·javascript·面试