《互联网产品的 AI 架构·推理性能与成本工程》3.3 上下文压缩与检索裁剪

21 条检索、历史与工具片段压缩到 9 条,输入 token 从 767 降到 415,但一次去掉保护标记的实验丢掉了退款单号、金额与时效三个事实。本节给出压缩流水线、逐步丢弃原因与事实探针验收,说明怎样在省钱的同时证明关键信息没有丢。文中数字均为示例样本运行结果。

压缩省下的钱,可能是拿事实换的

「回声」把检索条数从 10 条降到 4 条,输入 token 降了 38%,账单立刻好看。两周后客服发现,退款单号和金额开始答错:压缩后的片段里,正好是那条写着“退款单号 R7788,金额 129 元”的内容被相关性分数排在后面,整条裁掉了。

这类事故的麻烦之处在于,它不会报错。模型仍然流畅地回答,只是依据少了一段。仅看 token 曲线,这是一次成功的优化;看事实,这是一次信息丢失。

这一节把压缩拆成四种手段:去重、摘要、相关性裁剪、全局预算。每一种都记录丢弃原因,再用“事实探针”做验收。示例样本上的运行结果:21 条片段、767 token,压缩后 9 条、415 token,保留比例 54.1%;六个事实探针全部存活。去掉保护标记后的对照实验则丢了三个事实,验收明确失败。

四种手段,四种风险

手段做什么省什么主要风险
去重合并近似重复的检索段落重复 token阈值过低会误合并不同事实
摘要旧历史抽取首句合成一段历史原文 token细节、否定词、数字被截掉
相关性裁剪低于分数阈值的片段直接丢低价值 token分数不准时先丢关键事实
全局预算仍超预算时按分数从低到高丢最后一段差额无保护标记时丢沉默事实

顺序很重要。先去重再摘要,避免同一段历史被摘要两次;先裁剪再全局预算,避免预算阶段把高价值片段误伤。mustKeep 标记贯穿全程:它不参与分数竞争,也不因为有近似重复就被合并掉。

摘要这一项要特别说明:示例实现是抽取式的,取每条旧历史的第一句拼接,不调用模型。它便宜、确定、可复现,但也会丢细节。生产里可以用模型摘要,但验收方法不变:先用事实探针定义“不能丢什么”,再比较不同摘要器的通过率与成本。

示例样本上的压缩结果

样本包含 12 条检索、8 条历史、1 条工具结果。策略是:最低分 0.35、近似重复阈值 0.7、历史保留最近 2 条、摘要最多 120 字、总预算 480 token。

指标结果
压缩前21 条,767 token
压缩后9 条,415 token
保留比例54.1%
去重丢弃2 条,110 token
低分丢弃5 条,147 token
摘要替换6 条原文变 1 条摘要,177 token 变 82 token

两个数字值得注意。第一,摘要贡献最大:6 条历史从 177 token 压到 82 token,省了 95 token。第二,去重省下的 110 token 来自两组近似段落,其中一组是退款说明的两个版本,保留的是带 mustKeep 的那条,另一条以 duplicateOf 指向它。

压缩不是越多越好。把预算从 480 降到 300,摘要和一条旧政策会被全局预算丢掉,token 继续降到 289,但“旧版退款规则”这条信息就没了。示例里的探针没有覆盖它,所以验收仍然通过;这提醒我们:探针覆盖不到的事实,压缩照样会丢。

丢失信息的验收

验收不靠人读一遍摘要,而是预先列出必须存活的事实,逐条在保留文本里查找。示例用了六个探针:

探针内容完整样本去掉保护标记
order-idA1024通过通过
refund-noR7788通过丢失
amount129 元通过丢失
sla三个工作日通过丢失
trackingSF1234567890通过通过
compensation运费券通过通过

对照实验只改了一处:把退款片段的 mustKeep 去掉,分数降到 0.2。它先因为低分被裁掉,随后 refund-no、amount、sla 三个探针全部失败。如果没有验收,这次压缩会以 49.7% 的保留比例通过评审。

探针不是越多越好,而是每个都必须能判定。好的探针是订单号、金额、日期、时效、否定条件这类硬事实;“语气友好”“回答有帮助”不是探针,无法用字符串匹配判定。覆盖不到的事实,要靠人工抽检补,但不能用抽检替代自动验收。

与卷一 6.2、3.2 的分工

卷一 6.2 在“已经拼好的上下文”上做预算裁剪,回答“超了先丢谁”;本节在“拼装之前”做压缩,回答“哪些内容根本不值得进入候选集”。6.2 的输出是一份放得下的上下文,本节的输出是一组带丢弃原因与验收结果的片段。

