《互联网产品的 AI 架构·模型接入与能力边界》8.1 端到端延迟预算怎么拆

「回声」智能回复承诺 2 秒,团队却把 2000 毫秒直接当成提供方超时,结果网络、排队与后处理叠加后稳定超时。本节把端到端预算拆成网络、排队、首 token、生成与后处理五段,给出整数毫秒分配、超时传播与审计代码,用 Node 测试逐毫秒验证,并复盘一次余量被当成预算的事故。

两秒从哪里来

「回声」的私信页有一个智能回复按钮。产品评审时定下的体验目标是:用户点下去,两秒内拿到一段完整可用的回复。工程团队把这句话翻译成了一个数字,deadlineMs: 2000,原样传给模型提供方,然后上线了。

上线后的 P95 是 2.6 秒。拆开看,提供方拿到 2000 毫秒后确实会用满它:排队长的时候等到 1800 毫秒才发出首包,生成到 1900 毫秒,网关再做敏感词检查、结构化校验和落库,又花 400 毫秒。客户端在 2000 毫秒时已经显示“生成失败”,用户点了重试,第二次请求重新排队,成本也翻了一倍。

问题不在某一个组件慢,而在“2000 毫秒”被当成了每一跳都能用的预算。端到端承诺是一个总账,任何一跳把它当成自己的超时,所有跳加起来必然超支。

文中出现的延迟、比例与耗时,除非特别标注为本机测试输出,都是案例假设值,用来演示预算分配,不是任何真实产品的线上数据。

延迟不是一段,是五段

一次智能回复从点击到看见文字,至少经过五段。每一段的尾部风险不同,不能用同一个比例拍脑袋。

阶段案例假设预算包含什么尾部风险的来源
网络180 ms客户端到网关的请求与回包弱网、跨区域、TLS 重连
排队180 ms网关排队、提供方排队、限流等待高峰并发、租户突发、区域容量
首 token360 ms预填充、首包到达长上下文、冷启动、模型切换
生成720 ms解码并输出其余 token输出过长、解码抖动、中途重试
后处理360 ms审核、结构校验、落库、组装下游依赖慢、同步审核
余量200 ms不分配,留给抖动与收尾不可预测的尾部叠加

五个阶段加余量正好 2000 毫秒。这张表最重要的不是数字,而是两个决定。

第一,余量必须单独留出来。如果五个阶段把 2000 毫秒分完,任何一段抖动都会直接击穿承诺。余量不是“没用完的预算”,而是明确不允许日常逻辑动用的部分。

第二,阶段预算不是各阶段 P95 的和。端到端 P95 小于各段 P95 之和,因为尾部很少同时发生;反过来,把各段 P95 相加再去定总预算,会得到一个过于宽松的数字。正确顺序是先定端到端承诺,再按业务容忍度与实测尾部把总预算切开,最后用真实样本审计。

首 token 与生成要分开

首 token 的预算覆盖预填充和首包,生成的预算覆盖后续解码。它们的影响因素不同:首 token 慢,通常是上下文太长、排队太久或冷启动;生成慢,通常是输出太长或解码抖动。混成一个“模型耗时”,优化时就没有抓手。

对智能回复来说,首 token 比总时长更重要。用户看到文字开始出现,就会继续等;两秒内一个字都没有,就会怀疑按钮坏了。所以首 token 预算 720 毫秒(网络加排队加首包),比生成预算 720 毫秒更硬。

后处理不能无限兜底

后处理包括敏感词、结构校验、落库和 BFF 组装。它最容易变成“剩下的时间都给它”,也最容易在生成完成后才发现没时间。正确做法是给它固定预算,并且允许降级:审核可以异步补做,结构校验失败可以返回模板,落库可以重试,但不能让用户一直等。

把预算写成可断言的计划

预算表写在文档里,过两周就会和代码脱节。解决办法是把它变成函数:输入总预算、余量和权重,输出每个阶段的毫秒数与绝对截止偏移。下面的实现用整数毫秒和最大余数法,保证各阶段之和精确等于可用预算。

// file: src/cost/latency-budget.ts
// tier: L1
// verify: node --test test/cost/latency-budget.test.ts

import { AiError } from "../core/errors.ts";
import type { Clock } from "../core/types.ts";

/** 端到端预算的五个阶段,顺序就是一次请求的经过顺序。 */
export type LatencyStage = "network" | "queue" | "first-token" | "generation" | "postprocess";

export const LATENCY_STAGES: LatencyStage[] = [
  "network",
  "queue",
  "first-token",
  "generation",
  "postprocess",
];

