一次摘要把 P99 拉高
「回声」的 200 条示例请求平均输出 257.94 token,看起来不算长。但按能力拆开,会话摘要的 P50 是 805、P99 是 1,193;把全部请求排开,最长的 5% 贡献了 20.13% 的输出 token。团队一直按平均值做预算,于是每次容量评审都偏低,月底账单总比模型估算高两成。
长度对成本的影响有两层。第一层是线性的:多写一个 token,就多付一个 token 的钱,多花一点解码时间。第二层是非线性的:输出越长,撞上截断阈值、触发重生成或人工返工的概率越高,期望成本在某个长度之后开始加速上升。
文中数字除标注为本机测试输出外,都是案例假设值。
长度分布先于平均值
平均值把两个峰抹平了:智能回复集中在 150 token 附近,摘要集中在 862 token 附近,平均 257.94 既不描述前者,也不描述后者。做长度治理,先看四个数:P50 看典型体验,P90 看产品承诺,P99 看预算尾部,最大 5% 的 token 占比看治理收益。
示例样本的总体分布:
| 指标 | 结果 |
|---|---|
| 样本数 | 200 |
| 总 token | 51,588 |
| 平均 | 257.94 |
| P50 / P90 / P95 / P99 | 180 / 487 / 805 / 1,169 |
| 最大 | 1,193 |
| 最长 5% 的 token 占比 | 20.13% |
同一份样本按能力拆开,差异立刻出现:
| 能力 | 请求数 | P50 | P90 | P99 | 最大 | 最长 5% 占比 |
|---|---|---|---|---|---|---|
| chat.reply | 140 | 151 | 204 | 217 | 219 | 7.17% |
| ticket.suggest | 40 | 340 | 459 | 487 | 487 | 7.26% |
| summary.long | 20 | 805 | 1,169 | 1,193 | 1,193 | 6.92% |
如果不拆能力,summary.long 会把整体 P99 拉到 1,169,团队会误以为私信回复也需要 1,200 token 的预算,于是把所有能力的 max_tokens 一起调大。
成本曲线:阈值之后非线性
延迟可以拆成两段:首 token 的固定开销,加上按解码速度线性增长的生成时间。成本在截断阈值之前也是线性的;超过阈值后,截断、修复或重生成的概率上升,期望成本按概率叠加一次重生成。
以首 token 120ms、每秒 50 token、每 token 2 微元、阈值 400 token、每多 100 token 增加 2% 失败概率、失败后成本翻倍为例:
| 输出 token | 延迟 | 线性成本 | 失败概率 | 期望成本 | 非线性溢价 |
|---|---|---|---|---|---|
| 200 | 4,120ms | 400 微元 | 0 | 400 微元 | 0 |
| 400 | 8,120ms | 800 微元 | 0 | 800 微元 | 0 |
| 800 | 16,120ms | 1,600 微元 | 8% | 1,728 微元 | 128 微元 |
| 1,200 | 24,120ms | 2,400 微元 | 16% | 2,784 微元 | 384 微元 |
这张表说明两件事。第一,延迟仍然近似线性,长输出的慢主要是“解码久”,不是“越写越慢”。第二,成本在阈值后出现溢价,溢价来自失败后的重生成,而不是单价变贵。把重试率单独建模,才能解释为什么长度翻三倍,期望成本涨得比三倍多。
长尾治理的三个手段
第一个手段是硬上限。把超过某个长度的输出截断,直接省 token,但会损失信息。示例样本上试算:
| 上限 | 受影响请求 | 受影响比例 | 省下 token | 省下微元 | 占总 token |
|---|---|---|---|---|---|
| 805(P95) | 10 | 5.00% | 2,336 | 4,672 | 4.53% |
| 1,169(P99) | 2 | 1.00% | 45 | 90 | 0.09% |
P95 上限影响 10 条请求,省下 4.53% 的 token;P99 上限只影响 2 条,省下 0.09%。收益集中在 P95 到 P99 之间,但这一段也最可能损失信息。上限设在哪,取决于业务能接受多少条答案被改写,而不是只看省下多少 token。
第二个手段是软目标。把“P90 不超过多少 token”写进提示与评测,让模型在正常路径上收敛,而不是靠截断兜底。第三个手段是提前停止,也就是 4.2 的置信度停止:在答案已经足够时停下,比事后截断更不伤内容。
按能力设长度目标
目标必须按能力签,且同时写 P90 与 P99。示例样本上:
| 能力 | P90 目标 | P90 实际 | P99 上限 | P99 实际 | 结论 |
|---|---|---|---|---|---|
| chat.reply | 220 | 204 | 220 | 217 | 通过 |
| ticket.suggest | 450 | 459 | 500 | 487 | P90 未过 |
| summary.long | 1,000 | 1,169 | 1,000 | 1,193 | 未通过 |
ticket.suggest 的 P90 只超了 9 token,但这不是“差不多就行”:P90 目标对应产品承诺,超了就是超了。处理方式有两种:调提示让建议更短,或者把目标改成 460 并记录理由。两种都可以,唯独不能既不改目标也不改实现,让门禁长期飘红。
summary.long 的两个指标都未过,说明这个能力的长度已经超出当前预算假设。它能继续上线,但必须单独核算,不能和短回复共用一套容量与成本模型。
实现
代码分成三块:分布摘要、按能力设目标、长度成本与截断试算。输入是一组已经结算好的 token 数,不读取日志,也不访问网络。
// file: src/perf/output-length.ts
// tier: L1
// verify: node --test test/perf/output-length.test.ts
import { AiError } from "../core/errors.ts";
export interface LengthSample {
capability: string;
tokens: number;
}
export interface LengthSummary {
samples: number;
totalTokens: number;
meanTokens: number;
p50Tokens: number;
p90Tokens: number;
p95Tokens: number;
p99Tokens: number;
maxTokens: number;
/** 最长的 tailRatio 那部分请求的数量。 */
tailCount: number;
/** 长尾请求贡献的 token 占总 token 的比例。 */
tailTokenShare: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `output-length: ${message}`);
}
function requireInteger(value: number, field: string, min: number): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function requireRatio(value: number, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 1) {
fail(`${field} must be in (0, 1]`);
}
return value;
}
/** 最近秩法:第 ceil(ratio * n) 个值,结果可以逐条样本复算。 */
export function percentileNearestRank(values: readonly number[], ratio: number): number {
requireRatio(ratio, "ratio");
if (values.length === 0) fail("percentile of empty sample");
const sorted = [...values].sort((left, right) => left - right);
const rank = Math.ceil(ratio * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, rank))];
}
/** 目标不是平均值,而是分位数与长尾占比:P50 看典型,P99 看预算尾部。 */
export function summarizeLengths(lengths: readonly number[], tailRatio = 0.05): LengthSummary {
if (lengths.length === 0) fail("cannot summarize an empty sample");
requireRatio(tailRatio, "tailRatio");
for (const value of lengths) requireInteger(value, "tokens", 0);
const sorted = [...lengths].sort((left, right) => left - right);
const total = sorted.reduce((sum, value) => sum + value, 0);
const tailCount = Math.max(1, Math.ceil(sorted.length * tailRatio));
const tailTotal = sorted.slice(sorted.length - tailCount).reduce((sum, value) => sum + value, 0);
return {
samples: sorted.length,
totalTokens: total,
meanTokens: total / sorted.length,
p50Tokens: percentileNearestRank(sorted, 0.5),
p90Tokens: percentileNearestRank(sorted, 0.9),
p95Tokens: percentileNearestRank(sorted, 0.95),
p99Tokens: percentileNearestRank(sorted, 0.99),
maxTokens: sorted[sorted.length - 1],
tailCount,
tailTokenShare: total === 0 ? 0 : tailTotal / total,
};
}
export interface CapabilityLengthReport {
capability: string;
summary: LengthSummary;
}
/** 按能力分组,顺序稳定,避免同一份样本在不同机器上得到不同表格。 */
export function summarizeByCapability(
samples: readonly LengthSample[],
tailRatio = 0.05,
): CapabilityLengthReport[] {
const groups = new Map<string, number[]>();
for (const sample of samples) {
if (sample.capability.trim() === "") fail("capability is required");
requireInteger(sample.tokens, "tokens", 0);
const bucket = groups.get(sample.capability) ?? [];
bucket.push(sample.tokens);
groups.set(sample.capability, bucket);
}
return [...groups.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([capability, lengths]) => ({ capability, summary: summarizeLengths(lengths, tailRatio) }));
}
export interface LengthTarget {
capability: string;
p90TargetTokens: number;
p99CeilingTokens: number;
}
export interface LengthTargetResult {
capability: string;
p90Met: boolean;
p99Met: boolean;
detail: string;
}
/** 按能力设目标,而不是全站一个平均数;不同能力的长度分布本来就不该相同。 */
export function evaluateLengthTargets(
reports: readonly CapabilityLengthReport[],
targets: readonly LengthTarget[],
): LengthTargetResult[] {
const byCapability = new Map(targets.map((target) => [target.capability, target]));
return reports.map((report) => {
const target = byCapability.get(report.capability);
if (target === undefined) {
return { capability: report.capability, p90Met: false, p99Met: false, detail: "no target defined" };
}
requireInteger(target.p90TargetTokens, "p90TargetTokens", 1);
requireInteger(target.p99CeilingTokens, "p99CeilingTokens", 1);
return {
capability: report.capability,
p90Met: report.summary.p90Tokens <= target.p90TargetTokens,
p99Met: report.summary.p99Tokens <= target.p99CeilingTokens,
detail: `P90 ${report.summary.p90Tokens}/${target.p90TargetTokens}, P99 ${report.summary.p99Tokens}/${target.p99CeilingTokens}`,
};
});
}
export interface LengthCostPolicy {
ttftMs: number;
decodeTokensPerSecond: number;
microsPerToken: number;
/** 超过该长度后,截断与修复概率开始上升。 */
truncationThresholdTokens: number;
failurePer100Tokens: number;
maxFailureProbability: number;
/** 失败后重生成的总成本倍数,含输入与重建输出。 */
retryCostMultiplier: number;
}
export interface LengthCostPoint {
tokens: number;
latencyMs: number;
baseMicros: number;
failureProbability: number;
expectedMicros: number;
nonlinearPremiumMicros: number;
}
function requireNumber(value: number, field: string, min: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
fail(`${field} must be a finite number >= ${min}`);
}
return value;
}
/** 延迟 = 首 token 固定开销 + 线性解码;成本在截断阈值后叠加重生成成本。 */
export function lengthCost(tokens: number, policy: LengthCostPolicy): LengthCostPoint {
requireInteger(tokens, "tokens", 0);
requireNumber(policy.ttftMs, "ttftMs", 0);
requireNumber(policy.decodeTokensPerSecond, "decodeTokensPerSecond", 1);
const micros = requireInteger(policy.microsPerToken, "microsPerToken", 0);
const threshold = requireInteger(policy.truncationThresholdTokens, "truncationThresholdTokens", 0);
const failurePer100 = requireNumber(policy.failurePer100Tokens, "failurePer100Tokens", 0);
const maxFailure = requireRatio(policy.maxFailureProbability, "maxFailureProbability");
const retryMultiplier = requireNumber(policy.retryCostMultiplier, "retryCostMultiplier", 1);
const latencyMs = Math.ceil(policy.ttftMs) + Math.ceil((tokens * 1_000) / policy.decodeTokensPerSecond);
const baseMicros = Math.ceil(tokens * micros);
const over = Math.max(0, tokens - threshold);
const failureProbability = Math.min(maxFailure, (over / 100) * failurePer100);
const expectedMicros = Math.ceil(baseMicros * (1 + failureProbability * (retryMultiplier - 1)));
return {
tokens, latencyMs, baseMicros, failureProbability, expectedMicros,
nonlinearPremiumMicros: expectedMicros - baseMicros,
};
}
export function costCurve(lengths: readonly number[], policy: LengthCostPolicy): LengthCostPoint[] {
return lengths.map((tokens) => lengthCost(tokens, policy));
}
export interface LengthCapPlan {
capTokens: number;
affected: number;
affectedShare: number;
savedTokens: number;
savedMicros: number;
savedShare: number;
}
/** 试算“把输出截到 cap”的 token 收益;质量损失必须另用评测集验证。 */
export function planLengthCap(
lengths: readonly number[],
capTokens: number,
microsPerToken: number,
): LengthCapPlan {
if (lengths.length === 0) fail("cannot plan a cap on an empty sample");
requireInteger(capTokens, "capTokens", 1);
requireInteger(microsPerToken, "microsPerToken", 0);
let affected = 0;
let savedTokens = 0;
let totalTokens = 0;
for (const value of lengths) {
requireInteger(value, "tokens", 0);
totalTokens += value;
if (value > capTokens) {
affected += 1;
savedTokens += value - capTokens;
}
}
return {
capTokens, affected,
affectedShare: affected / lengths.length,
savedTokens,
savedMicros: savedTokens * microsPerToken,
savedShare: totalTokens === 0 ? 0 : savedTokens / totalTokens,
};
}
分位数用最近秩法而不是插值。样本量小的时候,插值会造出一个不存在的长度值;最近秩法取真实样本,虽然粗糙,但每个数字都能逐条复算。长尾占比取最长的 5%,是因为治理资源有限,先看最贵的那一小段。
lengthCost 的失败概率是分段线性的,不是真实模型行为,而是一个可解释的预算模型。它的作用不是预测某一次请求,而是回答“把上限从 A 调到 B,期望成本会怎么变”。真实参数要用自己的重试与返工数据拟合。
测试与运行结果
测试覆盖最近秩、摘要与长尾占比、按能力分组、目标判定、阈值前后的成本曲线、截断试算与非法输入。执行:
node --test test/perf/output-length.test.ts
本机一次运行的真实输出摘要:
ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 152.29525
示例样本由固定种子的线性同余发生器生成,同样的代码在任何机器上都得到同一组数字。这不是为了让样本“好看”,而是为了让每一次目标调整都能和上一版逐项对比。
和 4.1、4.2 的关系
4.1 决定单次请求的 max_tokens 怎么填,4.2 决定流式生成什么时候停,本节决定一组请求的长度分布应该长什么样。三者合起来才闭环:单次上限防止爆预算,提前停止减少无效输出,分布目标让容量与成本估算有稳定输入。
如果只做 4.1,团队会不断调大上限来压截断率;如果只做本节,没有单次硬边界,长尾仍会击穿预算。分布目标必须和单次上限一起发布。
小结
生成长度的成本有两层:按 token 的线性成本,以及超过截断阈值后的重生成溢价。治理长度先看分位数与长尾占比,不看平均值;目标按能力分别设定,同时约束 P90 与 P99;截断上限的收益要在自己的样本上试算,不能照搬别人的 P95。
下一章换一个复用维度:同样的请求能不能不重新生成,结果缓存的经济性判据是什么。
阅读导航:上一节:4.2 流式输出的提前终止 · 下一节:5.1 结果缓存的经济性判据 。
常见错误与边界
- 用平均长度做容量与成本预算。长尾被抹平,月底一定超。
- 把 P90 目标当成软建议。超了不改实现也不改目标,门禁会失去意义。
- 全站共用一个长度上限。短回复被浪费预算,长摘要被频繁截断。
- 把截断当成免费的省钱手段。省下的是 token,损失的是内容与返工。
- 用插值算小样本 P99。会造出样本里不存在的长度值,无法复核。
- 只治理平均长度,不看长尾占比。最贵的 5% 往往决定账单。
- 把示例样本的截断收益直接上线。分布一换,受影响请求数就变了。
- 长度目标不跟评测集绑定。模型变短了但答案变差,指标仍然全绿。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。