3.2 改变的是顺序,不改变内容;本节改变的是内容,不改变顺序。两者可以叠加:先压缩,再把稳定片段排到前面做前缀缓存。顺序不能反:先缓存再压缩,压缩会让已建立的前缀全部失效。

实现

src/perf/compression.ts 实现五步流水线:校验、去重、历史摘要、相关性裁剪、全局预算,最后输出保留片段、丢弃原因与事实验收。它不调用模型,所有判断都是确定性的。

// file: src/perf/compression.ts
// tier: L2
// verify: node --test test/perf/compression.test.ts

import { AiError } from "../core/errors.ts";
import { estimateTokens } from "../prompt/context-budget.ts";

export const SEGMENT_KINDS = ["retrieval", "history", "tool"] as const;
export type SegmentKind = (typeof SEGMENT_KINDS)[number];

export interface SourceSegment {
  id: string;
  kind: SegmentKind;
  text: string;
  /** 相关性分数,0 到 1;由检索或规则给出,压缩阶段不重新打分。 */
  score: number;
  /** 越大越新,用于历史保留最近若干条。 */
  recency: number;
  /** 订单号、金额、日期等事实所在的片段,任何情况下都不能被静默丢弃。 */
  mustKeep?: boolean;
}

export interface CompressionPolicy {
  maxTokens: number;
  minScore: number;
  /** 近似重复的 Jaccard 阈值。 */
  duplicateThreshold: number;
  /** 历史只保留最近几条原文,更早的走抽取式摘要。 */
  recentHistory: number;
  summaryChars: number;
}

export const DEFAULT_POLICY: CompressionPolicy = {
  maxTokens: 480,
  minScore: 0.35,
  duplicateThreshold: 0.7,
  recentHistory: 2,
  summaryChars: 120,
};

export type DropReason = "duplicate" | "low-score" | "summarized" | "global-budget";

export interface KeptSegment {
  id: string;
  kind: SegmentKind;
  text: string;
  tokens: number;
  score: number;
}

export interface DroppedSegment {
  id: string;
  kind: SegmentKind;
  tokens: number;
  reason: DropReason;
  duplicateOf?: string;
}

export interface CompressionResult {
  kept: KeptSegment[];
  dropped: DroppedSegment[];
  beforeTokens: number;
  afterTokens: number;
  /** 保留比例,after / before。 */
  ratio: number;
}

function fail(message: string, kind: "invalid-request" | "budget-exceeded" = "invalid-request"): never {
  throw new AiError(kind, `compression: ${message}`);
}

function validate(segments: readonly SourceSegment[], policy: CompressionPolicy): void {
  if (segments.length === 0) fail("至少需要一个片段");
  if (!Number.isInteger(policy.maxTokens) || policy.maxTokens <= 0) fail("maxTokens 必须是正整数");
  if (!(policy.minScore >= 0 && policy.minScore <= 1)) fail("minScore 必须落在 [0, 1]");
  if (!(policy.duplicateThreshold > 0 && policy.duplicateThreshold <= 1)) {
    fail("duplicateThreshold 必须落在 (0, 1]");
  }
  if (!Number.isInteger(policy.recentHistory) || policy.recentHistory < 0) fail("recentHistory 必须是非负整数");
  if (!Number.isInteger(policy.summaryChars) || policy.summaryChars < 0) fail("summaryChars 必须是非负整数");
  const seen = new Set<string>();
  for (const segment of segments) {
    if (segment.id.trim() === "") fail("片段 id 不能为空");
    if (seen.has(segment.id)) fail(`片段 id 重复:${segment.id}`);
    seen.add(segment.id);
    if (!SEGMENT_KINDS.includes(segment.kind)) fail(`未知片段类型:${String(segment.kind)}`);
    if (segment.text.trim() === "") fail(`片段 ${segment.id} 的 text 不能为空`);
    if (!Number.isFinite(segment.score) || segment.score < 0 || segment.score > 1) {
      fail(`片段 ${segment.id} 的 score 必须落在 [0, 1]`);
    }
    if (!Number.isFinite(segment.recency)) fail(`片段 ${segment.id} 的 recency 必须是有限数`);
  }
}

