多轮聊天里,把完整历史原样塞回模型,成本会随轮次线性涨,早期试错也会变成噪声。长期记忆、仓库裁剪和工具分页解决的是另一些问题,不能直接拿来当会话压缩。本文用 Node.js 把压缩拆成最近窗口、旧轮次摘要、离线校验与失败回退,并把原始记录单独落盘,压缩之后仍然可回放、可回归。
多轮膨胀不是记忆、裁剪或分页
做助手时很容易把三件事混成一个“上下文模块”。长期记忆解决的是跨会话检索:用户上周说过自己用 Postgres,这周新开一轮还要找得回来。上下文裁剪面向代码仓库:一次塞不进整个 monorepo,得按引用和相关文件挑。工具分页处理的是单次结果过大:搜索命中上百条,模型只需要前几条,再加上续拉句柄。
同一会话里的 token 膨胀是另一类工程问题。每一轮都把历史原样拼回去,上下文随轮次变长;改口、重复确认、已经作废的方案都会变成噪声。它不需要向量库,也不该在压缩时把原始对话扔掉。排障时你得回答两个问题:模型当时究竟看见了什么,以及压缩器有没有把关键约束弄丢。
所以下面只处理将要送进模型的 messages:把它压进预算,同时把未压缩原文写成追加日志。压缩是派生视图,不是唯一真相。
压缩流水线的四段
策略保持克制,不追求一次摘要定终身。
第一段是最近窗口。用户刚说的话、模型刚做的承诺,必须原文保留。窗口按消息条数切,一对 user / assistant 算两条。条数可配置,先用较小的值把链路跑通,再按账单调。
第二段是旧轮次摘要。窗口之外的消息合成一段短文本,放在窗口前面。摘要只承担“已经定下来的事实和约束”,不承担文学性复述。开头的系统提示要钉住,不参与旧轮次摘要,避免把角色设定摘要没了。
第三段是质量校验。摘要可能漏掉错误码、文件路径、用户明确否决的方案。校验必须能离开模型运行:长度、必填小节、锚点覆盖。校验失败就不要把坏摘要送进模型。
第四段是失败回退。校验不过,或摘要后仍超预算,就丢弃摘要、扩大原文窗口;还超预算再硬截断,并在 system 里写明“这是截断不是摘要”。成本会高一点,但比静默丢失约束更可解释。
四段都要留下结构化日志:压缩前、压缩后、校验原因、用了哪条策略。线上“模型怎么突然忘了”才不会变成无法复现的玄学。
用 Node.js 实现可测试的压缩器
下面这份脚本只用 Node.js 内置模块。摘要函数可注入:本地用确定性 mock,有 OPENAI_API_KEY 时再走官方 Chat Completions。token 用字符数粗估,只作预算闸门,不能拿去对账。保存为 compress-session.mjs,Node 18+ 直接运行。
import { appendFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
export function estimateTokens(text) {
return Math.ceil([...String(text)].length / 2);
}
export function extractAnchors(text) {
const found = new Set();
const rules = [
/\b[A-Za-z][A-Za-z0-9]+(?:Error|Exception)\b/g,
/\b[\w./-]+\.(?:js|mjs|cjs|ts|json|md)\b/g,
/\bE[A-Z0-9]{3,}\b/g,
/https?:\/\/[^\s]+/g,
];
for (const re of rules) {
for (const m of String(text).match(re) || []) found.add(m);
}
return [...found];
}
export function validateSummary(summary, oldMessages) {
const reasons = [];
const text = String(summary || "").trim();
if (text.length < 20) reasons.push("too_short");
for (const tag of ["【事实】", "【未决】", "【约束】"]) {
if (!text.includes(tag)) reasons.push("missing_sections");
}
const source = oldMessages.map((m) => m.content).join("\n");
const anchors = extractAnchors(source);
const hit = anchors.filter((a) => text.includes(a));
if (anchors.length >= 3 && hit.length / anchors.length < 0.3) {
reasons.push("low_anchor_coverage");
}
if (estimateTokens(text) > Math.max(400, estimateTokens(source))) {
reasons.push("longer_than_source");
}
return { ok: reasons.length === 0, reasons: [...new Set(reasons)], anchors, hit };
}
function splitLeadingSystem(messages) {
let i = 0;
while (i < messages.length && messages[i].role === "system") i += 1;
return { pinned: messages.slice(0, i), rest: messages.slice(i) };
}
function tokensOf(msgs) {
return msgs.reduce((n, m) => n + estimateTokens(m.content), 0);
}
function rawFallback(messages, keepRecent, tokenBudget, validator) {
const { pinned, rest } = splitLeadingSystem(messages);
let n = Math.min(rest.length, keepRecent + 6);
let slice = rest.slice(-n);
while (slice.length > 2 && tokensOf([...pinned, ...slice]) > tokenBudget) {
slice = slice.slice(1);
}
const note = {
role: "system",
content:
"摘要校验未通过或压缩后仍超预算,已回退为原文窗口(可能含截断)。与最近消息冲突时,以最近消息为准。",
};
const next = [...pinned, note, ...slice];
return {
messages: next,
strategy: "raw_fallback",
validator,
tokens: tokensOf(next),
};
}
export async function compressSession(messages, options = {}) {
const keepRecent = options.keepRecentMessages ?? 8;
const tokenBudget = options.tokenBudget ?? 3000;
const summarize = options.summarize;
if (!Array.isArray(messages) || messages.length === 0) {
return { messages: messages || [], strategy: "empty", validator: { ok: true, reasons: [] }, tokens: 0 };
}
const { pinned, rest } = splitLeadingSystem(messages);
if (rest.length <= keepRecent) {
return { messages, strategy: "raw_window", validator: { ok: true, reasons: [] }, tokens: tokensOf(messages) };
}
const recent = rest.slice(-keepRecent);
const older = rest.slice(0, -keepRecent);
let summary = "";
try {
summary = await summarize(older);
} catch {
return rawFallback(messages, keepRecent, tokenBudget, { ok: false, reasons: ["summarize_threw"], anchors: [], hit: [] });
}
const validator = validateSummary(summary, older);
if (!validator.ok) return rawFallback(messages, keepRecent, tokenBudget, validator);
const compressed = [
...pinned,
{
role: "system",
content: `以下是较早轮次的压缩摘要,不是原文。若与最近窗口冲突,以最近窗口为准。\n${summary}`,
},
...recent,
];
const tokens = tokensOf(compressed);
if (tokens > tokenBudget) {
return rawFallback(messages, keepRecent, tokenBudget, {
...validator,
reasons: [...validator.reasons, "over_budget_after_summary"],
});
}
return { messages: compressed, strategy: "window_plus_summary", validator, tokens };
}
export function mockSummarize(oldMessages) {
const source = oldMessages.map((m) => `${m.role}: ${m.content}`).join("\n");
const anchors = extractAnchors(source);
return [
"【事实】较早对话已压缩;用户在调试 Node.js 会话压缩与回放。",
`关键锚点:${anchors.length ? anchors.join("、") : "无"}。`,
"【未决】以最近窗口的用户问题为准,未在摘要中关闭。",
"【约束】不要覆盖用户最新指令;摘要与窗口冲突时听窗口。",
].join("\n");
}
export async function openaiSummarize(oldMessages) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error("OPENAI_API_KEY missing");
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
temperature: 0,
messages: [
{
role: "system",
content:
"把对话压成中文短摘要,必须含【事实】【未决】【约束】三节。保留文件名、错误码、URL。不要编造未出现的信息。",
},
{
role: "user",
content: oldMessages.map((m) => `${m.role}: ${m.content}`).join("\n"),
},
],
}),
});
if (!res.ok) throw new Error(`openai_http_${res.status}`);
const data = await res.json();
const text = data.choices?.[0]?.message?.content;
if (!text) throw new Error("openai_empty");
return text;
}
export async function appendReplay(file, event) {
await mkdir(path.dirname(file), { recursive: true });
const line =
JSON.stringify({ id: randomUUID(), ts: new Date().toISOString(), ...event }) + "\n";
await appendFile(file, line, "utf8");
}
const demoMessages = [
{ role: "system", content: "你是会话内助手,回答要短。" },
{ role: "user", content: "我在写 compress-session.mjs,请求 OpenAI 时遇到 ETIMEDOUT。" },
{ role: "assistant", content: "把网络错误从摘要函数抛出,让校验走 raw_fallback。" },
{ role: "user", content: "原始记录放到 ./replay/session.jsonl,文档见 README.md。" },
{ role: "assistant", content: "JSONL 只追加。压缩视图不要就地改原文数组。" },
{ role: "user", content: "校验规则不要再调用模型。" },
{ role: "assistant", content: "用必填小节加锚点覆盖率即可。" },
{ role: "user", content: "最近窗口留 4 条,预算用字符粗估。" },
{ role: "assistant", content: "可以。下面按这个预算压一轮。" },
{ role: "user", content: "开始压缩,并告诉我 strategy。" },
];
async function main() {
const replayFile = path.join("replay", "session.jsonl");
const summarize = process.env.OPENAI_API_KEY
? openaiSummarize
: async (old) => mockSummarize(old);
const result = await compressSession(demoMessages, {
keepRecentMessages: 4,
tokenBudget: 1200,
summarize,
});
await appendReplay(replayFile, {
sessionId: "demo-001",
strategy: result.strategy,
validator: result.validator,
tokens: result.tokens,
originalCount: demoMessages.length,
compressedCount: result.messages.length,
original: demoMessages,
compressed: result.messages,
});
console.log(
JSON.stringify(
{
strategy: result.strategy,
tokens: result.tokens,
validator: result.validator,
compressedCount: result.messages.length,
replayFile,
},
null,
2
)
);
}
const isMain =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
无密钥时跑 node compress-session.mjs,应看到 strategy 为 window_plus_summary,并在 ./replay/session.jsonl 追加一行。演示数据里含文件名和 ETIMEDOUT,是为了让锚点校验真正干活;换自己的会话时,闲聊轮次可能抽不到锚点,覆盖率规则不会误伤。
摘要输入只应是旧轮次,不要把整段历史再喂回去做摘要,避免压缩过程自己把预算打爆。openaiSummarize 用的是 Chat Completions 的 messages 字段;模型名按账号里实际可用的来,这里只是可运行的默认值。
系统提示与冲突处理
开头连续的 system 会钉在压缩结果最前。摘要也写成 system,并明确“与最近窗口冲突时听窗口”。这比在摘要里尝试裁决对错更稳:用户改口是常态,压缩器不负责当裁判。
校验失败就回退,原始记录单独留
校验规则要能在单测里跑。空摘要、缺小节、比原文还长、明显漏掉错误码或路径,这几类用字符串检查就够。锚点阈值不要设成 100%:不是每轮都有 ETIMEDOUT 这种硬标记。summarize 抛错(超时、HTTP 非 2xx、空 content)与校验失败走同一条回退,避免调用方再包一层“尽量成功”。
回退优先级固定下来:坏摘要丢弃,扩大最近窗口;仍超预算则从更早原文砍,并打上 raw_fallback。调用方只看 strategy 就能解释这一轮多花了钱还是少给了历史。不要在回退里再调一次模型“修摘要”,那会把失败路径变成第二条不稳定链路。
原始记录不要和送进模型的数组共用一份可变引用。压缩前先拷贝或只读传入,JSONL 只追加。每条日志带上 sessionId、original、compressed、validator。排障时按会话重放:读出原文,注入当时的 summarize(或 mock),对比 strategy 是否变化。压缩器保持纯函数、摘要可注入,回归测试才写得动。
可回放不等于把 JSONL 再整段塞回模型。回放是给人看、给测试看的;线上请求仍然只发压缩后的 messages。若产品还要跨会话记忆,那是检索另一条链路,不要把本会话的摘要文件当成记忆库。
总结
多轮历史不该原样回灌。长期记忆、仓库裁剪、工具分页各有对象,混用会做成既贵又难查的系统。会话压缩用最近窗口保连贯,用旧轮次摘要控成本,用可离线跑的校验决定是否采纳,用回退避免坏摘要静默生效。系统提示钉住,冲突听最近窗口。原始 JSONL 与压缩视图分开,排障和回归才有依据。字符粗估、窗口长度、锚点阈值都是旋钮,先把流水线测通,再按账单和误伤率去拧,而不是先上一个“智能记忆层”。
评论