一次“省了三成”的评审
「回声」的推理账单连续两个月上涨。团队提出把一条非核心能力从 16 位降到 8 位:压测里 P95 从 1160ms 降到 953ms,单次成本大约省三成。评审材料只有一张表:平均质量分从 0.93 降到 0.90,结论是“可接受”。
这个结论跳过了三个问题。第一,0.90 是哪些样本的平均值,长尾掉得有多深。第二,哪些能力根本不允许降到 8 位。第三,验收样本里有没有覆盖金额、单号这类逐字符事实。量化不是把模型调暗一点,它是在用一个更粗的数值表示换取延迟与成本,代价必须逐条测出来。
本节只讨论三个档位:不量化、8 位、4 位。文中所有延迟、成本与质量数字都标注为「示例样本上的运行结果」,由确定性 mock 产生,不是任何厂商的量化参数,也不是生产实测。
量化改的不只是位宽
| 档位 | 位宽 | 延迟系数 | 显存系数 | 质量惩罚倍率 |
|---|---|---|---|---|
| none | 16 | 1.00 | 1.00 | 0 |
| w8 | 8 | 0.82 | 0.55 | 1.0 |
| w4 | 4 | 0.68 | 0.35 | 2.2 |
三个系数回答三个不同的问题。延迟系数决定交互是否更快,显存系数决定能不能装进同一张卡或同一台机器,质量惩罚倍率决定精度损失的量级。它们不能互相替代:延迟降了 18%,不代表吞吐也降 18%,更不代表质量只降 18%。
精度损失必须配对测量
量化前后不能用两批样本各算一个平均分再相减。两批样本难度不同,差值里混进了抽样误差。正确做法是同一批样本、同一提示、同一评分器,逐条记录未量化分与量化分,再对差值做统计。
| 指标 | 定义 | 回答的问题 |
|---|---|---|
| 平均下降 | 每条样本差值取均值 | 整体让了多少 |
| P05 下降 | 第 5 百分位质量分的差 | 长尾掉得多深 |
| 最差单条 | 最大单条下降 | 有没有极端个例 |
| 灾难性比例 | 单条下降 ≥ 0.12 的占比 | 会不会出现不可用输出 |
| 关键样本失败 | 关键样本是否灾难性下降 | 业务能不能接受 |
平均值最容易骗人。24 条样本平均只降 0.0299,看起来温和;但 P05 降了 0.0424,最差一条降了 0.0640,说明损失集中在少数难样本上。如果这些难样本正好是退款、合规或代码缺陷,平均值再好看也不能上线。
哪些能力不能量化
| 能力 | 敏感度 | 最高档位 | 理由 |
|---|---|---|---|
| search.rerank | low | w4 | 排序偏好可容忍小幅扰动 |
| chat.reply | medium | w4 | 自然语言允许措辞差异 |
| doc.summarize | medium | w4 | 摘要允许改述,事实必须存活 |
| code.review | high | w8 | 缺陷定位依赖稳定推理 |
| finance.extract | high | none | 金额、单号必须逐字符一致 |
| safety.classify | high | none | 安全误判不可逆 |
准入规则不是“平均分达标就放行”,而是先按能力判断档位是否允许。财务抽取和安全分类直接禁止量化;代码评审最多到 8 位;只有排序、闲聊与摘要可以尝试 4 位。未登记的新能力默认禁止,逼着团队显式给出理由,而不是继承一个宽松默认值。
验收集怎么建
示例用 24 条样本:4 个可量化能力各 6 条,分数、延迟与成本都可由索引复算。另加 6 条财务抽取作为“准入探针”,它不参与主验收,只用来验证能力规则真的会拦截。
样本量门槛写成常量:总数至少 20 条,每个能力至少 4 条。低于门槛时结论不是“通过”,而是“证据不足”。验收还必须包含关键样本;关键样本一旦灾难性下降,即使平均值达标也拒绝。
示例样本上的运行结果
8 位量化在 24 条可量化样本上的结果:
| 指标 | 结果 |
|---|---|
| 未量化平均分 | 0.9308 |
| 量化后平均分 | 0.9009 |
| 平均下降 | 0.0299 |
| P05 下降 | 0.0424 |
| 最差单条下降 | 0.0640 |
| 灾难性比例 | 0 |
| P95 延迟 | 1160ms → 953ms(0.822) |
| 总成本 | 13440 → 9412 微元(0.700) |
| 结论 | 通过 |
分能力看,排序降 0.0125、闲聊降 0.0244、摘要降 0.0260、代码评审降 0.0568。越依赖长链推理,损失越明显。代码评审虽然没有灾难性样本,但已经逼近 0.08 的单条容差。
同一档位加入 6 条财务抽取后,平均下降变成 0.0344,指标本身仍在阈值内,但结论被能力规则直接拒绝:finance.extract 最高只允许 none。这正是能力准入的价值:它不让“平均值还行”覆盖“这个能力不能碰”。
4 位量化的结果更直接:平均下降 0.0658,P05 下降 0.1053,灾难性比例 0.125,代码评审 6 条里 3 条越线;P95 延迟虽然降到 0.682,成本降到 0.450,仍被四条理由同时拒绝。
实现
模块包含档位表、能力规则、逐条配对评估、能力聚合与验收决策。它不访问网络,也不调用模型;输入是一批已经打好分的样本。
// file: src/perf/compress.ts
// tier: L2
// verify: node --test test/perf/compress.test.ts
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
/** 档位只描述位宽与相对开销,不绑定任何厂商的量化格式。 */
export const QUANT_LEVELS = ["none", "w8", "w4"] as const;
export type QuantLevel = (typeof QUANT_LEVELS)[number];
export interface QuantProfile {
level: QuantLevel; bits: number; latencyFactor: number; memoryFactor: number;
/** 质量惩罚倍率:0 表示不量化。 */
penaltyScale: number;
}
export const QUANT_PROFILES: Record<QuantLevel, QuantProfile> = {
none: { level: "none", bits: 16, latencyFactor: 1, memoryFactor: 1, penaltyScale: 0 },
w8: { level: "w8", bits: 8, latencyFactor: 0.82, memoryFactor: 0.55, penaltyScale: 1 },
w4: { level: "w4", bits: 4, latencyFactor: 0.68, memoryFactor: 0.35, penaltyScale: 2.2 },
};
export type Sensitivity = "low" | "medium" | "high";
export interface CapabilityQuantRule {
capability: string;
sensitivity: Sensitivity;
/** 允许的最激进档位;none 表示该能力禁止量化。 */
maxLevel: QuantLevel;
reason: string;
}
export const DEFAULT_QUANT_RULES: Record<string, CapabilityQuantRule> = {
"search.rerank": { capability: "search.rerank", sensitivity: "low", maxLevel: "w4", reason: "排序偏好可容忍小幅扰动" },
"chat.reply": { capability: "chat.reply", sensitivity: "medium", maxLevel: "w4", reason: "自然语言回答允许措辞差异" },
"doc.summarize": { capability: "doc.summarize", sensitivity: "medium", maxLevel: "w4", reason: "摘要允许改述,事实必须存活" },
"code.review": { capability: "code.review", sensitivity: "high", maxLevel: "w8", reason: "缺陷定位依赖稳定推理" },
"finance.extract": { capability: "finance.extract", sensitivity: "high", maxLevel: "none", reason: "金额、单号必须逐字符一致" },
"safety.classify": { capability: "safety.classify", sensitivity: "high", maxLevel: "none", reason: "安全误判不可逆" },
};
const PENALTY: Record<Sensitivity, number> = { low: 0.012, medium: 0.026, high: 0.055 };
const RANK: Record<QuantLevel, number> = { none: 0, w8: 1, w4: 2 };
export interface QuantQualitySample {
id: string;
capability: string;
/** 未量化时的质量分,0 到 1,来自同一套离线评测。 */
baselineScore: number;
baselineLatencyMs: number;
baselineCostMicros: number;
critical?: boolean;
}
export interface QuantSampleResult {
id: string; capability: string; critical: boolean;
baselineScore: number; quantizedScore: number; drop: number;
baselineLatencyMs: number; quantizedLatencyMs: number;
baselineCostMicros: number; quantizedCostMicros: number; catastrophic: boolean;
}
export interface CapabilityQuantSummary {
capability: string; samples: number; baselineMean: number;
quantizedMean: number; meanDrop: number; catastrophic: number;
}
export interface BlockedCapability { capability: string; maxLevel: QuantLevel; reason: string }
export interface QuantAcceptancePolicy {
minSamples: number; minSamplesPerCapability: number; maxMeanDrop: number;
maxP05Drop: number; maxCatastrophicRate: number; maxLatencyP95Ratio: number; maxCostRatio: number;
}
export const DEFAULT_ACCEPTANCE: QuantAcceptancePolicy = {
minSamples: 20,
minSamplesPerCapability: 4,
maxMeanDrop: 0.04,
maxP05Drop: 0.08,
maxCatastrophicRate: 0.01,
maxLatencyP95Ratio: 0.9,
maxCostRatio: 0.8,
};
export interface QuantEvaluation {
level: QuantLevel; profile: QuantProfile; samples: QuantSampleResult[];
baselineMean: number; quantizedMean: number; meanDrop: number;
baselineP05: number; quantizedP05: number; p05Drop: number; worstDrop: number;
catastrophicRate: number; baselineP95LatencyMs: number; p95LatencyMs: number; latencyP95Ratio: number;
baselineCostMicros: number; quantizedCostMicros: number; costRatio: number;
byCapability: Record<string, CapabilityQuantSummary>;
blockedCapabilities: BlockedCapability[];
decision: { accepted: boolean; reasons: string[] };
}
function fail(message: string): never {
throw new AiError("invalid-request", `compress: ${message}`);
}
function fnv1a(text: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
function mean(values: readonly number[]): number {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
}
/** 未登记的能力默认禁止量化:新能力必须显式给规则,而不是继承宽松默认值。 */
export function ruleFor(capability: string): CapabilityQuantRule {
return DEFAULT_QUANT_RULES[capability] ?? {
capability, sensitivity: "high", maxLevel: "none", reason: "未登记的能力默认禁止量化",
};
}
export function checkQuantizable(capability: string, level: QuantLevel): { allowed: boolean; reason: string } {
const rule = ruleFor(capability);
const allowed = RANK[level] <= RANK[rule.maxLevel];
return {
allowed,
reason: allowed
? `${capability} 允许 ${level}:${rule.reason}`
: `${capability} 最高只允许 ${rule.maxLevel},目标 ${level} 被拒绝:${rule.reason}`,
};
}
function validate(sample: QuantQualitySample): void {
if (sample.id.trim() === "" || sample.capability.trim() === "") fail("样本 id 与 capability 不能为空");
if (!(sample.baselineScore >= 0 && sample.baselineScore <= 1)) fail(`样本 ${sample.id} 的 baselineScore 必须落在 [0, 1]`);
if (!Number.isFinite(sample.baselineLatencyMs) || sample.baselineLatencyMs <= 0) fail(`样本 ${sample.id} 的延迟必须是正数`);
if (!Number.isInteger(sample.baselineCostMicros) || sample.baselineCostMicros < 0) fail(`样本 ${sample.id} 的成本必须是非负整数`);
}
/** 质量损失用确定性模型模拟:能力敏感度 × 档位倍率 × 样本噪声。 */
function simulate(sample: QuantQualitySample, profile: QuantProfile): QuantSampleResult {
const rule = ruleFor(sample.capability);
const noise = 0.8 + (fnv1a(`${sample.id}:quality`) % 401) / 1000;
const penalty = PENALTY[rule.sensitivity] * profile.penaltyScale * noise;
const quantizedScore = Math.min(1, Math.max(0, sample.baselineScore - penalty));
const quantizedLatencyMs =
Math.round(sample.baselineLatencyMs * profile.latencyFactor) + (fnv1a(`${sample.id}:latency`) % 5);
const costFactor = profile.level === "w4" ? 0.45 : profile.level === "w8" ? 0.7 : 1;
const drop = sample.baselineScore - quantizedScore;
return {
id: sample.id,
capability: sample.capability,
critical: sample.critical === true,
baselineScore: sample.baselineScore,
quantizedScore,
drop,
baselineLatencyMs: sample.baselineLatencyMs,
quantizedLatencyMs,
baselineCostMicros: sample.baselineCostMicros,
quantizedCostMicros: Math.round(sample.baselineCostMicros * costFactor),
catastrophic: drop >= 0.12,
};
}
function decide(
data: Omit<QuantEvaluation, "decision" | "blockedCapabilities">,
policy: QuantAcceptancePolicy,
blocked: readonly BlockedCapability[],
): QuantEvaluation["decision"] {
const reasons: string[] = [];
if (blocked.length > 0) reasons.push(`能力不允许该档位:${blocked.map((item) => item.capability).join(",")}`);
if (data.samples.length < policy.minSamples) reasons.push(`样本不足:${data.samples.length}/${policy.minSamples}`);
for (const summary of Object.values(data.byCapability)) {
if (summary.samples < policy.minSamplesPerCapability) {
reasons.push(`能力 ${summary.capability} 样本不足:${summary.samples}/${policy.minSamplesPerCapability}`);
}
}
if (data.meanDrop > policy.maxMeanDrop) reasons.push(`平均质量下降 ${data.meanDrop.toFixed(4)} > ${policy.maxMeanDrop}`);
if (data.p05Drop > policy.maxP05Drop) reasons.push(`P05 质量下降 ${data.p05Drop.toFixed(4)} > ${policy.maxP05Drop}`);
if (data.catastrophicRate > policy.maxCatastrophicRate) {
reasons.push(`灾难性样本比例 ${data.catastrophicRate.toFixed(4)} > ${policy.maxCatastrophicRate}`);
}
if (data.latencyP95Ratio > policy.maxLatencyP95Ratio) {
reasons.push(`P95 延迟比例 ${data.latencyP95Ratio.toFixed(3)} > ${policy.maxLatencyP95Ratio}`);
}
if (data.costRatio > policy.maxCostRatio) reasons.push(`成本比例 ${data.costRatio.toFixed(3)} > ${policy.maxCostRatio}`);
if (data.samples.some((sample) => sample.critical && sample.catastrophic)) {
reasons.push("关键样本出现灾难性质量下降");
}
return { accepted: reasons.length === 0, reasons };
}
/** 同一批样本配对评估:每条样本同时有未量化分与量化分,差值可逐条复算。 */
export function evaluateQuantization(
samples: readonly QuantQualitySample[],
level: QuantLevel,
policy: QuantAcceptancePolicy = DEFAULT_ACCEPTANCE,
): QuantEvaluation {
if (samples.length === 0) fail("至少需要一条评测样本");
const profile = QUANT_PROFILES[level];
if (profile === undefined) fail(`未知量化档位:${String(level)}`);
for (const sample of samples) validate(sample);
const blockedMap = new Map<string, BlockedCapability>();
for (const sample of samples) {
const rule = ruleFor(sample.capability);
if (RANK[level] > RANK[rule.maxLevel]) {
blockedMap.set(sample.capability, { capability: sample.capability, maxLevel: rule.maxLevel, reason: rule.reason });
}
}
const results = samples.map((sample) => simulate(sample, profile));
const groups = new Map<string, QuantSampleResult[]>();
for (const result of results) {
const bucket = groups.get(result.capability) ?? [];
bucket.push(result);
groups.set(result.capability, bucket);
}
const byCapability: Record<string, CapabilityQuantSummary> = {};
for (const [capability, bucket] of groups) {
const baselineMean = mean(bucket.map((item) => item.baselineScore));
const quantizedMean = mean(bucket.map((item) => item.quantizedScore));
byCapability[capability] = {
capability, samples: bucket.length, baselineMean, quantizedMean,
meanDrop: baselineMean - quantizedMean,
catastrophic: bucket.filter((item) => item.catastrophic).length,
};
}
const baselineScores = results.map((item) => item.baselineScore);
const quantizedScores = results.map((item) => item.quantizedScore);
const baselineP05 = percentile(baselineScores, 0.05);
const quantizedP05 = percentile(quantizedScores, 0.05);
const baselineP95LatencyMs = percentile(results.map((item) => item.baselineLatencyMs), 0.95);
const p95LatencyMs = percentile(results.map((item) => item.quantizedLatencyMs), 0.95);
const baselineCostMicros = results.reduce((sum, item) => sum + item.baselineCostMicros, 0);
const quantizedCostMicros = results.reduce((sum, item) => sum + item.quantizedCostMicros, 0);
const baselineMean = mean(baselineScores);
const quantizedMean = mean(quantizedScores);
const catastrophic = results.filter((item) => item.catastrophic).length;
const data = {
level, profile, samples: results, baselineMean, quantizedMean,
meanDrop: baselineMean - quantizedMean,
baselineP05, quantizedP05, p05Drop: baselineP05 - quantizedP05,
worstDrop: Math.max(...results.map((item) => item.drop)),
catastrophicRate: catastrophic / results.length,
baselineP95LatencyMs, p95LatencyMs,
latencyP95Ratio: p95LatencyMs / baselineP95LatencyMs,
baselineCostMicros, quantizedCostMicros,
costRatio: quantizedCostMicros / baselineCostMicros,
byCapability,
};
return { ...data, blockedCapabilities: [...blockedMap.values()], decision: decide(data, policy, [...blockedMap.values()]) };
}
evaluateQuantization 先检查能力是否允许目标档位,再做配对模拟。被拦截的能力仍然会出现在 blockedCapabilities 里,方便评审看到“不是指标没过,是规则不许”。
drop 定义为未量化分减量化分,正数表示质量下降,避免“提升/下降”在不同函数里各有一套符号。P05 用最近秩法,和基线章节保持同一口径。灾难性阈值 0.12 是业务常量,不是统计显著性;团队可以调整,但必须和关键样本清单一起版本化。
测试与运行结果
测试覆盖 8 位通过、4 位拒绝、加入财务抽取后能力拦截、关键样本灾难性下降、能力规则、收紧阈值、重复评估一致性与空样本报错。执行:
node --test test/perf/compress.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 8
ℹ pass 8
ℹ fail 0
测试没有断言“8 位一定好”,而是断言同一份样本上的具体差值、P05、灾难性比例与成本比例。这样换一台机器、换一版 mock,结论是否变化会立刻暴露。
灰度顺序
第一步是影子流量:线上请求照常走原模型,同时把同一批输入送进量化候选,只记录配对分数,不影响用户。第二步按能力白名单放量,先排序与摘要,再闲聊,代码评审只到 8 位。第三步才是按租户放量,每个租户单独看 P05 与关键样本。
回滚条件要提前写:P05 下降超过 0.08、出现一条关键样本灾难性下降、或成本没有降到预期的 80% 以下,任何一条触发就回到上一档。灰度不是“上线后观察”,而是每一步都有可判定的退出条件。
小结
量化的取舍可以压缩成三个判断:目标档位在目标硬件上带来多少延迟与成本收益,同一批样本上的质量损失分布如何,这个能力是否允许量化。三者缺一不可。示例样本上,8 位在 24 条可量化样本上通过,但加入财务抽取后立刻被能力规则拒绝;4 位则连平均值与灾难性比例都过不了。
下一节讨论另一种替代:不改变数值精度,而是把教师模型的能力蒸馏到一个更小的学生模型,再决定哪些能力可以替换、哪些必须回退。
阅读导航:上一节:5.3 缓存失效与一致性 · 下一节:6.2 蒸馏与小模型替代 。
常见错误与边界
- 用两批样本各算平均分再相减。差值里混进抽样误差,结论不可复算。
- 只看平均值,不看 P05 与最差单条。损失集中在少数难样本时,平均值会掩盖风险。
- 先上量化再补验收集。没有基线分数,事后无法证明损失大小。
- 对逐字符事实使用概率性精度。金额、单号、日期没有“差不多对”。
- 对安全分类做量化。误判不可逆,省下的成本抵不过一次事故。
- 用未登记能力继承默认档位。新能力必须显式给出上限与理由。
- 把灾难性阈值当成统计显著性。它是业务容差,需要和关键样本一起评审。
- 把示例 mock 的数字写成生产实测。所有数字必须标注来源与样本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。