/** 归一化只用于比较:全半角、大小写、空白与标点不承担语义。 */
export function normalizeForMatch(text: string): string {
  return text.normalize("NFKC").toLowerCase().replace(/[\s,。!?、;:,.!?;:'"“”‘’()()\[\]【】<>《》·\-—_/\\]+/g, "");
}

function trigrams(text: string): Set<string> {
  const chars = Array.from(normalizeForMatch(text));
  const grams = new Set<string>();
  if (chars.length <= 3) {
    if (chars.length > 0) grams.add(chars.join(""));
  } else {
    for (let index = 0; index + 3 <= chars.length; index += 1) {
      grams.add(chars.slice(index, index + 3).join(""));
    }
  }
  return grams;
}

/** 字符三元组 Jaccard:便宜、确定,足够识别检索结果里的重复段落。 */
function similarity(left: string, right: string): number {
  const a = trigrams(left);
  const b = trigrams(right);
  if (a.size === 0 || b.size === 0) return 0;
  let shared = 0;
  for (const gram of a) if (b.has(gram)) shared += 1;
  return shared / (a.size + b.size - shared);
}

interface WorkingSegment extends SourceSegment {
  tokens: number;
  position: number;
}

function firstSentence(text: string): string {
  const sentence = (text.match(/^[^。!?!?;;]+[。!?!?;;]?/u)?.[0] ?? text).trim();
  return Array.from(sentence).slice(0, 60).join("");
}

/**
 * 压缩流水线:保护事实、去重、历史摘要、相关性裁剪、全局预算。
 * 每一步都记录丢弃原因,结果可以被逐条复核。
 */
export function compressContext(
  segments: readonly SourceSegment[],
  policy: CompressionPolicy = DEFAULT_POLICY,
): CompressionResult {
  validate(segments, policy);
  const working: WorkingSegment[] = segments.map((segment, position) => ({
    ...segment,
    tokens: estimateTokens(segment.text),
    position,
  }));
  const dropped: DroppedSegment[] = [];
  const beforeTokens = working.reduce((sum, segment) => sum + segment.tokens, 0);

  // 1. 去重:mustKeep 先参与比较,避免事实片段被“分数更高的近似文本”挤掉。
  const survivors: WorkingSegment[] = [];
  const order = [...working].sort((left, right) => {
    if (Boolean(left.mustKeep) !== Boolean(right.mustKeep)) return left.mustKeep ? -1 : 1;
    return right.score - left.score || right.recency - left.recency || left.id.localeCompare(right.id);
  });
  for (const segment of order) {
    const duplicate = survivors.find(
      (kept) => kept.kind === segment.kind && similarity(kept.text, segment.text) >= policy.duplicateThreshold,
    );
    if (duplicate) {
      dropped.push({ id: segment.id, kind: segment.kind, tokens: segment.tokens, reason: "duplicate", duplicateOf: duplicate.id });
    } else {
      survivors.push(segment);
    }
  }

  // 2. 历史摘要:最近的保留原文,更早的抽取首句合成一条摘要。
  const recent = survivors
    .filter((segment) => segment.kind === "history" && !segment.mustKeep)
    .sort((left, right) => right.recency - left.recency)
    .slice(0, policy.recentHistory);
  const recentIds = new Set(recent.map((segment) => segment.id));
  const old = survivors
    .filter((segment) => segment.kind === "history" && !segment.mustKeep && !recentIds.has(segment.id))
    .sort((left, right) => left.recency - right.recency);
  const oldIds = new Set(old.map((segment) => segment.id));
  const active = survivors.filter((segment) => !oldIds.has(segment.id));
  if (old.length > 0 && policy.summaryChars > 0) {
    const text = Array.from(old.map((segment) => firstSentence(segment.text)).join(";"))
      .slice(0, policy.summaryChars)
      .join("");
    if (text.trim() !== "") {
      active.push({
        id: "history-summary",
        kind: "history",
        text,
        score: Math.max(...old.map((segment) => segment.score)),
        recency: Math.max(...old.map((segment) => segment.recency)),
        tokens: estimateTokens(text),
        position: Math.min(...old.map((segment) => segment.position)),
      });
    }
  }
  for (const segment of old) {
    dropped.push({ id: segment.id, kind: "history", tokens: segment.tokens, reason: "summarized" });
  }

  // 3. 相关性阈值;mustKeep 绕过阈值,事实优先于分数。
  const afterQuota: WorkingSegment[] = [];
  for (const segment of active) {
    if (segment.mustKeep || segment.score >= policy.minScore) {
      afterQuota.push(segment);
    } else {
      dropped.push({ id: segment.id, kind: segment.kind, tokens: segment.tokens, reason: "low-score" });
    }
  }

  // 4. 全局预算:先丢低分普通片段,mustKeep 放不下时直接失败。
  let total = afterQuota.reduce((sum, segment) => sum + segment.tokens, 0);
  const removed = new Set<string>();
  const droppable = afterQuota
    .filter((segment) => !segment.mustKeep)
    .sort((left, right) => left.score - right.score || left.recency - right.recency || left.id.localeCompare(right.id));
  for (const segment of droppable) {
    if (total <= policy.maxTokens) break;
    removed.add(segment.id);
    total -= segment.tokens;
    dropped.push({ id: segment.id, kind: segment.kind, tokens: segment.tokens, reason: "global-budget" });
  }
  if (total > policy.maxTokens) {
    fail(`mustKeep 片段需要 ${total} token,超过预算 ${policy.maxTokens}`, "budget-exceeded");
  }

  return {
    kept: afterQuota
      .filter((segment) => !removed.has(segment.id))
      .sort((left, right) => left.position - right.position || left.id.localeCompare(right.id))
      .map((segment) => ({
        id: segment.id,
        kind: segment.kind,
        text: segment.text,
        tokens: segment.tokens,
        score: segment.score,
      })),
    dropped: dropped.sort((left, right) => left.id.localeCompare(right.id)),
    beforeTokens,
    afterTokens: total,
    ratio: beforeTokens === 0 ? 0 : total / beforeTokens,
  };
}

export interface FactProbe {
  id: string;
  text: string;
}

export interface CompressionAudit {
  passed: boolean;
  probes: { id: string; found: boolean }[];
  missing: string[];
}

/**
 * 压缩验收不能只看 token 降了多少:预先列出必须存活的事实,
 * 逐条在保留文本里查找。找不到就是信息丢失,不是“摘要得更简洁”。
 */
export function auditCompression(result: CompressionResult, probes: readonly FactProbe[]): CompressionAudit {
  const haystack = normalizeForMatch(result.kept.map((segment) => segment.text).join("\n"));
  const results = probes.map((probe) => {
    const needle = normalizeForMatch(probe.text);
    if (needle === "") fail(`事实探针 ${probe.id} 的 text 不能为空`);
    return { id: probe.id, found: haystack.includes(needle) };
  });
  const missing = results.filter((item) => !item.found).map((item) => item.id);
  return { passed: missing.length === 0, probes: results, missing };
}

compressContext 的每一步都往 dropped 里写原因,而不是只返回最终片段。原因字段让“为什么这条没了”可以被逐条回答。auditCompression 只返回报告,不抛异常;是否把信息丢失当成发布阻断,由调用方决定。需要强阻断时,检查 passed 后自行抛出业务错误即可。

测试与运行结果

node --test test/perf/compression.test.ts

本机一次运行的输出摘要:

ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 152.699417

测试覆盖:完整样本的 767→415 与九条保留片段、四类丢弃原因计数、六个探针全部通过、去掉保护标记后三个探针失败、预算 300 时全局丢弃顺序,以及 mustKeep 自身超预算时抛 budget-exceeded。最后一条是关键边界:宁可让请求失败,也不静默截断受保护的事实。

小结

压缩的价值不只是 token 变少,而是每一笔删除都有原因、每一个关键事实都有验收。去重、摘要、相关性裁剪、全局预算各有风险;mustKeep 保护硬事实,事实探针证明保护有效。没有验收的压缩,本质上是把信息丢失的风险藏进了更短的提示里。

下一章从输入转向输出:输出上限、停止条件与提前终止,讨论怎样让模型少说废话而不少给答案。

阅读导航:上一节:3.2 前缀缓存与提示结构 · 下一节:4.1 输出上限与停止条件 。

常见错误与边界

  • 只看 token 降幅,不看事实是否还在。省得越多,越需要验收。
  • 把摘要当成无损压缩。抽取式会丢细节,模型摘要也可能改写数字。
  • 去重阈值凭感觉定。过低会合并不同事实,过高则省不下多少。
  • 相关性分数当成事实重要性。分数低不代表这条事实可以丢。
  • 全局预算先丢“看起来长”的片段。长度不等于价值。
  • 没有 mustKeep 之类的保护标记。关键事实会和普通片段一起参与竞争。
  • 探针只覆盖容易匹配的词。订单号、金额、否定条件才是有判别力的探针。
  • 验收失败后调低探针标准。那是把门禁改成装饰。
  • 压缩后再建前缀缓存。内容一变,前缀全部失效,顺序应该反过来。
  • 把示例摘要器当成生产方案。它只用于建立口径,替换实现后要重跑验收。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查