针对 Anthropic 推理 API 题目的完整解答:prefill 与 decode、KV 缓存、continuous batching、分页内存、GPU 容量计算、过载控制、故障处理,以及固定批大小变体。

作者:The Forward Deployed 编辑团队 已审阅

本文属于 Anthropic 系统设计题库。该题目能代表该轮面试的水平。分析与解答为本站原创。

问题描述

该服务位于 API 客户端与一组 GPU 机器之间。它接收生成请求,决定哪些请求一起运行在哪些 GPU 上,以流式方式返回 token,并在需求超过容量时保护自身。训练、微调和计费系统不在范围内。安全检查仅作为请求路径中的一个阶段,属于范围内。

每个请求一个线程的 Web 服务是naive的答案,它几乎浪费了全部 GPU 算力。设计的其余部分就是解释为什么,以及用什么来替代它。

澄清问题

若干参数会影响设计。下面每个问题确定其中一个,并给出本方案所采用的假设。

  • 是否流式返回? 两者都要。聊天客户端随 token 到达逐步流式接收。批处理任务则只想要一个完整响应。
  • 一个模型还是多个? 若干个模型,且在发布期间每个模型会有多个版本同时在线。
  • 延迟目标是多少? 两个指标,因为成因不同。首 token 时间(TTFT)涵盖排队和 prompt 处理。每输出 token 时间(TPOT)涵盖每个生成步骤。
  • 存在哪些流量层级? 付费与免费。过载时优先保护付费流量。
  • 流量形态如何? 突发式。一次发布或一个爆款产品可能在几分钟内让需求翻倍。
  • prompt 和回答有多长? 练习设定:prompt 平均 1,500 token,回答 300 token,部分 prompt 可达 100,000 token。

LLM 服务的难点在哪里

推理请求不同于普通 Web 请求。三个事实使它与众不同。

第一,每个请求的工作量在准入时是未知的。服务器知道 prompt 长度,但在模型停止之前,没人知道回答需要多少 token。

第二,生成是逐个 token 进行的。每个新 token 都需要对模型做一次完整前向计算。一个 300 token 的回答就是 300 次串行计算。

第三,每次计算都受内存带宽限制。GPU 必须从内存读取全部模型权重,才能为一个序列生成一个 token,而随后对这些权重几乎没做多少算术运算。单个序列会让 GPU 的大部分算力闲置。

因此核心矛盾是吞吐量与延迟。把多个序列放进每次计算中,GPU 每次读取权重能完成多得多的有效工作,使每个 token 的成本更低。但更大的批次会让每一步变慢,请求也要等更久才能加入。下面的每个设计选择都在这条线上权衡。

flowchart LR C([Clients]):::user --> G[Gateway]:::svc G --> S[Scheduler<br/>forms batches]:::svc S --> R[GPU replica<br/>model + KV cache]:::store R -->|tokens| G G -->|stream| C classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 LLM 服务是一个调度问题。只有当许多序列共享每一轮计算时,GPU 才能发挥全部性能,因此调度器同时决定了成本和延迟。

关键概念

本节介绍该设计所依赖的思想。如果遇到不熟悉的术语,请先阅读本节再看需求部分。

预填充与解码

生成分为两个阶段。预填充在一次计算中处理整个提示词。所有提示词 token 并行通过模型,因此预填充在每次读取权重时执行大量算术运算。它是计算受限的,并产生第一个输出 token。

解码随后每次一轮计算生成一个剩余 token。每一轮为每个序列处理一个新 token。它读取全部权重却只做少量数学运算,因此解码是内存带宽受限的。请求的大部分耗时都花在解码上。

flowchart LR P["Prefill: 1,500 prompt tokens<br/>one pass, compute-bound"]:::hot --> T1["token 1"]:::svc T1 --> D["Decode: one pass per token<br/>memory-bound, repeated 299 times"]:::svc --> TN["token 300"]:::svc classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef hot fill:#fbe9e4,stroke:#c4492d,color:#171717;

KV 缓存

注意力机制需要为每个先前的 token 保存每一层的键向量和值向量。在每一步重新计算它们会重复之前所有的计算。因此服务器在请求的整个生命周期内将它们保存在 GPU 内存中。这就是 KV 缓存。

KV 缓存为每个 token 在每一层增加一个条目,并一直保留到请求结束。除权重之外,它是 GPU 内存的最大占用者。在实践中,它决定了一个批次能容纳多少序列,这使它成为吞吐量的真正瓶颈。

批处理策略

策略如何组成批次优势劣势
无批处理每次计算处理一个请求简单,零负载时延迟最低几乎浪费全部算力
静态批处理等待 N 个请求,运行直到全部完成易于实现短回答要等最长的;序列完成后槽位闲置
连续批处理在每个解码步骤重新组成批次批次保持满载;新请求在一个步骤内即可启动调度器每隔几毫秒运行一次,且必须逐步管理内存

