跳转到内容

模型服务、路由与推理预算

一个聊天服务的平均响应时间仍是 3 秒,但用户开始集中投诉“点了以后很久没有任何字”。GPU 利用率接近满载,服务没有明显报错,自动扩容也没有触发。团队只保存了总耗时和 HTTP 状态码,因此无法回答:请求是在入口排队、等待 KV 空间、执行长 Prompt 的 Prefill,还是在 Decode 阶段被其他请求拖慢?一次重试会减轻故障,还是把已经过载的队列再放大一倍?

这不是模型原理课能够回答的问题。Token、Attention 与 键值缓存(KV Cache)键值缓存KV Cache自回归解码时保存历史Token各层的K/V,避免每生成一步都重复计算整段前缀。打开术语条目 → 解释单次推理为什么消耗计算和内存;模型服务工程要决定大量不同长度、不同优先级和不同Deadline的请求如何共享有限设备,并在过载、版本发布和供应商失败时保持可判断行为。

本课不部署真实GPU,也不比较任何厂商榜单。你将建立一个确定性模型服务适配层:输入请求预算和模型池状态,输出准入、路由或拒绝决定,并留下可回放的原因、版本身份和预测预算。

REQUEST PATH把“模型很慢”拆成可控制的服务阶段
01 · Admission准入与预算Deadline、Token上限、租户配额
02 · Route版本与供应路径能力、地区、成本、健康度
03 · Schedule排队与连续批处理公平性、KV容量、负载保护
04 · InferPrefill → DecodeTTFT、ITL、吞吐、KV Cache
05 · Deliver流式输出与验收取消、完整性、结果分类
数据平面执行一次请求;控制平面用分阶段指标调整准入、路由与发布策略。平均延迟正常不能证明尾延迟、排队和拒绝路径健康。

最小服务边界包含五段:

Client / Agent
→ Admission:身份、Deadline、Token与并发预算
→ Routing:模型版本、地区、供应路径和降级策略
→ Scheduling:排队、批处理、优先级与KV容量
→ Inference:Prefill、Decode与流式输出
→ Validation:终态、使用量、取消和版本证据

如果适配层只把请求转发给一个SDK,它无法统一以下行为:

  • 调用方给了 2 秒Deadline,但后端队列预计已需 3 秒;
  • max_tokens 未设上限,单个请求可能长期占用KV和Decode槽位;
  • 主模型健康但已过载,备用模型健康且质量略低;
  • 首字延迟超标,但总吞吐看起来很高;
  • 流在中途断开,服务不知道后端是否仍继续生成和计费;
  • 同一模型名背后的权重、Tokenizer或推理配置已发生变化;
  • 自动重试把一个请求复制到多个供应路径,却没有取消输掉的副本。

服务层的输出不能只有模型文本。它至少要返回:

interface InferenceReceipt {
requestId: string;
status: 'completed' | 'rejected' | 'cancelled' | 'failed' | 'unknown';
modelId: string;
modelRevision: string;
tokenizerRevision: string;
servingConfigRevision: string;
routeId: string;
routeReason: string;
inputTokens: number;
outputTokens: number;
queueMs: number;
prefillMs: number;
decodeMs: number;
firstTokenMs: number | null;
estimatedCostUnits: number;
terminalErrorCode: string | null;
}

Receipt不是调试装饰。它决定一次异常能否回放、两个版本能否比较、成本能否归因,以及重试前是否知道上一次请求的终态。

2. 请求级SLO必须拆成排队、Prefill与Decode

Section titled “2. 请求级SLO必须拆成排队、Prefill与Decode”