export interface LatencyBudgetSpec {
  /** 业务承诺的端到端上限,单位毫秒。 */
  totalMs: number;
  /** 留给抖动与收尾的余量,不参与阶段分配。 */
  reserveMs: number;
  /** 阶段权重,必须是正整数;按最大余数法分配,保证总和精确。 */
  weights: Record<LatencyStage, number>;
}

export interface StageBudget {
  stage: LatencyStage;
  budgetMs: number;
  /** 从请求开始算起的累计截止偏移,不是本阶段耗时。 */
  deadlineMs: number;
}

export interface LatencyPlan {
  totalMs: number;
  usableMs: number;
  reserveMs: number;
  stages: StageBudget[];
  /** 首字出现前的累计预算:network + queue + first-token。 */
  firstTokenBudgetMs: number;
}

export interface StageTiming {
  stage: LatencyStage;
  durationMs: number;
}

export interface StageOverrun {
  stage: LatencyStage;
  budgetMs: number;
  actualMs: number;
  overMs: number;
}

export interface LatencyAudit {
  totalMs: number;
  firstTokenMs: number;
  overruns: StageOverrun[];
  withinBudget: boolean;
  firstTokenWithinBudget: boolean;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `latency-budget: ${message}`);
}

function requireInteger(value: number, field: string, min: number): void {
  if (!Number.isInteger(value) || value < min) {
    fail(`${field} must be an integer >= ${min}`);
  }
}

/**
 * 按权重把可用预算分到五个阶段。最大余数法保证各阶段之和精确等于
 * totalMs - reserveMs,余数相同时按阶段顺序分配,结果可被测试逐毫秒断言。
 */
export function planLatency(spec: LatencyBudgetSpec): LatencyPlan {
  requireInteger(spec.totalMs, "totalMs", 1);
  requireInteger(spec.reserveMs, "reserveMs", 0);
  if (spec.reserveMs >= spec.totalMs) {
    fail("reserveMs must be smaller than totalMs");
  }
  for (const stage of LATENCY_STAGES) {
    requireInteger(spec.weights[stage], `weights.${stage}`, 1);
  }

  const usableMs = spec.totalMs - spec.reserveMs;
  const totalWeight = LATENCY_STAGES.reduce((sum, stage) => sum + spec.weights[stage], 0);

  const shares = LATENCY_STAGES.map((stage, index) => {
    const exact = (usableMs * spec.weights[stage]) / totalWeight;
    return {
      stage,
      index,
      budgetMs: Math.floor(exact),
      remainder: exact - Math.floor(exact),
    };
  });

  let left = usableMs - shares.reduce((sum, share) => sum + share.budgetMs, 0);
  const byRemainder = [...shares].sort(
    (left, right) => right.remainder - left.remainder || left.index - right.index,
  );
  for (const share of byRemainder) {
    if (left === 0) break;
    share.budgetMs += 1;
    left -= 1;
  }

  let cursor = 0;
  const stages = shares.map((share) => {
    cursor += share.budgetMs;
    return { stage: share.stage, budgetMs: share.budgetMs, deadlineMs: cursor };
  });

  const firstTokenBudgetMs = stages
    .slice(0, 3)
    .reduce((sum, stage) => sum + stage.budgetMs, 0);

  return {
    totalMs: spec.totalMs,
    usableMs,
    reserveMs: spec.reserveMs,
    stages,
    firstTokenBudgetMs,
  };
}

export function stageDeadlineMs(plan: LatencyPlan, stage: LatencyStage): number {
  const found = plan.stages.find((item) => item.stage === stage);
  if (!found) fail(`unknown stage "${stage}"`);
  return found.deadlineMs;
}

/** 距离本阶段截止还剩多少毫秒;已经超时返回 0,不抛错,便于观测。 */
export function remainingMs(plan: LatencyPlan, stage: LatencyStage, elapsedMs: number): number {
  requireInteger(elapsedMs, "elapsedMs", 0);
  return Math.max(0, stageDeadlineMs(plan, stage) - elapsedMs);
}

/**
 * 计算可以传给下一跳的超时。前面阶段省下的时间允许借用,但绝不允许
 * 借用 reserveMs;余额为零时直接按 timeout 失败,而不是传 0 让下游空转。
 */
export function nextTimeoutMs(plan: LatencyPlan, stage: LatencyStage, elapsedMs: number): number {
  const left = remainingMs(plan, stage, elapsedMs);
  if (left <= 0) {
    throw new AiError("timeout", `latency-budget: stage "${stage}" has no time left at ${elapsedMs}ms`);
  }
  return left;
}

export function elapsedSince(startedAtMs: number, clock: Clock): number {
  const elapsed = clock.now() - startedAtMs;
  return Math.max(0, Math.trunc(elapsed));
}

