笔者所在小团队维护着星悟,是司内用的一款 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 的 itemId 和 responseId 即使同模型也会剥掉。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.json 的 compact.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 以后候选菜单会收起。这些不影响算法,但决定用户敢不敢在长会话里真的去点它。
小结
- 会话压缩在社区里已经有成熟做法,旧段摘要、近窗留原文就能用。特殊约束出现时才需要自己写一层。星悟这边的特殊约束是多模型切换加网关无状态转发。
- 多模型网关上,压缩结果也是一种历史。历史不能携带下一任模型读不懂的私有状态。现在还没做每轮自动压,也没有把 OpenAI 或 Claude 的服务端 compact 做成默认路径。以后若有人始终钉在同一条 GPT Responses 上,官方 compact 可以当隐藏优化。默认路径继续走可移植的
UIMessage。 - 还有一个方向这次没试。把一部分知识查询和总结拆给子 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,
];
}