“延迟”不是一个数字。对流式生成,至少区分:

  • queueMs:请求被接受到获得执行机会;
  • prefillMs:处理已有输入并建立初始KV状态;
  • firstDecodeMs:生成首个Token的Decode开销;
  • 首Token延迟(Time to First Token, TTFT)首Token延迟Time to First Token, TTFT从服务接受请求到客户端收到第一个输出Token的时间,通常包含排队与Prefill。打开术语条目 →:用户从发送到看到第一个Token;
  • Token间延迟(Inter-Token Latency, ITL)Token间延迟Inter-Token Latency, ITL流式生成过程中相邻输出Token之间的时间分布,用于描述解码阶段的连续体验。打开术语条目 →:相邻输出Token间隔的分布;
  • e2eMs:从接受请求到终态或流关闭;
  • cancelAckMs:取消发出到后端确认停止。

近似分解:

TTFT = 排队时间 + Prefill时间 + 首次Decode时间 + 首字节网络时间
总延迟 = 排队时间 + Prefill时间 + Decode时间 + 网络输出时间

例:排队 0.24s,Prefill 0.36s,首次Decode 0.04s,首字节网络 0.02s

TTFT = 0.24 + 0.36 + 0.04 + 0.02 = 0.66s

若共输出 80 个Token,后续 79 个Token平均间隔 0.035s

E2E ≈ 0.66 + 79 × 0.035 = 3.425s

这两个请求可以拥有相同E2E,却给用户完全不同的体验:一个在 0.3 秒开始稳定输出,另一个等待 2.5 秒后快速吐完。因此交互SLO不能只看E2E。

假设 100 个请求中 95 个排队 20ms,5 个排队 4s。平均排队约 219ms,看起来可能仍可接受;但P95边界已经进入长尾。服务门禁至少报告P50、P95、P99,并按请求类别拆分:

切片为什么分开
交互 / 批处理用户首字体验与离线吞吐目标不同
输入Token区间长Prompt的Prefill不能与短请求混为一谈
输出Token区间长Decode天然占用更多迭代
模型与版本切流后回归必须能定位到具体版本
租户与优先级一个大租户可能挤占全局队列
正常 / 降级路径降级后的“成功”不能掩盖主路径不可用

输入长度影响Prefill计算与初始KV占用,输出上限影响Decode时间和KV增长。服务在入队前就应知道:

interface InferenceBudget {
maxInputTokens: number;
maxOutputTokens: number;
maxTotalTokens: number;
deadlineMs: number;
maxEstimatedCostUnits: number;
priority: 'interactive' | 'normal' | 'batch';
}

必须在Tokenize后重新检查,而不是用字符数猜Token数。请求契约还要固定Tokenizer版本,因为版本变化会改变长度、截断点和成本。

3.1 KV容量不是“显存够不够模型权重”

Section titled “3.1 KV容量不是“显存够不够模型权重””

用简化公式估算每个Token的KV字节数:

KV bytes/token ≈ 层数 × 2(K与V) × KV Head数 × Head维度 × 每元素字节数

一个示意配置:32层、8个KV Head、Head维度128、FP16每元素2字节:

32 × 2 × 8 × 128 × 2 = 131,072 bytes = 128 KiB / token

单请求8,192 Token的KV约为:

128 KiB × 8,192 = 1 GiB

这只是示意估算,不含分配器元数据、碎片、临时Workspace和实现差异。它说明为什么只看权重能否加载远远不够:并发数、上下文长度分布、KV复用和回收速度共同决定可接纳容量。

坏路径:请求先排几秒,拿到GPU后才发现上下文超过上限。正确路径在准入阶段返回结构化拒绝:

{
"status": "rejected",
"reasonCode": "TOKEN_BUDGET_EXCEEDED",
"limits": {
"maxInputTokens": 8192,
"observedInputTokens": 9340
},
"retryable": false
}

客户端可以缩短输入或换支持更长上下文的路由;盲目重试同一请求没有意义。

4. 连续批处理是调度,不是免费吞吐

Section titled “4. 连续批处理是调度,不是免费吞吐”

固定批处理等待凑齐一组请求再整体执行,短请求会被最长请求拖住。连续批处理(Continuous Batching)连续批处理Continuous Batching在生成迭代边界动态加入和移出请求,以提高设备利用率并控制排队的服务调度机制。打开术语条目 → 在生成迭代边界加入新请求、移除已完成请求,使设备持续有工作。

