预计阅读 10 分钟

前置知识:从 Transformer 到 SGLang 关心的那几件事

谁该读这一篇? 完全没接触过 LLM 推理引擎、但希望快速跟上后续章节的工程师。 前置阅读: 无(这就是起点)。 耗时: 50 分钟 学完能: 1. 用自己的话讲清楚 token、KV cache、Prefill / Decode 两阶段、TTFT / TPOT 的物理含义; 2. 算出一个 7B / 70B 模型在 H100 上的 KV cache 上限和单步耗时量级; 3. 解释为什么"前缀缓存"是 SGLang 最大的卖点之一; 4. 看到一个 LLM 服务化的请求,能在脑子里画出"从 HTTP 进来 → token 出去"的全链路; 5. 后续读 RadixAttention 时不会被"radix tree、page、block、token slot"这些词卡住。


1. Token 是什么,为什么这是一切的起点

LLM 不读字符,也不读单词,读的是 token:一段被分词器(tokenizer)切出来的离散单元。 对中文,token 通常对应 1-2 个汉字;对英文,一个 token 平均 ~0.75 个单词。

from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
print(tok.encode("你好,世界"))
# → [108386, 3837, 99489]   # 3 个 token
print(tok.encode("Hello, world!"))
# → [9707, 11, 1879, 0]      # 4 个 token

后续所有性能讨论都按 token 计:吞吐 = token/s,时延 = token 间隔(毫秒)。


2. 一个请求的两阶段:Prefill 和 Decode

[输入 prompt: 1024 token] ──Prefill──▶ KV cache (1024 个 token 的 K/V)
                                          │
                                          ▼
                                    采样 1 个新 token
                                          │
                                  ─────Decode 循环─────
                                  ▲      │
                                  │   采样 1 个新 token
                                  │      │
                                  └──────┘
                                     直到 EOS 或达到 max_tokens
  • Prefill:把整段 prompt 一次喂给模型,算出每层每个 token 的 Q/K/V,把 K/V 存进 cache。一次完成。
  • Decode:每次只算一个新 token 的 Q/K/V(拿前面所有 token 的 K/V 当 attention 的 K 和 V),把新算的 K/V 追加进 cache。多次循环

两个阶段的计算模式截然不同:

维度 Prefill Decode
一步喂进模型的 token 数 N(prompt 长度) 1
算子主要瓶颈 算力(matmul 大) 显存带宽(每步只算 1 token,矩阵很瘦)
GPU 利用率 低(除非 batch 起来)
影响的 SLO TTFT(首 token 时延) TPOT(token 间隔)

这两个阶段为什么不能合一?因为它们的算力/带宽配比不同; 把 prefill 和 decode 混着调度 + 切分(chunked prefill / disaggregated)是 vLLM、SGLang 这类引擎共同的核心议题。后面 02-core-concepts/05-token-attention.md05-distributed/04-disaggregated.md 会展开。


3. TTFT vs TPOT:用户感知的两个时延

指标 含义 用户感知
TTFT(Time To First Token) 从请求到第一个 token 输出的时间 "卡了多久才开始打字"
TPOT(Time Per Output Token) 平均每个后续 token 的间隔 "打字快不快"
E2E latency TTFT + (output_len - 1) × TPOT 整体响应时间
Goodput 满足 SLO 的请求 token/s 容量规划的硬指标

业务对 chat 类需求通常是 TTFT < 500 msTPOT < 50 ms。 SGLang 的几乎所有优化都在调这两个数:

  • RadixAttention → 降 TTFT(命中前缀就不用再 prefill)
  • Cache-aware scheduling → 降 TTFT(让命中前缀的请求优先排队)
  • 压缩 FSM → 降 TPOT(约束生成时 jump-ahead 多吐 token)
  • Speculative decoding → 降 TPOT

4. KV Cache:必须存在的原因 & 算它有多大

朴素实现的灾难:每生成一个新 token,前面所有 token 的 K、V 都要重新计算一遍。 长 prompt + 长 output 的复杂度是 $O(N^2)$

KV cache 把每层 attention 的 K 和 V 存下来,新 token 只算自己的 Q/K/V 然后拼接。 代价是显存 —— 这个显存有多大?

$$\text{per\_token\_layer\_bytes} = 2 \times h_{\text{kv}} \times d_{\text{head}} \times \text{dtype\_bytes}$$
$$\text{KV\_total\_bytes} = N_{\text{tokens}} \times L_{\text{layers}} \times \text{per\_token\_layer\_bytes}$$

其中 $h_{\text{kv}}$ 是 KV head 数(GQA 模型下远小于 attention head 数),$d_{\text{head}}$ 是每个 head 的维度。