/** 用一次完整样本核对预算;overruns 按阶段顺序输出,便于直接写进日志。 */
export function auditLatency(plan: LatencyPlan, timings: StageTiming[]): LatencyAudit {
  const seen = new Map<LatencyStage, number>();
  for (const timing of timings) {
    if (!LATENCY_STAGES.includes(timing.stage)) fail(`unknown stage "${timing.stage}"`);
    if (seen.has(timing.stage)) fail(`duplicate timing for stage "${timing.stage}"`);
    requireInteger(timing.durationMs, `durationMs for "${timing.stage}"`, 0);
    seen.set(timing.stage, timing.durationMs);
  }

  const overruns: StageOverrun[] = [];
  for (const budget of plan.stages) {
    const actualMs = seen.get(budget.stage);
    if (actualMs === undefined) continue;
    if (actualMs > budget.budgetMs) {
      overruns.push({
        stage: budget.stage,
        budgetMs: budget.budgetMs,
        actualMs,
        overMs: actualMs - budget.budgetMs,
      });
    }
  }

  const totalMs = [...seen.values()].reduce((sum, value) => sum + value, 0);
  const firstTokenMs = (["network", "queue", "first-token"] as LatencyStage[]).reduce(
    (sum, stage) => sum + (seen.get(stage) ?? 0),
    0,
  );

  return {
    totalMs,
    firstTokenMs,
    overruns,
    withinBudget: totalMs <= plan.usableMs,
    firstTokenWithinBudget: firstTokenMs <= plan.firstTokenBudgetMs,
  };
}

这段代码有四个刻意设计。

第一,权重是正整数,不是浮点比例。浮点比例在分配时会产生 0.30000000000000004 这样的尾巴,测试只能近似断言;整数权重配最大余数法,每一毫秒都能对上。

第二,deadlineMs 是累计偏移,不是阶段耗时。网络阶段的截止是 180,排队是 360,首 token 是 720。下游拿到的是“从请求开始算,你必须在第 720 毫秒前结束”,而不是“你有 360 毫秒”。这两种表述在串行链路里等价,在重试、并发和跨进程传递时完全不同。

第三,余量不参与分配。usableMs 是 totalMs - reserveMs,任何阶段都拿不到那 200 毫秒。这让“余量被日常逻辑吃掉”变成一个测试可以拦住的问题。

第四,审计按阶段输出超支,不只给一个总数。总数没超但不代表体验达标:首 token 超了 100 毫秒、生成省了 200 毫秒,总数仍然合格,用户却多等了 100 毫秒才看到第一个字。

超时只传剩余,不传原始值

计划算出来以后,最容易犯的错是把阶段预算原样传给下一跳。比如首 token 阶段预算 360 毫秒,网关就把 360 毫秒设为提供方超时。请求到达提供方时,网络和排队已经花掉 420 毫秒,提供方再用 360 毫秒,首 token 会在第 780 毫秒出现,越过 720 毫秒的截止。正确的超时不是 360,而是 720 减 420,只剩 300 毫秒。

正确的传播规则只有一条:传给下游的超时,等于本阶段截止偏移减去已经流逝的时间。前面阶段省下的时间可以借用,但永远不能借用余量。

nextTimeoutMs 实现的就是这条规则。它在余额为零时抛 timeout,而不是返回 0。返回 0 会让下游把请求当成“立即失败”还是“无限等待”,取决于每个 SDK 的实现,故障语义就散了。

传播还有两个配套规则。

第一,重试不重置时钟。第一次尝试花了 500 毫秒,第二次只能用同一个截止偏移减去 500 毫秒后的余额。很多重复生成的事故,都是因为每次重试都重新拿到完整的阶段预算。

第二,跨进程传递用绝对时间。网关把截止偏移换算成时间戳传给 BFF,BFF 不再自己算一遍;否则两边的时钟漂移和排队时间会被重复计算或漏算。

测试与真实输出

测试覆盖八个场景:精确拆分、余数分配、累计截止、超支审计、借用余量、余额耗尽、注入时钟、非法输入。在实验工程根目录执行:

node --test test/cost/latency-budget.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 预算按权重精确拆分,总和等于可用预算 (1.561208ms)
✔ 最大余数法在余数相同时按阶段顺序分配 (0.138667ms)
✔ 累计截止偏移单调递增,首字预算只含前三段 (0.115917ms)
✔ 审计同时发现单阶段超支与首字超支 (0.182083ms)
✔ 前面阶段省下的时间可以借给生成阶段 (0.090625ms)
✔ 余额耗尽时抛 timeout,而不是把 0 传给下游 (0.282667ms)
✔ 注入时钟只用于观测耗时,逻辑本身保持纯函数 (0.077792ms)
✔ 非法权重与过大的余量被拒绝 (0.140209ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 191.230417

第二个测试值得多看一眼:可用预算 1002 毫秒、五个阶段等权,除不尽,代码把多出来的 2 毫秒按阶段顺序分给前两段。这个规则不是唯一正确的,但它必须是确定的——换一台机器、换一个 Node 版本,结果不能变。

第五个测试验证借用:生成阶段的截止是第 720 毫秒,如果前两段只花了 100 毫秒,生成阶段实际可用 620 毫秒,而不是它名义上的 360 毫秒。借用让快路径更快,但截止偏移不变,所以不会把总预算撑破。

审核批量任务为什么允许 30 秒

同样是 AI 能力,批量内容审核的端到端预算是 30 秒,比智能回复宽松十五倍。这不是团队更宽容,而是两类任务的等待结构不同。

维度私信智能回复批量内容审核
谁在等用户盯着屏幕后台任务,无人同步等待
主要目标首 token 尽快出现单位时间吞吐
端到端预算2000 ms30000 ms
关键阶段排队、首 token批处理窗口、生成
失败代价用户重试、体验受损单条重试、整批延迟

30 秒预算也不能平均分给每一条。一次批量任务可能包含 1000 条内容,如果把 30 秒当成单条超时,整批最坏要 8 个多小时。正确做法是两层预算:父任务 30 秒,子请求每条 2 秒,并且父任务剩余时间不足时不再派发新子请求。

代码里没有“批量专用”的分支,因为预算的形状是调用方传进来的。批量任务传 totalMs: 30000、reserveMs: 2000、权重偏向排队与生成;智能回复传 totalMs: 2000、reserveMs: 200。同一个 planLatency,两种预算,测试各自断言。

一次把余量当预算的事故

某次版本为了“让模型充分发挥”,网关把 deadlineMs 直接透传给提供方,没有减去网络与排队时间。事故当天高峰排队 600 毫秒,提供方又用满 2000 毫秒,后处理拿到的是一个已经过期的截止时间。

后处理代码里有一个判断:如果剩余预算小于 100 毫秒,就跳过审核直接返回。这个降级本身没错,错在它没有把“跳过审核”告诉调用方。BFF 以为审核已经完成,把结果写进了对外的推荐位;风控事后追溯,发现那批内容里有一条本该被拦下。

复盘时找到三个叠加的问题:

  • 提供方拿到的是原始预算,不是剩余预算,单跳就吃掉了全部时间。
  • 重试逻辑每次重新计时,第一次超时后第二次又拿到完整预算,总耗时翻倍。
  • 后处理的降级没有向上传播,调用方无法区分“审核通过”和“审核被跳过”。

修复不是把超时调大,而是三件事:统一用 nextTimeoutMs 传播剩余预算;重试共享同一个截止偏移;降级必须带原因,ChatResponse.degraded 里记录 deadline-exceeded,BFF 据此决定是否展示。

小结

端到端延迟预算是一个总账,不是某一跳的超时。它至少包含网络、排队、首 token、生成与后处理五段,另外必须留出一段不允许日常逻辑动用的余量。阶段预算用整数毫秒分配,用绝对截止偏移表达,才能被测试逐毫秒断言。

超时传播只传剩余时间:本阶段截止减去已经流逝的时间。前面阶段省下的时间可以借用,余量不能借用,余额为零时立即失败而不是传 0。重试共享同一个截止偏移,跨进程传递绝对时间。

下一节换一个维度算账:一次请求到底花了多少 token、多少钱,配额为什么要先预留再结算,以及失败为什么也必须计量。

阅读导航:上一节:7.3 解析失败的重试、修复与降级 · 下一节:8.2 Token 成本模型与配额 。

常见错误与边界

  • 把端到端承诺直接当成提供方超时。网络、排队与后处理都会叠加,单跳用满总预算必然超支。
  • 把各阶段 P95 相加当成端到端 P95。尾部很少同时发生,相加会得到过于宽松的预算。
  • 不预留余量。五段分完整数后,任何抖动都会直接击穿承诺。
  • 用浮点比例分配毫秒。测试只能近似断言,账单和日志里会出现小数毫秒。
  • 向下游传阶段预算而不是剩余预算。排队一慢,下游就会在已经过期的截止时间上继续工作。
  • 重试时重置计时。每次重试都拿到完整预算,总耗时和成本都会翻倍。
  • 余额为零时传 0 超时。不同 SDK 对 0 的解释不一致,故障语义会散掉。
  • 后处理降级不向上传播。调用方分不清“审核通过”和“审核被跳过”,会把风险当成正常。
  • 把批量任务的 30 秒平均分给每条内容。父任务与子请求必须有两层预算,否则整批会被单条拖死。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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