但更高利用率不自动等于更好服务:

  • 大批次可提高吞吐,却可能增加首Token等待;
  • 长Prompt集中Prefill会挤压正在Decode的交互流;
  • 长输出请求持续占槽,短请求产生队头阻塞;
  • 高优先级持续到达可能饿死批处理队列;
  • KV不足时,抢占和重算会带来额外成本;
  • 取消若不能及时回收KV,客户端断开后仍消耗容量。

调度器至少需要以下公开状态:

interface SchedulerSnapshot {
observedAt: string;
waitingRequests: number;
runningRequests: number;
waitingInputTokens: number;
reservedKvTokens: number;
freeKvTokens: number;
recentOutputTokensPerSecond: number;
queueP95Ms: number;
rejectedByReason: Record<string, number>;
}

不要让路由器读取一个几分钟不更新的“GPU利用率”缓存后决定继续灌流量。Snapshot必须带观测时间和版本;过期状态只能触发保守准入或健康探测,不能当作当前容量事实。

5. 准入控制:在队列外拒绝比在队列内超时更诚实

Section titled “5. 准入控制:在队列外拒绝比在队列内超时更诚实”

到达工作量超过可持续处理能力时,队列会持续增长。示意:每秒30个请求,平均输出800 Token,则到达负载为:

30 × 800 = 24,000 output tokens/s

若当前配置只能稳定提供20,000 output tokens/s:

负载比 = 24,000 / 20,000 = 1.2

在没有降载的情况下,积压不会自行消失。扩容也有冷启动时间,因此服务需要 负载丢弃(Load Shedding)负载丢弃Load Shedding容量不足时按明确优先级主动拒绝或降级部分请求,保护已接纳请求和系统恢复能力。打开术语条目 →:按明确优先级拒绝、缩短或转移部分请求,保护已接纳请求的Deadline。

准入顺序建议:

  1. 验证身份、租户、模型能力与请求Schema。
  2. Tokenize并检查输入、输出和总Token硬上限。
  3. 计算剩余Deadline;不足以覆盖保守预测则拒绝。
  4. 检查租户在途数、速率与成本预算。
  5. 检查候选路由健康度、KV容量和队列年龄。
  6. 接纳后生成稳定requestId,预算不可被下游静默扩大。

结构化拒绝要区分:

原因可重试调用方动作
TOKEN_BUDGET_EXCEEDED缩短输入或选择长上下文路由
DEADLINE_INSUFFICIENT条件性放宽Deadline或改用更快降级模型
TENANT_CONCURRENCY_LIMIT按服务给出的退避窗口重试
CAPACITY_SHED退避、异步排队或降级
CAPABILITY_UNAVAILABLE改请求或等待具备能力的版本恢复
POLICY_DENIED不应自动换路由绕过策略

HTTP状态只能表达协议层类别,业务原因仍需稳定reasonCode

6. 路由必须基于能力和预算,而不是随机试错

Section titled “6. 路由必须基于能力和预算,而不是随机试错”

模型路由输入包括:

  • 必需能力:上下文上限、结构化输出、工具协议、语言或模态;
  • 质量等级:任务可接受的最低离线评测门槛;
  • 用户路径:交互、后台、评测或恢复;
  • 地区与数据边界;
  • 剩余Deadline和成本预算;
  • 当前健康、队列、容量与版本发布状态。

路由先做硬过滤,再做可解释排序:

候选 = 健康
∩ 满足能力
∩ 满足地区/策略
∩ 支持输入与输出Token预算
∩ 预测能在剩余Deadline内完成
∩ 预测成本不超过预算

排序可以考虑预计完成时间、质量等级、成本和稳定性,但每次决定要记录规则版本与理由。不要把未经校准的LLM再放到最底层“自主挑供应商”,否则路由失败无法稳定回放。

model="good-model"不是版本身份。最小身份:

interface ModelDeploymentIdentity {
logicalModel: string;
weightsRevision: string;
tokenizerRevision: string;
runtimeName: string;
runtimeRevision: string;
quantization: string;
servingConfigRevision: string;
promptPolicyRevision: string;
}

即使权重未变,Tokenizer、量化、采样默认值或结构化输出适配器变化也可能改变行为。版本证据要进入Receipt和离线评测结果。

降级可能改变:

  • 输出质量和语言能力;
  • 上下文长度;
  • Tool/Schema支持;
  • 安全策略或数据地区;
  • Tokenizer与截断行为;
  • 成本和延迟分布。

所以每条Fallback边必须声明前置条件:

interface FallbackEdge {
fromRoute: string;
toRoute: string;
allowedReasons: Array<'overloaded' | 'unhealthy' | 'deadline_risk'>;
requiredEvaluationSuite: string;
qualityFloor: number;
preservesDataBoundary: boolean;
preservesRequiredCapabilities: boolean;
}

以下情况不能自动Fallback:

  • 目标路由不满足数据地区或隐私边界;
  • 结构化输出能力缺失,而调用方依赖机器解析;
  • 主路由产生未知终态,备用路由会导致重复副作用;
  • 质量门槛没有离线证据;
  • 安全策略版本落后。

对无副作用的读取式推理,可以在长尾条件下延迟启动第二副本,以首个有效结果为准。但必须记录两个请求身份并取消输掉的请求;否则延迟下降以双倍成本和容量为代价。Tool调用或其他外部副作用不应隐藏在无法对账的Hedge中。

标准推理协议可以统一模型名、版本、输入和输出字段,但不能证明两个后端具有相同:

  • Tokenizer;
  • 采样语义;
  • 停止序列行为;
  • Tool/JSON Schema支持;
  • 日志与取消语义;
  • 使用量统计时点;
  • 错误分类。

适配层应先把各供应路径的原始结果归一为内部状态:

type ProviderOutcome =
| { status: 'completed'; text: string; usage: Usage; providerRequestId: string }
| { status: 'rejected'; reasonCode: string; retryAfterMs?: number }
| { status: 'failed'; reasonCode: string; retryable: boolean }
| { status: 'cancelled'; acknowledged: boolean }
| { status: 'unknown'; providerRequestId?: string };

超时只说明调用方停止等待,不证明后端没有完成。若供应路径支持按providerRequestId查询,进入对账;不支持查询时要把终态标为unknown,不能伪造失败后安全重试。

抽象成本模型:

requestCost = inputTokens × inputUnitCost
+ outputTokens × outputUnitCost
+ fixedRequestCost
+ retryAndHedgeCost

假设一批1,000个请求,每个输入1,000 Token、输出200 Token;输入每百万Token 2个抽象单位,输出每百万Token 8个单位:

1,000 × (1,000 × 2 + 200 × 8) / 1,000,000 = 3.6个成本单位

这里使用抽象单位,避免把厂商实时价格写死进课程。真实系统应版本化费率表,并把费率版本写进成本Receipt。

单位成本至少按以下分母观察:

  • 每个完成请求;
  • 每个通过质量门的请求;
  • 每个有引用且可验证的答案;
  • 每个用户完成的任务;
  • 每个降级路径成功结果。

只算“每Token成本”会奖励输出很短但任务失败的系统。成本必须和质量、可靠性一起看。

模型服务常见SLI:

SLI示例切片超标后的动作
TTFT P95/P99交互请求、输入Token桶限制Prefill并发、调整调度、扩容
ITL P95输出长度桶减小批次压力、检查Decode容量
有效完成率模型版本、路由回滚版本、切断不健康路由
拒绝率原因、租户扩容或调整配额;策略拒绝不算容量失败
取消确认延迟后端版本修复取消传播与KV回收
每有效结果成本任务类型调整模型、Token预算或缓存策略
降级占比主路由降级持续升高应视为主路径事故