连续批处理,也称为迭代级调度,是现代服务引擎的基线。它由 Orca 论文提出,如今已是开源引擎的标准做法。

分页 KV 内存

如果服务器为每个请求的最大可能长度预留 KV 内存,那么大部分内存将保持空闲,能容纳的序列也很少。分页分配将 KV 内存拆分成小的固定块,例如每块 16 个 token。每个序列都有一个块表,就像操作系统中的页表一样。内存只随序列增长而增长。共享前缀的请求(例如相同的系统提示词)可以指向相同的块。

核心思想。 解码受内存带宽限制,因此批处理是关键杠杆;KV 缓存占用内存,因此它限制了批的大小。连续批处理保持批次满载,而分页内存使批次大小达到内存允许的上限。

需求

在继续阅读之前。 列出功能需求和非功能需求。然后指出你会不惜一切代价保护的那一个属性,以及塑造设计的那一个约束。

1.1 功能需求

  • 接收生成请求,包含模型、消息、采样设置和最大输出长度。
  • 在 token 生成时将其流式传输给客户端,或返回单个响应。
  • 同时服务多个模型及其多个版本。
  • 按 API 密钥和按层级实施速率限制。
  • 为每个请求报告 token 用量。
  • 对输入和输出运行安全检查。

1.2 非功能需求

  • 延迟。 作为练习:付费流量的 TTFT 在 p95 下低于一秒,TPOT 在 p95 下低于 50 毫秒。
  • 每 GPU 吞吐量。 在延迟目标允许的范围内尽可能高,因为 GPU 是主要的成本。
  • 可用性。 API 在 GPU 故障和流量突发期间仍能持续响应,必要时可以快速拒绝部分流量。
  • 隔离性。 一个客户的流量突发不能破坏其他所有人的延迟。

1.3 约束与属性

API 的可用性是要保护的属性。 在过载情况下,系统可以拒绝请求,但必须快速、明确地拒绝,并且付费流量必须持续流动。GPU 成本是塑造设计的约束。 GPU 稀缺、扩容缓慢且昂贵,因此设计的存在就是为了在延迟目标内从每 GPU 秒中提取最多的 token。延迟是成本所交换的预算。

核心思想。 通过快速、分层的拒绝来保护可用性。围绕 GPU 成本进行设计。在延迟目标范围内,用延迟换取吞吐量。

背面估算

这些数字用于估算三件事:一个副本能容纳多少序列、一个副本的生成速度有多快,以及流量需要多少 GPU。所有输入均为练习用。硬件参数来自厂商公布的规格。

2.1 每 token 的 KV 缓存

每 token 的 KV 字节数 = 2(键和值)× 层数 × KV 头数 × 头大小 × 每个数字的字节数。作为练习,取一个 700 亿参数的模型,具有 80 层、8 个 KV 头、头大小为 128、16 位数字:

2 × 80 × 8 × 128 × 2 = 327,680 字节,约每 token 0.33 MB。

一段 2,000 token 的对话需要约 650 MB 的 KV 缓存。

2.2 能容纳多少条序列

权重占用 700 亿 × 2 字节 = 140 GB。一组 8 张各 80 GB 的 GPU 共有 640 GB。扣除权重和工作内存后,假设还剩 400 GB 可用于 KV 缓存。

400 GB / 327,680 字节 ≈ 122 万 token 的缓存。按每条序列 2,000 token 计算,大约能同时容纳 610 条序列。这就是批大小的内存上限。

2.3 一组副本的解码速度

每个解码步骤必须读取全部 140 GB 权重,加上批中每条序列的 KV 缓存。一张 H100 SXM GPU 的内存读取速度约为 3.35 TB/s,因此 8 张合计约 26.8 TB/s。

批大小为 1 时,一个步骤读取 140 GB:140 / 26,800 ≈ 5.2 ms。也就是说,单个用户约每秒 190 个 token,整组副本也是每秒 190 个 token。

批大小为 256 条序列、每条 2,000 token 时,该步骤还要读取 256 × 2,000 × 327,680 字节 ≈ 168 GB 的 KV 缓存,多耗时约 6.3 ms。整个步骤约需 11.5 ms。每个用户仍然看到约每秒 87 个 token,而整组副本产出 256 × 87 ≈ 每秒 22,000 个 token。

这个对比正是批处理的核心论据:以约两倍的单 token 延迟换取 117 倍的吞吐量。 这些都是上限值。真实的引擎会因调度、采样以及 GPU 之间的通信损失一部分性能,所以规划时应使用实测数字。

2.4 支撑流量所需的 GPU 数量

实际场景:峰值每秒 500 个请求,每个请求 1,500 个提示 token 和 300 个输出 token。