Llama-3-70B(H100,fp16)举例:

  • 80 层,KV heads = 8,head_dim = 128,fp16 (2 bytes)
  • 每 token 每层:$2 \times 8 \times 128 \times 2 = 4096$ bytes = 4 KiB
  • 整模型每 token:$80 \times 4 \text{ KiB} = 320$ KiB
  • 一张 80 GB H100 装完权重(140 GB → 必须 TP=2)后剩 ~20 GB 给 KV cache → 单卡 ~65k token 上限
  • 一个 batch 装 64 路请求、每路 1k token = 64 × 320 KiB = 20 GB —— 恰好用满

这里有个关键认知:KV cache 是显存里最大的一块,几乎所有推理引擎的设计都围绕"怎么把 KV 装得更密、怎么复用更多"展开。


5. Prefix Cache:SGLang 真正的特色

业务场景:一个 chat agent 经常有相同的 system prompt(几百到几千 token); RAG 系统的同一份检索结果会喂给不同问题;多轮对话的历史也是天然共享前缀。

朴素做法:每个请求自己存自己的 KV,前缀 token 也算 N 遍 prefill。 Prefix cache:把已经算过的 KV 存住,新请求来的时候做"最长前缀匹配",命中就跳过对应的 prefill。

请求 A: "system: 你是助手 ... user: 1+1=?"     (prompt 1500 token)
请求 B: "system: 你是助手 ... user: 2+2=?"     (prompt 1500 token)
        └────共享 1490 token────┘└── 各自 10 ──┘

朴素:    A 跑 1500 prefill,B 跑 1500 prefill   = 3000 token prefill
prefix cache:A 跑 1500 prefill,B 命中 1490 后只跑 10  = 1510 token prefill

命中率高的服务下,prefix cache 能把 TTFT 降一个数量级。

关键工程问题

  1. 怎么组织这些"已经算过的 KV"?vLLM 用 hash 表(block 级),SGLang 用 radix tree(token 级)。
  2. 显存满了怎么淘汰?LRU?引用计数?
  3. 命中之后怎么排队?让命中的请求优先 → SGLang 的 cache-aware scheduling

这三个问题就是 02-core-concepts/ 整一章的内容。


6. Continuous Batching:和静态 batch 的区别

朴素静态 batch:N 个请求一起发,所有请求做完才能进下一批。最慢的一个拖死整批。 连续批处理(continuous batching):每"一步"(iteration)都重新决定这一步喂哪些请求 —— 谁结束就让谁走,新进来的可以加入。

静态 batch(4 路):
step 1: A B C D ──┐
step 2: A B C D   │  D 在 step 3 已完成但要等到全部完成
step 3: A B C D ──┘
step 4: A B ── ──     C 在 step 5 完成
step 5: A   ── ──     B 在 step 6 完成
...

连续批:
step 1: A B C D
step 2: A B C D     D 完成 → 出去
step 3: A B C E     E 新加入
step 4: A B   E     C 完成 → 出去
...

GPU 利用率显著提高。vLLM 把这个做成 v0 的核心招牌;SGLang 在这个基础上更进一步加了 cache-aware 排序。