服务级别目标(Service Level Objective, SLO)服务级别目标Service Level Objective, SLO在一个时间窗口内,对用户可感知可靠性指标设定的明确目标。打开术语条目 → 不是Dashboard上的红线,而是发布、扩容、限流和回滚的输入。错误预算(Error Budget)错误预算Error Budget由可靠性目标允许的失败量,用于决定发布、降级和修复优先级。打开术语条目 → 消耗过快时暂停新版本切流,把容量用于恢复;不能一边主路径大量降级,一边因为HTTP 200很多而继续发布。

{
"event": "inference.route.selected",
"requestId": "req-0187",
"observedAt": "2026-07-22T12:00:00Z",
"policyRevision": "route-policy-v4",
"routeId": "local-fast-v2",
"reasons": ["capability_match", "deadline_fit", "cost_fit"],
"budget": {
"deadlineMs": 3000,
"inputTokens": 820,
"maxOutputTokens": 180
},
"capacitySnapshotRevision": "capacity-991"
}

不要记录原始Prompt、Secret或完整用户内容来换取可观测性。Trace记录身份、大小、时间、策略和结果分类;内容调试另走受控、最小化和有保留期的证据路径。

11. 发布:模型版本也需要金丝雀和回滚

Section titled “11. 发布:模型版本也需要金丝雀和回滚”

发布单元不只包含权重:

Release = weights
+ tokenizer
+ runtime
+ quantization
+ serving config
+ route policy
+ prompt/policy revision

切流前离线Gate检查固定评测集、结构化输出、工具调用、安全用例和性能基线。线上先给小比例、可识别流量,并比较:

  • 同请求类型的TTFT、ITL和有效完成率;
  • Token长度分布是否漂移;
  • 输出Schema失败率;
  • 用户取消率;
  • 每有效结果成本;
  • 主动Fallback和人工接管率。

回滚条件要在发布前定义。例如新版本在连续两个窗口中:有效完成率下降超过阈值,或交互TTFT P99超标,同时样本量达到最低要求,则停止切流并恢复旧版本。不要等事故发生后临时挑一个有利指标解释。

12. 确定性TypeScript准入与路由演示

Section titled “12. 确定性TypeScript准入与路由演示”

下面的完整示例不调用真实模型。它验证Token、租户、Deadline、能力、健康度和成本预算,再按预测完成时间与成本稳定排序。输出是决定与理由,不伪造模型文本。

