两秒从哪里来
「回声」的私信页有一个智能回复按钮。产品评审时定下的体验目标是:用户点下去,两秒内拿到一段完整可用的回复。工程团队把这句话翻译成了一个数字,deadlineMs: 2000,原样传给模型提供方,然后上线了。
上线后的 P95 是 2.6 秒。拆开看,提供方拿到 2000 毫秒后确实会用满它:排队长的时候等到 1800 毫秒才发出首包,生成到 1900 毫秒,网关再做敏感词检查、结构化校验和落库,又花 400 毫秒。客户端在 2000 毫秒时已经显示“生成失败”,用户点了重试,第二次请求重新排队,成本也翻了一倍。
问题不在某一个组件慢,而在“2000 毫秒”被当成了每一跳都能用的预算。端到端承诺是一个总账,任何一跳把它当成自己的超时,所有跳加起来必然超支。
文中出现的延迟、比例与耗时,除非特别标注为本机测试输出,都是案例假设值,用来演示预算分配,不是任何真实产品的线上数据。
延迟不是一段,是五段
一次智能回复从点击到看见文字,至少经过五段。每一段的尾部风险不同,不能用同一个比例拍脑袋。
| 阶段 | 案例假设预算 | 包含什么 | 尾部风险的来源 |
|---|---|---|---|
| 网络 | 180 ms | 客户端到网关的请求与回包 | 弱网、跨区域、TLS 重连 |
| 排队 | 180 ms | 网关排队、提供方排队、限流等待 | 高峰并发、租户突发、区域容量 |
| 首 token | 360 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 ms | 30000 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 秒平均分给每条内容。父任务与子请求必须有两层预算,否则整批会被单条拖死。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。