解码需要每秒 500 × 300 = 150,000 个输出 token。假设基准测试显示一组副本在满足 TPOT 目标的同时能维持每秒 9,000 个 token。这大约需要 17 组副本,即 136 张 GPU。

预填充需要每秒 500 × 1,500 = 750,000 个提示 token。一次前向传播对每个 token 约需 2 × 参数量次浮点运算,因此 2 × 700 亿 × 750,000 ≈ 每秒 1.05 × 10^17 次运算。一张 H100 可提供约 989 teraflops 的 16 位稠密计算能力。按 50% 利用率计算,预填充大约需要 212 张 GPU。

合计约 350 张 GPU。再加上 30% 以应对突发流量和机器故障,规划约 57 组副本,即 456 张 GPU。

注意这些数字说明的问题:当提示很长而回答很短时,预填充占用的 GPU 比解码更多。 这一事实将在第 6 节中深入探讨。

2.5 每 token 成本

每百万输出 token 的成本 = (每 GPU 小时价格 × 每组副本 GPU 数) / (每组副本每秒 token 数 × 3,600) × 1,000,000。批大小每提高一档每秒 token 数,这个数字就按比例直接下降。这就是为什么调度器是系统中最重要的成本组成部分。

核心思想。 KV 缓存限制了批大小,批大小决定吞吐量,吞吐量决定 GPU 账单。对于提示密集型流量,预填充计算可能主导整个集群。

API 设计

阅读前须知。 集群已经过载。API 应该将请求排队等待,还是立即拒绝?客户端会看到什么?

两个极端都不可行。一个短小、有界的队列可以吸收小的突发流量。超过等待预算后,API 应立即拒绝,并返回明确的状态码和重试提示。一个等待 60 秒然后超时的客户端,比一个快速收到拒绝并采用退避策略重试的客户端处境更糟。

3.1 生成

POST /v1/messages
Headers: x-api-key, Idempotency-Key (optional, for non-streamed calls)
{
  "model": "model-large-2026-09",
  "messages": [{"role": "user", "content": "..."}],
  "max_tokens": 1024,
  "temperature": 0.7,
  "stream": true
}

stream events (server-sent events):
  message_start   {id, model}
  content_delta   {text}
  message_stop    {stop_reason, usage: {input_tokens, output_tokens}}

non-streamed response:
  {id, content, stop_reason, usage}

3.2 错误

400 prompt 加上 max_tokens 超出了模型的上下文窗口 401 API 密钥无效 429 该密钥或层级触发速率限制,附带 Retry-After 529 服务过载,附带 Retry-After 500 内部错误;携带相同 Idempotency-Key 的非流式调用可以安全重试

服务器根据自己统计的用量进行计数和计费。

max_tokens

是必需的,因为调度器需要用它来预留容量。

核心思想。 过载时快速且明确地拒绝,将按密钥的限制(429)与集群过载(529)区分开,并要求提供 max_tokens 以便调度器进行规划。

数据模型

大部分状态保存在服务路径的内存中。持久化存储只保存配置和用量数据。

4.1 调度器上的请求状态

Request {
  id, api_key, tier, model_version,
  prompt_tokens: int[], max_tokens, sampling,
  arrived_at, deadline,           // admission deadline for queued requests
  state: queued | prefilling | decoding | finished | cancelled,
  generated: int,                 // tokens produced so far
  block_table: int[]              // KV blocks owned by this sequence
}

4.2 每个副本上的 KV 内存

free_blocks: 块 ID 的栈 block_refcount[block_id]: int // 当序列共享前缀时 >1 prefix_index: hash(前缀 token) -> 块 ID

4.3 集群注册表与用量

replica(id, model_version, gpus, status, queue_len, kv_free_blocks, last_heartbeat) usage(request_id, api_key, model, input_tokens, output_tokens, finished_at) // 仅追加 limits(api_key, tier, tokens_per_minute, requests_per_minute) // 配置存储

路由器每隔几百毫秒读取一次注册表。用量记录先写入日志,再进入计费系统,不占用请求路径。

核心思想。 热路径将状态保存在内存中:队列、块表和副本负载。持久化写入只针对用量数据,且绝不阻塞 token 生成。

高层设计

从最小可行版本开始构建,设计最容易理解。每一次故障都会引入下一个组件。

5.1 每个 GPU 通道处理一个请求

最简单的服务器加载模型,一次只运行一个请求:先 prefill,然后解码直到完成,再处理下一个请求。

flowchart LR C([Client]):::user --> A[API server]:::svc --> M[Model on GPUs<br/>one request at a time]:::store classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

它能运行,但浪费了 GPU。第 2.3 节展示了一个序列在能够产出 22,000 token 的硬件上每秒只能获得 190 个 token。其他所有请求都在排队等待。

5.2 修复 1:将请求批量处理

收集多个请求并在一次通道中运行。每次权重读取现在可以服务于批次中的所有序列。