examples/model-serving-admission.runnable.ts
import assert from 'node:assert/strict';
type Capability = 'json_schema' | 'tools' | 'long_context';
type Priority = 'interactive' | 'normal' | 'batch';
type InferenceRequest = {
requestId: string;
tenantId: string;
priority: Priority;
inputTokens: number;
maxOutputTokens: number;
deadlineMs: number;
maxCostUnits: number;
requiredCapabilities: Capability[];
};
type Route = {
routeId: string;
modelRevision: string;
healthy: boolean;
maxInputTokens: number;
maxOutputTokens: number;
maxTotalTokens: number;
capabilities: Capability[];
predictedQueueMs: number;
predictedPrefillMs: number;
predictedInterTokenMs: number;
inputCostPerMillion: number;
outputCostPerMillion: number;
freeKvTokens: number;
};
type AdmissionContext = {
tenantInFlight: number;
tenantConcurrencyLimit: number;
};
type Decision =
| {
status: 'accepted';
requestId: string;
routeId: string;
modelRevision: string;
predictedTtftMs: number;
predictedE2eMs: number;
predictedCostUnits: number;
reasonCodes: string[];
}
| {
status: 'rejected';
requestId: string;
reasonCode:
| 'INVALID_TOKEN_BUDGET'
| 'TENANT_CONCURRENCY_LIMIT'
| 'NO_CAPABLE_ROUTE'
| 'DEADLINE_INSUFFICIENT'
| 'COST_BUDGET_EXCEEDED'
| 'CAPACITY_SHED';
retryable: boolean;
};
const FIRST_DECODE_MS = 40;
const FIRST_BYTE_NETWORK_MS = 20;
function hasCapabilities(route: Route, required: Capability[]): boolean {
return required.every((capability) => route.capabilities.includes(capability));
}
function estimate(route: Route, request: InferenceRequest) {
const predictedTtftMs =
route.predictedQueueMs +
route.predictedPrefillMs +
FIRST_DECODE_MS +
FIRST_BYTE_NETWORK_MS;
const remainingTokens = Math.max(0, request.maxOutputTokens - 1);
const predictedE2eMs =
predictedTtftMs + remainingTokens * route.predictedInterTokenMs;
const predictedCostUnits =
(request.inputTokens * route.inputCostPerMillion +
request.maxOutputTokens * route.outputCostPerMillion) /
1_000_000;
return { predictedTtftMs, predictedE2eMs, predictedCostUnits };
}
function decide(
request: InferenceRequest,
routes: Route[],
context: AdmissionContext,
): Decision {
if (
request.inputTokens <= 0 ||
request.maxOutputTokens <= 0 ||
request.deadlineMs <= 0
) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'INVALID_TOKEN_BUDGET',
retryable: false,
};
}
if (context.tenantInFlight >= context.tenantConcurrencyLimit) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'TENANT_CONCURRENCY_LIMIT',
retryable: true,
};
}
const capable = routes.filter(
(route) =>
route.healthy &&
request.inputTokens <= route.maxInputTokens &&
request.maxOutputTokens <= route.maxOutputTokens &&
request.inputTokens + request.maxOutputTokens <= route.maxTotalTokens &&
hasCapabilities(route, request.requiredCapabilities),
);
if (capable.length === 0) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'NO_CAPABLE_ROUTE',
retryable: false,
};
}
const withCapacity = capable.filter(
(route) =>
route.freeKvTokens >= request.inputTokens + request.maxOutputTokens,
);
if (withCapacity.length === 0) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'CAPACITY_SHED',
retryable: true,
};
}
const estimates = withCapacity.map((route) => ({
route,
...estimate(route, request),
}));
const withinDeadline = estimates.filter(
(item) => item.predictedE2eMs <= request.deadlineMs,
);
if (withinDeadline.length === 0) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'DEADLINE_INSUFFICIENT',
retryable: false,
};
}
const withinCost = withinDeadline.filter(
(item) => item.predictedCostUnits <= request.maxCostUnits,
);
if (withinCost.length === 0) {
return {
status: 'rejected',
requestId: request.requestId,
reasonCode: 'COST_BUDGET_EXCEEDED',
retryable: false,
};
}
withinCost.sort(
(a, b) =>
a.predictedE2eMs - b.predictedE2eMs ||
a.predictedCostUnits - b.predictedCostUnits ||
a.route.routeId.localeCompare(b.route.routeId),
);
const selected = withinCost[0];
assert(selected);
return {
status: 'accepted',
requestId: request.requestId,
routeId: selected.route.routeId,
modelRevision: selected.route.modelRevision,
predictedTtftMs: selected.predictedTtftMs,
predictedE2eMs: selected.predictedE2eMs,
predictedCostUnits: selected.predictedCostUnits,
reasonCodes: [
'CAPABILITY_MATCH',
'KV_CAPACITY_AVAILABLE',
'DEADLINE_FIT',
'COST_FIT',
],
};
}
const routes: Route[] = [
{
routeId: 'fast-v2',
modelRevision: 'model-fast-sha256-a1',
healthy: true,
maxInputTokens: 8_192,
maxOutputTokens: 1_024,
maxTotalTokens: 8_192,
capabilities: ['json_schema', 'tools'],
predictedQueueMs: 120,
predictedPrefillMs: 260,
predictedInterTokenMs: 24,
inputCostPerMillion: 2,
outputCostPerMillion: 8,
freeKvTokens: 24_000,
},
{
routeId: 'long-context-v4',
modelRevision: 'model-long-sha256-b7',
healthy: true,
maxInputTokens: 65_536,
maxOutputTokens: 2_048,
maxTotalTokens: 65_536,
capabilities: ['json_schema', 'tools', 'long_context'],
predictedQueueMs: 260,
predictedPrefillMs: 540,
predictedInterTokenMs: 36,
inputCostPerMillion: 4,
outputCostPerMillion: 12,
freeKvTokens: 80_000,
},
];
const normal: InferenceRequest = {
requestId: 'req-normal',
tenantId: 'tenant-a',
priority: 'interactive',
inputTokens: 1_000,
maxOutputTokens: 200,
deadlineMs: 8_000,
maxCostUnits: 0.01,
requiredCapabilities: ['json_schema'],
};
const accepted = decide(normal, routes, {
tenantInFlight: 1,
tenantConcurrencyLimit: 4,
});
assert.equal(accepted.status, 'accepted');
if (accepted.status === 'accepted') {
assert.equal(accepted.routeId, 'fast-v2');
assert(accepted.predictedE2eMs <= normal.deadlineMs);
}
const longContext = decide(
{
...normal,
requestId: 'req-long',
inputTokens: 12_000,
deadlineMs: 9_000,
requiredCapabilities: ['long_context'],
maxCostUnits: 0.1,
},
routes,
{ tenantInFlight: 0, tenantConcurrencyLimit: 4 },
);
assert.equal(longContext.status, 'accepted');
if (longContext.status === 'accepted') {
assert.equal(longContext.routeId, 'long-context-v4');
}
const quotaRejected = decide(normal, routes, {
tenantInFlight: 4,
tenantConcurrencyLimit: 4,
});
assert.deepEqual(quotaRejected, {
status: 'rejected',
requestId: 'req-normal',
reasonCode: 'TENANT_CONCURRENCY_LIMIT',
retryable: true,
});
const deadlineRejected = decide(
{ ...normal, requestId: 'req-deadline', deadlineMs: 300 },
routes,
{ tenantInFlight: 0, tenantConcurrencyLimit: 4 },
);
assert.equal(deadlineRejected.status, 'rejected');
if (deadlineRejected.status === 'rejected') {
assert.equal(deadlineRejected.reasonCode, 'DEADLINE_INSUFFICIENT');
}
const totalWindowRejected = decide(
{
...normal,
requestId: 'req-total-window',
inputTokens: 8_000,
maxOutputTokens: 512,
},
[routes[0]!],
{ tenantInFlight: 0, tenantConcurrencyLimit: 4 },
);
assert.equal(totalWindowRejected.status, 'rejected');
if (totalWindowRejected.status === 'rejected') {
assert.equal(totalWindowRejected.reasonCode, 'NO_CAPABLE_ROUTE');
}
const noCapacity = routes.map((route) => ({ ...route, freeKvTokens: 0 }));
const shed = decide(
{ ...normal, requestId: 'req-shed' },
noCapacity,
{ tenantInFlight: 0, tenantConcurrencyLimit: 4 },
);
assert.equal(shed.status, 'rejected');
if (shed.status === 'rejected') {
assert.equal(shed.reasonCode, 'CAPACITY_SHED');
assert.equal(shed.retryable, true);
}
console.log(
JSON.stringify(
{
status: 'passed',
acceptedRoute:
accepted.status === 'accepted' ? accepted.routeId : null,
longContextRoute:
longContext.status === 'accepted' ? longContext.routeId : null,
rejectionChecks: 3,
},
null,
2,
),
);

