用 RadixAttention 复用前缀 KV Cache
Day 33 关注 continuous batching:请求如何在运行中进出 batch。 Day 34 关注另一个推理服务里的常见收益点:多个请求共享同一段 prefix 时, 能不能少做 prefill?SGLang 的答案是 RadixAttention: 把已经处理过的 token prefix 和 KV Cache 放进 radix tree, 新请求到来时自动找最长共享前缀,复用命中的 KV,只计算后面的差异部分。
思维导图
为什么需要 Prefix Cache
在线 LLM 服务里,请求经常共享大段前缀: 同一套 system prompt、同一组工具定义、同一段 few-shot 示例、同一篇 RAG 文档。 如果每条请求都重新 prefill 这些相同 token,就会浪费 GPU 计算和 TTFT。
所有用户共享规则
客服、代码助手、Agent 平台通常有很长的系统提示和工具协议。只要字符串和 chat template 完全一致,就有复用机会。
同文档多问题
多个问题围绕同一篇论文、合同、日志或代码文件时,文档上下文可以作为共享 prefix,后面接不同 query。
对话历史递增
多轮对话的后续请求会包含前面轮次,天然形成“旧历史 + 新问题”的树状复用结构。
每条请求都重复 prefill 同一段共享上下文。
共享前缀只计算一次,后续请求复用已缓存的 K/V。
RadixAttention 的核心机制
RadixAttention 不是新的注意力数学公式,而是一套 KV Cache 组织和调度机制。 它把已处理过的 token 序列压进 radix tree:公共前缀共享同一个路径, 分叉处拆成不同子节点。新请求进来时先找最长已缓存 prefix, 命中的部分直接拿 KV Cache,未命中的 suffix 再进入 prefill。
| 操作 | 发生时机 | 做什么 | 工程含义 |
|---|---|---|---|
| Prefix match | 请求进入 waiting queue 后。 | 在 radix tree 中找最长已缓存 token prefix。 | 命中越长,prefill 越少,TTFT 越低。 |
| Insertion | 请求完成或生成推进后。 | 把 token 序列和对应 KV cache index 插入树。 | 后续请求可以复用这段路径。 |
| Node split | 新请求在已有节点中间分叉。 | 拆分节点,让公共部分继续共享,差异部分变成子分支。 | 适合多轮对话和树状 Agent 任务。 |
| Eviction | KV Cache 空间不足时。 | 按 LRU/LFU/FIFO 等策略回收可驱逐节点。 | 缓存命中率和显存压力之间需要平衡。 |
启动 SGLang Server
SGLang 提供 OpenAI-compatible API,默认常用端口是
30000。今天用小模型先跑通观察链路;
如果有足够显存,可以换成 7B/8B Instruct 模型。
安装与启动
python -m venv .venv-sglang
source .venv-sglang/bin/activate
pip install -U pip
pip install -U "sglang[all]" openai
export MODEL=qwen/qwen2.5-0.5b-instruct
python3 -m sglang.launch_server \
--model-path ${MODEL} \
--host 0.0.0.0 \
--port 30000 \
--enable-cache-report \
--log-requests服务 ready
等待日志出现 server ready 类似信息后,访问 http://localhost:30000/docs 或发送 OpenAI API 请求。
关闭 radix cache
为了做 baseline,可以另起一组服务加 --disable-radix-cache,对比相同请求下 cached tokens 和 TTFT。
OpenAI API 烟测
curl http://localhost:30000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen/qwen2.5-0.5b-instruct",
"messages": [
{"role": "system", "content": "你是一个简洁的 AI Infra 助教。"},
{"role": "user", "content": "用一句话解释 RadixAttention。"}
],
"temperature": 0,
"max_tokens": 96
}'动手:同 system prompt 观察命中
实验设计很简单:保持 system prompt 完全相同,只改变 user question。
第一个请求会把共享前缀放进缓存;后续请求应该能复用这段 prefix。
我们用 --enable-cache-report 和
return_cached_tokens_details 观察 cached tokens。
from openai import OpenAI import time MODEL = "qwen/qwen2.5-0.5b-instruct" client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY") SYSTEM_PROMPT = """你是一个 AI Infra 助教。 回答必须使用中文。 回答结构固定为:概念、为什么重要、排查建议。 不要输出无关寒暄。""" questions = [ "解释 KV Cache 为什么会成为推理显存大头。", "解释 PagedAttention 解决了什么问题。", "解释 continuous batching 为什么能提高吞吐。", "比较 vLLM 和 SGLang 的缓存思路。", ] def cached_tokens(resp): details = getattr(resp.usage, "prompt_tokens_details", None) return getattr(details, "cached_tokens", None) if details else None for i, q in enumerate(questions, 1): t0 = time.perf_counter() resp = client.chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": q}, ], temperature=0, max_tokens=128, extra_body={"return_cached_tokens_details": True}, ) dt = time.perf_counter() - t0 usage = resp.usage print({ "i": i, "latency_s": round(dt, 3), "prompt_tokens": usage.prompt_tokens, "cached_tokens": cached_tokens(resp), "completion_tokens": usage.completion_tokens, }) print(resp.choices[0].message.content[:120], "\n")
先跑一轮预热
第一次请求通常包括加载、编译、缓存插入等额外开销,不要把它当稳定指标。
固定 system prompt
保持字符串完全一致,包括换行、空格、标点和大小写。
记录 cached tokens
后续请求如果命中共享 prefix,应该看到 cached tokens 增加。
做反例
改一个空格或标点,观察 cached tokens 和延迟是否变化。
反例:一个字符也可能破坏命中
SYSTEM_PROMPT_A = "你是一个 AI Infra 助教。回答必须使用中文。" SYSTEM_PROMPT_B = "你是一个AI Infra助教。回答必须使用中文。" # 对人类看起来差不多,但 tokenizer 看到的是不同 token 序列。 # prefix cache 按 token id 精确匹配,不要依赖“语义相似”。
共享 Prefix 的请求调度
Prefix cache 不是单纯的“查一下字典”。 调度器要在等待队列里识别哪些请求已经命中缓存、命中多长、哪些 KV 正在被活跃请求引用, 然后把有限的 GPU token budget 分给 prefill 和 decode。
| 调度问题 | 没有 Prefix Cache | 有 RadixAttention | 观测指标 |
|---|---|---|---|
| 长 system prompt | 每个请求都完整 prefill。 | 首个请求 prefill,后续复用命中 prefix。 | cached_tokens、TTFT。 |
| RAG 同文档多问 | 同一文档反复占用 prefill 计算。 | 文档部分成为共享路径,问题部分成为分支。 | prompt throughput、cache hit rate。 |
| 缓存满了 | 通常只保留活跃请求 KV。 | 已完成请求的可复用 KV 也可能保留,满时再驱逐。 | protected/evictable cache size。 |
| 请求格式漂移 | 影响不明显。 | 会降低 prefix 命中率。 | cached tokens 突然下降。 |
把 prompt 模板标准化
如果你希望系统 prompt、工具 schema、few-shot 示例被缓存,就要把它们集中在模板层生成, 不要让各业务方自由拼字符串。缓存命中率经常输在“多了一个换行”。
压测与对照实验
Day34 的压测目标不是最大吞吐,而是证明 prefix cache 真的生效。
用相同请求集跑两组:默认 RadixAttention 与 --disable-radix-cache baseline。
对比 cached tokens、TTFT、总耗时和 prompt throughput。
使用 bench_serving 随机负载
python3 -m sglang.bench_serving \ --backend sglang-oai-chat \ --host http://localhost:30000 \ --dataset-name random \ --num-prompts 100 \ --random-input 512 \ --random-output 128 \ --request-rate 10 \ --max-concurrency 32
更适合 Day34 的自定义负载
workload:
shared_prefix:
- long_system_prompt
- tool_schema
- few_shot_examples
variable_suffix:
- user_question_001
- user_question_002
- user_question_003
compare:
A: default radix cache
B: --disable-radix-cache
metrics:
- cached_tokens
- p50_ttft_ms
- p95_ttft_ms
- output_tokens_per_s
- prompt_throughput
- gpu_memory_used常见疑问
Q1 RadixAttention 和 PagedAttention 是替代关系吗? +
不是简单替代。PagedAttention 主要解决 KV Cache 的分页管理和碎片问题;RadixAttention 关注跨请求的共享 prefix 复用。两者都围绕 KV Cache,但切入点不同。
Q2 为什么语义相同的 prompt 不能复用? +
KV Cache 对应的是精确 token 序列的中间状态。语义相似但 token id 不同,attention 的输入就不同,不能直接复用。prefix cache 匹配的是 token,不是自然语言含义。
Q3 缓存命中会不会改变模型输出? +
正常情况下不会。它复用的是同一 token prefix 已经计算出的 K/V,等价于重新 prefill 那段 prefix。输出差异通常来自采样随机性、并发非确定性或实现细节,不是 prefix cache 的语义变化。
Q4 哪些业务最适合 RadixAttention? +
长 system prompt、多工具 Agent、同文档多问、few-shot 分类、多轮对话、代码仓库问答都很适合。完全随机的短 prompt 工作负载收益会小很多。
复盘问题
- 用自己的话解释 RadixAttention 为什么要用 radix tree 保存 KV Cache。
- Prefix cache 为什么必须做 token 级精确匹配?哪些模板变化会破坏命中?
- 对比 PagedAttention 和 RadixAttention:它们分别解决 KV Cache 的什么问题?
- 如果 cached tokens 很少,你会从 prompt 模板、chat template、请求顺序哪些方向排查?
- 设计一个“同文档多问”的压测负载,说明你会记录哪些指标。
- 为什么缓存命中能降低 TTFT,但不一定明显降低长输出阶段的 TPOT?
今日检查清单
- 能解释 SGLang 的定位:高吞吐、低延迟推理框架,支持 OpenAI-compatible API。
- 能用
python3 -m sglang.launch_server启动一个本地服务。 - 能用 OpenAI SDK 请求
http://localhost:30000/v1。 - 能解释 prefix cache 与 KV Cache 的关系。
- 能画出共享 system prompt 的 radix tree 分叉示意。
- 能解释 prefix match、node split、insertion、eviction 四个操作。
- 能开启
--enable-cache-report并读取 cached tokens。 - 能用相同 system prompt 多请求观察缓存命中。
- 能做
--disable-radix-cache对照实验。 - 能写出 prompt 模板标准化建议,避免无意破坏 cache hit。
推荐阅读
权重量化
Day35 进入推理成本优化的另一条主线:INT8/INT4、GPTQ、AWQ,以及如何用量化降低模型权重显存。
Day 35 预告
量化:INT8 / INT4 / GPTQ / AWQ
今天我们通过 prefix cache 复用减少重复 prefill。 Day35 会从另一个角度降低推理成本:把权重用更低 bit 表示, 学习 INT8、INT4、GPTQ、AWQ 的基本原理,并动手量化一个模型。