推理优化与工程部署 Inference Optimization & Deployment
当模型能力不再是瓶颈,决定项目生死的就是推理成本与延迟。这一阶段的目标很实际:把同样的模型跑得更快、更省、更稳。2026 年的就业市场上,「有大模型推理优化经验」是明确的加分项,因为这部分能力直接对应真金白银的账单。
阶段总览
- 能用第一性原理判断一个推理负载是显存带宽受限还是算力受限
- 掌握 vLLM / SGLang / TensorRT-LLM 的适用场景与关键参数
- 理解量化方法(GPTQ / AWQ / FP8 / KV Cache 量化)的取舍与验证方式
- 能解释并配置连续批处理、投机解码、前缀缓存、chunked prefill
- 理解 PD 分离架构与多卡并行(TP / PP / EP)的部署取舍
- 能写一个生产级推理服务:异步、流式、限流、重试、降级、灰度
- 能做容量规划与单位经济核算,回答「要几张卡、每千次请求多少钱」
| 周次 | 主题 | 交付物 |
|---|---|---|
| 第 1 周 | 推理性能原理 | 一份性能瓶颈分析(roofline 判断) |
| 第 2 周 | 推理引擎与关键优化 | vLLM / SGLang 压测对比报告 |
| 第 3 周 | 量化与验证 | 不同量化方案的精度–速度–显存对比 |
| 第 4 周 | 服务化与容错 | 生产级推理服务(流式 / 限流 / 降级) |
| 第 5 周 | 容量规划与 MLOps | 成本模型 + 上线与回滚流程 |
1. 推理指标体系:先定义再优化
学习路径
- 读 1.1:用 measure_stream 对一次流式响应算出 TTFT / TPOT(体感 20–60ms)
- 跑 latency_budget 分解代码,观察 decode 占端到端 80%+
- 完成 1.4 自测:对给定样本算 P50/P95/P99 并判断 P99/P50 健康度
- 对接 M16:压测时同时记录 TTFT 与分位数,作为 vLLM 调优基线
核心知识点详解
- 端到端 = TTFT + (n−1)×TPOT:交互产品把延迟拆三截看:TTFT 首 token 决定「响应快不快」,TPOT 每步间隔决定「生成稳不稳」;参考量级 TPOT 20–60ms(约 16–50 tokens/s)。用
measure_stream对一次流式响应逐 token 打点即可同时算出两项与分位数。 - 分位数用线性插值:P99 表示 99% 请求都不超过的延迟:先
sorted,再取rank=p×(n−1)在相邻两点间插值。看健康度用P99/P50 ≤ 2–4×,超过就怀疑长尾(排队 / KV 碎片 / GC)。 - 常见坑:超时请求被剔除:统计分位数时把超时 / 失败请求丢掉,P99 显得很健康——用户其实早已超时。超时请求要按 SLO 记为失败并参与计数,否则 goodput 与压测结论全是幻觉。
学习路径
- 读 1.2:理解 goodput 是满足 SLO 的有效吞吐,名义吞吐是幻觉
- 跑 goodput 与 sweep 代码,观察并发 128 时 goodput 腰斩
- 完成 1.4 自测:构造名义吞吐高但 goodput 低的数据,说明限流设在哪
- 对接 M16:按 SLO 门限给 vLLM 并发档位设限流参考
核心知识点详解
- goodput 是满足 SLO 的吞吐:名义吞吐是系统塞多少算多少;goodput 只统计达到延迟 SLO(如 P99<2s 且无超时)的请求。并发 128 时名义吞吐还在涨,goodput 可能腰斩。
- 限流设在 goodput 顶点:并发从 1 扫到过载,goodput 先升后降,拐点处就是限流阈值(常再 ×0.8 留安全余量)。慢请求挤压排队让更多请求超时,goodput 塌得比名义吞吐更快。
- 常见坑:拿名义吞吐定容量:按峰值名义吞吐买卡、设限流,进真实流量后大量请求因排队超时,SLA 破口。决策永远看 goodput 拐点,而不是理论上限。
学习路径
- 读 1.3:理解平均值骗人、用户体感由 P95/P99 决定
- 跑 tail_ratio 代码,对比平稳与长尾两种分布的 P99/P50
- 完成 1.4 自测:解释 P99 涨到 9s 而 P50 几乎不变时先怀疑什么
- 对接 M16:监控告警把 TTFT 分位数当指标并设阈值告警
核心知识点详解
- 平均值骗人,体感由 P99 决定:接口平均 800ms 看着不错,但 P99=6s 用户早已流失。要分位数就得留数据:每请求打点落 Prometheus Histogram,才能还原 P50/P95/P99。
- 长尾逐一排查:P99 涨到 9s 而 P50 几乎不变,优先怀疑三件事:并发超标引起排队、KV 碎片挤压 batch、GC / 调度抖动。先按预算分解定位段,再调参,别盲目加卡。
- 常见坑:只盯平均值:只监控 avg latency,长尾恶化毫无察觉,直到用户大量投诉。监控必须单独看分位数并设 P99 告警,如
TTFT P99 > 1s 持续 5min。
学习路径
- 读 1.1:把端到端预算拆成网络 / 网关 / 检索 / prefill / decode
- 跑 latency_budget,确认输出长度决定端到端(decode 占比大)
- 完成 1.4 自测:把自己服务拆成预算表并指出占比最大一项
- 对接 M16:在架构文档里写清预算分解与优化优先级
核心知识点详解
- 预算 = 网络+网关+检索+prefill+decode:端到端拆成:网络往返 50–150ms、网关鉴权 5–20ms、检索/工具 100–800ms、prefill 100–600ms(TTFT 主体)、decode (n−1)×TPOT。把每段写进
latency_budget表,找到占比最大一项才有优化抓手。 - decode 决定端到端:TPOT=40ms、输出 300 token 时,decode 占 (299)×40ms≈12s——端到端几乎由输出长度线性决定。想压延迟先问「能不能让模型少写点」,再谈引擎调参。
- 常见坑:全链路一起优化:不知道每段占多少就乱改,往往优化了最便宜的网关还自我感动。先量化分解、优先占比最大的段,每个优化都用预算表佐证收益。
1.1 先定义指标,再谈优化
优化的第一原则是「先定义指标,再动手」——否则你无法判断一次改动是变好还是变差。大模型服务天然分成 prefill 与 decode 两个阶段,因此指标体系也要分成「首 token」与「生成中」两条线。下面这张表是所有后续讨论的词汇表;面试与实际调优里,能准确说出每个指标受什么影响,比背公式更重要。
| 指标 | 定义 | 主要受什么影响 | 经验目标 |
|---|---|---|---|
| TTFT(Time To First Token) | 请求发起到收到首个输出 token 的延迟 | 输入长度、prefill 排队、前缀缓存命中率 | 交互场景 < 0.5s,批量 < 2s |
| TPOT / ITL | 相邻两个输出 token 的间隔(Time / Inter-Token Per Output Token) | decode 并发 batch、量化、KV 是否放得下 | 20–60ms(约 16–50 tokens/s 体感) |
| 端到端延迟 | TTFT + (输出长度 − 1) × TPOT | 长度分布、TPOT、TTFT | 与输出长度近似线性 |
| 吞吐 tokens/s | 单位时间生成 token 总量(系统容量) | 并发度、batch、量化、权重体积 | 越大越省,是容量核心 |
| 吞吐 requests/s(QPS) | 单位时间完成的请求数 | 单请求耗时、并发上限 | 直接决定所需副本数 |
| 并发度 | 同一时刻正在处理的请求数 | 限流、排队、调度 | 由 SLA 与显存共同决定 |
| goodput | 满足全部 SLO 的有效吞吐(剔除超时/失败) | SLO 阈值、尾延迟、错误率 | 比名义吞吐更反映真实价值 |
| P50 / P95 / P99 | 延迟分位数(从小到大第 50/95/99 百分位) | 排队、GC、KV 碎片、调度抖动 | 体验由 P99 决定 |
prefill 与 decode 的资源特性差异是后面所有优化的根源:prefill 算力密集(一次算出整个 prompt 的 KV,矩阵乘并行度高,算术强度高,接近 compute-bound),decode 带宽密集(每步只产 1 个 token,却要把全部权重与 KV 读一遍,算术强度低,严重 memory-bound)。正因 decode 是带宽受限,优化它的关键永远是两条——提高 batch(让一次权重读取服务更多 token)与降低显存访问(量化权重与 KV、减少读写量)。理解这一点,后面所有技术(批处理、量化、KV 管理、投机解码)都是这两条的推论。
python# 从一个流式响应里直接算出 TTFT / TPOT / 分位数(压测与线上监控同款逻辑)
import time, statistics
def measure_stream(chunks):
"""chunks: 按时间顺序到达的 (timestamp, token_text) 列表"""
t0 = chunks[0][0]
first = chunks[1][0] if len(chunks) > 1 else t0 # 首个生成 token 时刻
ttft = first - t0
gaps = [chunks[i][0] - chunks[i-1][0] for i in range(1, len(chunks))]
tpot = statistics.median(gaps) # 用中位数抗抖动
return {"ttft_s": round(ttft, 3), "tpot_ms": round(tpot*1000, 1),
"n_tokens": len(chunks) - 1}
def percentile(xs, p):
if not xs: return None
s = sorted(xs); n = len(s)
if n == 1: return s[0]
rank = p * (n - 1); lo = int(rank); hi = min(lo + 1, n - 1); frac = rank - lo
return s[lo] * (1 - frac) + s[hi] * frac
# 用法:把一批请求的端到端延迟传进来
# print("P50/P95/P99 =", percentile(ends, .5), percentile(ends, .95), percentile(ends, .99))
把指标变成「预算」才可执行。交互式产品的端到端预算通常这样拆:网络往返 50–150ms + 网关鉴权 5–20ms + 检索/工具 100–800ms + prefill 100–600ms(TTFT 主体)+ decode (n−1)×TPOT。当 TPOT=40ms、输出 300 token 时,decode 就占 (299)×40ms ≈ 12s——端到端延迟几乎由输出长度线性决定,想压延迟先问「能不能少写点」。
python# 延迟预算分解:找出「该优化的那一项」,而不是盲目调引擎
def latency_budget(in_tok, out_tok, ttft_ms=350, tpot_ms=40,
net_ms=80, gateway_ms=15, retrieve_ms=250, tool_ms=0):
prefill_decode = ttft_ms + (out_tok - 1) * tpot_ms
total = net_ms + gateway_ms + retrieve_ms + tool_ms + prefill_decode
return {"total_ms": total,
"ttft_ms": net_ms + gateway_ms + retrieve_ms + tool_ms + ttft_ms,
"decode_pct": round(prefill_decode / total * 100, 1)}
print(latency_budget(800, 300))
# 预期输出(约):{'total_ms': 12305, 'ttft_ms': 695, 'decode_pct': 94.3}
# 关键结论:输出 300 token、TPOT=40ms 时 decode 占端到端约 94%,
# 优化 prefill / 检索都是小事,降 TPOT 或缩短输出才是大头。
1.2 goodput:满足 SLO 的有效吞吐
名义吞吐(tokens/s)会骗人:一个系统可能「生成得很快」但大量请求超时或报错,这些 token 对用户毫无价值。goodput = 在 SLO 内成功完成的请求数 / 时间,才是真正衡量服务价值的指标。调优目标应当是「在给定 SLO 下最大化 goodput」,而不是单纯拉高名义吞吐——因为后者可以通过牺牲尾延迟换到,而用户感知不到那部分。
| SLO 维度 | 宽松配置(批处理/离线) | 严格配置(实时交互) |
|---|---|---|
| TTFT | < 5s | < 0.5s |
| TPOT | < 200ms | < 50ms |
| 请求成功率 | > 99% | > 99.9% |
| 单请求最大长度 | 32K | 4K |
python# goodput:把 SLO 当作筛选条件,而非平均值
SLO = {"ttft_max": 0.8, "tpot_max": 0.06, "err_max": 0.01}
def goodput(records):
"""records: [{ttft, tpot, ok, out_tokens, start, end}]"""
ok = [r for r in records
if r["ok"] and r["ttft"] <= SLO["ttft_max"] and r["tpot"] <= SLO["tpot_max"]]
good_rate = len(ok) / max(1, len(records))
good_tokens = sum(r.get("out_tokens", 0) for r in ok)
wall = max(r["end"] for r in records) - min(r["start"] for r in records)
return {"good_rate": round(good_rate, 3),
"goodput_tps": round(good_tokens / wall, 1),
"nominal_tps": round(sum(r.get("out_tokens",0) for r in records)/wall, 1)}
# 典型现象:把并发拉到拐点之上,nominal_tps 还涨,但 good_rate 暴跌
# —— 这时名义吞吐是幻觉,goodput 才是真相,限流阈值应按 goodput 设。
python# 并发扫描下 goodput 与名义吞吐的分叉 —— 限流阈值应设在 goodput 最大处
def sweep(records_by_conc):
"""records_by_conc: {concurrency: [rec, ...]}"""
for c, recs in records_by_conc.items():
g = goodput(recs)
print(f"conc={c:>4} nominal={g['nominal_tps']:>7} "
f"good={g['goodput_tps']:>7} rate={g['good_rate']}")
# 预期(8B / FP8 / H100,SLO 门限 p95 TTFT 0.8s):
# conc= 8 nominal= 420 good= 415 rate=0.99
# conc= 32 nominal=1180 good=1150 rate=0.98
# conc= 64 nominal=1620 good=1480 rate=0.91
# conc= 128 nominal=1850 good=1010 rate=0.55 <- 名义还在涨,goodput 已腰斩
# 结论:名义吞吐到 128 并发仍上升,但真实价值在 64 附近见顶 -> 限流设在 64。
1.3 分位数与尾延迟:为什么平均值会骗人
平均值会被「大量很快的请求」掩盖「少数极慢的请求」。但用户体验由最慢的那批决定——一个 P99 为 8s 的服务,意味着每 100 个用户里有 1 个等了 8 秒。线上监控与 SLA 一律看 P95/P99,不看平均。尾延迟的来源通常是:请求排队、KV Cache 碎片触发重分配、CUDA 内核启动抖动、垃圾回收,以及某个长请求把整个 batch 拖慢。
python# 分位数的稳健实现(线性插值,与 numpy/perf 一致)
def percentile(xs, p):
if not xs: return None
s = sorted(xs); n = len(s)
if n == 1: return s[0]
rank = p * (n - 1) # 线性插值位置
lo = int(rank); hi = min(lo + 1, n - 1)
frac = rank - lo
return s[lo] * (1 - frac) + s[hi] * frac
# 示例:同样的均值,分布天差地别
a = [0.2]*90 + [3.0]*10 # 均值 0.48,P99 = 3.0(长尾)
b = [0.45]*100 # 均值 0.45,P99 = 0.45(平稳)
print("A: mean=%.2f p99=%.2f" % (sum(a)/len(a), percentile(a, .99)))
print("B: mean=%.2f p99=%.2f" % (sum(b)/len(b), percentile(b, .99)))
# 输出:A 均值更低但 P99 是 B 的 6.7 倍 —— 用户体感由 A 的尾巴决定
python# 用 P99/P50 比值当「尾延迟健康度」告警指标(比绝对 P99 更抗流量波动)
def tail_ratio(lat):
p50, p99 = percentile(lat, .5), percentile(lat, .99)
health = "OK" if p99 <= 3 * p50 else ("WARN" if p99 <= 6 * p50 else "BAD")
return round(p99 / p50, 2), health
print(tail_ratio([0.45]*100)) # (1.0, 'OK') 平稳
print(tail_ratio([0.2]*90 + [3.0]*10)) # (13.5, 'BAD') 长尾
# 经验:健康的在线服务 P99/P50 常在 2–4 倍;一旦 > 6 倍,
# 通常是排队、KV 碎片或 GC 抖动,先查并发与显存再谈扩容。
1.4 动手练习与自测
- 用 measure_stream 对一次真实流式响应算出 TTFT / TPOT,并解释为什么用中位数而非均值(判据:能指出前几个 token 常因编译/首块抖动而偏大)。
- 给定 500 次请求延迟样本,写出 P50/P95/P99,并判断 P99/P50 是否健康(判据:比值 2–4 健康,> 6 需先查排队与显存)。
- 把某个你熟悉的服务拆成延迟预算表,指出占比最大的一项(参考答案:输出长度通常让 decode 占 80%+)。
- 构造一组「名义吞吐高但 goodput 低」的数据,说明限流阈值该设在哪(参考答案:goodput 见顶处,本书示例为 64 并发)。
- 如果线上 P99 从 3s 涨到 9s 而 P50 几乎没变,最先怀疑什么?(参考答案:KV 碎片 / 长请求阻塞 / 抢占,而非整体变慢)。
| 指标 | 经验量级 / 阈值 | 说明 |
|---|---|---|
| TTFT | 0.2–2 s | 受 prompt 长度与 prefill 并行度影响;前缀缓存可数倍下降 |
| TPOT | 20–60 ms/token | decode 每 token 时间;人眼流畅阈值约 < 50 ms |
| P95 / P50 | 1.5–3 × | 健康区;P99 / P50 > 6 需先查排队与显存 |
| goodput | 拐点并发 × 0.8 | 有效吞吐见顶处即限流阈值参考 |
2. 推理性能的第一性原理
学习路径
- 读 2.1:理解 prefill 算力受限、decode 带宽受限
- 跑 roofline 代码,观察 decode batch=1 强度约 1 FLOP/Byte 属 memory-bound
- 估理论最快生成速度 ≈ 显存带宽 / 模型大小
- 对接 M16:据此决定 vLLM 更该靠增大 batch 摊薄权重读取
核心知识点详解
- prefill 算力密集、decode 带宽密集:prefill 一次算出整个 prompt 的 KV,矩阵乘并行度高、算术强度高,接近 compute-bound;decode 每步只产 1 个 token 却要把权重全读一遍,算术强度约 1 FLOP/Byte,严重 memory-bound。
- 理论最快 TPS ≈ 带宽 / 模型大小:decode 吞吐上界 ≈ 显存带宽 ÷ 权重体积。H100 约 3.35TB/s、8B FP16 权重 16GB:
3.35e12/16e9≈210 tokens/s;INT8 一半权重则理论 TPS 近翻倍,这是量化与 KV 优化的物理依据。 - 常见坑:拿算力卡跑 decode:小生成负载买「算力爆炸」的卡,结果带宽打满、算力闲置,钱花错地方。先判断负载受带宽还是算力约束,再决定买带宽 or 上 batch。
学习路径
- 读 2.2:理解 PagedAttention 分页与块表映射、前缀缓存命中复用
- 跑前缀缓存命中示例,观察命中复用时 TTFT 显著下降
- 完成 2.6 自测:估算单序列 KV 显存并解释碎片如何被分页抹平
- 对接 M16:在 vLLM 确认 prefix caching 开启并记录命中率
核心知识点详解
- KV 显存按 token 算:每 token KV = 2 × 层数 × (KV_heads×head_dim) × 字节数。PagedAttention 把 KV 切成固定小块按需分配、非连续存放,把显存利用率从典型 20% 提到 95% 量级。
- 前缀缓存命中复用:共享前缀(System Prompt / 多轮开头)命中时无需重算,TTFT 显著下降;命中率 80% 时 TTFT 可降约 5×。vLLM 用
--enable-prefix-caching开启并记录命中率。 - 常见坑:给每序列预分配连续显存:不切块时按 max_len 预留,窄分布下空间被碎片浪费,可并发数被压到十分之一。选引擎务必确认内存管理是分页式(PagedAttention)的。
学习路径
- 读 2.3:理解连续批处理与 max-num-seqs 的权衡
- 跑并发扫描示例,观察吞吐 ~ 并发^0.72、TPOT ~ log(并发)
- 完成 2.6 自测:解释为什么并发数不是越大越好
- 对接 M16:调 vLLM max-num-seqs 找吞吐与 P99 的平衡点
核心知识点详解
- 连续批 vs 静态批:静态批等整批都完才换下一批;连续批不等完整序列,谁先完成谁先进。经验曲线:吞吐 ~ 并发^0.72、TPOT ~ log(并发)——并发涨 4 倍吞吐只涨约 2.4 倍,还换来 P99 上涨。
- max-num-seqs 是核心旋钮:vLLM
--max-num-seqs决定单次 batch 家数:调大摊薄权重读取、提吞吐,代价是单请求排队变长。应在压测曲线上选「吞吐已见顶而 P99 还能忍」的平衡点,不是越大越好。 - 常见坑:并发无限涨:排队论下并发过高时吞吐饱和、延迟线性恶化,P99 崩掉。记住「并发数不是越大越好」,超限多副本横向拆,别让同一副本死扛全量。
学习路径
- 读 2.4:理解长 prompt 分块交织如何避免阻塞 decode
- 跑长短 prompt 对比,观察 TTFT 与 P99 尾延迟变化
- 完成 2.6 自测:说明 chunked prefill 为何峰值吞吐略降
- 对接 M16:为长上下文场景配置 max-num-batched-tokens
核心知识点详解
- chunked prefill 交错长 prompt:把长 prefill 切块与 decode 交错执行,避免一个大 prompt 独占整卡几十毫秒、卡住别的 decode。代价是峰值吞吐略降,换来 P99 尾延迟明显改善。
- max-num-batched-tokens:vLLM 用
--max-num-batched-tokens控制单步最多处理的 token 数,长上下文尤其敏感;块太大又滑回「堵 decode」,块太小则 prefill 步数变多、TTFT 变高,需压测取中间值。 - 常见坑:长短均匀也乱开:负载长短均匀时过度切块反而增加调度开销、降低有效吞吐。只有存在长 prompt 拖累尾部时才开,按你的输入长度分布决定。
学习路径
- 读 2.5:按公式估算 KV 显存,理解 GQA / MQA 压缩原理
- 跑 FP8 / INT4 KV 量化示例,对比显存节省与精度变化
- 完成 2.6 自测:口算 8B GQA 32K 单序列约 2GB
- 对接 M16:显存受限时量化 KV 并复核是否掉点
核心知识点详解
- KV 显存公式口算:每 token 每层 KV = 2×KV_heads×head_dim,再 ×层数×字节。GQA 把 KV heads 缩到 1/2–1/4;8B GQA 在 32K 上下文下单序列约 2GB——这决定单卡能并多少并发。
- KV 量化:FP8 / INT4:KV Cache 是最吃显存的之一,量化到 FP8/INT4 可把 KV 显存几乎减半 / 减到 1/4,从而多塞并发(vLLM 参数如
--kv-cache-dtype fp8)。记得量化后复核长上下文质量。 - 常见坑:只量化权重不量 KV:长上下文场景瓶颈常是 KV 显存而非权重。只动权重、KV 仍 FP16,并发照样上不去。先算权重与 KV 各占比重,再决定量化哪一个。
学习路径
- 完成 2.6 自测:给出 8B GQA 32K 单序列 KV 显存估算与 80GB 单卡并发上限
- 复述 prefix 命中 80% 为何能把 TTFT 降约 5 倍
- 把三个经验值(KV 显存 / 并发 / prefix 加速)并入一张速查卡
核心知识点详解
- 三张速查卡:8B GQA 32K ≈2GB/序列;prefix 命中 80% 令 TTFT 降约 5×;80GB 单卡短上下文约 7 并发。面试被问「这模型要几卡」第一反应就是查这三条。
- 估算骨架:给任意模型估容量:先权重(参数×bit/8),再 KV(公式),再除以可用显存得单卡并发上界,进而折算出 QPS。能把推导途讲出来比背结论可信。
- 常见坑:背结论不背推导:只记得「8B 要多少」,换个模型就懵。把公式与量级记牢、能口算任意规模,才有含金量。
1.1 Prefill 与 Decode:两个完全不同的阶段
| 阶段 | 做什么 | 计算特征 | 瓶颈 | 优化方向 |
|---|---|---|---|---|
| Prefill(预填充) | 一次性处理整个输入 prompt,计算并缓存 KV | 矩阵乘为主,并行度高 | 算力受限(compute-bound) | 大 batch、chunked prefill、FP8 |
| Decode(解码) | 逐 token 生成,每步复用 KV Cache | 每步只算 1 个 token | 显存带宽受限(memory-bound) | 增大并发 batch、量化权重与 KV、投机解码 |
| 首 token 延迟 TTFT | 主要取决于 prefill | 与输入长度近似线性 | 长 prompt 卡首字 | 前缀缓存、chunked prefill 与 decode 交织 |
| 生成速度 TPOT / 吞吐 | 主要取决于 decode | 与并发数强相关 | 显存带宽 | 连续批处理提高并发、量化权重 |
python# 用算术强度判断瓶颈:算力 / 访存量
def roofline(flops, bytes_moved, gpu_tflops=990, gpu_bw_gbs=3350):
"""H100 SXM 参考值:约 990 TFLOPs(BF16稠密) / 3.35 TB/s"""
intensity = flops / bytes_moved # FLOPs per Byte
ridge = gpu_tflops * 1e12 / (gpu_bw_gbs * 1e9) # 拐点强度
return {"intensity": round(intensity, 2), "ridge": round(ridge, 1),
"bound": "compute" if intensity > ridge else "memory"}
# Decode 阶段:batch=1,每 token 需要读全部权重
N, d = 8e9, 8192 # 8B 模型
flops = 2 * N # 每 token 约 2N FLOPs
bytes_ = 2 * N # BF16 权重读一遍:2N 字节
print("decode batch=1 ->", roofline(flops, bytes_))
# 强度约 1 FLOP/Byte,远低于拐点 -> 严重 memory-bound
# 结论:单请求推理时 GPU 算力大量闲置,必须靠「增大并发 batch」摊薄权重读取成本
# 同一个权重读一遍服务 32 条请求,算术强度提升约 32 倍
print("decode batch=32 ->", roofline(flops * 32, bytes_))
# 另一个视角:理论最快生成速度 ≈ 显存带宽 / 模型大小
bw, model_gb = 3350e9, 16 # 8B BF16 约 16GB
print(f"batch=1 理论上限 ≈ {bw / model_gb:.0f} tokens/s(实际会更低)")
# 这解释了为什么「量化到 4bit」能显著提速:权重变小 -> 读得更快
prefill 与 decode 的算力差异可用一个数字刻死:处理 L 个输入 token 的 prefill 近似 2·N·L FLOPs,而 decode 每步只有 2·N FLOPs(N 为参数量)。因此 L=8192 的一次 prefill ≈ 8192 次 decode 步的算力——这解释了为什么长 prompt 的 TTFT 昂贵,也让「prefix caching 把已算过的 L 直接归零」成为最大杠杆之一。
python# prefill 与 decode 的算力/带宽对照(N 参数、L 输入、B 并发 decode)
N, L, B = 8e9, 8192, 32
prefill_flops = 2 * N * L # 一次性
decode_flops = 2 * N # 每个 decode step
print("prefill FLOPs:", f"{prefill_flops:.2e}") # 约 1.3e+14
print("decode step FLOPs:", f"{decode_flops:.2e}") # 约 1.6e+10
print("等价 decode 步数:", int(prefill_flops / decode_flops)) # 8192
print("decode 每步读权重 GB:", round(2*N/1024**3, 1)) # 约 14.9
# 结论:prefill 用算力换时间,decode 用带宽换时间;两者的最优硬件与
# batching 策略完全不同 —— 这正是 PD 分离的动机。
1.2 KV Cache 管理:PagedAttention 与前缀缓存
KV Cache 是推理显存的主要占用方,也是吞吐的关键约束。传统实现按最大长度预分配连续显存,造成严重碎片与浪费(通常浪费 60%–80%)。PagedAttention 借鉴操作系统分页思想,把 KV Cache 切成固定大小的块按需分配,几乎消除碎片,使可用 batch 大幅增加——这是 vLLM 高吞吐的核心。
- 前缀缓存(Prefix Caching):多请求共享相同前缀(同一系统提示、同一长文档)时,KV 只算一次并复用。对「同一份资料反复提问」的场景收益极大。
- RadixAttention(SGLang):用基数树管理前缀,支持更复杂的前缀共享结构,在多轮对话与树状分支场景收益明显。
- Chunked Prefill:把长 prompt 分块处理,与 decode 请求交织调度,避免一个超长请求阻塞所有短请求(改善尾延迟)。
- KV Cache 量化:把 KV 从 FP16 压到 FP8 / INT8,显存减半,是长上下文场景的关键手段,但需要验证精度。
python# 显存预算测算:决定你能开多大并发(容量规划的第一张表)
def vram_plan(params_b, weight_bits, layers=32, kv_heads=8, d_head=128,
dtype_kv=2, max_len=8192, gpu_gb=80, reserve_gb=6):
weights = params_b * weight_bits / 8 # 权重占用 GB
per_seq_kv = (layers * max_len * kv_heads * d_head * 2 * dtype_kv) / 1024**3
free = gpu_gb - reserve_gb - weights
max_concurrency = max(0, int(free / per_seq_kv))
return {"weights_gb": round(weights, 1), "kv_per_seq_gb": round(per_seq_kv, 2),
"free_gb": round(free, 1), "max_concurrency": max_concurrency}
print("8B BF16, 8K 上下文:", vram_plan(8, 16))
print("8B INT4, 8K 上下文:", vram_plan(8, 4))
print("8B BF16, 32K 上下文:", vram_plan(8, 16, max_len=32768))
# 观察:量化把权重从 16GB 降到 4GB,多出来的 12GB 直接变成并发能力;
# 而上下文从 8K 拉到 32K,并发能力下降到 1/4 —— 长上下文是昂贵的。
python# 前缀缓存收益估算:命中率 h 对 TTFT 的直接影响
def prefix_cache_effect(prompt_len, hit_ratio, prefill_ms_per_1k=90):
cached = int(prompt_len * hit_ratio)
fresh = prompt_len - cached
ttft_before = prompt_len / 1000 * prefill_ms_per_1k
ttft_after = fresh / 1000 * prefill_ms_per_1k
return {"ttft_before_ms": round(ttft_before),
"ttft_after_ms": round(ttft_after),
"speedup": round(ttft_before / max(1, ttft_after), 2)}
print(prefix_cache_effect(8192, 0.80))
# 预期:{'ttft_before_ms': 737, 'ttft_after_ms': 147, 'speedup': 5.0}
# 经验:多轮对话 / 同一文档问答命中率常 60%–90%;命中率 <20% 时收益
# 会被哈希与块管理开销吃掉,不要为了它增加复杂度。
2.3 批处理:从静态批处理到连续批处理
批处理是推理吞吐的命脉。最简单的是静态批处理(static batching):攒够 N 个请求一起算,但要等最慢的那个生成完才释放——短请求被长请求拖死,GPU 在等待时大量闲置。现代引擎几乎都用连续批处理(continuous batching / in-flight batching):每个请求独立排队、生成完一步就立即离开、新请求随时填充空位,GPU 始终有活干。
| 维度 | 静态批处理 | 连续批处理 |
|---|---|---|
| 请求聚合方式 | 凑满固定大小再算 | 动态调度,完成即离开 |
| 短请求体验 | 被同批长请求拖慢 | 不受影响,生成完即返回 |
| GPU 利用率 | 低(等待期闲置) | 高(始终有 sequence 可算) |
| 实现复杂度 | 低 | 高(需管理变长 batch) |
| 代表实现 | 早期 Triton 动态批、简单封装 | vLLM / SGLang / TensorRT-LLM 默认 |
python# 连续批处理的调度骨架(直觉版):每个 step 只做「凑当前活着的序列然后跑一步」
class ContinuousScheduler:
def __init__(self, max_seqs=128):
self.waiting = [] # 已到达但未开始
self.running = [] # 正在 decode 的序列
self.max_seqs = max_seqs
def step(self):
# 1) 从 waiting 补充到 running,直到达到并发上限
while self.waiting and len(self.running) < self.max_seqs:
self.running.append(self.waiting.pop(0))
# 2) 对 running 里所有序列执行一步 decode(一次前向,batch 推理)
logs = engine.forward(self.running)
# 3) 完成的序列立即离场,腾出名额给 waiting(这是与静态批处理的关键区别)
done = [s for s in self.running if s.finished]
self.running = [s for s in self.running if not s.finished]
return logs, done
# 调度策略的三种常见选择:
# FCFS(先到先服务):公平、实现简单,但一个超长请求会占用名额;
# 优先级:按租户等级/付费/紧急度排序,高优先调度,需配合配额防饿死;
# 抢占:低优序列跑到预算上限时被暂停(KV 暂存),高优插队,恢复时续跑。
python# 连续批处理的收益曲线:吞吐随并发次线性上升,TPOT 近似对数变差
import math
def cb_curve(max_seqs_list, tps_base=1.0, tpot_base=25):
for m in max_seqs_list:
tps = tps_base * m ** 0.72 # 经验拟合:吞吐 ~ 并发^0.72
tpot = tpot_base * (1 + 0.9 * math.log2(max(1, m)))
print(f"max_seqs={m:>4} 相对吞吐={tps:5.1f} TPOT={tpot:6.1f}ms")
cb_curve([1, 8, 32, 128, 256])
# 预期输出(约):
# max_seqs= 1 相对吞吐= 1.0 TPOT= 25.0ms
# max_seqs= 32 相对吞吐= 11.9 TPOT= 79.0ms
# max_seqs= 128 相对吞吐= 30.7 TPOT= 137.0ms
# max_seqs= 256 相对吞吐= 51.4 TPOT= 162.0ms
# 结论:吞吐 ~ 并发^0.72(次线性),延迟 ~ log(并发)(超线性变差);
# 最优 max_seqs 由 SLA 定,通常落在 64–256。
| 调度策略 | 优点 | 代价 / 风险 |
|---|---|---|
| FCFS 先到先服务 | 公平、可预测、实现简单 | 长请求阻塞短请求(队头阻塞) |
| 优先级调度 | 保障付费/高优用户体感 | 需配额机制防止低优饿死 |
| 抢占(preemption) | 应对突发高优流量 | 需保存/恢复 KV,增加复杂度与碎片 |
2.4 Chunked Prefill:让长 prompt 不再阻塞 decode
连续批处理解决了 decode 侧,但 prefill 仍是一个「大块」:一个 8K token 的 prompt 要一次性算完才能开始出字,期间 batch 里的其他短请求 decode 被卡住,尾延迟飙升。Chunked Prefill 把长 prompt 切成若干小块,每块算完就把槽位让给 decode 请求,二者交织调度,既不让长请求独占、也不让短请求饿死。代价是 prefill 本身略有开销(小块间的调度与拼接),但尾延迟改善显著。
python# Chunked Prefill 的直觉:把 prefill 按 token 块切分,与 decode 交织
PREFILL_CHUNK = 512 # 每个 prefill 块最大 token 数
def schedule(running_prefills, running_decodes):
plan = []
# 先给等待中的 decode 各一步(保障 TPOT)
for d in running_decodes:
plan.append(("decode", d))
# 再给每个未完成 prefill 喂一个 chunk(不一次喂完,避免占满)
for p in running_prefills:
chunk = p.take_chunk(PREFILL_CHUNK)
plan.append(("prefill", p, chunk))
if p.done: running_prefills.remove(p)
return plan # decode 与 prefill 混合进同一次前向
# 效果:长 prompt 用户仍要走多个 step 才出首字(TTFT 略升),
# 但同 batch 的短请求 decode 几乎不被影响(P99 大幅下降)。
bash# vLLM 开启 chunked prefill 并控制其与 decode 的 token 预算配比
vllm serve Qwen/Qwen3-8B \
--enable-chunked-prefill \
--max-num-batched-tokens 4096 \ # 单次迭代总 token 预算(prefill+decode 共享)
--max-num-seqs 128 # 并发序列上限
# max-num-batched-tokens 是核心旋钮:
# 调小(如 2048) -> 每次迭代算得更少,decode 抖动量小、P99 更好,吞吐略降;
# 调大(如 8192) -> prefill 更快、吞吐更高,但长 prompt 更易阻塞 decode。
# 经验:交互服务取 2048–4096;批量离线可取 8192 以上。
# 配合 prefix caching 时,已缓存的前缀块不再计入本预算,TTFT 进一步下降。
2.5 KV Cache 显存公式、量化与驱逐
KV Cache 的显存占用直接决定你能开多大并发。公式为:每序列 KV 字节 = 层数 × 序列长度 × KV 头数 × 每头维度 × 2(K 和 V)× 每元素字节。当模型用 GQA/MQA 时,KV 头数远小于注意力头数,从而大幅压缩 KV——这是长上下文能跑起来的前提。
python# KV Cache 显存公式 + 实例(注意 GQA/MQA 的压缩效果)
def kv_per_seq_gb(layers, seq, kv_heads, d_head, bytes_per=2):
return layers * seq * kv_heads * d_head * 2 * bytes_per / 1024**3
# Llama-3-8B: 32 层, 32 注意力头, GQA 下 kv_heads=8, d_head=128
print("8B GQA 8K :", round(kv_per_seq_gb(32, 8192, 8, 128), 3), "GB/序列")
# 对比若用 MHA (kv_heads=32): 4 倍 -> 约 2.0 GB/序列,并发直接砍到 1/4
# 实例:单卡 80GB,权重 16GB(BF16 8B),保留 6GB 框架开销
free = 80 - 6 - 16
print("8B GQA 8K 最大并发:", int(free / kv_per_seq_gb(32,8192,8,128))) # 约 29
print("8B GQA 32K 最大并发:", int(free / kv_per_seq_gb(32,32768,8,128))) # 约 7
python# KV 量化后的显存账本:FP16 -> FP8 -> INT4 的并发增益
def kv_quant_gain(layers=32, seq=32768, kv_heads=8, d_head=128, free_gb=39):
base = layers*seq*kv_heads*d_head*2*2/1024**3 # FP16
return {f"{b}bit": {"kv_gb": round(base*b/16, 2),
"max_conc": int(free_gb/(base*b/16))}
for b in (16, 8, 4)}
print(kv_quant_gain())
# 预期(约):
# 16bit: kv_gb≈2.0 max_conc≈19
# 8bit: kv_gb≈1.0 max_conc≈39
# 4bit: kv_gb≈0.5 max_conc≈78
# 风险:FP8 KV 通常几乎无损;INT4 KV 在长依赖 / 低资源语言上掉点明显,
# 必须用长上下文评测集专门验证,不要只看短问答。
| 注意力类型 | KV 头数(示例) | 相对 KV 体积 | 说明 |
|---|---|---|---|
| MHA(多头) | = 注意力头数(如 32) | 1×(基准) | 早期模型,KV 最大 |
| GQA(分组查询) | 如 8(Llama-3-8B)/ 4(70B) | 1/4–1/8 | 2024 后主流,几乎无损 |
| MQA(多查询) | 1 | 1/32 | 极致压缩,质量略损 |
KV 量化是长上下文的关键手段:把 KV 从 FP16 压到 FP8(体积减半)或 INT8(减半),4bit 进一步减半但精度风险明显上升——长依赖、低资源语言、数值敏感任务容易掉点,必须专门验证。当显存仍不够时,还有两类兜底:① 分层/滑窗驱逐(如 H2O、StreamingLLM 类的重计算策略):保留最近若干层或最近 token 的 KV,丢弃不重要历史,用少量质量损失换显存;② KV 卸载到 CPU 内存 / SSD:显存省了,但每次访问要多走 PCIe / NVMe,带宽下降一到两个数量级,只适合「偶尔访问的历史 KV」而非热路径。
2.6 动手练习与自测
- 用 roofline 判断 batch=1 与 batch=32 的 decode 分别是 compute 还是 memory bound,并说出结论(判据:能指出 batch=1 强度约 1 FLOP/Byte,远低于拐点)。
- 写出 KV Cache 显存公式,并算出 8B / GQA(8 头, d=128) / 32K 的单序列占用与 80GB 单卡可开并发(参考答案:约 2GB/序列,约 7 并发)。
- 说明为什么 chunked prefill 能改善尾延迟、代价是什么(参考答案:把长 prompt 分块与 decode 交织;代价是峰值吞吐略降)。
- 给定 prefix 命中率 80%、prompt 8K,估算 TTFT 的下降倍数(参考答案:约 5 倍)。
- 解释 max-num-seqs 从 64 调到 256 时吞吐与 TPOT 各如何变化(参考答案:吞吐次线性上升,TPOT 近似对数变差)。
| 量 | 公式 / 经验值 |
|---|---|
| KV / 序列 | 2 × L × seq × kv_heads × d_head × bytes |
| 8B GQA(8,128) 32K | ≈ 2 GB/序列 → 80 GB 单卡约 7 并发 |
| batch=1 算术强度 | ≈ 1 FLOP/Byte(memory bound) |
| prefix 命中 80% | TTFT 约降 5 × |
| max-num-seqs 64→256 | 吞吐次线性上升,TPOT 近似对数变差 |
3. 推理引擎与关键优化
学习路径
- 读 3.1:按表对比 vLLM / SGLang / TensorRT-LLM / llama.cpp 取舍
- 起一个 vLLM 服务,设 max-model-len 与 gpu-mem-util 0.90–0.92
- 完成 3.6 自测:说明本项目该选哪个引擎及理由
- 对接 M16:用 vLLM 后端替换朴素推理
核心知识点详解
- vLLM 通用首选:自带 PagedAttention、连续批处理、量化与分布式,接口 OpenAI 兼容,
vllm serve Qwen2.5-7B-Instruct --gpu-mem-util 0.92一行起服务,绝大多数生产场景选它就够了。 - SGLang 强在共享前缀:RadixAttention 前缀树对多轮 / Agent / 结构化输出的共享前缀复用更强(多轮 TTFT 降 40%–70%);TensorRT-LLM 求极致吞吐但开发重。
- 关键参数别拍脑袋:
--max-model-len按 P99 请求长度设置而非模型上限,--gpu-mem-util 0.90–0.92给 KV 与调度留余量。设太满会 OOM,设太低浪费显存、并发上不去。 - 常见坑:不量输入就设长度:先统计输入长度 P50/P99 再设 max-model-len,否则要么吃光 KV 要么浪费显存,是工程基本功。
学习路径
- 读 3.2:理解连续批处理 / 投机解码 / 前缀缓存 / PD 分离四类的适用条件
- 跑每类技术开与关的对比,观察吞吐与 TTFT 的变化
- 完成 3.6 自测:逐条说出每类技术适用与不适用场景
- 对接 M16:在 vLLM 开启前缀缓存复用
核心知识点详解
- 四类技术各有所长:连续批处理提平均吞吐;投机解码加速 decode 且不损精度;前缀缓存复用历史 KV 降 TTFT;Chunked prefill 保 P99;PD 分离把两阶段分开订制。
- 不是全开都收益:投机解码在低接受率 / compute-bound 场景是负收益,PD 分离在小规模是净亏。每项技术都要开与关对比压测,用「吞吐↑ / TTFT↓」双指标拍板。
- 常见坑:全功能拉满:把 speculative、前缀缓存、chunked prefill 一起开,互相抢显存与调度,复杂度暴涨收益互相抵消。按负载特征逐项启用并各留基线。
学习路径
- 读 3.3:跟代码走一遍逻辑块到物理块的块表映射
- 理解 Scheduler / Block Manager 分工,观察显存利用率 20%→95%
- 完成 3.6 自测:解释吞吐为何可提升 2–4×
- 对接 M16:压测中对比开启分页前后的显存占用
核心知识点详解
- 逻辑块→物理块映射:KV 按 16/32 token 的块切,维护 block table 记录逻辑块到物理位置,按需分配、非连续存储。Scheduler 管请求调度,Block Manager 管显存分配。
- 显存利用率 20%→95%:传统按最大长度预分配连续缓冲区,典型浪费 60%–80%;分页几乎消除碎片。配合连续批处理,吞吐常在 2–4× 量级提升,是 vLLM 的核心卖点。
- 常见坑:把注意力算法当瓶砖:吞吐上不去常不是注意力算得慢,而是显存放不下更多并发。先看显存利用率与并发数,再谈内核优化。
学习路径
- 读 3.4:理解 RadixAttention 前缀树如何复用共享前缀
- 跑多轮对话与 JSON schema 约束解码示例
- 完成 3.6 自测:说明 Agent 多轮场景为何受益
- 对接 M16:若用 SGLang,配置结构化输出并测多轮 TTFT
核心知识点详解
- RadixAttention 前缀树:以 token 序列的前缀建树节点,共享前缀(System Prompt、多轮开头、Agent 工具上下文)只存一份、多请求复用,多轮对话 TTFT 可降 40%–70%。
- 结构化输出:SGLang / vLLM 支持 JSON schema 约束解码,让输出必然符合 schema(
return_schema=...指定),省去解析容错,是工具调用与 Agent 场景的关键。 - 常见坑:前缀不稳定导致命中趋零:前缀混入随机 uid / 时间戳会让树分支爆炸、复用率归零。把 System Prompt 做成稳定常量,才能吃到前缀缓存收益。
学习路径
- 读 3.5:理解 GGUF 量化与 Q4_K_M 精度-速度甜点
- 跑 llama.cpp 示例,用 -ngl 把层放到 GPU 并观察加速
- 完成 3.6 自测:说明本地 / 隐私场景的取舍
- 完成本章自测:对比 Q4_K_M 与 Q8_0 的质量损失
核心知识点详解
- GGUF 量化格式:llama.cpp 生态用 GGUF 单文件跑本地。Q4_K_M 是精度/速度甜点,Q8_0 近乎无损;Q8 文件更大但质量更好,按内存预算取舍。
- 把层放 GPU:
llama-server -m model.gguf -ngl 999把尽可能多层放 GPU,带宽快得多;CPU-only 或-ngl 20适合低配,速度差一个数量级。 - 常见坑:无脑 Q4 到底:数学 / 长链推理 / 代码对 Q4 敏感,低资源语言掉点明显。本地先用 Q8_0 定基线,确认 Q4 不掉点再下放。
2.1 引擎选型与核心参数
| 引擎 | 核心特色 | 适合 | 注意 |
|---|---|---|---|
| vLLM | PagedAttention + 连续批处理,生态最成熟 | 通用自托管、开源模型首选 | 参数众多,需按负载调优 |
| SGLang | RadixAttention 前缀复用 + 结构化输出优化 | 多轮对话、共享前缀、Agent 工作流 | 生态略小但增长快 |
| TensorRT-LLM | NVIDIA 深度优化,极致性能 | 固定模型、追求极限吞吐 | 编译复杂、迭代慢、绑定 NVIDIA |
| TGI | HuggingFace 出品,易用 | 快速上线、HF 生态 | 极限性能略逊 |
| llama.cpp / Ollama | CPU / 端侧推理、GGUF 量化 | 本地开发、边缘部署 | 大规模服务不适合 |
| 云端服务 | 各家托管推理 | 无运维、弹性 | 成本与数据合规需评估 |
bash# vLLM 生产启动示例(关键参数逐个说明)
vllm serve Qwen/Qwen3-8B \
--host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 \ # 张量并行:单卡放不下或想降延迟时用
--max-model-len 32768 \ # 必须按真实需求设置,设太大会吃光 KV 空间
--gpu-memory-utilization 0.92 \ # 留给 KV Cache 的比例,太高易 OOM
--max-num-seqs 128 \ # 最大并发序列数:吞吐与延迟的核心权衡
--enable-prefix-caching \ # 共享前缀场景必开(多轮/同文档问答)
--enable-chunked-prefill \ # 改善长 prompt 造成的尾延迟
--quantization fp8 \ # 权重 FP8 量化(需硬件支持)
--kv-cache-dtype fp8 \ # KV 量化,长上下文关键
--speculative-model Qwen/Qwen3-0.6B \ # 投机解码草稿模型
--num-speculative-tokens 5
# 关键权衡:max-num-seqs 越大吞吐越高,但单请求延迟(TPOT)会变差。
# 必须按业务 SLA 找平衡点,而不是一味拉大。
bash# SGLang 生产启动:RadixAttention + 结构化输出 + 投机解码
python -m sglang.launch_server --model-path Qwen/Qwen3-8B \
--port 30000 --tp 1 --mem-fraction-static 0.88 \
--enable-torch-compile \ # 编译加速,首请求慢、后续快
--speculative-algorithm EAGLE --speculative-draft-model-path ./eagle-qwen3 \
--schedule-policy lpm \ # 最长前缀匹配优先 -> 提升前缀复用命中率
--max-running-requests 256
# 经验对比(8B / 单 H100 / 8K 上下文,社区与实测常见量级):
# vLLM 连续批处理 + PagedAttention:吞吐基线,生态最稳;
# SGLang RadixAttention:共享前缀场景 TTFT 降 30%–60%;
# 两者裸吞吐同档,差异主要在「前缀复用结构」与「结构化生成」。
--max-model-len 设成模型上限(如 128K)却没做长度校验——少量长请求会把 KV 空间吃光,导致其他请求排队或 OOM。正确做法是按实际 p99 输入长度 × 安全系数设置,并对超长请求做截断或拒绝。② --gpu-memory-utilization 设到 0.98——框架本身需要显存,容易在压力下 OOM。0.90–0.92 通常更稳。2.2 四类加速技术的适用条件
| 技术 | 原理 | 收益 | 前提条件 |
|---|---|---|---|
| 连续批处理 | 请求按到达顺序动态进出 batch,而非等整批生成完 | 吞吐可提升数倍 | 引擎内置(vLLM 默认开);需要足够并发 |
| 投机解码 | 小模型草拟多个 token,大模型一次并行验证 | 延迟降 1.5–2.5 倍 | 有同族小模型;草稿命中率高才有效 |
| 前缀缓存 | 共享前缀的 KV 只算一次 | 首 token 延迟大幅下降 | 请求间前缀重复率高(系统提示、同文档) |
| PD 分离 | prefill 与 decode 部署在不同节点,各自优化 | 资源利用率与尾延迟改善 | 架构复杂、需 KV 传输;中大规模才划算 |
| Chunked Prefill | 长 prompt 分块与 decode 交织 | 尾延迟显著改善 | 引擎支持;轻微影响峰值吞吐 |
python# 用压测找到「吞吐–延迟」的平衡点(容量规划的核心实验)
import asyncio, time, statistics
async def bench(client, concurrency, n_requests=200, prompt=None):
sem = asyncio.Semaphore(concurrency)
lat, ttft = [], []
async def one():
async with sem:
t0 = time.perf_counter()
stream = await client.chat_stream(prompt or DEFAULT_PROMPT)
first = await anext(stream) # 首 token
ttft.append(time.perf_counter() - t0)
async for _ in stream: pass
lat.append(time.perf_counter() - t0)
t0 = time.perf_counter()
await asyncio.gather(*[one() for _ in range(n_requests)])
wall = time.perf_counter() - t0
return {"concurrency": concurrency,
"throughput_rps": round(n_requests / wall, 1),
"p50_latency": round(statistics.median(lat), 3),
"p95_latency": round(sorted(lat)[int(len(lat) * 0.95)], 3),
"ttft_p95": round(sorted(ttft)[int(len(ttft) * 0.95)], 3)}
async def sweep(client):
for c in (1, 4, 16, 32, 64, 128):
print(await bench(client, c))
# 典型曲线:并发上升 -> 吞吐上升、延迟上升;到某点吞吐不再涨但延迟急剧上升。
# 那个拐点就是你的最优服务并发;线上按它配置限流与扩容阈值。
python# 实测投机解码是否划算:比较接受率与端到端 TPOT
def spec_gain(accept_rate, n_draft=5, draft_cost_ratio=0.15):
# 期望接受 token 数(见 4.1 推导)
e = sum(k * accept_rate**(k-1) * (1-accept_rate) for k in range(1, n_draft+1)) \
+ n_draft * accept_rate**n_draft
# 每次「主模型前向」总成本 = 1(验证)+ draft 草稿成本
cost = 1 + draft_cost_ratio * n_draft
return {"accepted_tokens": round(e, 2), "speedup": round(e / cost, 2)}
for a in (0.4, 0.6, 0.8):
print(a, spec_gain(a))
# 预期:0.4 -> speedup≈1.0;0.6 -> ≈1.7;0.8 -> ≈2.6
# 判据:实测 speedup < 1.2 就不要上线——草稿模型同样吃显存与算力。
3.3 PagedAttention 深入:块表映射与 vLLM 架构
PagedAttention 借鉴操作系统的虚拟内存分页:把每个序列的 KV Cache 切成固定大小的逻辑块,再映射到非连续的物理块,用一张块表(block table)记录映射。逻辑上连续、物理上离散,分配按需进行,几乎消除内部碎片(预留块)与外部碎片(空洞)。vLLM 论文报告:传统实现显存利用率通常只有 20%–40%,PagedAttention 可提升到 接近 95%–100%,对应并发容量提升数倍、整体吞吐提升 2–4 倍。
| 实现方式 | 典型显存利用率 | 后果 |
|---|---|---|
| 连续预分配(最大长度) | 20%–40% | 并发被严重限制,尾延迟高 |
| PagedAttention(分页) | 95%–100% | 并发容量大幅提升,吞吐 2–4× |
vLLM 的核心架构组件可以拆成几条协作的线:Scheduler(调度器)决定哪些序列进 batch、是否抢占;Block Manager(块管理器)负责物理块的分配/释放/共享;GPU Worker / Model Runner 执行实际前向;KV Cache Manager 维护分页 KV;对外是兼容 OpenAI 的 API Server。理解这条链路,你才能在出问题时定位是调度、内存还是计算。
- prefix caching 复用:逻辑块按内容哈希命中时,直接共享物理块——同一系统提示、同一长篇文档被多请求引用时,prefill 只算一次。
- 块级共享 vs 复制:多轮对话的历史前缀、多候选采样(beam / 树搜索)都能共享前缀块,显存只存一份。
- 与连续批处理的协同:分页让变长序列能灵活进出 batch,是连续批处理能高效运作的内存基础。
python# 分页 KV 的块表映射(PagedAttention 的核心数据结构)
BLOCK = 16 # 每块 16 个 token 的 KV
class BlockTable:
def __init__(self): self.pages = [] # 逻辑块 -> 物理块号
def append(self, physical_id): self.pages.append(physical_id)
def slot_of(self, pos): # 第 pos 个 token 落在哪
return self.pages[pos // BLOCK], pos % BLOCK
bt = BlockTable()
for pid in (7, 2, 9): bt.append(pid) # 物理块可以不连续
print("logical->physical:", bt.pages) # [7, 2, 9]
print("token 20 ->", bt.slot_of(20)) # (2, 4):物理块 2,块内偏移 4
# 关键收益:逻辑连续即可、物理可离散 -> 按需分配,几乎零碎片;
# 相同内容的逻辑块可共享同一物理块(prefix caching 的基础)。
3.4 SGLang 的 RadixAttention 与结构化生成
SGLang 的 RadixAttention 用一棵基数树(radix tree)组织所有请求的 KV 前缀,不仅支持线性前缀共享,还能表达树状分支(如一个 prompt 的多个采样分支、多轮对话的分叉),自动在树中找到可复用节点。它在「多轮对话、同一文档反复提问、Agent 的树状探索」场景收益明显。此外 SGLang 对结构化生成(JSON / 正则约束解码)做了内核级加速,让 constrained decoding 几乎不掉速。
| 引擎 | 核心机制 | 最擅长的场景 | 主要代价 |
|---|---|---|---|
| vLLM | PagedAttention + 连续批处理,生态最成熟 | 通用自托管、开源模型首选、社区大 | 超长共享前缀的树状复用不如 SGLang |
| SGLang | RadixAttention 前缀树复用 + 结构化生成加速 | 多轮对话、共享前缀、Agent 树状探索、结构化输出 | 生态略小,调优资料较少 |
| TensorRT-LLM | NVIDIA 深度编译优化(CUDA graph、 fused 内核) | 固定模型追求极限吞吐/延迟 | 编译复杂、迭代慢、绑定 NVIDIA 硬件 |
| llama.cpp / Ollama | GGUF 量化 + CPU/GPU 混合执行 | 本地开发、边缘、消费级显卡 | 大规模高并发服务不适合 |
| TGI | HuggingFace 出品,开箱即用 | 快速上线 HF 生态模型 | 极限性能略逊于 vLLM/TRT-LLM |
python# 结构化生成:让模型只在合法 JSON 空间里输出(SGLang / 多数引擎支持)
# 用 JSON schema 约束解码,比「让模型自己写 JSON 再校验」更稳更快
import json
from sglang import function, gen, set_default_backend, RuntimeEndpoint
@function
def extract(s, question):
s += "请只输出符合 schema 的 JSON:\n"
s += gen("answer", json_schema={
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"required": ["name", "age"]})
return s["answer"]
# 约束解码的价值:① 输出 100% 可解析,省去「解析失败重试」;
# ② 不需要额外提示词描述格式,省 token;
# ③ 在 SGLang 里有内核加速,延迟接近非约束生成。
| 场景 | 前缀复用机制 | 典型收益 | 说明 |
|---|---|---|---|
| 多轮对话 | 历史前缀整段复用 | TTFT 降 40%–70% | 轮数越多,累积命中越高 |
| 同文档多问 | 文档前缀共享 | prefill 算力降 60%+ | 配合 chunked prefill 更佳 |
| Agent 树状探索 | 分支共享公共路径 | 分支越多收益越大 | RadixAttention 强项 |
| 结构化 JSON 输出 | 约束解码内核加速 | 约束下几乎不掉速 | 对比朴素实现省 30%+ 时间 |
| n 选 1 / beam | 多候选共享前缀 KV | 显存只存一份前缀 | PagedAttention 块级共享 |
3.5 llama.cpp 与边缘 / 消费级部署
llama.cpp 用 C++ 实现、以 GGUF 格式打包量化权重,支持纯 CPU、CPU+GPU 混合(Metal / CUDA / Vulkan),是本地开发、消费级显卡与边缘设备的事实标准(Ollama 即基于它)。它的价值不是极致吞吐,而是极低门槛地把模型跑在笔记本、手机、树莓派上,适合数据不出端、离线、隐私敏感的场景。
| GGUF 等级 | 相对体积 | 质量 | 适用 |
|---|---|---|---|
| Q8_0 | 约 1/2(相对 FP16) | 几乎无损 | 质量要求高的本地部署 |
| Q5_K_M | 约 1/3 | 轻微损失 | 大多数本地场景的甜点 |
| Q4_K_M | 约 1/4 | 可接受 | 显存/内存紧张时的默认 |
| Q2_K | 约 1/6 | 明显损失 | 仅验证可行性,不用于生产 |
bash# llama.cpp 本地量化推理:一条命令跑起 GGUF
llama-cli -m Qwen3-8B-Q4_K_M.gguf \
-p "用一句话解释 PagedAttention" -n 128 \
-ngl 99 # 尽量多的层放到 GPU(-ngl 0 则纯 CPU)
# 让 Ollama 也能本地 serving(OpenAI 兼容)
ollama run qwen3:8b-q4_K_M
# 实测量级(M2 Pro 32G / 8B Q4_K_M):约 15–25 tokens/s,显存占用 ~5.5GB;
# 同模型 Q8_0 约 9GB、质量几乎无损但更慢;Q2_K 可跑但明显掉智,
# 只用于「验证可行性」,不要用于生产。
3.6 动手练习与自测
- 为你的模型写一份 vLLM 启动参数清单,并逐条说明为什么这么设(判据:max-model-len 按 p99 而非模型上限、gpu-memory-utilization 取 0.90–0.92)。
- 对比 vLLM 与 SGLang 在你负载下的选择依据(参考答案:共享前缀/结构化输出/Agent 树状探索选 SGLang,否则 vLLM 更稳)。
- 画出 PagedAttention 的块表映射示例,说明为什么逻辑连续、物理离散能消除碎片。
- 给定接受率 0.4 / 0.6 / 0.8,估算投机解码加速比并判断是否上线(参考答案:≈1.0 / 1.7 / 2.6,<1.2 不上线)。
- 为「本地隐私场景」选一个 GGUF 等级并说明理由(参考答案:Q4_K_M 甜点、Q8_0 近乎无损但更慢)。
| 场景 | 推荐引擎 / 参数 | 要点 |
|---|---|---|
| 共享前缀多 | SGLang(RadixAttention) | 前缀缓存命中率高 |
| 通用 / 稳定 | vLLM | PagedAttention + 连续批处理 |
| 结构化 / Agent | SGLang | 约束解码 + 树状探索 |
| 本地隐私 | llama.cpp GGUF Q4_K_M | 甜点;Q8_0 近无损但更慢 |
| 启动参数 | gpu-mem-util 0.90–0.92 | max-model-len 按 p99 而非上限 |
4. 推测解码:用草稿模型换延迟
学习路径
- 读 4.1:理解草稿模型预测 n token、主模型一次并行验证的流程
- 代期望收益公式 E,观察不同接受率下的加速比
- 完成 4.4 自测:解释贪心接受与重采样的规则
- 对 M16:在 vLLM 评估是否值得开 speculative 解码
核心知识点详解
- 草稿 - 验证流程:草稿模型一次预测 n 个候选 token,主模型并行验证:连续匹配的前缀直接采纳,首次不一致处用主模型分布重采样,保证输出等价于主模型自回归。
- 期望收益与加速比:加速 ~ 接受率 α 相关,α 越高、n 越大、验证边际成本越低收益越好(α=0.7、n=5 约 2.5×)。用
tokens_per_forward实测开与关的吞吐差,比估算更可靠。 - 常见坑:compute-bound 也硬开:收益只在 decode 带宽瓶颈成立;prefill 主导时验证开销反超收益、吞吐下降。压测验证再开,别拍脑袋。
学习路径
- 读 4.1:记下接受率与加速比的对照(α=0.7 n=5 ≈ 2.5× 等)
- 跑加速比敏感性示例,观察接受率与 n 的敏感度
- 完成 4.4 自测:用 tokens_per_forward 实测一台服务
- 对接 M16:压测时实测加速比并做性价比判断
核心知识点详解
- 接受率 - 加速对照:经验值:α=0.7、n=5 约 2.5×;α=0.9 可到 4.0×;α=0.5 仅 1.9×。接受率是杠杆核心,决定值得投入多少。
- 加速比 <1.2 不上线:上线要承担草稿模型的额外显存与调度复杂度;实测加速比低于 1.2 时收益撑不起成本,直接关闭。
- 常见坑:照搬论文加速比:加速比高度依赖你的 prompt 分布与输出长度。必须在自己的负载上开与关各压一遍,用实测数说话。
学习路径
- 读 4.2:对比独立小模型 / Medusa / EAGLE / n-gram 四种草稿来源
- 跑 n-gram 查表示例(零训练)并观察接受率
- 完成 4.4 自测:针对自己的模型选草稿来源
- 完成本章自测:说明四种来源的训练成本差异
核心知识点详解
- 四种来源训练成本递增:n-gram 查表零训练直接可用;独立小模型要额外训一个;Medusa 在主干上加多头预测头;EAGLE 在特征层预测更准但更复杂。按「要不要动原模型」选。
- 选型看分布与预算:有预算训一个好的草稿模型抬高接受率;不想动原模型就上 n-gram,代价是接受率平平。草稿与主模型分布越接近越好。
- 常见坑:为省事硬用 n-gram:通用生成场景 n-gram 命中率低、几乎无收益。至少评估独立小模型,用实测接受率决定值不值。
学习路径
核心知识点详解
- 三个前提缺一不可:① 仅 decode 带宽瓶颈划算;② 草稿与主模型分布足够接近(接受率高);③ 长生成累计收益大——输出才 20 token 就算 4× 也省不了多少。
- 一句话判断:输出足够长 + 带宽受限 + 草稿够准,三条件都满足才开,否则按成本收益关掉。
- 常见坑:短输出也开:输出极短的分类/抽取,投机解码省不了半毫秒还引入延迟与显存开销。先看平均输出长度再决定。
4.1 draft-then-verify 与期望加速
推测解码(speculative decoding)的核心是 draft-then-verify:用一个小而快的草稿模型连续预测 n 个 token,再交给大模型一次并行验证。验证时采用「贪心接受 + 重采样」:大模型只要在草稿 token 上的分布概率不低于草稿模型,就接受;否则从修正分布中采样一个。由于大模型一次前向就能验证 n 个 token,而 decode 是 memory-bound(权重只读一遍),当接受率足够高时,等效于免费生成多个 token。
python# 期望加速的直觉:给定接受率 alpha,草稿步数 n
# 一次大模型前向「产出」的有效 token 期望约为:
# E = sum_{k=1..n} k * alpha**(k-1) * (1-alpha) + n * alpha**n
# 单次前向成本 ~ 1(一次 decode),朴素 decode 每次前向只产 1 个 token
# 期望加速比 ~ E / 1 = E(忽略草稿模型成本,草稿远小于主模型)
import numpy as np
def expected_speedup(alpha, n=5):
k = np.arange(1, n+1)
e = np.sum(k * (alpha**(k-1)) * (1-alpha)) + n * (alpha**n)
return round(float(e), 2)
for a in (0.5, 0.7, 0.9):
print(f"alpha={a} n=5 -> 期望加速约 {expected_speedup(a)}x")
# alpha=0.7 -> 约 2.5x;alpha=0.9 -> 约 4x;alpha=0.5 -> 约 1.9x
# 结论:接受率每提升一点,加速比接近线性放大,所以「草稿模型要像主模型」是核心。
| 接受率 α | n=5 期望加速 | 说明 |
|---|---|---|
| 0.5 | 约 1.9× | 收益有限,接近临界 |
| 0.7 | 约 2.5× | 多数同族小模型可达 |
| 0.9 | 约 4.0× | 高质量草稿(EAGLE 类)可达 |
python# 实测一次投机解码会话:统计接受率与真实加速(而非只看理论)
def analyze_spec(log):
"""log: [{'drafted': k, 'accepted': a}] 每次主模型前向记录"""
tot_d = sum(r["drafted"] for r in log)
tot_a = sum(r["accepted"] for r in log)
return {"accept_rate": round(tot_a / max(1, tot_d), 3),
"tokens_per_forward": round((tot_a + len(log)) / len(log), 2)}
# tokens_per_forward 含每次前向至少 1 个「自主」token
print(analyze_spec([{"drafted":5,"accepted":4},{"drafted":5,"accepted":3},
{"drafted":5,"accepted":5}]))
# 预期:{'accept_rate': 0.8, 'tokens_per_forward': 5.0}
# 判据:tokens_per_forward / (1 + 草稿成本) > 1.2 才值得开。
4.2 Medusa / EAGLE / n-gram:四种常见形态
| 方法 | 草稿来源 | 是否需要训练 | 接受率/特点 |
|---|---|---|---|
| 独立小模型 | 另训一个同族小模型做 draft | 需要(小模型) | 实现简单,但分布差异大时接受率一般 |
| Medusa | 在主干末层挂多个 MLP 头,各自预测未来第 1/2/3… 个 token | 需要(加头微调) | 多头并行预测,加速稳定 |
| EAGLE | 在特征层(而非 token 层)预测,用自回归头迭代 | 需要(轻量微调) | 接受率更高,接近上界 |
| n-gram 查表 | 从输入/历史 n-gram 直接查下一个 token | 不需要 | 零成本,适合重复性强的任务(模板、代码) |
选型直觉:n-gram 查表 零成本、无需训练,是「先免费试」的首选,特别适合代码补全、模板化输出;Medusa / EAGLE 需要少量微调但接受率更高,适合追求极致延迟的生产服务。EAGLE 在特征层预测之所以更强,是因为它利用了主干已经算出的隐藏状态,比单纯从 token 预测更准。
bash# vLLM 里三种投机解码后端的启用方式(2026 常见)
# 1) 独立草稿模型
vllm serve Qwen/Qwen3-32B --speculative-model Qwen/Qwen3-1.7B \
--num-speculative-tokens 5
# 2) EAGLE / EAGLE-3(特征层预测,接受率最高)
vllm serve Qwen/Qwen3-32B --speculative-model ./eagle3-qwen3-32b \
--speculative-model-draft-tensor-parallel-size 1
# 3) n-gram 查表(零训练,代码补全 / 模板场景)
vllm serve Qwen/Qwen3-32B --speculative-model "[ngram]" \
--num-speculative-tokens 4 --ngram-prompt-lookup-max 4
# 经验接受率:EAGLE-3 > 独立小模型 > n-gram(强重复域除外)。
# EAGLE-3 尤其适合「长生成 + 分布稳定」的推理与代码场景。
4.3 适用条件:什么时候该上推测解码
- 主场景是 decode 带宽瓶颈:权重读取是主要成本时,批量验证才划算;若主瓶颈是 prefill(compute-bound),推测解码几乎无收益。
- 草稿与主模型分布接近:同架构小模型、或 EAGLE/Medusa 微调过的头,接受率才有保障。跨架构、能力差距过大的草稿会拉低接受率。
- 长生成任务更划算:每个 token 都省,长输出累计收益大;短输出(几个 token)的启动开销占比高,收益有限。
- 不合适的场景:高不确定性生成(如创意写作,草稿难命中)、纯 prefill 重负载、或草稿模型本身不比主模型小很多的场合。
python# 该不该上投机解码:先估收益再决定(避免为「看起来快」买单)
def should_spec(avg_accept, n=5, draft_vram_gb=1.6, free_vram_gb=8):
e = sum(k*avg_accept**(k-1)*(1-avg_accept) for k in range(1, n+1)) + n*avg_accept**n
gain = e / (1 + 0.15*n)
fits = draft_vram_gb <= free_vram_gb
return {"speedup": round(gain, 2), "fits_vram": fits, "go": gain > 1.2 and fits}
print(should_spec(0.65)) # {'speedup': 1.75, 'fits_vram': True, 'go': True}
print(should_spec(0.35)) # speedup≈0.9 -> go False(反而变慢)
# 不适合:纯 prefill 重负载、创意写作(低接受率)、草稿与主模型差距大。
4.4 动手练习与自测
- 推导接受率 α=0.7、n=5 时的期望加速(参考答案:约 2.5×),并说明为什么用「一次前向验证多个」等效免费。
- 对比独立小模型 / Medusa / EAGLE / n-gram 四种草稿来源的训练成本与接受率(判据:能说 EAGLE 在特征层预测、接受率最高)。
- 给定一次投机解码日志,算出接受率与 tokens_per_forward(判据:能判断是否 >1.2 才上线)。
- 举一个「不该上投机解码」的场景并说明原因(参考答案:纯 prefill 重负载或创意写作低接受率)。
- 解释为什么草稿模型不能太大(参考答案:草稿成本计入分母,太大反而拉低有效加速)。
| 接受率 α | n=5 期望加速(约) | 结论 |
|---|---|---|
| 0.4 | 1.0–1.2 × | 不上线 |
| 0.6 | ≈ 1.7 × | 视负载上线 |
| 0.8 | ≈ 2.5 × | 明显收益 |
| 草稿来源 | 独立小模型 / Medusa / EAGLE / n-gram | EAGLE 在特征层预测、接受率最高 |
5. 量化:用精度换速度与显存
学习路径
- 读 5.1:对比 GPTQ / AWQ / FP8 的取舍与适用场景
- 跑不同方法量化同一模型,记录质量、显存与速度变化
- 完成 5.6 自测:按自己的负载选量化方案
- 对接 M16:产出 FP16 / INT8 / AWQ 的质量-速度-显存三角权衡表
核心知识点详解
- 方法目的不同:GPTQ 用二阶 Hessian 补偿逐层量化误差;AWQ 感知激活、保护重要通道;FP8 走原生张量核心是全面折中(E4M3 范围 ±448);QAT 要训练。
- 三角权衡表三栏:同一模型做 FP16 / INT8 / AWQ-INT4,测质量(评测集)、显存(权重体积)、速度(吞吐/QPS)三栏。FP16 最准最重,INT4 最省最快但可能掉点。
- 常见坑:用公开任务单点测精度:公开 benchmark 代表不了你的业务。量化方案必须在自建业务评测集上按任务子类看掉点,否则上生产埋雷。
学习路径
- 读 5.2:理解 PTQ 仅需校准、QAT 需训练的区别
- 跑 W8A16 / W8A8 示例,对比显存与精度变化
- 完成 5.6 自测:说明何时该上 QAT
- 完成本章自测:用校准集覆盖真实分布的一个检查点收尾
核心知识点详解
- PTQ 仅需校准:PTQ 跑一小段校准集算缩放 / 零点 / 补偿即可,零训练几乎零成本;QAT 要在训练中插入伪量化、重训一轮,成本高但精度好。
- 何时上 QAT:当 PTQ 掉了不可接受的点、又无法退出量化时上 QAT。一般先 PTQ 试水,W8A8 / W8A16 通常够用,极端才 QAT。
- 常见坑:校准集与业务分布不符:拿通用语料校准、一上业务就崩。校准集必须覆盖真实分布:中文 / 代码 / 长文本的比例贴近线上。
学习路径
- 读 5.3:理解 GPTQ 补偿 / AWQ 保护通道 / SmoothQuant 难度迁移
- 跑各方法的敏感度对比示例
- 完成 5.6 自测:解释 alpha=0.5 的平衡作用
- 完成本章自测:说出三种方法各自保护的对象
核心知识点详解
- 三种保护对象:GPTQ 保护「权重建模误差」——用 Hessian 给敏感权重更小步长;AWQ 保护「活性通道」——少数激活大的通道不量化;SmoothQuant 把激活难度迁移到权重侧。
- alpha=0.5 的平衡:SmoothQuant 的 alpha 决定把量化难度往权重还是激活迁移,0.5 表示均衡分配,两边的量化都更容易。
- 常见坑:三种方法随便挑一个:优化点不同。对着「哪个退化在你任务上最痛」选:改建模误差→GPTQ,活性离群→AWQ,激活要量化→SmoothQuant。
学习路径
- 读 5.4:记住 INT8 / INT4 / FP8 / MXFP4 的精度速度取舍
- 用量化引擎对比 INT8 / FP8 的速度与显存
- 完成 5.6 自测:解释为何能上 FP8 就上 FP8
- 对接 M16:在部署里定权重格式并复核质量
核心知识点详解
- 格式决定折中:INT8 稳定安全;FP8 E4M3 用硬件张量核心、范围 ±448,W8A8 再量化激活更省;INT4 / MXFP4(块级缩放)更省但更易掉点。
- 能上 FP8 就上 FP8:现代卡原生支持 E4M3/E5M2,精度接近 FP16(多数任务掉 0–2 个点)而省一半显存、吞吐近翻倍;硬件不支才退而考虑 INT8。
- 常见坑:把位宽当唯一标准:「INT8 一定更快」是错觉——没走 Tensor Core 的 INT8 可能更慢。要实测才能定格式,不能只看位宽。
学习路径
核心知识点详解
- 按任务子类评估:别只给一个总分。量化前后分别测各任务子类(中文 / 代码 / 数学 / 长上下文),才看得见「数学类掉 8 个点」这种隐藏损伤。
- 逐层敏感度回退:对层做敏感度扫描,找出掉点最多的层单独回退到 FP16(
group-wise/per-channel粒度),用最小显存代价补回精度。 - 常见坑:上线前不设精度红线:不定义「可接受掉点阈值」,上线后才知道损失。先定红线(如业务指标掉点 ≤2%),越线就回退或换方案。
5.1 方法对比与选型
| 方法 | 类型 | 精度损失 | 适用 | 注意 |
|---|---|---|---|---|
| GPTQ | 训练后量化(PTQ),逐层误差补偿 | 低(4bit 通常可接受) | 权重 4bit,通用 | 需要校准数据;不同任务表现差异需实测 |
| AWQ | 激活感知的权重量化,保护重要通道 | 较低,通常优于 GPTQ | 权重 4bit,主流选择 | 工程实现依赖特定内核 |
| FP8 | 8bit 浮点 | 极低 | H100/Blackwell 原生支持,大模型首选 | 需硬件支持;可同时用于权重与 KV |
| KV Cache 量化 | 把 KV 从 FP16 压到 FP8 / INT8 | 低但需验证 | 长上下文、高并发场景 | 对长依赖任务要专门验证 |
| QAT(量化感知训练) | 训练时模拟量化 | 最低 | 极致部署要求(端侧、大规模服务) | 需要训练资源,成本高 |
| NVFP4 等低比特 | 4bit 浮点(新硬件) | 中(需验证) | 新一代 GPU 的极致吞吐 | 生态与工具链仍在成熟 |
python# 量化必须验证:不能只看速度,要测「精度是否仍满足业务要求」
from lm_eval import simple_evaluate
def eval_quant(model_path, tasks=("gsm8k", "arc_easy"), limit=None):
return simple_evaluate(model="vllm", model_args=f"pretrained={model_path}",
tasks=list(tasks), limit=limit)
base = eval_quant("./Qwen3-8B") # BF16 基线
for tag, path in [("int8", "./Qwen3-8B-int8"),
("awq-int4", "./Qwen3-8B-AWQ"),
("fp8", "./Qwen3-8B-FP8")]:
r = eval_quant(path)
print(tag, {t: r["results"][t]["acc,none"] for t in r["results"]})
# 判断标准:相对基线下降在可接受范围内(通常 <1-2 个点),
# 并且要在「你自己的业务评测集」上复验,而不只看公开任务。
# 特别要注意:量化对长链推理、代码生成、低资源语言的损伤常大于平均值。
bash# 量化的两条路:现成量化权重直接下载 vs 自己量化(AWQ/GPTQ)
# 路线 A:直接用社区量化版本(推荐先验证质量)
vllm serve Qwen/Qwen3-8B-AWQ --quantization awq --max-model-len 8192
# 路线 B:自己用校准集量化(autoawq / llm-compressor)
pip install autoawq
python -m awq.entry --model_path ./Qwen3-8B --w_bit 4 --q_group_size 128 \
--calib_data ./calib.jsonl --calib_n 128 --export_path ./Qwen3-8B-AWQ
# 关键:校准集必须覆盖真实输入分布(含长文本与专业术语),calib_n 经验取
# 128–512;太少会让激活统计失真、掉点。之后务必跑 5.5 节的掉点诊断。
5.2 PTQ 与 QAT、权重量化与激活量化
| 维度 | PTQ(训练后量化) | QAT(量化感知训练) |
|---|---|---|
| 是否需要训练 | 否,仅校准 | 是,需反向与微调 |
| 成本 | 低(几小时校准) | 高(需训练资源与数据) |
| 精度 | 好(4bit 通常可接受) | 最好(可到更低比特) |
| 适用 | 绝大多数部署 | 端侧/极致压缩/精度敏感 |
另一个关键区分是量化谁:权重量化(如 W8A16、W4A16)只压缩权重,decode 阶段每步都要读权重,所以收益直接体现在显存与读取带宽上,实现最简单;激活量化(W8A8)还要量化激活,能进一步加速 compute-bound 的 prefill,但激活里有离群值(outlier),处理不好会严重掉点,通常需要在线统计或 SmoothQuant 这类平滑手段。
python# 混合精度的最简决策:先量化权重,不够再量化激活,最后才动 KV
# W8A16 -> 权重 INT8,激活保持 BF16(最稳,decode 显存减半)
# W4A16 -> 权重 INT4(如 AWQ),激活 BF16(长上下文常用)
# W8A8 -> 权重与激活都 INT8(prefill 加速最大,需处理离群值)
# 经验:从 W8A16 起步验证质量,再逐步下探到 W4A16;W8A8 留给
# prefill 占比高、且已确认离群值被妥善处理的场景。
| 量化配置 | 权重 | 激活 | decode 收益 | prefill 收益 | 风险 |
|---|---|---|---|---|---|
| W16A16(基线) | BF16 | BF16 | — | — | 无 |
| W8A16 | INT8 | BF16 | 显存减半,速度 ~1.3–1.6× | 小 | 低 |
| W4A16(AWQ) | INT4 | BF16 | 速度 ~1.8–2.5× | 小 | 中(需校准) |
| W8A8(SmoothQuant) | INT8 | INT8 | 中 | 大(compute-bound 提升) | 中(离群值) |
| FP8(E4M3) | FP8 | FP8 | 高 | 高(原生张量核心) | 极低 |
5.3 GPTQ / AWQ / SmoothQuant 的机制差异
- GPTQ:逐层量化,用二阶信息(Hessian)做误差补偿——量化某一列时,把误差通过逆 Hessian 摊回未量化的列,从而把 4bit 的损伤压到最低。需要一小批校准数据,不训练。
- AWQ(Activation-aware Weight Quantization):观察到权重的「重要性」极不均匀,且由对应激活的幅度决定。它对激活幅度大的通道做缩放保护(等效提升其有效精度),而不是对所有权重一视同仁,因此无需反向、实现更轻,常在 4bit 上优于 GPTQ。
- SmoothQuant:把激活里的离群值「难度」迁移到权重——用一个平滑系数把激活除以 s、权重乘 s,使两边都落到可低比特表示的范围内,从而让 W8A8 几乎不掉点。
python# SmoothQuant 的缩放直觉:平滑系数 s 平衡激活与权重的量化难度
# X' = X / s, W' = W * s 使得 X' 与 W' 的数值范围都更友好
# s 通常取 per-channel:s_j = (max(|X_j|)^alpha) / (max(|W_j|)^(1-alpha))
# alpha 默认 0.5:激活与权重各承担一半难度
# 效果:原本激活离群值大、无法 INT8 的层,平滑后可整层 W8A8 而不掉点。
python# GPTQ 误差补偿的直觉(标量例子,展示「把误差摊回未量化列」)
import numpy as np
w = np.array([0.12, -0.98, 0.55, 0.03]) # 一行的权重
scale = w.max() / 7 # INT4 对称量化步长(范围 -8..7)
q = np.round(w / scale).clip(-8, 7)
deq = q * scale
print("原权重:", w)
print("反量化:", deq)
print("误差 :", np.round(w - deq, 4)) # GPTQ 会把 err 按 Hessian 摊给未量化列
# 关键:GPTQ 不是「独立取整」,而是「取整后补偿」,因此 4bit 掉点远小于朴素 round。
# AWQ 换个思路:不补偿误差,而是按激活幅度保护「重要通道」。
5.4 INT8 / INT4 / FP8 / MXFP4 的精度与速度取舍
| 格式 | 相对 FP16 显存 | 相对速度 | 精度风险 | 适用硬件 |
|---|---|---|---|---|
| INT8 | 1/2 | 高 | 低 | 几乎所有 GPU / CPU |
| FP8(E4M3) | 1/2 | 很高(原生张量核心) | 极低 | H100 / H200 / Blackwell |
| INT4 | 1/4 | 中–高 | 中(需校准/补偿) | Ampere+(需内核支持) |
| MXFP4 | 1/4 | 高(新硬件极致) | 中高(需验证) | Blackwell 及后续 |
优先级建议:能上 FP8 就上 FP8——它在 H100 级硬件上有原生张量核心支持,显存减半且精度损失极小,是 2025–2026 年大模型部署的默认选择;硬件不支持 FP8 时再退回 INT8/INT4(AWQ)。4bit 以下(含 MXFP4)务必在业务集上验证,它对长推理链和数值敏感任务的损伤不成比例地大。
python# 各格式的数值范围与精度(决定为什么 FP8 比 INT8 更「稳」)
import numpy as np
print("FP16 范围约", np.finfo(np.float16).min, "~", np.finfo(np.float16).max)
print("INT8 均匀量化,范围固定 -128~127,动态范围小、依赖 scale")
print("FP8-E4M3 范围约 -448 ~ 448,尾数 3 位(相对精度 ~2^-3)")
# 结论:FP8 有指数位、动态范围大,权重里的极小/极大值都不易溢出,因此
# 「几乎无损」;INT8 靠 per-channel scale 才够用;NVFP4 用块级缩放
# (micro-scaling) 弥补 4bit 动态范围的不足。
5.5 量化后掉点的诊断方法
- 按任务子类分别评估:不要只看总平均分。把评测集拆成「长链推理 / 代码 / 数学 / 抽取 / 低资源语言」分别统计,量化损伤往往集中在某几类。
- 逐层敏感度分析:逐层回退到 FP16(混合精度),定位「哪些层一量化就掉点」,只对敏感层保留高精度。
- 检查校准集质量:校准数据要覆盖真实输入分布;分布偏移会让激活统计失真,导致离群值没被照顾到。
- 改用 group-wise / per-channel 而非 per-tensor:更细的粒度通常能显著回收精度。
- 看生成稳定性:量化有时不降平均分,但让输出方差变大、偶尔吐乱码——要用多次采样的稳定性指标兜底。
python# 逐层敏感度分析:找出「一量化就掉点」的层,只对它们回退高精度
def layer_sensitivity(eval_fn, model_layers):
"""对每层分别回退 FP16,观察指标回升幅度"""
base = eval_fn(model_layers) # 全量化基线分
for i in range(len(model_layers)):
mixed = model_layers.copy(); mixed[i] = "FP16"
gain = eval_fn(mixed) - base # 该层回退带来的回升
if gain > 0.01: # 回升 >1 个点视为敏感层
print(f"layer {i}: sensitive (+{gain:.3f})")
# 预期:敏感层通常集中在少数几层(经验上 10%–25% 的层贡献大部分掉点),
# 对这些层保留 FP16/BF16 即可「混合精度」回收大部分精度。
# 注意:必须用真实业务评测集,公开任务常掩盖这种层间差异。
5.6 动手练习与自测
- 为同一模型设计 BF16 / FP8 / AWQ-INT4 三套部署,列出各自的显存、预期速度与风险(判据:能说 FP8 近乎无损、INT4 需校准)。
- 解释 GPTQ 的误差补偿与 AWQ 的激活感知差异,并说明为什么 AWQ 在 4bit 常优于 GPTQ。
- 给定一份量化后评测结果,判断掉点集中在哪些任务子类,给出处置顺序(参考答案:先复验集、再混合精度、后换方法)。
- 写出 W8A16 / W4A16 / W8A8 的适用场景与 prefill/decode 收益差异。
- 说明为什么 FP8 比 INT8 更「稳」(参考答案:有指数位、动态范围大、不易溢出)。
| 方案 | 显存 / 速度 | 风险 |
|---|---|---|
| BF16 | 基准 | 无(但显存与带宽要求最高) |
| FP8(E4M3) | 显存约半、速度约 1.5–2 × | 近乎无损,Hopper/Blackwell 首选 |
| W8A16 | 权重减半 | 激活仍 16bit,收益有限 |
| AWQ-INT4 | 速度约 2–3 × | 需校准,掉点须看业务评测集 |
6. 并行策略:从单卡到集群
学习路径
- 读 6.1:理解 TP 切权重矩阵 + 每层 all-reduce 的通信模型
- 跑单机多卡 TP 示例,对比单请求延迟
- 完成 6.6 自测:解释为何 TP 只在单机 NVLink 内用
- 对接 M16:多卡部署时确定 TP 度数
核心知识点详解
- 切权重、每层 all-reduce:TP 把一层权重按列切成 TP 份,每卡算一部分,层末 all-reduce 通信一次。NVLink 快、开销可控,但通信/计算比限制它只适合单机。
- 降单请求延迟:TP 把一次请求摊到多卡、单请求加速,适合延迟敏感负载;跨卡带宽不足时吞吐反而受限,TP 度数一般 2–8。
- 常见坑:跨节点开大 TP:TP 依赖 NVLink 高带宽,跨节点后每层 all-reduce 都走网络、延迟爆炸。TP 只在单机卡力内,大模型要靠 PP/EP。
学习路径
- 读 6.2:理解 PP 按层切 stage 与气泡率公式
- 跑 micro-batch 填充示例,观察气泡占比
- 完成 6.6 自测:计算 (P−1)/(m+P−1) 的气泡比例
- 完成本章自测:评估 PP 相对 TP 的适用规模
核心知识点详解
- 按层切 stage:PP 按层切成 P 段分给 P 卡,前向一条条过。气泡率
(P−1)/(m+P−1)(m 为 micro-batch 数):P=8、m=16 时仍约有 43% 空转。 - micro-batch 填充:把请求切成 micro-batch 流水化,延后每段的空隙,气泡率随之下降。PP 允许跨节点廉价网络,是卡间扩展的保底方案。
- 常见坑:不切 micro-batch 直接跑:一个 batch 整段同步,气泡率拉满、PP 吞吐优势归零。必须切 micro-batch 并靠流水把空隙填上。
学习路径
- 读 6.3:理解 MoE 专家并行 + all-to-all 通信
- 跑 top-k 路由与负载均衡示例,观察热点专家
- 完成 6.6 自测:说明序列并行如何切长上下文
- 完成本章自测:评估 EP 的通信与均衡取舍
核心知识点详解
- MoE 专家并行:MoE 的专家经
top-k路由到不同卡,卡间 all-to-all 交换 token;负载均衡防止热点专家拖垮整体。 - 序列并行切长上下文:把单个长序列的 attention 沿序列维度切开到多卡,规避单卡装不下超长 KV 的墙,常与 PP 配合使用。
- 常见坑:路由不均衡:部分专家被热点 token 打爆、别的卡闲,延迟由最忙卡决定。要做均衡约束(如每个 token 落到不同卡),否则 EP 收益被热点吃掉。
学习路径
- 读 6.4:理解 prefill 高算力卡 / decode 高带宽卡的解耦部署
- 跑 PD 分离对比,观察 KV 跨网络传输开销
- 完成 6.6 自测:判断小规模为何不划算
- 对接 M16:压测时评估 PD 分离是否值得上
核心知识点详解
- prefill 算力卡 + decode 带宽卡:把 prefill 放算力型卡、decode 放带宽型卡,各自打满对应瓶颈;中间需把 KV 跨网络传输,有显式开销。
- 小规模不划算:QPS 不够高时,KV 传输开销、双份调度与运维复杂度大于收益。只有两阶段真的各自受限、单卡寸显时才值得上。
- 常见坑:负载不大硬上 PD:额外 KV 传输与部署复杂度让成本反升。用压测数据判断两阶段是否各自受限,别被「先进架构」带节奏。
学习路径
核心知识点详解
- 带宽量级速记:NVLink ≈900GB/s、PCIe 5.0 ≈64GB/s、IB / 400Gb RoCE ≈50GB/s。通信量过大时就要降并行度或换互联。
- 通信/计算比判据:若每层通信量 ÷ 计算量比值过高,all-reduce 会成为瓶颈,据此判断给定互联下一个并行方案可不可行。
- 常见坑:给以太网配 TP=8:TP 每层全量传输,走 50GB/s 以太网直接变网络等待。通信密集的并行(TP/EP)必须配 NVLink 或高带宽 IB/InfiniBand。
6.1 张量并行(TP):切矩阵
张量并行(Tensor Parallelism)把每一层的权重矩阵沿特定维度切到多张卡,每个设备算一部分,再在层内做 all-reduce 汇总。它的通信极其频繁(每层前向/反向各一次 all-reduce),因此严重依赖高带宽互联(NVLink),一般只在单机多卡内使用,跨节点 TP 会因为网络延迟迅速恶化。TP 的两个典型用途:① 单卡放不下模型;② 降低单请求延迟(把一次大矩阵乘分摊到多卡并行)。
python# 张量并行的直觉:把一个矩阵乘切成 N 份,各卡算一份再汇总
# 以列并行 Linear 为例(Megatron 风格)
# Y = [X @ W1, X @ W2, ...] (沿输出维切 W),各卡算一段
# 若后面紧跟需要完整 Y 的操作(如 GeLU/RMSNorm),则需 all-gather
# 若后面是行并行 Linear,则可直接用分段结果做 reduce-scatter,省一次通信
#
# 通信量近似:每层 2 * (参数 / TP) 的激活要在卡间传递(forward + backward)
# → TP 越大,单卡算力/显存省,但通信量近似不变(与层数成正比)
# → 所以 TP 受限于「互联带宽 / 单层计算量」的比值
bash# 张量并行启动与「是否值得」的判据
# 8 卡单机(NVLink),70B BF16 单卡放不下 -> TP=4 或 8
vllm serve Qwen/Qwen3-72B --tensor-parallel-size 4
# 判据:TP 的代价是每层一次 all-reduce,通信时间 ~ 2*(激活字节)/带宽
# NVLink ~900GB/s: 8K 激活 all-reduce 约几十微秒,可忽略 -> 值
# PCIe ~64GB/s : 同样 all-reduce 要十几倍时间 -> 常拖慢整体
# 经验:TP 只用于「单机内」且务必配 NVLink;跨机请改 PP/EP 或多副本。
# 一个反例:在 PCIe-only 机器上开 tp=4,吞吐反而比 tp=1 + 多副本低。
6.2 流水线并行(PP):切层
流水线并行(Pipeline Parallelism)把模型按层切成若干 stage,每个 stage 在一组卡上,激活在 stage 间传递。问题是气泡(bubble):前几个 stage 等第一个 micro-batch 流到末尾才能开始第二个,期间空闲。用 micro-batch 把大 batch 拆细、交错执行,可以填补气泡,但气泡率仍约为 (P−1)/m 量级(P 为 stage 数、m 为 micro-batch 数),需要足够多的 micro-batch 才能压低。PP 适合跨节点扩展(通信少,只在 stage 边界传激活),但对延迟不友好。
| 并行方式 | 通信模式 | 主要代价 | 何时用 |
|---|---|---|---|
| TP 张量并行 | 每层 all-reduce,频繁 | 依赖 NVLink | 单节点、降延迟、放不下 |
| PP 流水线并行 | stage 间传激活,少 | 气泡、延迟高 | 跨节点扩展 |
| EP 专家并行 | 专家间 all-to-all | 路由通信 | MoE 必需 |
| 多副本 | 无模型内通信 | 冗余资源 | 提吞吐/可用性的首选 |
python# 流水线气泡率:micro-batch 越多,气泡越小(但有上限)
def bubble_rate(P, m):
# 近似:气泡占比 = (P-1)/(m+P-1)
return round((P-1)/(m+P-1), 3)
for P in (2, 4, 8):
print(f"P={P}:", [bubble_rate(P, m) for m in (1, 4, 16, 64)])
# 预期:P=4 时 m=1 -> 0.75(!),m=16 -> 0.158,m=64 -> 0.045
# 结论:PP 必须配足够多 micro-batch 才划算;这也意味着 PP 对
# 「大 batch 离线」友好,对「小 batch 低延迟在线」不友好。
6.3 专家并行(EP)与序列并行
MoE 模型每层有多个专家,专家并行(Expert Parallelism)把不同专家放到不同卡上,token 按路由结果被送到对应卡——带来 all-to-all 通信,是 MoE 部署的主要成本。与之相对,序列并行(Sequence Parallelism)把长序列的激活沿序列维切到多卡,专门解决单卡放不下超长上下文激活的问题,常与 TP 配合(TP 切隐藏维、SP 切序列维)。两者都是「单卡放不下」时的必备手段。
python# MoE 专家并行的通信量估算:all-to-all 是主要成本
def ep_alltoall(tokens, hidden=4096, bytes_per=2, top_k=2):
# 每个 token 送 top_k 个专家,每个方向 hidden 维激活
per_dir = tokens * top_k * hidden * bytes_per / 1024**2
return {"send_MB": round(per_dir, 1), "total_MB": round(per_dir*2, 1)}
print(ep_alltoall(tokens=4096))
# 预期:{'send_MB': 64.0, 'total_MB': 128.0}
# 结论:batch 越大 all-to-all 越大,且它是同步点 -> 对跨机带宽敏感;
# DeepSeek 系 MoE 用 EP + 负载均衡(辅助损失 / 无辅助损失路由)控制热点。
# 经验失败模式:专家负载不均 -> 少数卡成瓶颈,整体吞吐被最慢专家拖死。
6.4 PD 分离:prefill 与 decode 解耦部署
传统部署把 prefill 与 decode 放在同一批卡上,但二者资源特性相反(prefill 算力密集、decode 带宽密集),混部会让 GPU 在两种模式间反复切换、利用率都上不去。PD 分离(prefill/decode disaggregation)把二者部署在不同节点:prefill 集群用高算力卡 + 较大 TP,decode 集群用高带宽/大显存卡 + 高并发 batch。请求先在 prefill 节点算完 KV,再把 KV 传输到 decode 节点继续生成。代价是一次额外的 KV 跨网络传输,但当规模足够大时,资源利用率与尾延迟的改善显著。
| 阶段 | 资源特性 | 推荐硬件倾向 | 并行策略 |
|---|---|---|---|
| Prefill | 算力密集(compute-bound) | 高 FLOPs 卡(如 H100 密集) | 较大 TP,少副本 |
| Decode | 带宽密集(memory-bound) | 高带宽/大显存卡 | 高并发 batch,多副本 |
| 传输 | KV 跨节点搬运 | 高带宽网络(如 400Gb RoCE) | 异步传输 + 流水线重叠 |
python# PD 分离到底值不值:把「KV 传输成本」算清楚再决定
def pd_worth(kv_gb_per_req, reqs_per_s, link_gbps=400, prefill_tps_gain=0.35):
link_gb_s = link_gbps / 8 # 400Gb/s -> 50GB/s
transfer_ms = kv_gb_per_req / link_gb_s * 1000
bw_used = reqs_per_s * kv_gb_per_req / link_gb_s
return {"transfer_ms": round(transfer_ms, 2),
"link_utilization": round(bw_used, 3),
"prefill_gain": prefill_tps_gain}
print(pd_worth(kv_gb_per_req=0.2, reqs_per_s=20))
# 预期:{'transfer_ms': 4.0, 'link_utilization': 0.08, 'prefill_gain': 0.35}
# 结论:单请求 KV ~0.2GB、20 QPS 时,400Gb 链路只用 8%,传输成本可忽略,
# 而 prefill/decode 各自优化可提效 ~35% -> 大规模值得。
# 反例:单机 8 卡小规模,两套调度+传输的复杂度 > 收益,先调单机。
6.5 通信量与互联:NVLink 还是以太网
| 互联 | 典型带宽 | 对哪种并行友好 | 说明 |
|---|---|---|---|
| NVLink / NVSwitch | 约 900 GB/s(H100) | TP(频繁 all-reduce) | 节点内首选,TP 几乎必须 |
| PCIe 5.0 | 约 64 GB/s | 弱(TP 会受限) | 无 NVLink 时的退路 |
| InfiniBand / 400Gb RoCE | 约 50 GB/s | PP / EP / PD 传输 | 跨节点主力 |
| 以太网(普通) | 10–25 GB/s | 仅多副本/控制面 | 不适合模型内通信 |
一句话判断:TP 吃互联带宽,所以 TP 必须配 NVLink 且限单机;PP/EP/PD 的通信稀疏,可以跨节点用 IB/RoCE。很多团队「多卡反而更慢」的根因,就是把需要 NVLink 的 TP 铺到了跨节点以太网上。
python# 互联带宽选型:把「通信/计算比」算出来,决定能用多大 TP
def tp_viable(activation_mb, layer_tflops, bw_gbs, peak_tflops=990):
comm_ms = activation_mb * 2 / (bw_gbs * 1000) # 双向 all-reduce 近似
comp_ms = layer_tflops / peak_tflops * 1000 # H100 峰值折算
return {"comm_ms": round(comm_ms, 4), "comp_ms": round(comp_ms, 4),
"ratio": round(comm_ms / comp_ms, 4), "ok": comm_ms / comp_ms < 0.3}
print("NVLink:", tp_viable(32, 300, 900))
print("PCIe :", tp_viable(32, 300, 64))
# 预期:NVLink ratio≈0.0001;PCIe ratio≈0.0014(单层都很小)
# 说明:单层看 PCIe 也还行,但每层累积 + 激活随序列/并发增长会放大;
# 工程判据仍取「TP 限单机、优先 NVLink」,跨机走 PP/EP 或多副本。
6.6 动手练习与自测
- 给定「单卡放不下 70B BP16」的约束,选出并行策略并说明理由(参考答案:单机内 TP=4/8 配 NVLink;跨机走 PP/EP)。
- 计算 P=4、m=1/16/64 的流水线气泡率(参考答案:0.75 / 0.158 / 0.045),说明 PP 需要多少 micro-batch。
- 估算 4096 token、top-2 专家、hidden=4096 的 all-to-all 通信量(参考答案:约 128MB 双向)。
- 判断「单机 8 卡、KV 0.2GB/请求、20 QPS」要不要上 PD 分离(参考答案:链路利用率仅 8%,可上;但小规模更应先调单机)。
- 解释为什么在 PCIe-only 机器上开大 TP 反而更慢(参考答案:all-reduce 通信时间被互联带宽拖爆)。
| 并行 | 通信 / 代价 | 适用 |
|---|---|---|
| TP | 每层 all-reduce,量大 | 单机 NVLink |
| PP | 点对点,量小 | 有空泡,适合跨机 |
| EP | all-to-all | MoE 专家 |
| 气泡率 P=4 | m=1/16/64 → 0.75/0.158/0.045 | micro-batch 越大越低 |
7. 服务化:让它稳定地跑在线上
学习路径
核心知识点详解
- 五项必备:① SSE/WebSocket 流式输出;② 超时与取消生成(断开即取消,避免烧 GPU);③ 限流与并发控制(令牌桶);④ 重试与熔断(保护下游);⑤ 优雅降级 + Prometheus 暴露指标。
- 每条对应一个故障:没有超时→请求永远挂起;没有取消→GPU 空转烧钱;没有限流→被突发打死;没有熔断→雪崩传染;没有指标→问题无法定位。
- 常见坑:只做流式不做取消:SSE 流出了但客户端关页面后端还在生成。必须实现
cancel:断开即终止循环,省 token 省显存。
学习路径
- 读 7.2:理解 liveness / readiness / startup 与 initialDelay 120s
- 写 K8s GPU 资源声明与探针配置
- 完成 7.4 自测:配置多副本 + 负载均衡示例
- 对接 M16:产出 K8s 部署清单 + HPA 自动扩缩
核心知识点详解
- 三种探针各司其职:startup 解决冷启动(权重加载几十秒,
initialDelaySeconds给足,参考 120s);readiness 控制是否接流量;liveness 决定是否重启(加载阶段设 liveness 会反复重启)。 - GPU 资源声明 + 多副本:K8s 声明
resources.requests[gpu]=1,Service 做负载均衡。初步优化优先纵向(把单副本调满)而非急着横向多副本。 - 常见坑:liveness 误杀推理服务:长请求冻住指标采集窗口,被 liveness 杀掉反而更糟。liveness 阈值要容忍长推理与 GC,快失败逻辑放 readiness 与超时。
学习路径
- 读 7.3:理解网关鉴权 / 令牌桶限流 / SSE 心跳
- 跑 Idempotency-Key 幂等与优雅停机 drain 示例
- 完成 7.4 自测:写灰度路由与回滚流程
- 对接 M16:实现按流量比例切流的灰度发布与回滚脚本
核心知识点详解
- 令牌桶与幂等:网关用令牌桶限流(突发可容忍、均值可控);写操作带
Idempotency-Key,重试不重复计费;SSE 用分帧 + 心跳保活防代理超时断流。 - 灰度与优雅停机:按流量比例切流灰度(先 5%)、核心链路可回滚;优雅停机走 drain:先停接新连接、把在途请求跑完再关进程。
- 常见坑:灰度没有回滚脚本:灰开头容易,出问题却没一键回滚只能手工改配置。回滚脚本要跟灰度一起写好,故障时 1 分钟内切旧版本。
7.1 生产级推理服务的必备要素
- 流式输出:SSE / WebSocket,配合前端「停止生成」。首 token 延迟比总延迟更影响体感。
- 超时与取消:服务端必须有生成超时;客户端断开要能取消计算,否则 GPU 被白白占用。
- 限流与并发控制:按用户 / 租户限流,配合队列与优先级,防止单个用户打满。
- 重试与熔断:下游失败要有次数上限与退避;上游过载要熔断并返回降级响应。
- 优雅降级:模型不可用时降级到小模型、缓存答案或明确的错误提示(而非超时报错)。
- 健康检查与就绪探针:区分「进程活着」与「模型已加载可服务」,K8s 部署必需。
- 指标暴露:QPS、TTFT、TPOT、排队时长、GPU 利用率、显存、错误率,接入 Prometheus。
python# 生产级推理接口的骨架:流式 + 取消 + 超时 + 限流 + 指标
import asyncio, time
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from prometheus_client import Counter, Histogram
app = FastAPI()
REQS = Counter("llm_requests_total", "total", ["status"])
TTFT = Histogram("llm_ttft_seconds", "time to first token")
@app.post("/v1/chat")
async def chat(req: Request, body: ChatBody):
if not limiter.allow(body.user_id): # 1) 限流
REQS.labels("rate_limited").inc()
return {"error": "too_many_requests", "retry_after": 1}
async def gen():
t0 = time.perf_counter()
first = True
try:
async with asyncio.timeout(60): # 2) 总超时
async for chunk in engine.stream(body):
if await req.is_disconnected(): # 3) 客户端断开就取消
log.info("client disconnected, cancelling")
return
if first:
TTFT.observe(time.perf_counter() - t0)
first = False
yield f"data: {chunk.model_dump_json()}\n\n"
REQS.labels("ok").inc()
yield "data: [DONE]\n\n"
except asyncio.TimeoutError:
REQS.labels("timeout").inc()
yield 'data: {"error":"timeout"}\n\n'
except Exception as e:
REQS.labels("error").inc()
log.exception("generation failed")
yield 'data: {"error":"internal"}\n\n' # 4) 失败也要给结构化响应
return StreamingResponse(gen(), media_type="text/event-stream")
python# 分级超时与降级:不同下游给不同预算,超时即降级而非硬等
import asyncio
async def with_budget(coro, ms, fallback):
try:
return await asyncio.wait_for(coro, ms/1000)
except asyncio.TimeoutError:
return fallback # 降级值(缓存/小模型/提示)
async def answer(q):
retrieved = await with_budget(retrieve(q), 300, fallback=[]) # 检索 300ms
tools = await with_budget(call_tools(q), 500, fallback=None) # 工具 500ms
return await generate(q, ctx=retrieved, tool=tools)
# 原则:① 每个下游独立预算;② 超时返回可用降级(宁可少检索也别超时);
# ③ 总预算再兜一层(见 sv-api 主体)。避免一个慢下游拖垮整链。
7.2 多卡部署与选型
| 并行方式 | 何时用 | 代价 |
|---|---|---|
| 单卡 | 模型能放下且吞吐足够 | 最简单,首选 |
| 张量并行 TP | 单卡放不下,或需要降低单请求延迟 | 每层都要通信,必须有高速互联(NVLink);建议不超过单机卡数 |
| 流水线并行 PP | 跨机扩展 | 有气泡,需要调微批次;延迟敏感场景不友好 |
| 专家并行 EP | MoE 模型部署 | token 路由的通信开销大;需要专门支持 |
| 多副本 + 负载均衡 | 提升总吞吐与可用性 | 最简单可靠的扩展方式,优先于复杂并行 |
bash# K8s 部署 vLLM:GPU 资源 + 就绪探针 + 分副本负载均衡(Deployment 片段)
# containers:
# - name: vllm
# image: vllm/vllm-openai:latest
# args: ["--model","Qwen/Qwen3-8B","--max-num-seqs","128",
# "--enable-prefix-caching","--gpu-memory-utilization","0.90"]
# resources: { limits: { nvidia.com/gpu: 1 } }
# readinessProbe:
# httpGet: { path: /health, port: 8000 }
# initialDelaySeconds: 120 # 大模型加载慢,探测过早会反复重启
# startupProbe:
# httpGet: { path: /health, port: 8000 }
# failureThreshold: 60 # 给最长约 10 分钟加载窗口
# Service 用 ClusterIP + Ingress;副本间由 Service 轮询,无需模型内并行。
# 关键:readiness 没过之前绝不可导流量,否则首个用户吃到「冷启动」超时。
7.3 服务化工程:网关、限流、熔断与发布
- API 网关与鉴权:统一入口做 API key / OAuth / JWT 校验,按租户解析配额;模型调用不应在业务服务里裸奔,网关层做鉴权与审计。
- 限流与配额:令牌桶 / 漏桶按用户或租户限流,配合排队与优先级;超出配额返回 429 与 retry-after,而非让请求打爆后端。
- 超时与熔断:下游(模型、检索、工具)设分级超时;上游错误率超阈值时熔断,快速失败或降级,避免雪崩。
- 重试与幂等:客户端重试必须带幂等键(Idempotency-Key),否则网络抖动会导致重复计费或重复副作用(如重复下单)。
- 流式响应的正确实现:SSE 要正确分帧(每条
data:后跟两个换行)、带心跳防代理超时断开、客户端断开要能取消后端计算。 - 多模型路由与灰度:按任务难度/成本路由到不同模型(大模型/小模型/缓存),新模型先放 5% 流量做 canary,指标无劣化再放量。
- 优雅停机与滚动升级:停机前先停止接收新请求、排空在途请求(drain),K8s 用 readiness 探针摘流;滚动升级保证总有可用副本。
- 健康检查:liveness(进程活着)、readiness(模型已加载可服务)、startup(首次加载很慢,给足时间避免被误杀)。
python# 幂等重试:客户端必须带幂等键,服务端去重,否则抖动会重复扣费/重复动作
import uuid
def call_with_idempotency(client, payload, idem_key=None):
idem_key = idem_key or str(uuid.uuid4()) # 每个业务动作一个稳定键
for attempt in range(4):
try:
return client.post("/v1/chat", json=payload,
headers={"Idempotency-Key": idem_key})
except (TimeoutError, ConnectionError):
continue # 仅网络类可安全重试
raise RuntimeError("exhausted retries")
# 服务端:用 key -> 结果缓存做去重;同一 key 的并发/重复请求返回首次结果,
# 而不是重复消费 GPU 与计费。这是「计费/下单类」接口的上线硬要求。
python# 令牌桶限流 + 租户配额(生产网关的最小实现)
import time
class TokenBucket:
def __init__(self, rate, burst):
self.rate, self.burst = rate, burst # 每秒补充速率、桶容量
self.tokens, self.ts = burst, time.monotonic()
def allow(self, n=1):
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now-self.ts)*self.rate)
self.ts = now
if self.tokens >= n:
self.tokens -= n
return True
return False # 返回 429 + retry-after
buckets = {} # 按租户隔离
def allow(tenant, rate=5, burst=10):
return buckets.setdefault(tenant, TokenBucket(rate, burst)).allow()
# 经验:交互服务按租户 5–20 rps、突发 2 倍;批量任务走独立队列与配额,
# 避免用同一桶把在线与离线混在一起导致互相挤占。
7.4 动手练习与自测
- 为一个推理服务列出「生产必备要素」清单,并各写一句你的实现方式(判据:含流式、超时取消、限流、熔断降级、探针、指标)。
- 写出 liveness / readiness / startup 三种探针的差异,并给出大模型服务的 initialDelay 与 failureThreshold 经验值。
- 说明为什么客户端断开后必须取消后端计算(参考答案:否则 GPU 空转烧钱,且占用 KV 空间)。
- 给「按租户 5rps、突发 2 倍」实现一个令牌桶并说明 429 与 retry-after 的处理。
- 解释为什么重试接口必须带幂等键(参考答案:网络抖动会导致重复扣费/重复副作用,需服务端去重)。
| 机制 | 参数 / 要点 |
|---|---|
| 探针 | startup 给足权重加载时间;readiness 控流量;liveness 只判死锁 |
| 限流 | 令牌桶,租户 5 rps、突发 ×2,超限返 429 + retry-after |
| 取消 | 客户端断开即取消后端计算,省 GPU 与 KV 空间 |
| 幂等 | 重试接口带幂等键,服务端去重防重复副作用 |
8. 压测方法论:找到真实容量
学习路径
核心知识点详解
- 闭环掩盖排队:闭环「上一个完成才发起下一个」,请求率跟着吞吐走、掩盖积压。开环按固定 RPS 打,能暴露真实拐点与积压。
- 压测用开环:开环固定速率灌流量,观察延迟与超时如何随速率恶化,才能画出真实容量曲线。工具如
locust、wrk、ghz。 - 常见坑:拿闭环基准当容量:闭环给的是「服务自身节奏」,开环才是用户真实到达。两者差别大时容量规划会严重偏乐观。
学习路径
- 读 8.2:理解权重加载 / CUDA graph 捕获 / 内核 JIT 编译
- 压测前做 warmup 并丢弃前 N≥50 个样本
- 完成 8.5 自测:说明丢弃前样本的原因
- 对接 M16:在 loadtest.md 里记录 warmup 处理
核心知识点详解
- 三大启动开销:权重加载数十秒、CUDA graph 捕获、内核 JIT 编译,都让首批请求奇慢。压测不 warmup 得出的 P99 全是脏数据。
- 正确做法:压测前先发若干热请求做 warmup,并丢弃前 N≥50 个样本再统计,同时避免 CPU/GPU 频率冷启动影响。
- 常见坑:头 50 条也进报告:把冷启动慢请求混进样本,P99 被严重拉高误导调优。先 warmup + 丢前样本,报告才可信。
学习路径
- 读 8.3:理解并发档位扫描与吞吐 + P99 双轴报告
- 跑多档位扫描,记录 GPU 利用率与错误分类
- 完成 8.5 自测:写一份完整压测报告
- 对接 M16:产出 bench/loadtest.md(QPS/TTFT/TPOT/P99/GPU 利用)
核心知识点详解
- 并发档位扫描:并发从低到高逐档压,记录档位 → 吞吐(QPS)、P50/P95/P99、错误/超时率、GPU 利用率,画 x 并发、左轴吞吐右轴 P99 的双轴图。
- 报告要素要齐:写清每个数怎么来:负载模型、warmup 处理、样本数、GPU 利用率(几卡、多少)。产出
bench/loadtest.md存档。 - 常见坑:报告只有平均值:没有档位扫描与分位数、GPU 利用率、错误分类,读到的人无法判断该不该信。可复现性比美观更重要。
学习路径
核心知识点详解
- 二阶差分找 knee:对吞吐~并发曲线做二阶差分,变化骤降的点就是 knee(拐点)。拐点前的吞吐增长有意义,拐点后进入过载区。
- 限流阈值 ×0.8:把限流设到拐点 80%,留 20% 余量应对波动;在安全区内跑、永远不进过载区。HPA 扩容阈值同理参考拐点。
- 常见坑:把最大吞吐当容量:在吞吐巅峰跑等于在悬崖边跑,一个波动就超时崩 SLA。容量真值是「能稳定维持的 goodput」,以拐点 ×0.8 为准。
8.1 开环 vs 闭环负载模型
压测最重要的一个选择是负载模型。闭环(closed loop):一个虚拟用户必须等上一个请求返回,才发下一个——这会掩盖排队问题,因为系统变慢时到达率自动下降,曲线看起来「很健康」。开环(open loop):请求按泊松过程独立到达(如每秒固定发 N 个),不管是否返回——这才符合真实线上流量,能暴露排队与饱和。
| 负载模型 | 到达方式 | 能否暴露排队 | 适用 |
|---|---|---|---|
| 闭环 | 完成才发下一个 | 否(慢时到达率自动降) | 测单请求延迟上限 |
| 开环 | 按速率独立到达 | 是(慢时积压) | 测容量与拐点(正确选择) |
python# 开环负载生成:按固定速率发放,不等待返回(暴露排队)
import asyncio, time
async def open_loop(client, rps, duration):
tasks, t0, n = [], time.perf_counter(), 0
while time.perf_counter() - t0 < duration:
n += 1
tasks.append(asyncio.create_task(client.one(tag=n))) # 不等返回,立即发下一个
await asyncio.sleep(1.0 / rps) # 恒定到达率
await asyncio.gather(*tasks)
return [t.result() for t in tasks]
# 关键差异:闭环(等返回再发)会在系统变慢时自动降压 -> 掩盖排队;
# 开环在系统变慢时会持续积压 -> P99 迅速暴露真实拐点。
8.2 warmup 与编译开销
现代引擎首次请求很慢:要加载权重、分配 KV、捕获 CUDA graph、编译内核(尤其 TRT-LLM 与变长输入)。压测前必须 warmup(先跑几十到几百个请求把热路径跑热),并且排除 warmup 阶段的样本,否则会把一次性开销算进稳态延迟,得出错误的容量。
python# 压测骨架:先 warmup,再统计稳态
async def load_test(client, rps, duration_s=60, warmup=50):
# 1) warmup:跑一批但不计入统计
for _ in range(warmup):
await client.one(priority="warmup")
# 2) 稳态:开环按 rps 发放,记录时间戳
start = time.perf_counter()
tasks = []
while time.perf_counter() - start < duration_s:
tasks.append(asyncio.create_task(client.one()))
await asyncio.sleep(1.0 / rps) # 泊松到达的近似:固定间隔发
results = await asyncio.gather(*tasks)
# 3) 剔除 warmup 与异常,计算 TTFT/TPOT/P99/错误率
return summarize([r for r in results if r.ok])
| warmup 来源 | 一次性开销 | 处理方式 |
|---|---|---|
| 权重加载 + KV 分配 | 数十秒到数分钟 | 就绪探针挡住流量,不计入统计 |
| CUDA graph 捕获 | 首次若干形状各一次 | 用代表性长度各 warmup 一轮 |
| 内核 / JIT 编译(TRT-LLM / torch.compile) | 可达数分钟 | 启动后自动预热,压测前先跑满 |
| KV 池增长到稳态 | 前数百请求 | 丢弃前 N 个样本(N ≥ 50) |
| 缓存冷启动(prefix cache 为空) | 直到命中率稳定 | 用真实 prompt 分布预热缓存 |
8.3 指标采集与报告写法
一份能用来决策的压测报告至少包含:测试配置(模型、量化、max-num-seqs、硬件、并发档位)、核心曲线(横轴并发、纵轴吞吐与 P99 两条线)、拐点标注、错误率与超时分布、GPU 利用率/显存、以及结论与建议配置。结论要明确回答「单副本支撑多少并发、P99 多少、每千次请求成本多少」。
| 报告字段 | 为什么要 | 错误写法 |
|---|---|---|
| 并发档位扫描 | 找出拐点 | 只测一个并发就下结论 |
| 吞吐 + P99 双轴 | 看饱和点 | 只看平均延迟 |
| GPU 利用率 | 判断是否还有余量 | 只看 QPS 不看利用率 |
| 错误/超时分类 | 定位瓶颈类型 | 只报总错误率 |
python# 自动生成压测报告的核心字段(JSON -> Markdown 表)
def report(rows):
lines = ["| 并发 | 吞吐(tps) | P50(ms) | P99(ms) | 错误率 | GPU% |",
"|---|---|---|---|---|---|"]
for r in rows:
lines.append(f"| {r['c']} | {r['tps']} | {r['p50']} | {r['p99']} "
f"| {r['err']:.2%} | {r['gpu']} |")
lines.append("\n拐点并发 ≈ %d(此后吞吐停滞而 P99 上升)" % find_knee(rows))
return "\n".join(lines)
# 报告必须回答三个问题:单副本安全并发=? 对应 P99=? 每千次请求成本=?
# 缺失任何一个,报告都只是「数据罗列」而非「可决策结论」。
8.4 找到容量拐点
容量拐点(knee)是:继续加并发,吞吐几乎不再涨,但 P99 延迟开始指数级上升的那一点。这个点之前的并发是「安全区」,之后是「过载区」。线上限流阈值应设在拐点稍下方,并据此配置扩容触发条件。
python# 用二阶差分找拐点:吞吐增量骤减、延迟增量骤增的位置
def find_knee(rows):
"""rows: [{concurrency, tps, p99}],按并发升序"""
best = None
for i in range(1, len(rows)):
d_tps = rows[i]["tps"] - rows[i-1]["tps"]
d_p99 = rows[i]["p99"] - rows[i-1]["p99"]
# 吞吐增量 < 5% 且延迟增量 > 50% -> 进入过载区
if d_tps < 0.05 * rows[i-1]["tps"] and d_p99 > 0.5 * rows[i-1]["p99"]:
return rows[i]["concurrency"] # 拐点并发
return rows[-1]["concurrency"]
# 实践:把拐点并发 × 安全系数(0.8) 作为单副本目标并发,
# 并作为 HPA 扩容的触发阈值(而非凭 GPU 利用率拍脑袋)。
| 并发 | 吞吐 tps | P50 ms | P99 ms | 状态 |
|---|---|---|---|---|
| 8 | 420 | 180 | 320 | 安全区 |
| 32 | 1180 | 260 | 540 | 安全区 |
| 64 | 1480 | 420 | 980 | 逼近拐点 |
| 128 | 1620 | 900 | 3200 | 过载区(P99 爆炸) |
| 256 | 1650 | 1800 | 8900 | 严重过载(吞吐不再涨) |
8.5 动手练习与自测
- 写一个开环负载生成器,并说明它与闭环压测在「暴露排队」上的差异(判据:能指出闭环会掩盖拐点)。
- 列举 warmup 的三类一次性开销,并给出各自的处理方式(参考答案:权重加载、CUDA graph 捕获、内核编译)。
- 给定一组并发–吞吐–P99 数据,用二阶差分找拐点并给出限流阈值(参考答案:拐点×0.8)。
- 写出一份压测报告必须回答的三个问题(单副本安全并发 / 对应 P99 / 每千次请求成本)。
- 解释为什么「只看平均延迟」会得出错误容量结论。
| 项 | 判据 |
|---|---|
| 开环压测 | 暴露排队;闭环会掩盖拐点 |
| warmup | 权重加载 / CUDA graph 捕获 / 内核编译三类一次性开销 |
| 拐点 | 二阶差分找拐点,限流阈值取拐点 × 0.8 |
| 报告三问 | 单副本安全并发 / 对应 P99 / 每千次请求成本 |
9. 容量规划、成本与 MLOps
学习路径
- 读 9.1:理解每千次请求成本与每百万 token 成本口径
- 跑成本公式,观察利用率如何决定单位成本
- 完成 9.5 自测:算一次自托管 vs 商业 API 的盈亏平衡点
- 对接 M16:产出每千次请求成本模型
核心知识点详解
- 每千次请求成本:成本 = (输入/输出 token × 每 token 单价) + 部署固定成本 ÷ 请求数。利用率是分母,利用率 40% 时闲置 60% 也按满付钱、单位成本翻倍。
- 自托管 vs API 盈亏平衡:比较自托管机器成本 ÷ (理想吞吐 × 利用率) 与 API 每 token 单价:请求量大、利用率高、自托管跨过平衡点后更省,反之买 API。
- 常见坑:忽略固定成本与利用率:把 7×24 闲置的 GPU 当「免费」,模型严重失真。把机器折旧、电、运维都算进分母。
学习路径
核心知识点详解
- 可回滚的发布闭环:版本化模型/权重/配置 → 评测门禁(没达基线不发)→ 灰度 5%/A/B → 一键回滚,让一次模型或 Prompt 变更可安全上线并快速撤回。
- 监控告警 + HPA:Prometheus + Grafana 盯 P99 / 排队时长 / GPU 利用率 / 错误率;HPA 按排队时长扩容而非只看 GPU,避免用户超时才扩。
- 常见坑:回滚没有快速通道:出事后靠手工重发旧镜像恢复要半小时。一键回滚与版本清单要提前配好,故障时 1 分钟内切回去。
学习路径
核心知识点详解
- 先权重后 KV:权重显存 = 参数量×bit/8;再加每并发 KV(公式)。排 GPU 可用显存得单卡并发上界 → 单卡 QPS = 并发 ÷ 平均耗时。
- 副本数 = 目标 ÷ 单副本:所需副本 = 目标 QPS ÷ 压测的单副本容量,再加 N+1 冗余应对峰值与故障。模型路由(多模型共享卡)可进一步省卡。
- 常见坑:只按权重定卡:只算权重以为一张卡够了,长上下文下 KV 把显存吃光。必须 权重 + KV + 并发换算 QPS 才是完整算例。
学习路径
- 读 9.4:对比批处理打折 / 前缀缓存 / 语义缓存 / 量化四类杠杆
- 跑成本对比示例,观察利用率 30%→80% 降本约 2.6×
- 完成 9.5 自测:给自己的服务列出降本空间
- 对接 M16:在成本模型里列出各降本杠杆
核心知识点详解
- 四类杠杆:① 批处理 API 打 5 折(离线可异步);② 前缀缓存命中免去重复 prefill;③ 语义缓存命中直接返旧结果;④ 量化减权重与 KV。
- 利用率是最大杠杆:利用率 30%→80%,单位成本约降 2.6×——比任何算法优化都狠。先想办法把卡跑满,再谈级别省。
- 常见坑:一上来就量化:先查利用率并填缓存,往往比量化收益大得多、风险小得多。降本优先级:利用率 > 缓存 > 量化。
9.1 单位经济核算
python# 单位经济核算:回答「每千次请求要多少钱」
def unit_economics(gpu_hour_price, gpus, throughput_rps, p95_latency_s, n_requests=1000):
"""throughput_rps 来自压测(在满足 SLA 的并发下测得)"""
qps_per_gpu = throughput_rps / gpus
seconds_needed = n_requests / qps_per_gpu
cost = (seconds_needed / 3600) * gpu_hour_price * gpus
return {"cost_per_1k_requests": round(cost, 3),
"cost_per_1m_requests": round(cost * 1000, 1),
"gpus_needed_for_10rps": round(10 / qps_per_gpu + 0.999, 1),
"p95_latency_s": p95_latency_s}
# 自托管(参考量级,实际按供应商报价替换)
print("8B on 1xH100:", unit_economics(gpu_hour_price=2.5, gpus=1,
throughput_rps=12, p95_latency_s=2.1))
# 与 API 报价对比:把 API 的每 token 价换算成每千次请求成本,做同口径比较。
# 决策要点:请求量大、延迟要求稳定、数据敏感 -> 自托管更划算;
# 请求量小、波动大、要快速上线 -> API 更划算(省运维与闲置成本)。
python# 自托管 vs API 的盈亏平衡:把「利用率」纳入决策
def breakeven(gpu_hour=2.5, gpus=4, api_per_m_out=1.5, util=0.6, tps_per_gpu=30):
self_host_per_m = (gpus*gpu_hour) / (gpus*tps_per_gpu*util*3600) * 1e6
return {"self_host_per_m_usd": round(self_host_per_m, 2),
"api_per_m_usd": api_per_m_out,
"cheaper": "self" if self_host_per_m < api_per_m_out else "api"}
print(breakeven(util=0.6)) # 约 self=3.09 vs api=1.5 -> 用 API
print(breakeven(util=0.9)) # 约 self=2.06 vs api=1.5 -> 仍 API
print(breakeven(gpus=1, util=0.9, tps_per_gpu=60)) # 自托管才可能更省
# 关键:利用率与单卡吞吐同时决定结论;盲目自托管最容易在「闲置」上亏钱。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 低流量(< 1 万次/天) | 直接用 API | 自托管的固定成本与运维成本高于节省 |
| 中高流量、延迟敏感 | 自托管 + 多副本 | 单位成本随量下降,可精细控制延迟 |
| 流量波动大 | API + 自托管混合(按负载切换) | 用 API 兜峰,自托管承担基座流量 |
| 数据敏感 / 合规要求 | 自托管或私有云 | 数据不出域是硬约束 |
| 端侧 / 离线场景 | 小模型量化后本地部署 | 无网络依赖,隐私最好 |
9.2 LLMOps:让迭代可控
bash# K8s 部署的两个关键点:GPU 调度与就绪探针
# 1) 就绪探针要区分「进程活着」和「模型已加载可服务」
# readinessProbe:
# httpGet: { path: /health/ready, port: 8000 }
# initialDelaySeconds: 120 # 大模型加载慢,探测过早会反复重启
# periodSeconds: 10
# failureThreshold: 30
# livenessProbe:
# httpGet: { path: /health/live, port: 8000 }
# initialDelaySeconds: 300 # 给足加载时间,避免启动被误杀
# 2) GPU 资源与节点选择
# resources:
# limits: { nvidia.com/gpu: 2 } # 声明 GPU 数量,K8s 才会调度到带卡的节点
# nodeSelector: { gpu-type: h100 }
# 3) 扩容:LLM 的扩容指标不应只看 CPU/GPU 利用率
# 用「排队时长」或「TTFT p95」驱动 HPA,比 GPU 利用率更贴近用户体感
bash# HPA 用「自定义指标」扩容:排队时长比 GPU 利用率更贴近体感
# vLLM 暴露指标后,用 Prometheus Adapter 让 HPA 读取
kubectl apply -f - <<EOF
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: vllm-hpa }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: vllm }
minReplicas: 2
maxReplicas: 12
metrics:
- type: Pods
pods:
metric: { name: vllm_num_requests_waiting }
target: { type: AverageValue, averageValue: "4" } # 平均排队 >4 就扩容
behavior:
scaleDown: { stabilizationWindowSeconds: 300 } # 冷启动慢,缩容要保守
EOF
# 关键:① LLM 冷启动慢(1–3 分钟),缩容必须留足稳定窗口;
# ② 扩容指标用「排队/等待」而非 GPU 利用率,否则用户已超时还没扩容。
bash# 补充:GPU 节点的调度基础(GPU Operator 让 nvidia.com/gpu 可被 K8s 感知)
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/gpu-operator/latest/deployments/gpu-operator.yaml
kubectl get nodes -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable
# 经验:GPU Operator 统一提供驱动 / 容器运行时 / 设备插件;再配 MIG 可把一张
# H100 切成多个隔离实例,供小模型或低优先级任务共享,提升利用率。
python# 扩容阈值测算:从压测拐点反推 HPA 目标值
def hpa_target(knee_concurrency, qps_per_gpu, safe=0.8):
target_conc = knee_concurrency * safe
return {"target_concurrency_per_replica": int(target_conc),
"recommended_waiting_threshold": round(target_conc * 0.1, 1),
"note": "排队超过阈值即扩容;缩容用 5 分钟稳定窗口避免抖动"}
print(hpa_target(knee_concurrency=64, qps_per_gpu=20))
# 预期:{'target_concurrency_per_replica': 51, 'recommended_waiting_threshold': 5.1, ...}
# 结论:把「拐点×0.8」作为单副本目标,排队阈值取它的 ~10%,而非拍脑袋。
9.3 容量规划:一个完整的数值算例
给定:一个 70B 模型(BF16 权重约 140GB)、上下文 8K、目标并发 64、单卡 80GB H100。容量规划要回答:需要多少张卡、每张卡能开多大并发、P99 是否达标。
python# 完整数值算例:70B BF16, ctx 8K, 目标并发 64, 单卡 80GB
GB = 1024**3
weights = 140 # 70B BF16 权重(GB)
kv = 80 * 8192 * 8 * 128 * 2 * 2 / GB # 假设 GQA kv_heads=8, d_head=128
# -> 单序列 KV 约 0.20 GB
reserve = 6 # 框架/激活保留
free_per_gpu = 80 - reserve - weights # 负数!单卡根本放不下 140GB 权重
# 结论1:70B BF16 单卡放不下,必须张量并行(TP>=2,甚至 TP=4/8)
# 选 TP=4:每卡权重 35GB,free_per_gpu = 80-6-35 = 39GB
free_tp4 = 80 - reserve - weights/4
max_conc_tp4 = int(free_tp4 / kv) # 39 / 0.20 ≈ 190 -> 单副本可撑很高
# 但并发还受 max-num-seqs 与延迟约束,不是显存满了就开满
# 结论2:目标并发 64 远小于显存单副本上限,单副本(TP=4)即可;
# 若要更高可用性与吞吐,再加副本(replicas)而非加大 TP。
print("TP=4 单卡可容纳并发上限 ≈", max_conc_tp4)
# 成本:4 卡 × H100 × 小时价 2.5 美元 = 10 美元/小时;
# 若压测得满足 SLA 的单副本 QPS=20,则撑 64 并发需约 4 副本 = 16 卡。
python# 多模型混合场景的容量规划(大模型做难例、小模型做简单例)
import math
def plan_mix(total_qps, big_ratio, big_qps_per_gpu, small_qps_per_gpu, peak=3):
peak_qps = total_qps * peak
big, small = peak_qps * big_ratio, peak_qps * (1 - big_ratio)
g_big = math.ceil(big / big_qps_per_gpu)
g_small = math.ceil(small / small_qps_per_gpu)
ha = lambda n: n + max(1, n // 5) # N+1 冗余
return {"gpus_big": ha(g_big), "gpus_small": ha(g_small),
"total": ha(g_big) + ha(g_small)}
print(plan_mix(total_qps=12, big_ratio=0.2, big_qps_per_gpu=3, small_qps_per_gpu=18))
# 预期:{'gpus_big': 4, 'gpus_small': 3, 'total': 7}
# 结论:把 80% 的简单请求路由到小模型,卡数从「全上大模型」的十余张降到 7 张;
# 这就是模型路由的价值 —— 用少量质量换取巨大成本下降。
- 第一步算权重显存:决定能不能放下、要几卡(TP/PP)。放不下就先定并行策略。
- 第二步算 KV 显存:决定单副本能开多大并发(受 max-num-seqs 与显存双重约束)。
- 第三步压测定单副本 QPS:在满足 SLA 的并发下实测,而非用算力硬算。
- 第四步算副本数与成本:目标并发 / 单副本安全并发 → 副本数 → 卡数 → 每小时成本。
- 第五步留冗余:N+1 或 N+2,应对单卡故障与突发。
9.4 每百万 token 成本与盈亏平衡
把成本落到「每百万(M)token」是横向比较的通用口径。公式:每 M token 成本 = GPU 小时单价 × 单卡耗时(小时) ÷ 该时段生成 token 数 × 1e6。自托管看似便宜,但GPU 利用率才是真实成本——一张卡空闲也照常计费,利用率 30% 时单位成本是直接按满负荷算的 3 倍以上。
python# 每百万 token 成本:把利用率与吞吐都考虑进来
def cost_per_million_tokens(gpu_hour_price, gpus, tps_per_gpu, utilization=1.0):
"""tps_per_gpu: 满负荷单卡生成速度;utilization: 实际利用率"""
effective_tps = tps_per_gpu * utilization
gpu_hours = gpus * (1e6 / max(1, effective_tps))
return round(gpu_hours * gpu_hour_price, 2)
# H100 自托管:单卡约 30 tps(decode, 8B 级),利用率 0.6
print("自托管 8B @60% util:", cost_per_million_tokens(2.5, 1, 30, 0.6)) # 约 139 美元/M
# 对比商业 API:通常按 token 直接报价(如 0.5–3 美元/M 输出),无需运维
# 盈亏平衡点:自托管成本 = API 成本 时的月 token 量
# 月 token > (固定运维+卡成本)/单位差 -> 自托管更省
# 关键变量是「利用率」:利用率越高,自托管越划算;闲置越多越亏。
| 降本杠杆 | 机制 | 注意 |
|---|---|---|
| 批处理 API | 把异步请求攒批,价格常打 5 折 | 仅适合非实时场景 |
| 语义缓存 / 前缀缓存 | 命中率 20% 即省 20% 算力 | 需请求有可复用前缀 |
| 提升 GPU 利用率 | 从 30% 到 80%,单位成本降 2.6× | 靠稳定流量与排队,而非盲目堆卡 |
| 量化 | 权重/激活/KV 压缩,吞吐近似线性提升 | 要验证质量 |
python# 语义缓存 + 前缀缓存叠加后的成本下降(把杠杆量化)
def with_caches(base_per_m, prefix_hit=0.35, sem_hit=0.15,
prefix_save=0.3, sem_save=0.95):
# 前缀缓存省的是 prefill(约占成本 30%);语义缓存直接命中跳过生成
after_prefix = base_per_m * (1 - prefix_hit*prefix_save)
after_sem = after_prefix * (1 - sem_hit*sem_save)
return {"base": base_per_m, "after_prefix": round(after_prefix, 2),
"after_semantic": round(after_sem, 2),
"total_cut": f"{(1-after_sem/base_per_m)*100:.0f}%"}
print(with_caches(1.8))
# 预期:{'base': 1.8, 'after_prefix': 1.61, 'after_semantic': 1.35, 'total_cut': '25%'}
# 经验:前缀命中 30%–50%、语义缓存命中 10%–30% 是常见区间;
# 两者叠加通常能把单位成本压掉 20%–40%,且几乎不改质量。
9.5 动手练习与自测
- 给定日请求量与长度分布,算出平均 QPS 与峰值 QPS(峰值系数取 3–5),再除以单卡 QPS 得卡数并加冗余。
- 用 breakeven 函数比较自托管与 API,说明为什么「利用率」是决定性变量(判据:利用率低则自托管不划算)。
- 写出每百万 token 成本公式,并说明利用率从 30% 提到 80% 对单位成本的影响(参考答案:降约 2.6×)。
- 把前缀缓存 + 语义缓存叠加,估算单位成本下降幅度(参考答案:常见 20%–40%)。
- 为一个混合(大模型难例 + 小模型简单例)场景做容量规划,说明模型路由如何减少卡数。
| 量 | 经验值 / 结论 | |
|---|---|---|
| 峰值系数 | 3–5 × | 峰值 QPS = 平均 QPS × 峰值系数 |
| 冗余 | N+1(约 +20%) | 避免单副本故障打满 |
| 盈亏平衡 | 利用率是关键 | 利用率低则自托管不划算,优先 API |
| 优化叠加 | 前缀 + 语义缓存降 20%–40% | 模型路由进一步减卡数 |
项目里程碑
把 Hamauls Orion 真正跑起来:用 vLLM / SGLang 换掉朴素推理,做量化与批调度优化,容器化 + K8s 部署,灰度发布,压测出吞吐/延迟曲线与成本模型,并配好监控告警。
本阶段产出(直接进入项目仓库)hamauls_orion/serving/vllm_backend.py:vLLM 后端接入(连续批处理、PagedAttention、前缀缓存复用)- 量化对比:FP16 / INT8 / AWQ 或 GPTQ 的质量-速度-显存三角权衡表
- K8s 部署清单 + HPA 自动扩缩 + 灰度发布(按流量比例切流)与回滚脚本
bench/loadtest.md:Locust/k6 压测报告——QPS、TTFT、TPOT、P99、错误率、GPU 利用率- 成本模型:每千次请求成本拆解 + 与调用商业 API 的 TCO 对比
- 监控告警:GPU 显存/利用率、队列深度、TTFT 分位数、错误率阈值告警
阶段练习项目
- 对同一 7B–8B 模型完成 ≥3 组配置组合的压测,覆盖不同量化方案与是否开前缀缓存
- 输出可复现的 QPS / TTFT / TPOT / P99 / GPU 利用率对比,并给出最优配置建议
- 给出容量拐点与推荐并发档位,注明 warmup 与样本舍弃规则
- 用
vllm serve部署 7B–8B 模型,设置--gpu-mem-util与--max-num-seqs - 对 max-num-seqs(如 64 / 128 / 256)做并发档位扫描,用开环负载记录每档 QPS 与 P99(工具如
locust或自研脚本) - 对比是否开启前缀缓存(
--enable-prefix-caching)对未来命中率与 TTFT 的影响 - 压测前 warmup 并丢弃前 N≥50 个样本,产出
bench/loadtest.md报告 - 记录 GPU 利用率与错误 / 超时分类,定位容量拐点并给出推荐并发
bench/loadtest.md:QPS / 分位数 / TTFT / TPOT / 利用率完整报告- 配置对比表与「最优配置建议」(含理由与依据)
- 可复现的压测脚本与参数清单
不做量化精度验证与成本核算(交给同阶段其他卡);不做跨机多卡并行压测。
- BF16 / FP8 / AWQ-INT4 三种部署各产出质量、显存、吞吐三项量化对比
- 给出「可上线」结论:掉点不超过设定红线(如业务指标 ≤2%)并说明依据
- 对同一模型做 FP16(BF16) / FP8 / AWQ-INT4 三种部署,用 vLLM 或 SGLang 加载
- 在公开任务 + 自建业务评测集上按任务子类分别统计掉点
- 对比权重与 KV 显存占用,记录各自可同时并发的 batch 数与吞吐
- 覆盖长上下文 / 长输出专项,量化后复核是否掉点
- 掉点超线时做逐层敏感度回退 FP16 并重新验证
- 三维权衡对比表(FP16 / FP8 / INT4)
- 量化掉点诊断报告与回退方案
- 可上线的量化配置建议(含部署命令)
不做 QAT 训练;不做内核级编译优化。
- 服务具备 SSE 流式 / 超时取消 / 限流 / 熔断降级 / 指标暴露 / 健康探针六项
- 压测验证 SLA:给定错误率与 P99 下稳定达到目标 QPS,并在过载时正确降级
- 实现 SSE 流式输出与客户端断开后的生成取消(不烧 token)
- 实现令牌桶限流、重试分类(可重试 5xx / 不可重试 4xx)与熔断降级
- 暴露 Prometheus 指标:QPS / TTFT / TPOT / 错误率 / 排队时长,并配 Grafana 面板
- 配置 liveness / readiness / startup 探针,参考
initialDelaySeconds 120,写 K8s 部署清单 - 实现 Idempotency-Key 幂等与优雅停机 drain
- 可运行的流式推理服务代码与时序图
- K8s 部署清单(含探针、GPU 资源声明、多副本)
- 压测验证报告(SLA 达成情况)
不做跨节点多卡并行与 PD 分离;不做鉴权计费之外的增值网关。
- 对给定场景输出 API / 自托管单卡 / 自托管多卡三种方案的年成本与每千次请求成本
- 给出决策建议文档,明确盈亏平衡点与推荐方案及依据
- 以日请求量、输入 / 输出长度分布、延迟 SLA 作为输入假设
- 先算权重显存再算每并发 KV 显存,折算单卡 / 单副本容量与 QPS
- 用「副本数 = 目标 QPS ÷ 单副本容量 + N+1 冗余」推算所需卡数与利用率
- 分别测算三种方案的年成本,含机器折旧 / 电 / 运维 / API token 单价
- 列出降本杠杆(批处理打折 / 前缀缓存 / 语义缓存 / 量化)并给出优先级
- 成本模型(脚本或表格)与假设清单
- 三方案对比报告与盈亏平衡分析
- 决策建议文档(给评审的口径)
不做真实采购下单;不做财务审计,只做工程口径的成本估算。
- 把 Hamauls Orion 推理后端从朴素实现替换为 vLLM 并稳定跑通
- 产出可复现的压测、量化、K8s、成本全套交付物,验证达到设定 SLA 与成本目标
- 接入 vLLM:连续批处理、PagedAttention、prefix caching,接口保持流式兼容
- 完成 FP16 / INT8 / AWQ 质量–速度–显存三角对比,记录掉点与回退方案
- 编写 K8s 部署清单 + HPA(按排队时长扩容)自动扩缩
- 实现按流量比例切流的灰度发布与一键回滚脚本
- 开环压测输出 TTFT/TPOT/P99/错误率/GPU 利用率,配监控告警;给出每千次请求成本与商业 API 的 TCO 对比
- vLLM 服务化后端 + K8s 与 HPA 清单
bench/loadtest.md、量化对比表、成本模型与监控面板- 灰度回滚脚本 + 上线 / 回滚流程文档
不做多卡分布式并行(TP/PP/EP)与 PD 分离;不做训练侧改动。
常见误区
- 不看负载特征就调参:不知道瓶颈是 prefill 还是 decode,盲目加卡。
- 把 max-model-len 设成模型上限,少量长请求吃光 KV 空间导致整体不可用。
- 只测平均延迟,不看 p95/p99——生产体验由尾延迟决定。
- 量化后只测公开任务不测业务数据,上线后才发现长推理或代码能力明显下降。
- 跳过单卡优化直接上多卡并行,复杂度大增而单位成本没降。
- 服务没有超时与取消机制,客户端断开后 GPU 仍在空转烧钱。
- 没有版本记录与回滚能力,一次模型或 Prompt 变更引发的事故无法快速恢复。
- 扩容只看 GPU 利用率,忽略排队时长,导致用户侧已经超时但集群还没扩容。
面试高频问题速答
为什么大模型推理是显存带宽受限的?
Decode 阶段每生成一个 token 都要把全部权重(以及 KV Cache)从显存读一遍,而这一步的浮点运算量很小(约 2N FLOPs 对应 2N 字节权重)。因此算术强度约为 1 FLOP/Byte,远低于 GPU 的算力/带宽拐点(H100 约 300 FLOP/Byte),GPU 的算力大量闲置而带宽打满。结论:提升吞吐的关键是让每次权重读取服务更多 token——即增大并发批处理,或减小权重体积(量化)。
PagedAttention 解决了什么问题?
传统 KV Cache 按最大长度预分配连续显存,实际使用长度远小于最大长度,导致大量内部碎片与外部碎片(典型浪费 60%–80%),直接压缩了可用并发数。PagedAttention 把 KV Cache 划分为固定大小的块,按需分配、非连续存储,配合页表定位,几乎消除碎片,使可容纳的并发序列数大幅提升,从而提高吞吐。它不影响计算结果的正确性,只是更高效的内存管理。
什么情况下投机解码有效?为什么?
原理是用一个小而快的草稿模型连续预测多个 token,再由主模型一次并行验证,接受匹配的前缀。有效条件是:① 草稿模型与主模型行为足够一致(接受率高,通常要 60%+ 才有明显收益);② 主模型 decode 阶段是 memory-bound,一次验证多个 token 的边际成本低(权重只读一遍)。如果接受率低或场景是 compute-bound 的 prefill,收益会很有限甚至为负。
量化会伤害哪些能力?怎么验证?
量化对「长链推理、代码生成、数学计算、低资源语言、需要精细数值判断」的任务损伤通常更大,因为这些任务对权重微小扰动更敏感;对短文本分类、抽取等任务影响较小。验证方式:① 在公开任务上对比基线,看是否掉点;② 更关键的是在自建业务评测集上按任务子类分别统计;③ 做长上下文与长输出的专项对比;④ 设定可接受的精度损失阈值,超过阈值就不用该量化方案,或退回更高比特。
线上推理服务突然延迟飙升,你怎么排查?
按这条顺序看:① 是 TTFT 还是 TPOT 变差?TTFT 变差多为输入变长、排队变多(并发超限);TPOT 变差多为显存压力导致 batch 下降或 KV 空间不足。② 看排队时长与并发数:是否有突发流量或某个租户刷量(限流是否生效)。③ 看显存与 GPU 利用率:是否有长请求吃光 KV、是否有 OOM 后的重试风暴。④ 看下游依赖:检索、工具调用、模型网关是否有超时。⑤ 看版本变更:最近是否有模型、Prompt、配置或索引的变更。最后一步永远是「有没有版本记录可回溯」。