目录
一、加机器之前,先看 GPU 在等什么
单机跑 Ollama 很舒服:下载模型、加载、暴露一个 OpenAI 兼容接口,一个人用绰绰有余。但把它挪到多人环境,问题会以另一种形式出现——不是模型答得不好,而是显卡利用率上不去,排队时间下不来。
一个常见的现场是这样的:8 张卡、20 个并发用户,平均吞吐看起来还行,但 p95 的首 token 延迟到了好几秒;高峰期加一组副本,成本翻倍,尾部延迟只降了一点。于是开始怀疑模型太大、量化不够、网络太慢。
多数时候,瓶颈在调度,而不是算力。具体是三个问题:
- KV cache 怎么放:显存放得下多少条并发请求,决定了真正的并发上限。
- 请求怎么排队:一条长请求会不会把整批短请求卡住,决定了延迟分布。
- 前缀怎么复用:系统提示词、工具定义、RAG 上下文被反复预填,决定了首 token 要等多久。
vLLM 和 SGLang 之所以常被放在一起讨论,是因为它们都在回答这三个问题,只是各自的重心不同。下面按「负载特征 → 批处理 → 显存管理 → 前缀复用 → 延迟指标 → 选型与容量」的顺序拆开。
二、prefill 和 decode 是两种不同的负载
大模型推理分两个阶段,它们的瓶颈几乎相反。
Prefill(预填):把整段 prompt 一次性送进模型,并行计算所有 token 的注意力,产出 KV cache 和第一个输出 token。矩阵乘为主,属于计算密集,prompt 越长越重。它决定 TTFT(Time To First Token)。
Decode(解码):自回归地一次生成一个 token。每一步都要读一遍模型权重和已经积累的 KV cache,batch 小时是显存带宽瓶颈;batch 增大后,注意力计算占比上升,才逐渐转向计算瓶颈。它决定 ITL(Inter-Token Latency,也叫 TPOT)。
这解释了一个反直觉的现象:单条请求的 decode 再快,也不代表服务吞吐高。 batch=1 时,GPU 每生成一个 token 都要把权重从显存搬到计算单元,算力大量闲置;把多条请求拼进同一个 decode step,权重只读一次,吞吐才起得来。连续批处理的核心价值,就是让这个「拼」的过程不被最慢的请求绑住。
先把指标对齐,后面才不会各说各话:
| 指标 | 含义 | 主要受什么影响 |
|---|---|---|
| TTFT | 从请求到达到第一个 token 的时间 | 排队、prefill 长度、前缀命中 |
| ITL / TPOT | 相邻输出 token 的平均间隔 | decode batch、KV 读取、抢占 |
| tokens/s | 单位时间生成的 token 数 | batch 大小、显存带宽、并行度 |
| goodput | 满足 SLO 的请求吞吐 | 上面三者在真实流量下的组合 |
「tokens/s 提升了 30%」如果不说明 TTFT 和 ITL 怎么变,通常没有决策价值——把 batch 拉大、把请求多排一会儿,吞吐数字很容易变好看,代价是尾部延迟。
三、静态批处理:GPU 在等最长的那条请求
最朴素的批处理是静态批处理(static batching):攒够 N 条请求,把它们 padding 到同样的长度,一起送进模型,等这一批里最长的输出结束,再开始下一批。
问题有两层。
第一层是 padding 浪费。同一批里 prompt 长度从 300 到 8000 不等,按最长的补齐,短请求的大部分计算槽位是空的;输出侧同理,一条请求生成 1000 个 token,同批只生成 50 个的请求早就没事可做,但槽位要占到最后。
第二层是 队头阻塞。一批没结束,新来的请求只能等;批的持续时间由最慢的那条决定,而不是由平均值决定。流量越突发、长度分布越偏,浪费越明显。
写一段调度模拟来量化这件事。它不是 GPU benchmark,单位是「解码步」:一步代表一次迭代,所有活跃请求各生成一个 token;预填按每步 200 个 token 折算。这个比例接近中高并发下 prefill 与 decode 的相对速度,但具体数值会随模型、硬件、batch 变化,重点看两种调度的差距,而不是绝对时间。
// sim.mjs —— 单位是「解码步」,不是 GPU 毫秒const N = 240const PREFILL_TOKENS_PER_STEP = 200 // 每步可预填 200 个 tokenconst KV_LIMIT = 400_000 // KV cache 可容纳的 token 数const SHARED_PREFIX = 1_200 // 70% 请求共享的系统提示词长度const B = 64 // 静态批处理的批大小
let seed = 42const rand = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32)
function samplePrompt() { const r = rand() if (r < 0.6) return 200 + Math.floor(rand() * 600) if (r < 0.9) return 800 + Math.floor(rand() * 2200) return 3000 + Math.floor(rand() * 5000)}function sampleOutput() { const r = rand() if (r < 0.5) return 40 + Math.floor(rand() * 160) if (r < 0.9) return 200 + Math.floor(rand() * 400) return 600 + Math.floor(rand() * 600)}
const requests = []let clock = 0for (let i = 0; i < N; i++) { clock += -Math.log(1 - rand()) * 12 // 平均每 12 步到一个请求 requests.push({ id: i, arrival: Math.floor(clock), prompt: samplePrompt(), output: sampleOutput(), shared: rand() < 0.7, })}
const avg = (a) => a.reduce((s, x) => s + x, 0) / a.lengthconst pct = (a, p) => { const b = [...a].sort((x, y) => x - y) return b[Math.min(b.length - 1, Math.floor((p / 100) * b.length))]}const prefillSteps = (tokens) => Math.ceil(tokens / PREFILL_TOKENS_PER_STEP)
function summarize(name, makespan, ttfts, extra = {}) { console.log( `${name.padEnd(26)} makespan=${String(makespan).padStart(5)} ` + `TTFT mean=${avg(ttfts).toFixed(1).padStart(6)} p95=${String(pct(ttfts, 95)).padStart(5)} ` + Object.entries(extra) .map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(2) : v}`) .join(" "), )}
// 1) 静态批处理:凑满一批才发车,按最长 prompt / 最长 output 补齐function staticBatching() { let time = 0 let idx = 0 let paddedPrefill = 0 let realPrefill = 0 const ttfts = [] while (idx < requests.length) { const batch = requests.slice(idx, idx + B) const start = Math.max(time, Math.max(...batch.map((r) => r.arrival))) const maxP = Math.max(...batch.map((r) => r.prompt)) const maxO = Math.max(...batch.map((r) => r.output)) const pre = prefillSteps(maxP) paddedPrefill += batch.length * maxP realPrefill += batch.reduce((s, r) => s + r.prompt, 0) for (const r of batch) ttfts.push(start + pre - r.arrival) time = start + pre + maxO idx += B } summarize("静态批处理 (B=64)", time, ttfts, { "预填 padding 浪费": `${(100 * (1 - realPrefill / paddedPrefill)).toFixed(0)}%`, })}
// 2) 连续批处理:每一步都能收新请求,按 KV 容量准入;预填与解码并行function continuousBatch(usePrefixCache) { let time = 0 let next = 0 const waiting = [] const active = [] const ttfts = [] let peakKv = 0 const cacheSeen = new Set()
for (;;) { while (next < requests.length && requests[next].arrival <= time) { waiting.push(requests[next++]) }
// 推进预填;本步完成的请求从下一步开始解码 let kvUsed = active.reduce((s, a) => s + a.kv, 0) for (const a of active) { if (a.prefill > 0) { a.prefill -= 1 if (a.prefill === 0) ttfts.push(time + 1 - a.r.arrival) } }
// 准入:按到达顺序,KV 不够就等 while (waiting.length > 0) { const r = waiting[0] let prefillTokens = r.prompt if (usePrefixCache && r.shared) { if (cacheSeen.has("shared")) prefillTokens = Math.max(0, r.prompt - SHARED_PREFIX) else cacheSeen.add("shared") // 保守起见:KV 仍按整段计费,不计块级共享 } const pre = prefillSteps(prefillTokens) if (kvUsed + r.prompt > KV_LIMIT) break kvUsed += r.prompt waiting.shift() if (pre === 0) ttfts.push(time + 1 - r.arrival) active.push({ r, prefill: pre, kv: r.prompt, remaining: r.output }) }
// 已经预填完的请求各出 1 个 token,完成的释放 KV let released = 0 for (let i = active.length - 1; i >= 0; i--) { const a = active[i] if (a.prefill > 0) continue a.remaining -= 1 if (a.remaining <= 0) { released += a.kv active.splice(i, 1) } } peakKv = Math.max(peakKv, kvUsed - released) time += 1
if (next >= requests.length && waiting.length === 0 && active.length === 0) break }
summarize(usePrefixCache ? "连续批处理 + 前缀缓存" : "连续批处理", time, ttfts, { "峰值 KV (token)": String(peakKv), })}
console.log( `请求数=${N} 平均 prompt=${avg(requests.map((r) => r.prompt)).toFixed(0)} ` + `平均 output=${avg(requests.map((r) => r.output)).toFixed(0)} 平均到达间隔=12 步\n`,)staticBatching()continuousBatch(false)continuousBatch(true)运行结果(同一次随机种子,所以三组请求完全相同):
请求数=240 平均 prompt=1337 平均 output=336 平均到达间隔=12 步
静态批处理 (B=64) makespan= 5359 TTFT mean=1042.8 p95= 2027 预填 padding 浪费=80%连续批处理 makespan= 3475 TTFT mean= 8.2 p95= 24 峰值 KV (token)=51944连续批处理 + 前缀缓存 makespan= 3473 TTFT mean= 5.2 p95= 18 峰值 KV (token)=51944这组数字里有两件事值得注意。
80% 的预填槽位是 padding。 长度分布的尾部一长,静态批处理的平均浪费就迅速上升;模型越大、prompt 越长,这部分越贵。
静态批处理的 TTFT 均值是连续批处理的上百倍。 这不是说真实系统差这么多,而是因为模拟里静态批处理必须凑满 64 条才发车,而到达率又接近它的服务能力,队列会持续堆积。负载越接近饱和,两种调度的差距越大;负载很轻时,两者都很快,差距反而不明显。选型要看你的峰值,不是平均值。
另外,这组模拟把 KV 上限设得比较宽,是为了让容量不成为变量;真实系统里如果 KV 不够,连续批处理也会因为准入等待和抢占而变慢,这部分在第九节单独算。
四、连续批处理:把调度粒度降到每一步
连续批处理(continuous batching)把调度单位从「一批请求」改成「一个解码步」。这就是 Orca 提出的 iteration-level scheduling:每一步结束都重新看一遍队列,已经生成完的请求立刻离开,空出来的槽位马上给新请求。
它带来两个直接变化:
- 不需要等齐一批。 新请求在下一步就能加入,短请求不必陪跑。
- 槽位按真实进度释放。 输出长度差异不再变成 padding,而是变成流水线里的自然起伏。
真实实现还要处理准入和回收:每一步的活跃请求总 KV 不能超过显存预算;预算不够时,调度器要么让新请求等待,要么抢占(preemption)一条已有请求——把它的 KV 换出到 CPU(swap),或者直接丢掉、之后重新计算(recompute)。抢占能避免 OOM,但会制造 ITL 毛刺,所以 max-num-seqs、max-num-batched-tokens 这类参数不是越大越好,它们要和 KV 预算一起定。
上面的模拟里,连续批处理把 makespan 从 5359 降到 3475,TTFT 均值从 1042.8 降到 8.2。差距主要来自队列,不是单条请求变快了。连续批处理优化的是「等待」,不是「计算」。
但它单独使用时还有一个缺口:一条 32k token 的长 prompt 做 prefill 时,会占住整个 GPU 若干步,正在 decode 的请求只能干等,ITL 出现尖峰。这个问题要留到第七节的 chunked prefill。
五、PagedAttention:KV cache 的碎片就是吞吐
连续批处理解决了「怎么排」,接下来是「放得下多少」。放不下的不是权重,而是 KV cache。
KV cache 的大小随上下文线性增长。以常见的 GQA 模型为例,每个 token 的 KV 占用是:
每 token KV 字节数 = 2 × 层数 × KV 头数 × head_dim × 数据类型字节数其中的 2 是 K 和 V 两份。代入两个常见模型(bf16,2 字节):
| 模型 | 层数 | KV 头数 | head_dim | 每 token KV | 8k 上下文 |
|---|---|---|---|---|---|
| Llama 3.1 8B | 32 | 8 | 128 | 128 KiB | 1 GiB |
| Llama 3.1 70B | 80 | 8 | 128 | 320 KiB | 2.5 GiB |
注意 70B 那一行:它的参数量是 8B 的 8.75 倍,但每 token 的 KV 只有 2.5 倍,因为两者都用了 8 个 KV 头的 GQA。如果换成 64 个 KV 头的 MHA,70B 每 token 会是 2.5 MiB,8k 上下文一条请求就要 20 GiB——这就是为什么长上下文服务对 GQA/MQA 如此敏感。
传统做法是给每条请求按「最大可能长度」预留一块连续显存。请求实际只用了 2k,剩下的 6k 槽位也占着;不同请求的空闲区间大小不一,还会产生外部碎片。vLLM 的论文给出的数字是:已有系统里 60%–80% 的 KV 显存被碎片和预留浪费掉。
PagedAttention 借用了操作系统的虚拟内存分页思路:
- KV cache 切成固定大小的 block(vLLM 默认 16 个 token 一块)。
- 每条请求维护一张 block table,把逻辑块号映射到物理块号,物理块不必连续。
- 按需分配,最后一块可以共享;只有一块之内的内部碎片,上限是 block_size − 1 个 token。
- 因为块是引用计数的,相同前缀、并行采样(best-of-N)、beam search 可以共享物理块,写时复制。
这样一来,显存利用率上去了,能同时容纳的请求变多,连续批处理的 batch 才真正填得满。PagedAttention 和连续批处理是配套的:前者决定「能放多少」,后者决定「怎么把这些槽位用满」。
代价是间接寻址:每次注意力计算要读 block table,kernel 需要按块 gather。vLLM 把这部分做进了定制 CUDA kernel,换来了接近零的显存浪费。block 太小,block table 和寻址开销上升;太大,内部碎片增加。16 是一个在两者之间取平衡的常见默认值。
六、前缀缓存:Agent 场景的隐藏账单
Agent、RAG、客服机器人有一个共同特征:每次请求的前几千个 token 几乎一样。系统提示词、工具定义、few-shot 示例、检索到的固定知识,反复出现在每条请求的开头。如果没有缓存,每次都要把这些 token 重新 prefill 一遍。
前缀缓存(prefix caching)把「已经算过的 KV」留下来复用:
- vLLM 的自动前缀缓存按 block 计算哈希(哈希包含该块及其之前的所有 token),命中后半段块可以直接复用,不需要重算。
- SGLang 的 RadixAttention 把 KV 组织成一棵基数树,相同前缀共享路径,按 LRU 淘汰;它同时服务于前缀复用和分支式生成。
命中条件比很多人想象的严格:
- 必须是完整的前缀匹配。 开头差一个 token,后面全部失效。
- 通常按 block 对齐。 共享 1200 个 token,block 是 16,能复用的是前 1200 个 token 对应的完整块;剩下的部分仍要算。
- 前缀里的动态内容会击穿命中率。 把时间戳、用户名、随机 ID 放在系统提示词最前面,等于每条请求都是新前缀。稳定的内容放前面,变化的内容放后面。
- 缓存本身也占显存。 缓存块和活跃请求的 KV 抢同一块预算;命中率不是越高越好,要看省下的 prefill 是否值得挤掉的并发。
回到模拟:加上前缀缓存后,TTFT 均值从 8.2 降到 5.2,p95 从 24 降到 18,但 makespan 几乎没变(3475 → 3473)。原因很简单——这个负载是 decode 主导的,省掉 prefill 只影响首 token,不影响后续每个 token 的生成速度。反过来说,如果你的流量是「长 prompt、短输出」(比如分类、抽取、RAG 问答),前缀缓存和 prefill 优化的收益会大得多。
这也是为什么不能拿一个通用 benchmark 判断「谁更快」:流量结构不同,优化的着力点就不同。
七、chunked prefill 与 PD 分离:别让长 prompt 卡住 decode
连续批处理解决了「等整批」,但没有解决「一条长 prefill 占住整个 GPU」。在 decode 阶段,用户感知的是 ITL;一条 32k prompt 的 prefill 可能让所有正在 decode 的请求停顿几百毫秒,p99 ITL 直接爆炸。
Chunked prefill 把长 prompt 切成若干块,每块和当前的 decode 请求混在同一个 step 里执行。这样长 prefill 不再独占 GPU,代价是它自己的 TTFT 变长——原来一口气算完,现在要分几步。Sarathi-Serve 把这种思路形式化为「stall-free batching」:在吞吐和 ITL 之间做显式权衡,而不是让长请求自然地把别人卡住。
PD 分离(prefill-decode disaggregation)走得更远:把 prefill 和 decode 放到不同的实例池。prefill 池专注计算密集的长 prompt,decode 池专注显存带宽密集的逐 token 生成,两边按各自的 SLO 扩容,KV 通过高速互联从 prefill 池传到 decode 池。DistServe 和 Splitwise 是这条路线上的两个代表。
它适合什么场景?
- TTFT 和 ITL 的 SLO 都很紧,且两者对硬件的要求不同;
- 长 prompt 占比高,混部时相互干扰严重;
- 集群规模足够大,能把两个池都填满。
不适合什么场景?
- 小集群:两个池都填不满,GPU 利用率反而下降;
- 互联带宽不足:KV 传输会成为新的瓶颈;
- 团队还没有把连续批处理、KV 预算和指标监控跑顺——PD 分离会把问题从「调度」变成「分布式系统」,复杂度上一个台阶。
对多数团队来说,顺序应该是:先把连续批处理、chunked prefill、前缀缓存和容量规划做对,再考虑要不要拆池。
八、vLLM 和 SGLang 怎么选
两者都实现了同一组核心思想:PagedAttention 风格的块式 KV、连续批处理、前缀缓存、chunked prefill、OpenAI 兼容接口、张量并行。差异更多在重心,而不是「有没有」。
| 维度 | vLLM | SGLang |
|---|---|---|
| KV 管理 | PagedAttention + block table | RadixAttention,基数树组织 KV |
| 前缀复用 | 自动前缀缓存(按块哈希) | 基数树共享 + LRU 淘汰 |
| 调度 | 连续批处理、chunked prefill、抢占 | 连续批处理、chunked prefill、调度重叠 |
| 生态 | 模型、量化、TP/PP、多 LoRA 覆盖广 | 前端 DSL、结构化生成、多步程序 |
| 更适合 | 通用多模型服务、需要广覆盖 | 前缀重复高、Agent/多步调用密集 |
几条实践建议:
- 不要用别人的 benchmark 直接选型。 至少拿自己的流量分布(prompt/output 长度、前缀命中率、并发曲线、SLO)各跑一遍。
- 通用服务优先 vLLM。 它的模型和量化覆盖更广,遇到新模型、新精度格式时通常更省事。
- 前缀重复极高的 Agent 服务可以试 SGLang。 RadixAttention 对「同一系统提示词 + 不同分支」的场景更自然,但收益取决于真实命中率,不要假设缓存一定命中。
- 两者都别只调一个参数。
max-num-seqs、max-num-batched-tokens、gpu-memory-utilization和 KV 预算是联动的;只把并发拉高,换来的可能是抢占和 ITL 毛刺。
一个 vLLM 的启动示例(参数名以所装版本文档为准,较新版本的部分默认值可能已经变化):
vllm serve meta-llama/Llama-3.1-8B-Instruct \ --max-model-len 8192 \ --max-num-seqs 64 \ --gpu-memory-utilization 0.90 \ --enable-prefix-cachingSGLang 的对应启动方式:
python -m sglang.launch_server \ --model-path meta-llama/Llama-3.1-8B-Instruct \ --tp 1 \ --mem-fraction-static 0.85跑起来之后,至少盯这几个指标:TTFT、ITL 的 p50/p95/p99、运行中请求数、GPU KV cache 使用率、抢占次数。vLLM 提供 Prometheus 指标,具体名字随版本有差异,以 /metrics 的实际输出为准。没有这些指标,调参就是盲调。
九、容量规划:先算 KV,再谈并发
容量规划的顺序应该是「权重 → KV 预算 → 目标上下文下的并发 → 参数」,而不是先拍一个 max-num-seqs。
以 8B 模型、单张 80GB 卡、bf16 为例:
- 权重:8B × 2 字节 ≈ 16GB。
- 留给 KV 和激活的预算:按 90% 显存利用率算,约 72GB;扣掉权重和框架开销,KV 实际可用按 55–60GB 估。
- 每 token KV:128 KiB(见上表)。
- 目标上下文 8k:一条请求约 1 GiB,理论上限约 55–60 条;再扣掉碎片和激活,按 45–50 条规划更稳。
- 目标上下文 1k:一条约 128 MiB,同样预算下可以到 400 条以上,但这时瓶颈往往变成调度和计算,而不是显存。
70B 模型则是另一幅图景:bf16 权重约 140GB,两张 80GB 卡只剩 20GB 左右给 KV,8k 上下文一条 2.5 GiB,只能放个位数请求。要么上 4 张卡,要么用 FP8 权重/FP8 KV 把占用压下来,要么缩短上下文。长上下文服务的成本,主要写在 KV cache 上,不在参数量上。
几个容易忽略的约束:
- 张量并行(TP)不是免费的。 它把权重和 KV 按头切到多张卡,每层都要做 all-reduce;模型能放进单卡时,优先用数据并行(多副本)而不是 TP,尤其在低 batch 下,TP 的通信开销会吃掉收益。
- KV 量化要单独测质量。 FP8 KV 能把缓存减半,但对长上下文检索、精确复述这类任务可能有影响;省下的显存是否值得,要用自己的评测集回答。
- 前缀缓存会改变可用并发。 缓存块占用同一块显存预算,命中率高时省计算,命中率低时只是多占内存。
- 上下文长度是乘数。 把
max-model-len从 8k 提到 32k,KV 上限直接变成四分之一;先确认业务真的需要,再付这笔显存。
十、五个反复踩的坑
- 只看平均 tokens/s。 平均值会把长 prompt 造成的 ITL 尖峰抹平。至少同时看 TTFT 和 ITL 的 p95/p99,否则「吞吐上去了、用户更卡了」很难被发现。
- 前缀被动态内容击穿。 系统提示词开头放时间戳、请求 ID、用户名,命中率会接近零。把稳定内容前置,动态内容后置,再用指标验证命中率,而不是假设。
- 只算权重,不算 KV。 加载模型时显存看起来够,压测一会儿就 OOM 或频繁抢占。先按目标上下文算 KV,再定并发上限。
- 小 batch 上张量并行。 模型单卡放得下时,TP 带来的同步开销可能大于收益;优先多副本,把 TP 留给放不下的模型。
- 拿别人的 benchmark 选型。 别人的流量是长 prompt 还是长输出、前缀命中率多高、到达是否突发,和你的很可能完全不同。用自己的回放流量测 goodput,而不是看排行榜。
十一、小结
- prefill 和 decode 是两种负载。 前者计算密集、决定 TTFT;后者在 batch 小时受显存带宽限制、决定 ITL。优化之前先确认瓶颈在哪一段。
- 静态批处理浪费在 padding 和队头阻塞。 模拟里预填槽位有 80% 是 padding;连续批处理把调度粒度降到每一步,差距在接近饱和时最明显。
- PagedAttention 解决 KV 碎片。 块表 + 按需分配把浪费压到块内,顺带支持前缀共享和写时复制;它决定「能放多少并发」。
- 连续批处理解决排队。 但它不解决长 prefill 卡住 decode 的问题,后者要靠 chunked prefill;PD 分离是更大规模下的进一步拆分,不是默认答案。
- 前缀缓存只省 prefill。 对长 prompt、短输出的流量收益大;对 decode 主导的流量,TTFT 会降,但总吞吐未必变。
- 选型看自己的流量。 vLLM 覆盖面广,SGLang 在 RadixAttention 和前缀复用上更突出;两者都需要用真实指标调参,而不是照搬别人的结论。
- 容量先算 KV。 权重之后剩下的显存除以「每 token KV × 目标上下文」,才是并发上限;TP、KV 量化、前缀缓存都会改变这笔账。
参考来源
- vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention(PagedAttention、KV 碎片与吞吐数据)
- Orca: A Distributed Serving System for Transformer-Based Generative Models(iteration-level scheduling 与连续批处理)
- SGLang: Efficient Execution of Structured Language Model Programs(RadixAttention 与前缀共享)
- Sarathi-Serve: Efficient LLM Inference via Chunked Prefills and Stall-Free Batching(chunked prefill 与 ITL 权衡)
- DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving(PD 分离与 goodput)
- Splitwise: Efficient Generative LLM Inference Using Phase Splitting(按阶段拆分的硬件池)
- vLLM 文档(调度、前缀缓存、并行与指标)
- SGLang 文档(RadixAttention、启动参数与部署)