想看这个机制的最早论文:Orca (OSDI '22);在 vllm-learning 里有 vllm-learning/02-core-concepts/02-continuous-batching.md 详细展开,本书不重复。


7. 三种 Parallelism:TP / PP / DP(一句话各自)

并行 切什么 通信代价 典型场景
TP(Tensor Parallel) 一层内部的矩阵切多卡,AllReduce 拼回来 高(每层 1-2 次 AllReduce) 单模型放不下单卡(70B 必须 TP≥2)
PP(Pipeline Parallel) 把不同层放到不同卡 中(点对点 send/recv) 模型超大、跨节点
DP(Data Parallel) 多副本,每副本独立 低(只在 router 层) 单卡能装下,要扩 QPS

SGLang 还有 EP(Expert Parallel,MoE 专家切分)和 CP(Context Parallel,长上下文切分),后续 05-distributed/ 展开。


8. 一个请求在 SGLang 内部的旅程(鸟瞰)

sequenceDiagram
    participant U as 客户端
    participant H as HTTP Server
    participant T as TokenizerManager
    participant S as Scheduler
    participant W as ModelWorker (GPU)
    participant D as DetokenizerManager

    U->>H: POST /v1/chat/completions
    H->>T: 转发请求
    T->>T: tokenize prompt
    T->>S: GenerateReqInput (token ids)
    S->>S: match_prefix → 命中 1490 token
    S->>S: 加入 waiting queue,按 cache-aware policy 排序
    S->>W: ScheduleBatch (prefill + 已 decode 的请求合并)
    W->>W: forward(prefill) → 新 KV 写入 radix tree
    W->>W: sampler → 1 个新 token
    W->>S: BatchTokenIDOut
    S->>D: 转发 token id
    D->>D: 增量 detokenize → 文本片段
    D->>T: 文本片段
    T->>H: SSE chunk
    H->>U: data: {"choices": [...]}
    Note over S,W: decode 阶段重复 N 次直到 EOS

这就是后面所有源码章节的"主干"。卡住的话回到这张图。


9. 你需要熟悉的几个名词(卡片速查)

名词 一句话
Forward pass 模型从输入算到输出的一次前向
Logits 最后一层输出的、对每个 vocab token 的未归一化分数
Sampling 把 logits 变成下一个 token(greedy / top-k / top-p / temperature)
Page / Block KV cache 的连续单元,常见 16 / 32 / 64 token 一块
Page table / Block table 每个请求一张"逻辑 KV → 物理 page"的映射表
Radix tree 把所有缓存的前缀按"公共前缀分裂"组织成的多叉树(SGLang 用)
Hash table / 块级前缀 把前缀按 block 切,每块算 hash 存 dict(vLLM 用)
Continuous batching 每一步重新决定本步喂哪些请求
Cache-aware scheduling 调度时把"前缀命中长度"作为优先级权重
CUDA Graph 把一串 GPU 操作录下来一次提交,省 launch 开销
FlashAttention / FlashInfer 高效 attention kernel,SGLang 默认走 FlashInfer
Constrained decoding 强制输出符合 JSON Schema / regex / grammar
Compressed FSM SGLang 的快速受约束解码方案
DSL(Domain-Specific Language) SGLang 自己的前端语言,写 gen / select / fork

10. 自检

写下答案再看后文:

  1. 一个 7B 模型在 fp16、H100 单卡上跑,prompt = 2k、output = 512、batch = 32,KV cache 占多少显存?还能装多少 batch?
答案 Llama-2-7B 参数:32 层、num_kv_heads=32、head_dim=128(无 GQA)。 每 token 每层 KV:`2 × 32 × 128 × 2 = 16 KiB`;32 层 = 512 KiB/token。 单请求总 token = 2048 + 512 = 2560;KV = `2560 × 512 KiB ≈ 1.25 GB`。 batch=32 → `40 GB` KV。 H100 80 GB 装完权重 14 GB 后剩 ~66 GB,扣掉 workspace(~5-10 GB)剩 ~55 GB。 所以这个配置已经接近上限,再加 batch(如 64)会触发抢占。GQA 模型(如 Llama-3,KV head=8)能装 4× 更多。
  1. 为什么 prefill 阶段瓶颈是算力,decode 是带宽?画一下 Q × K^T 的形状变化你就懂了。
答案 Prefill:Q shape = `[N, d]`(N 个新 token),K^T = `[d, N]`,QK^T = `[N, N]` —— 是个**大方阵**,算力(FLOPs)∝ N²。GEMM 充分利用 Tensor Core,瓶颈在算力。 Decode:Q shape = `[1, d]`,K^T = `[d, L]`(L 是历史长度),QK^T = `[1, L]` —— 是个**瘦矩阵**,FLOPs 极小,但每步必须把整段 K/V 从 HBM 读出来。瓶颈在显存带宽。 这就是为什么 continuous batching 在 decode 阶段收益最大:把多请求的 Q 拼成 `[B, d]` 与同一份 K/V 共算,攒一次 K/V 读多次用。
  1. 同一个 system prompt(3000 token)被 100 个用户复用,prefix cache 能省多少 prefill 算力?
答案 无缓存:每用户 prefill 3000 token + user_query(设 100 token)≈ 3100 token,100 次 ≈ 310k token prefill。 有缓存:第一次完整 prefill 3100 token;之后 99 次只 prefill 100 token user_query → 共 `3100 + 99×100 = 13000` token。 节省 ≈ `(310000 - 13000) / 310000 ≈ 95.8%` 的 prefill 算力。 实际 TTFT 改善还要看长度平方关系(Attn 是 O(N²)):`(100/3100)² ≈ 0.1%` 的 prefill **时延**,TTFT 降到原来的 ~3%。
  1. 静态 batch 和 continuous batching 在 GPU 利用率上的差异,主要因为什么?
答案 静态 batch:N 路请求生成长度不一,**最慢的拖死全 batch**。后期只剩 1-2 路在 decode,其它 slot 空转。平均利用率 = ∑生成长度 / (N × 最长生成长度),常 < 50%。 Continuous batching:每"一步"(iteration)重新决定本步喂哪些请求,谁完成谁出去、谁新进来谁加入。slot 不会空转。平均利用率可到 85-95%。 核心使能:**iteration 级调度** + **paged/radix KV**(让请求随便加进 / 出 batch 而不破坏内存布局)。
  1. 你能在 5 秒钟里画出 §8 那张时序图吗?
答案 关键节点(5 秒能画出来的核心 6 步): Client → HTTP → **TM**(tokenize)→ **Scheduler**(match_prefix + 队列)→ **Worker**(forward)→ **Scheduler**(出 token id)→ **Detokenizer**(增量解码)→ **TM**(SSE chunk)→ Client。 决策点:进 Scheduler 后是否命中 RadixCache?是否触发 prefill 还是直接 decode?显存满了是否要 evict? 后续所有源码章节都是这条主干的展开。

11. 下一步

上游源码起点:sglang/python/sglang/launch_server.py