flowchart LR C([Clients]):::user --> A[API server]:::svc --> Q[[Queue]]:::svc --> B[Static batcher<br/>wait for N]:::new --> M[Model on GPUs]:::store classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

吞吐量大幅提升。但批次要一直运行到最长的答案完成为止。一个 20 token 的答案要等旁边 1,000 token 的答案,它的槽位在批次的大部分时间里都空着。新请求必须等整个批次排空。

5.3 修复 2:调度每一次解码步骤

用一个在每一步都重新组批的调度器取代静态批处理器。当一个序列完成时,它立即离开,其槽位交给一个等待中的请求。

flowchart LR Q[[Waiting queue]]:::svc --> S[Iteration scheduler<br/>runs every step]:::new S -->|batch for this step| M[Model on GPUs]:::store M -->|finished sequences leave| S classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

批次保持满载,新请求在一个步骤内(约 10 毫秒)即可开始。新的瓶颈是内存:如果没有空间容纳其 KV 缓存,调度器就无法接纳一个序列。

5.4 修复 3:对 KV 缓存分页

在连续分配方式下,每个请求预先为其最大长度预留内存。这些预留大部分从未被使用,因此能容纳的序列很少。分页分配则随着序列的增长分发小块。当空闲块足以容纳其提示词和几步增长时,调度器就接纳该请求。

flowchart LR S[Iteration scheduler]:::svc --> BM[Block manager<br/>16-token blocks]:::new BM --> KV[(KV memory pool)]:::store S --> M[Model on GPUs]:::store M --> KV classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

当内存中途耗尽时,调度器会抢占最新的序列,释放其占用的块,并将该请求放回队列以便稍后重新计算。共享前缀(例如同一客户的每个请求都使用的长系统提示词)只占用一组块,并带有引用计数。

5.5 修复 4:多副本与负载感知路由器

单个副本无法承载流量,而且单副本是单点故障。运行多个副本并在前面放置一个路由器。路由器必须具备负载感知能力:轮询调度会把新工作发给 KV 内存已满的副本,而另一个副本却半空着。

flowchart LR G[Gateway]:::svc --> RT[Router<br/>least queued tokens]:::new RT --> R1[Replica 1]:::store RT --> R2[Replica 2]:::store RT --> R3[Replica N]:::store R1 -. load report .-> REG[(Replica registry)]:::new R2 -. load report .-> REG R3 -. load report .-> REG REG -.-> RT classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

副本每隔几百毫秒报告一次队列长度和空闲 KV 块。路由器为请求的模型版本选择排队工作量最少的副本。当某个副本已将请求的前缀缓存在内存中且与最佳选择相近时,路由器也会优先选择该副本。

5.6 修复 5:准入控制与分层

突发流量的到来速度比集群扩容的速度更快。没有准入控制,队列会无限增长,每个请求都会变慢,客户端在已经消耗了 GPU 时间之后才超时。请在网关处添加一个准入层。

flowchart LR
  C([Clients]):::user --> GW[Gateway]:::svc
  GW --> RL{Per-key limit}:::new
  RL -->|over| R429[429]:::bad
  RL -->|ok| AD{Estimated wait<br/>under budget?}:::new
  AD -->|no| R529[529]:::bad
  AD -->|yes| TQ[[Paid queue / free queue]]:::new --> RT[Router]:::svc
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
  classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

网关根据排队 token 数和最近的集群吞吐量来估算等待时间。如果等待时间超出预算,它会立即以 529 拒绝请求。付费流量和免费流量使用独立的队列,路由器优先服务付费流量。

5.7 组合后的设计

sequenceDiagram
  autonumber
  actor C as Client
  participant G as Gateway
  participant R as Router
  participant S as Replica scheduler
  participant M as Model (GPUs)
  C->>G: POST /v1/messages (stream)
  G->>G: auth, per-key limit, input safety check
  G->>R: admit (tier, model, prompt tokens)
  R->>S: enqueue on least-loaded replica
  S->>M: prefill (possibly in chunks)
  M-->>S: first token
  S-->>G: token
  G-->>C: content_delta
  loop every decode step
    S->>M: step for the whole batch
    M-->>S: one token per sequence
    S-->>G: tokens
    G-->>C: content_delta
  end
  S-->>G: stop + usage
  G-->>C: message_stop
  G--)G: usage record to log (async)

每个组件都解决了简单设计中的一个缺陷。批处理修复了算力浪费。步级调度修复了空闲槽位。分页内存修复了 KV 空间浪费。路由器分散负载。准入控制防止突发流量演变成超时。

核心思想。 把它构建成一条修复链:批处理、按步调度、缓存分页、按负载路由,并且只准入集群能够服务的请求。

深入探讨

五个决策决定了这一轮的表现:prefill 和 decode 如何共享 GPU、系统如何在突发流量中存活、GPU 宕机时会发生什么、模型如何发布,以及要度量什么。

