多轮聊天里,把完整历史原样塞回模型,成本会随轮次线性涨,早期试错也会变成噪声。长期记忆、仓库裁剪和工具分页解决的是另一些问题,不能直接拿来当会话压缩。本文用 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 与压缩视图分开,排障和回归才有依据。字符粗估、窗口长度、锚点阈值都是旋钮,先把流水线测通,再按账单和误伤率去拧,而不是先上一个“智能记忆层”。