这个示例没有模拟真实GPU调度;它验证的是更靠前且必须稳定的决策边界。真实实现需要用观测到的分布更新预测,但不能因此丢失硬预算与Reason Code。

现象需要的最小证据可能根因不应立即做什么
TTFT P99升高,ITL稳定Queue、Prefill、输入Token桶排队增长、长Prompt集中、Prefill争用只调Decode批次
TTFT稳定,ITL恶化Decode时间、运行请求数、KV压力Decode过载、批次过大、KV抢占只加入口副本
GPU利用率高但吞吐下降每迭代Token、KV使用、抢占/重算内存碎片、长请求占槽、调度失衡继续增加并发上限
HTTP成功率高但用户取消增加首Token、取消时点、降级占比首字太慢、输出节奏差、降级质量下降用2xx证明系统健康
切流后成本突增模型/Tokenizer/配置版本、输入输出TokenTokenizer变化、输出变长、Hedge或重试只比较单价表
备用路由成功但Schema错误Capability、适配器版本、原始错误分类Fallback不等价、停止规则不同把所有完成都计为成功
超时后重复计费providerRequestId、取消确认、查询结果后端继续生成、重试复制请求把超时当作未执行
某租户正常、其他租户排队租户在途数、优先级、队列年龄公平性不足、配额缺失只扩大全局队列