6.1 Prefill 与 decode 的相互干扰

继续阅读之前。 一个带有 50,000 token 提示词的请求加入了一个繁忙的副本。该副本上的其他 200 个用户会受到什么影响?你会如何处理?

50,000 token 的 prefill 是一次巨大的算力突发。如果它作为一次完整运行,副本上的每个解码序列都要等待它。在那一步中,它们的每输出 token 时间会从约 11 ms 飙升到数百毫秒,用户会看到流式输出卡住。

有两种修复方法,而且可以结合使用。

分块 prefill 将长提示词拆分成块,例如每块 512 token,并在每个 decode 步中加入一个块。Decode 步会稍微变慢,但绝不会停滞。长请求的首 token 时间会略有增加,因为它的 prefill 被分散到了多个步骤中。

分离式部署在独立的副本池上分别运行预填充和解码。预填充副本处理提示词,然后通过高速网络将 KV 缓存发送给解码副本。每个池针对各自的阶段进行调优:预填充池侧重算力,解码池侧重内存。第 2.4 节已说明预填充可能比解码需要更多 GPU,因此分别规划两个池的规模可以节省成本。代价是传输 KV 缓存:50,000 个 token × 0.33 MB,该请求约需 16 GB,这需要高速互连。

flowchart LR RT[Router]:::svc --> PF[Prefill pool<br/>compute-heavy]:::store PF -->|KV cache transfer| DC[Decode pool<br/>memory-heavy]:::store DC -->|tokens| RT classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

从分块预填充开始,因为它简单且能解决停顿问题。当长提示词很常见且集群规模大到足以拆分时,再转向分离式部署。

区分答案优劣的关键:预填充与解码

弱把请求当作单一的工作单元

不知道两个阶段的成本不同,因此无法解释为什么一个长提示词会让其他用户停顿。

良好指出了干扰问题并提到分块预填充

解释预填充受算力限制、解码受内存限制,并通过分块处理长提示词来解决停顿。

强分别规划预填充和解码的规模

利用流量构成说明哪个阶段主导 GPU 数量,并论证何时分离式部署值得其所需的 KV 传输开销。

6.2 应对流量突发

在继续阅读之前。 流量在五分钟内翻倍。一个新的 GPU 副本需要八分钟才能启动。在这段空窗期内,靠什么保护用户?

自动扩缩器无法吸收比其启动时间更短的突发。一个副本需要一台机器、一个容器,以及数百 GB 的权重加载到 GPU 内存中。因此系统需要三层防御。

准入控制守住底线。网关用排队 token 数除以近期吞吐量来估算每个请求的等待时间。如果估算超出预算,就立即返回 529。客户端带抖动地退避,队列保持足够短,使被接纳的请求能达到其目标。

分层降级决定谁需要等待。随着队列增长,网关先收紧免费层限额,然后完全拒绝免费流量。付费流量保留一部分预留容量。限额跟随观测到的队列深度。每个 key 的静态令牌桶可以限制单个客户端,但当整个集群资源不足时它无能为力。

更快的容量缩短空窗期。保留一个预热副本池,权重已加载且无流量。将权重缓存在本地 NVMe 磁盘上。以全速网络从对等机器拉取权重,这本身就是一个设计问题(参见模型权重分发)。

根据正确的信号扩容。某个本可承担更多工作的副本,GPU 利用率看起来却很高,因为即使在小批量下,解码也会让 GPU 保持忙碌。应根据排队 token 数量和空闲 KV 内存来扩容。

flowchart LR
  B[Burst]:::bad --> A{Admission:<br/>wait under budget?}:::svc
  A -->|no| F[529 + Retry-After<br/>free tier first]:::bad
  A -->|yes| Q[[Queue]]:::svc --> RP[Replicas]:::store
  QD[Queued tokens<br/>KV free]:::svc --> AS[Autoscaler]:::svc --> WP[Warm pool<br/>weights loaded]:::store --> RP
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;

区分答案的关键:突发流量

较弱:依赖自动扩缩容

只说系统会扩容,却从未提及新容量就绪前的几分钟。

较好:添加速率限制和队列

对队列设上限并按 key 限流,使系统降级运行而不是崩溃。

强:将准入与队列深度和分层绑定

根据排队 token 估算等待时间,优先丢弃免费流量,基于排队 token 和 KV 内存扩容,并通过预热池和本地权重缓存缩短启动时间。

6.3 一块 GPU 在批次中途故障

在继续阅读之前。 一个 8-GPU 副本中的一块 GPU 发生故障。该副本上运行的 250 个序列会怎样?

在张量并行下,每块 GPU 持有每一层的一个分片。失去一块 GPU 会导致整个副本停机,其批次中的每个序列都会丢失 KV 缓存。路由器在心跳丢失或出错后将该副本标记为不健康,停止向其发送工作,并将其进行中的请求交还给网关。

