压缩省下的钱,可能是拿事实换的
「回声」把检索条数从 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-id | A1024 | 通过 | 通过 |
| refund-no | R7788 | 通过 | 丢失 |
| amount | 129 元 | 通过 | 丢失 |
| sla | 三个工作日 | 通过 | 丢失 |
| tracking | SF1234567890 | 通过 | 通过 |
| 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之类的保护标记。关键事实会和普通片段一起参与竞争。 - 探针只覆盖容易匹配的词。订单号、金额、否定条件才是有判别力的探针。
- 验收失败后调低探针标准。那是把门禁改成装饰。
- 压缩后再建前缀缓存。内容一变,前缀全部失效,顺序应该反过来。
- 把示例摘要器当成生产方案。它只用于建立口径,替换实现后要重跑验收。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。