实验一:平均延迟不变,P99排队爆炸

Section titled “实验一:平均延迟不变,P99排队爆炸”

构造95个queueMs=20和5个queueMs=4000的请求。分别计算平均值、P95、P99,并按交互/批处理切片。验收:告警由交互请求尾延迟触发,不能因平均值仍在宽松阈值内而通过。

实验二:KV容量不足时仍继续接纳

Section titled “实验二:KV容量不足时仍继续接纳”

把所有路由freeKvTokens降到请求总Token预算以下。验收:请求在队列外以CAPACITY_SHED拒绝,retryable=true,没有生成伪造的模型结果;恢复容量后同一业务请求可以受控重试。

实验三:Fallback丢失结构化输出能力

Section titled “实验三:Fallback丢失结构化输出能力”

将主路由标为不健康,备用路由移除json_schema能力。验收:返回NO_CAPABLE_ROUTE,不能因为备用路由能生成文本就绕过调用方的机器解析契约。

扩展实验:

  • 把容量Snapshot时间改为过期,验证路由器进入保守路径;
  • 模拟流中途断开,验证取消向后传播并记录确认状态;
  • 修改Tokenizer版本,让同一文本Token数改变,验证预算和Receipt能定位版本;
  • 启动Hedge但不取消副本,确认成本与在途数门禁失败;
  • 切入新版本时只看平均E2E,验证发布Gate能被尾延迟回归阻断。

完成本课后,作品必须能证明:

  • □ 每个请求在入队前检查Token、Deadline、租户和成本硬预算;
  • □ TTFT、ITL、E2E、排队和取消确认分别记录;
  • □ 指标按模型版本、请求类别和Token长度切片;
  • □ 路由先做能力与策略硬过滤,再做可解释排序;
  • □ 所有模型、Tokenizer、Runtime和策略版本进入Receipt;
  • □ 过载时有限队列与负载丢弃保护已接纳请求;
  • □ Fallback有质量与能力证据,不能绕过数据边界;
  • □ 超时、取消、失败和未知终态不混为一个异常;
  • □ 发布能金丝雀、比较、停止切流并回滚;
  • □ 成本按有效结果归因,而不是只统计Token单价。

静态Markdown中的方框仅表示阅读验收清单,不保存进度;课程完成状态使用页面顶部的进度控件。

  1. 为什么吞吐提高可能让首Token体验变差?
  2. TTFT、ITL与E2E分别定位哪类问题?
  3. 为什么KV容量要在准入阶段进入预算?
  4. 连续批处理解决什么,又会引入哪些公平性和尾延迟问题?
  5. 什么时候应该负载丢弃,而不是继续扩大队列?
  6. 为什么统一HTTP或推理协议不能证明后端能力等价?
  7. Fallback为什么需要离线质量证据与显式能力矩阵?
  8. 超时后为什么不能直接断言“模型没有执行”?
  9. 一次模型发布需要版本化哪些非权重资产?
  10. 如何证明一次路由决定在当时的容量、策略和预算下是合理的?

本课使用vLLM公开指标文档理解服务阶段可观测维度,使用PagedAttention与Orca论文理解KV内存和迭代调度问题,使用KServe开放推理协议说明模型/版本与请求边界,使用Google SRE材料说明SLO与控制动作。课程中的模型池、延迟、容量和成本数字都是教学夹具,不代表任何厂商、模型、GPU或部署的当前性能与价格。