接下来发生什么取决于请求类型。

  • 非流式请求很简单。客户端尚未收到任何内容,因此网关将其重新提交到另一个副本。客户端的幂等键意味着客户端重试不会产生第二个计费请求。
  • 流式请求已经发送了 token。一种选择是重新提交提示词加上已发送的 token 并继续。新副本对它们进行预填充并生成剩余部分。答案保持连贯,不过如果开启了采样,衔接处的风格可能发生变化。另一种选择是以可重试的错误结束流,让客户端自行决定。许多 API 为简单起见选择报错。请说明你选择哪种方式以及原因。

用量按实际交付的 token 记录。没有人会为丢失的工作付费。

区分答案的关键:故障处理

较弱:盲目重试

重发所有内容、重复计费,或忽略已送达用户的流式 token。

较好:区分流式与非流式

使用幂等键重试非流式请求,并以清晰、可重试的错误处理中断的流。

强:解释副本级故障与恢复

知道一块 GPU 会导致整个张量并行副本停机,描述健康检测与排空,并权衡通过重新预填充恢复与直接返回干净错误。

6.4 多模型与安全发布

在继续阅读之前。 一个新的模型版本已就绪。如何在不危及整个集群的情况下将流量迁移到它?

每个副本只服务一个模型版本。注册表将每个版本映射到其副本,路由器只将请求发送到其所请求版本的副本。模型别名,例如

model-large-latest

,在网关处解析为具体版本。

发布是一种流量切换。先启动一小池新版本副本,将一小部分别名流量发送给它们。对比错误率、延迟、输出检查和评测分数与旧版本的差异。按步进逐步扩大流量占比,并在新版本证明自己之前保持旧池热备。回滚只是一次路由变更,只需几秒钟。

很少使用的模型在加载状态下闲置会浪费 GPU。将它们放在一个小型共享池中,接受首次请求需要数分钟的冷启动,并通过清晰的错误信息或更长的等待时间告知客户端。

6.5 需要测量什么

按 p50、p95 和 p99 跟踪 TTFT 和 TPOT,并按层级和模型拆分。跟踪队列等待时间、准入拒绝数、批大小、空闲 KV 内存、抢占次数、每个副本每秒处理的 token 数以及每百万 token 的成本。抢占次数上升意味着调度器准入的请求超出了内存容量。队列已满但批大小下降,说明准入受到某种限制,通常是内存碎片或某个副本卡住。

核心思想。 测量调度器所控制的东西:批大小、KV 内存、抢占和队列等待,同时也要测量用户能感受到的两个延迟指标。

6.6 前缀缓存与多轮对话

阅读前思考。 一个聊天用户发送了对话的第 12 轮。提示词包含之前的 11 轮加上新消息,共 9,000 个 token。只有最后 300 个 token 是新的。服务器需要重新计算什么,能否避免?

没有前缀缓存时,服务器会重新预填充全部 9,000 个 token,尽管它在上一轮已经计算过前 8,700 个 token 的 KV 缓存。对于聊天负载来说,大部分预填充工作都是重复劳动。

前缀缓存在请求结束后保留 KV 块,并按它们所覆盖的 token 块的哈希值建立索引。当新请求到达时,调度器按块大小逐步遍历其提示词,对每个前缀计算哈希,并复用内存中已有的每个块。只有新的后缀需要预填充。

实际算一下,采用 2.4 节的数字:预填充每个 token 约需 2 × 700 亿次运算。重新计算 8,700 个 token 约需 1.2 × 10^15 次运算;在 8 块 GPU 上每块 GPU 有效算力为 500 teraflops 的情况下,这大约占用一个副本 0.3 秒的满载算力,而且每一轮都要花费。有了前缀缓存后,新的 300 个 token 只需约 10 毫秒。第 12 轮的首 token 时间从几百毫秒降至几十毫秒,副本每秒能服务多得多的轮次。

同样的机制也适用于共享的系统提示词。如果某个客户的每个请求都以相同的 3,000 token 系统提示词开头,那么一份块的副本就能服务所有请求,每个块带一个引用计数。

要在生产环境中用好它,有三个细节:

  • 路由必须跟随缓存。 缓存的块位于某一个副本上。路由器对会话或前缀计算哈希,优先选择持有该缓存的副本,除非该副本已过载。这就是带负载上限的一致性哈希。
  • 淘汰必须智能。 缓存块与运行中的序列竞争内存。优先淘汰最近最少使用的块,绝不淘汰运行中序列正在使用的块。
  • 正确性取决于精确的 token。 只有每个 token 完全一致时前缀才匹配,包括系统提示词和任何工具定义。把稳定内容放在提示词前面,把可变内容放在最后,这样前缀才能对齐。
