《互联网产品的 AI 架构·推理性能与成本工程》4.3 生成长度的非线性成本

会话摘要的 P50 是 805 token,P99 却到 1193 token,前 5% 的长请求吃掉两成输出预算。本节用 200 条确定性样本说明长度分布、分位数与长尾占比怎么算,给出延迟与期望成本曲线、按能力设长度目标和截断上限的收益试算,并用 node --test 的真实输出验证每一个数字。

一次摘要把 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
总 token51,588
平均257.94
P50 / P90 / P95 / P99180 / 487 / 805 / 1,169
最大1,193
最长 5% 的 token 占比20.13%

同一份样本按能力拆开,差异立刻出现:

能力请求数P50P90P99最大最长 5% 占比
chat.reply1401512042172197.17%
ticket.suggest403404594874877.26%
summary.long208051,1691,1931,1936.92%

如果不拆能力,summary.long 会把整体 P99 拉到 1,169,团队会误以为私信回复也需要 1,200 token 的预算,于是把所有能力的 max_tokens 一起调大。

成本曲线:阈值之后非线性

延迟可以拆成两段:首 token 的固定开销,加上按解码速度线性增长的生成时间。成本在截断阈值之前也是线性的;超过阈值后,截断、修复或重生成的概率上升,期望成本按概率叠加一次重生成。

以首 token 120ms、每秒 50 token、每 token 2 微元、阈值 400 token、每多 100 token 增加 2% 失败概率、失败后成本翻倍为例:

输出 token延迟线性成本失败概率期望成本非线性溢价
2004,120ms400 微元0400 微元0
4008,120ms800 微元0800 微元0
80016,120ms1,600 微元8%1,728 微元128 微元
1,20024,120ms2,400 微元16%2,784 微元384 微元

这张表说明两件事。第一,延迟仍然近似线性,长输出的慢主要是“解码久”,不是“越写越慢”。第二,成本在阈值后出现溢价,溢价来自失败后的重生成,而不是单价变贵。把重试率单独建模,才能解释为什么长度翻三倍,期望成本涨得比三倍多。

长尾治理的三个手段

第一个手段是硬上限。把超过某个长度的输出截断,直接省 token,但会损失信息。示例样本上试算:

上限受影响请求受影响比例省下 token省下微元占总 token
805(P95)105.00%2,3364,6724.53%
1,169(P99)21.00%45900.09%

P95 上限影响 10 条请求,省下 4.53% 的 token;P99 上限只影响 2 条,省下 0.09%。收益集中在 P95 到 P99 之间,但这一段也最可能损失信息。上限设在哪,取决于业务能接受多少条答案被改写,而不是只看省下多少 token。

第二个手段是软目标。把“P90 不超过多少 token”写进提示与评测,让模型在正常路径上收敛,而不是靠截断兜底。第三个手段是提前停止,也就是 4.2 的置信度停止:在答案已经足够时停下,比事后截断更不伤内容。

按能力设长度目标

目标必须按能力签,且同时写 P90 与 P99。示例样本上:

能力P90 目标P90 实际P99 上限P99 实际结论
chat.reply220204220217通过
ticket.suggest450459500487P90 未过
summary.long1,0001,1691,0001,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% 往往决定账单。
  • 把示例样本的截断收益直接上线。分布一换,受影响请求数就变了。
  • 长度目标不跟评测集绑定。模型变短了但答案变差,指标仍然全绿。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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