flowchart LR
  R[Request: 8,700 old + 300 new tokens]:::svc --> H[Hash prompt in 16-token blocks]:::svc
  H --> M{Blocks in cache?}:::svc
  M -->|first 8,700 tokens: yes| REUSE[Reuse KV blocks]:::store
  M -->|last 300: no| PF[Prefill 300 tokens]:::store
  REUSE --> D[Decode]:::svc
  PF --> D
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

区分答案的关键:重复的前缀

弱每次重新计算每个提示词

把每一轮对话当作新请求,每次都支付完整的预填充成本。

好使用前缀缓存

为常见前缀保留 KV 块,并在后续请求中复用它们。

强让缓存真正有效

在负载上限内将对话路由到持有其前缀的副本,按最近使用情况淘汰且不触碰活跃序列,并调整提示词内容的顺序使前缀精确匹配。

6.7 跟随容量的速率限制

继续阅读之前。 某客户的套餐允许每分钟 400,000 个 token。你如何在 20 台网关服务器上执行这一限制?当集群损失一半 GPU 时会发生什么?

按 token 而非请求数执行每个密钥的限制:一个带有 100,000 token 提示词的请求远比一个只有 100 token 的请求成本高。在准入时收取提示词 token 加上

max_tokens

预估的输出 token,请求完成后退还未使用的输出 token。这可以防止客户端发送许多看似很小、但每个都会生成数千 token 的请求。

跨 20 台网关时,将计数器保存在支持原子递增的共享内存存储中,每个 API 密钥每分钟窗口一个键,并使用滑动窗口计数器进行检查。每次检查增加一次远低于一毫秒的往返。对于极高流量的密钥,让每台网关在本地租用一部分配额,用尽后从中央计数器补充,这消除了往返开销,代价是少量超额。

每个密钥的限制保护集群免受单个客户端的影响,但不能保护集群免受自身的影响。当一半 GPU 故障时,总容量减半,而每个客户仍在套餐范围内。因此需要增加第二个动态层:基于实测吞吐量得出的全集群准入预算。网关将排队 token 与集群在等待预算内可服务的量进行比较,并按层级调整有效限制。免费层级首先下降,然后付费限制按比例缩减,每次拒绝都返回 529 并附带基于队列估计的 Retry-After。

这就是面试官期望的答案:每个密钥的静态 token 桶,加上与观测队列深度挂钩的动态节流。

变体

7.1 固定批次变体

有些面试官会固定 GPU 契约,排除连续批处理、自动扩缩容和速率限制。你得到一个无法更改的调用接口,只需围绕它设计调度层。作为练习:

run_batch(inputs: list[str]) -> list[str] - 每次调用 1 到 50 个输入 - 按顺序为每个输入返回一个输出 - 无论批次大小如何,始终耗时 200 毫秒 - 每个 GPU 同一时间只运行一次调用 流量:每秒 2,000 个请求,同步 HTTP,p95 预算 500 毫秒

容量。 一个满批次每 GPU 每秒可服务 50 / 0.2 = 250 个请求,因此 8 个 GPU 是底线。在半满批次的情况下,同样的流量需要 16 个 GPU。批次器决定了你要付出哪种代价。

批次器。 当队列中积压 50 个请求,或最老的请求已等待 T 毫秒时,就刷新一个批次,以先到者为准。分派给任意空闲 GPU。

pending: 每个层级的队列(付费优先) inflight: batch_id -> (gpu, request_ids, attempt) 循环:等待直到 len(pending) >= 50 或 oldest.wait >= T batch = 取最多 50 个,付费优先于免费 gpu = 下一个空闲 worker inflight[batch_id] = (gpu, ids, attempt=1) outputs = gpu.run_batch(batch) 按顺序对每个 (request, output):解析等待中的 HTTP 调用 删除 inflight[batch_id] worker 故障时:对该 worker 上的每个批次:重新分派到另一个 worker,attempt += 1

选择 T。 单个请求的最坏情况是 T,加上等待空闲 GPU 的时间,再加上 200 毫秒。为了在留出排队余量的情况下将 p95 保持在 500 毫秒以内,T 应约为 150 毫秒或更少。在每秒 2,000 个请求时,每 25 毫秒到达 50 个请求,因此批次早在 T 之前就按大小填满了。在每秒 20 个请求时,批次永远不会填满,此时 T 决定了延迟。

免费与付费。 两个队列汇入同一个批次器。每个批次先取付费请求,再用免费请求填满剩余槽位。在过载情况下,免费请求等待时间最长,最先超时。

7.2 设计文档交付

同样的系统有时会以一份待评审的文档形式出现:一个埋有缺陷的推理服务器。面试官主要关心的是批处理策略,所以要尽快切入。设计文档评审解法中介绍了方法。

7.3 超长上下文

在 200,000 个 token 时,该模型中一个序列需要约 65 GB 的 KV 缓存。它会挤占几十个正常请求的资源。将超长请求路由到单独的资源池,始终使用分块预填充,并考虑使用更低精度的 KV 缓存将其大小减半,同时进行评测以确认质量没有下降。

7.4 多个区域

在每个区域运行完整的集群,并将用户路由到最近的健康区域。当某个区域 GPU 短缺时,网关可以将非交互式流量的溢出请求发送到另一个区域。交互式流量保持在本地,因为跨区域跳转会给每个 token 增加延迟。

7.5 流量增长十倍

在每秒 5,000 个请求时,按 2.4 节的计算,在留出余量之前大约需要 3,500 个 GPU。有四件事会发生变化。

  • 分离式架构变得值得。 在该估算中,预填充约占集群的 60%;为预填充单独规划一个采用计算优化配置的资源池,以及一个采用内存优化批次大小的解码资源池,可以节省相当一部分 GPU。
  • 路由变成层级化的。 一个路由器无法实时跟踪 450 个副本的队列。将集群划分为 30 到 50 个副本的单元(cell),每个单元有自己的路由器,并在前面加一层轻量的全局层,根据负载和前缀亲和性来选择单元。
  • 权重分发变成一个系统。 在突发流量期间增加 50 个副本意味着需要快速移动数十 TB 的权重;点对点分发是必需的,而不是可选的。
  • 容量规划转向预测。 GPU 的采购要提前数周或数月。准入控制处理分钟级的问题;每周峰值的预测决定集群规模。

可迁移的模式

推理服务器是一个基于稀缺且有状态资源的调度器。资源是 GPU 内存,状态是每个序列的 KV 缓存。同样的形态出现在任何昂贵硬件服务大量小型、可变任务的场景中:GPU 训练集群、视频转码,甚至数据库连接池。这些问题是可以迁移的:调度的单位是什么,什么限制了并发,当达到上限时调度器做什么,以及准入如何保持队列短小?

回顾:30 秒答案

  • 解码受内存限制,所以要批处理。 一次权重读取可以服务批次中的每个序列。
  • 在每一步都进行调度。 连续批处理保持批次满载,并在一步之内启动新请求。
  • 对 KV 缓存进行分页。 按需分配的块可以容纳多得多的序列,并让前缀共享内存。
  • 按负载路由,按等待时间准入。 排队的 token 数量和空闲 KV 内存驱动路由、自动扩缩容和 529 响应,免费流量最先被削减。
  • 当提示词很长时,将预填充与解码分离。 先做分块;当集群规模大时再做分离部署。

测验

+为什么批处理在解码期间能大幅提升吞吐量?

每个解码步骤都必须从 GPU 内存读取所有模型权重,而这个读取占据了步骤时间的主导地位。只有一个序列时,这次读取只服务一个 token。有 256 个序列时,同样的读取服务 256 个 token,而步骤只会慢大约两倍,因为 KV 缓存的读取会增加。以适度的延迟代价,吞吐量提升了两个数量级。

+在实践中是什么限制了批次大小?

通常是 KV 缓存内存。批次中的每个序列都需要在 GPU 上保存其到目前为止每个 token 的键和值。一旦这块内存满了,调度器就无法在不抢占某个序列的情况下再接纳新序列。分页分配通过消除预先保留最大长度的浪费来提高这个上限。

+为什么静态批处理即使做了批处理也是浪费的?

批次要一直运行到其中最长的序列完成为止。短答案早早完成,留下无法被利用的空槽,而新请求必须等待整个批次排空。连续批处理通过在每一步重新组建批次解决了这两个问题。

+为什么 GPU 利用率是一个糟糕的自动扩缩容信号?

Decode 即使在小批量时也能让 GPU 忙于读取内存,因此一个本可以承担更多工作的副本看起来利用率很高。排队 token 数和空闲 KV 内存可以直接衡量剩余容量。

+长提示词如何影响其他用户,如何解决?

它的 prefill 是一次大规模的计算爆发。如果作为一个步骤运行,副本上每个解码中的序列都要等待,它们的流会冻结。分块 prefill 将其拆分到多个步骤中。分离式部署则把 prefill 完全移到独立的资源池。

+在固定批量变体中,低流量时延迟由什么决定?

批量超时。低流量时批量永远达不到大小上限,因此每个批量都要等待超时后才派发。超时必须连同排队等待和固定批量时间一起纳入延迟预算。

+前缀缓存如何加速多轮对话?

每一轮的提示词都重复了到目前为止的整个对话。由于之前各轮的 KV 块保留在内存中,只有新消息需要 prefill,这缩短了首 token 时间,并为其他请求释放算力。

+为什么按 token 计费速率限制,为什么在准入时预留 max_tokens?

请求之间的成本相差几个数量级,因此按请求数计数既不公平也不安全。在准入时预留 max_tokens 限定了请求可能消耗的上限;未使用的 token 在请求完成时退还。

来源与延伸阅读

NextDesign-Doc Review