[LLM 10/10] 部署:你等的不是计算,而是权重在路上
前面九章里,我们灌进了知识、教会了格式、对齐了 preference、把模型蒸馏变小、给它加上护栏,还诚实地做了测量。 但我们手上最好的那个模型,到现在为止仍然只是一个没有人能调用的 checkpoint 文件。 最后这一章把它真正送上线,并且证明一句话——这句话支配着 LLM 服务里的每一个决策: 逐 token 的 decode 不是被算力限制住的,而是被内存带宽限制住的—— 我们会在写下第一行代码之前,先从 GPU 的 datasheet 算出速度上限,然后再拿实测数字来对照。
Open in Colab10_deployment.ipynb
1. 问题(Problem statement)
你手上已经有一个训练完、并且从第 9 章的 sweep 里挑选出来的模型。接下来的问题,没有一个还是 machine learning 的问题:
| 出钱的人会问什么 | 回答它的数字 |
|---|---|
| 一个用户要等多久 | p50 / p99 延迟 |
| 能同时接住多少用户 | 并发(Little's law) |
| 需要几张 GPU | 吞吐量(tok/s) |
| context 能给到多长 | KV 缓存预算 |
大多数人回答这些问题的方式是"跑一下 generate 看看,感觉挺快的嘛",这不叫工程。
而大多数 benchmark 文章给出的答案,往往同样没有意义,原因非常具体:
- 报告 tok/s 却不说 batch size——batch 1 下的 27 tok/s 和 batch 32 下的 400 tok/s,完全可能是同一台机器
- 把 prefill(读 prompt)阶段的速度和 decode(生成回答)阶段平均到一起,可这两个阶段撞的根本不是同一个瓶颈
- 报告量化之后的速度却不报告质量——这是这类文章的原罪,本章还会反复提到它
这一章会用自己实测的数字回答上面每一个问题,而且仍然是在整个系列一路用下来的那台免费机器上。
2. 我们要做什么(Solution)
我们从一条物理事实出发,然后让其余的一切都从它流出来。
在 batch = 1 的逐 token decode 中,生成一个 token 需要把模型的每一个权重从 HBM 完整读一遍, 但每个权重上只做大约 2 FLOP 的计算——于是 GPU 只能干坐着,等数据走完这段路。 你等的不是计算,你等的是权重从内存赶到芯片上。
所有真正有意义的服务端优化——批处理、量化、paged KV 缓存—— 都是从不同角度攻击同一个瓶颈:减少要搬运的字节数,或者让搬运这一趟更划算。
这一章的计划非常直白,而我认为它是整个系列里"自我证明"力度最强的一次实验:
- 算出上限:从 T4 的 datasheet 推出 decode 的速度上限——此时还一行代码都不用跑
- 实测真实值:先用一个故意写得很糟的服务器来测,看看离上限差了几倍
- 逐级填平差距:static KV 缓存、
torch.compile、自己手写约 60 行的 continuous batching——每一步都重新测一遍,好知道每一项各自贡献了多少 - 代价是什么:量化成 int8 和 nf4,然后速度和质量必须成对测量,质量用 KobEval-TH
3. 公式(Equation)
3.1 服务时的显存预算
- = 参数量, = 每个权重占的字节数(fp16 = 2)
- = 层数, = key-value heads 的数量, = 每个 head 的维度, = cache 中每个值占的字节数
- = context 长度, = 同时持有的 sequence 数
- 最前面那个 2 是因为 K 和 V 各要存一份;而 在 inference 阶段小到几乎可以直接扔掉
代入 Qwen3-0.6B 的 config.json 里的真实数值(、、、fp16):
这里要当心大家最常踩的那个坑:Qwen3 用的是 grouped-query attention,必须用 ,
而不是 attention heads 的数量(16)——这一个数字取错,答案立刻翻倍。
而且这个 112 KiB 不是文章里随手写的数,它被 assert 在本站那个 widget 的 test suite 里
(memoryMath.test.ts)——系列的代码和文章被强制保持一致。
现在把它乘上模型的满额 context(,来自真实的 max_position_embeddings):
而这只是一条 sequence——大约是整个模型权重的 3.9 倍(596M 参数 × 2 字节 ≈ 1.19 GB)。 这就是长 context 之所以昂贵的算术原因:吃掉预算的不是模型,而是这段对话的记忆。
3.2 本章最重要的公式——decode 的上限
生成一个 token 需要把所有权重读一遍,再加上已经累积起来的 KV 缓存,因此
320 GB/s 就是 T4 datasheet 上写的 GDDR6 带宽,原封不动拿来用——~266 tok/s 就是 batch = 1 时的理论上限。 这世界上没有任何代码能让 T4 以单流方式把这个模型 decode 得更快,因为这是线路的极限,不是软件的极限。
再验一下瓶颈是不是真的在带宽:在 266 tok/s 下,计算量是 GFLOP/token, 合计约 0.32 TFLOPS,也就是 T4 那 65 TFLOPS(fp16)的 大约 0.5%——芯片有 99.5% 的时间在闲着。 notebook 会测出真实数字(会比上限低很多),然后第 8 节会逐层解释并填平这个差距。
3.3 Little's Law——系统要撑住多大
系统中滞留的任务数()等于任务到达速率()乘以每个任务的平均耗时()——永远成立,不需要任何关于分布的假设。 它可以直接拿来估算系统规模:如果用户以 requests/秒 打进来,每个回答耗时 秒, 那么系统必须同时持有 个 request——在平均 context 1,024 token 的情况下,这意味着 KV 缓存 GB 必须一直被占着。公式 3.1 和 3.3 其实是同一个公式的两个视角。
3.4 INT8 对称量化
把权重存成 8 位整数(),每一组配一个缩放系数 ,用的时候再乘回去。 每个值的误差不超过 。得到的收益是每个权重的字节数减半——而公式 3.2 说了每个 token 的耗时 和要读取的字节数成正比,所以理论上 decode 会快 2 倍。至于实践中,负责 dequantise 的 kernel 可能把这份收益吃光甚至吃到亏本(尤其是 bitsandbytes 的 LLM.int8() 跑在 T4 上)——必须测,不许猜。 另外别忘了:bitsandbytes 只作用于权重,KV 缓存仍然是 fp16,仍然是 112 KiB/token。
3.5 Prefill 与 Decode——两个绝对不能混为一谈的阶段
定义 arithmetic intensity = 每读一个字节能做多少 FLOPs,然后和 GPU 的"脊点"作比较:
- Decode(batch 1):读 2 个字节的权重,做 2 FLOP → ——比脊点低了大约 200 倍 → 受带宽限制
- Prefill:长度为 个 token 的 prompt 被一次性处理,一个权重读进来之后会被复用 次 → ——prompt 一旦超过 ~200 token,就已经受算力限制了
这两个阶段完全是两个世界:prefill 每秒能吞掉上千个 token,decode 只有几十到几百。 谁要是把这两者平均成一个"tok/s",那个数字基本什么都说明不了—— 所以我们的 notebook 永远把 TTFT(time to first token,衡量 prefill)和 ITL(inter-token latency,衡量 decode)分开报告。
4. 把公式画出来(Visualize)
吃掉预算的是 context,不是模型
Figure 10.1公式 3.1 给出的服务时显存预算——整条曲线都由 Qwen3-0.6B config 中的真实数值算出:KV 每个 token 每条 sequence 涨 112 KiB,而在 batch 16 时,context 到 ~8,000 token 显卡就满了
注意右下角那个方点:一条 sequence 在 40,960 token 的满 context 下要吃 4.7 GB 的 KV——差不多是模型权重本身的四倍。 而 batch 16 那条线在 ~8,070 token 就撞上了 16 GB 的天花板。这就是 LLM 服务商按 context 长度收钱的原因。
批处理:读一趟权重,换回多个 token
Figure 10.2公式 3.2 给出的 266 tok/s 上限(红色虚线——由真实 datasheet 算出),以及批处理摊薄固定成本的模型(实线——示意机制的插图,真实数字来自 notebook)
这张图两根轴都要读完:蓝线在涨(好事),可橙线也在涨(坏事)。 批处理不是免费的——它是在卖掉每个用户的延迟,买进整个系统的吞吐量。 你该站在这条曲线的哪个位置,是一个商业决策,不是技术决策。
队列在服务器满之前就先炸了
Figure 10.3M/M/1 队列的 p50/p99,由公式 -ln(1-q)/(μ-λ) 算出——这是数学模型,不是实测结果,但这个'膝盖'形状会在第 8 节的真实测量里出现
必须记住的一点:在 utilisation 80% 的时候,p99 已经冲破 11 秒了,而服务器名义上还"空着"20%。 所以真实系统必须永远留 headroom——谁把系统容量刚好卡在实测吞吐量的 100%,就是在设计一个 p99 为无穷大的系统。
自己动手玩一下服务时的预算——切到 Serving 模式,调调 context 和并发 request 数,看看什么时候撞上 16 GB:
- Weights1.11 GiB
- Gradients—
- Optimizer state—
- Activations68.00 KiB
- KV cache112.00 MiB
It fits.This run needs 1.22 GiB and leaves 14.78 GiB of headroom on a free Colab T4.
5. 准备环境(Environment)
打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度就够用——这也是本系列最后一次)。
如果你一路跟完了九章,这段你大概已经烂熟于心:T4 是 Turing 架构(SM 7.5),没有 bfloat16,没有 FlashAttention-2。 这个贯穿全系列的梗从来都不只是梗——这一章它还会再咬你两口:
torch_dtype=torch.float16 # 不是 bfloat16 —— 这是你最后一次从我这里读到这行
attn_implementation="sdpa" # 不是 flash_attention_2
# 还有先剧透一下:vLLM 必须写 dtype="half" —— 放任 auto 的话,它会在 config 里读到 bf16 然后当场拒绝
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。
请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。
Stage 1 —— 合并 adapter 并导出(约 2 分钟)
整个系列我们都在用 LoRA 训练,而它到了服务阶段就变成负担:每一次 forward 都要多算一遍 。 好消息是 LoRA 可以闭式地合并回底座权重:——合完之后,服务成本和基座模型分毫不差。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
policy = PeftModel.from_pretrained(base, "qwen3-th-lora-best") # 第 9 章 sweep 里胜出的那个
merged = policy.merge_and_unload() # W' = W + (α/r)·B·A
merged.save_pretrained("qwen3-th-serve")
tok.save_pretrained("qwen3-th-serve")
不要凭信心相信合并之后还是原来那个模型——用 logits 对比来证明它:
x = tok("泰国地方电力局的职责是什么?", return_tensors="pt").to("cuda")
with torch.no_grad():
d = (policy(**x).logits - merged(**x).logits).abs().max().item()
print(f"max |Δlogit| = {d:.4f}") # 大约在 ~1e-3 —— 接近零,但不是精确的零
值不会精确为零,是因为把 加进 时用的是 fp16,存在舍入误差——出现 1e-3 这个量级属于正常。 如果你看到的是 1.0 这个量级,说明 adapter 加载错了,或者 dtype 对不上。
要付的代价:约 20 MB 的 adapter 文件(r = 16 时是 10.1M 参数)变成了约 1.2 GB 的完整权重, 膨胀了约 60 倍,换来的是所有服务工具(包括 vLLM)都把它看成一个普普通通的单体模型。
6. 准备数据(Data)
这一章的"数据"不是训练集,而是 workload——而 workload 一旦测错方法,给出的 p99 永远漂亮得不真实。
- 60 条泰语 prompt,取自与 KobEval-TH 相同的池子——长中短混在一起,好让 prefill 具备真实工况那样的多样性
- 来自第 9 章的 TH-KNOW 质量评测集——对每一个测过速度的 configuration 都复用同一套
- open-loop 压测器:request 的到达时间按 Poisson 随机生成,然后按时刻表发射,不管服务器准不准备好
import numpy as np
rng = np.random.default_rng(42)
gaps = rng.exponential(1.0 / LAM, size=N_REQUESTS) # Poisson 过程:间隔 ~ Exp(λ)
arrivals = np.cumsum(gaps) # 发射时刻表 —— 严格照此执行
如果压测器每发一个请求就等到拿回回答才发下一个(closed-loop),那么服务器一慢,你的发射速度也会自动跟着变慢。 队列于是永远堆不起来,测出来的 p99 就漂亮得虚假,因为测量工具在"体谅"它正在测量的那个系统。 按照预先随机好的时刻表发射——哪怕上一个 request 还没做完——是让图 10.3 里那个膝盖真正显形的唯一办法。
每个 request 都记录三个值:TTFT、平均 ITL,以及 token 数——然后按 configuration 汇总成 p50/p99。
7. 核心代码(Main code)
7.1 Stage 2 —— 故意写得很糟的 baseline(约 4 分钟)
from fastapi import FastAPI
import threading, uvicorn
app = FastAPI()
@app.post("/generate")
def generate(body: dict):
ids = tok(body["prompt"], return_tensors="pt").to("cuda")
out = merged.generate(**ids, max_new_tokens=128, do_sample=False)
return {"text": tok.decode(out[0, ids.input_ids.shape[1]:],
skip_special_tokens=True)}
threading.Thread(
target=uvicorn.run, args=(app,),
kwargs=dict(host="127.0.0.1", port=8000, log_level="warning"),
daemon=True,
).start()
这个服务器违反了本章教的每一条:一次只收一个 request、没有批处理、KV 缓存每次都重新分配、 Python 逐 token 打转——而这正是它的职责所在。它是 baseline,之后每一项改进都要拿回来跟它比。 如果不测起点,"快了 5 倍"就只是一句广告词。
跑 uvicorn 的那个 thread 只能活到 session 还在为止——关掉标签页、屏幕挂太久,或者 runtime 被回收,服务器就悄无声息地没了。
所以 notebook 把测量拆成一小段一小段跑完,每一段结束立刻把结果写进 results.json。不要设计需要跨小时运行的测量方案,尤其在免费 Colab 上。
7.2 Stage 3a —— Static KV 缓存 + torch.compile
普通的 generate 会逐 token 扩展 KV 缓存,导致 shape 一直在变,compile 不了。
预先把整块 cache 一次性分配好(static),shape 就稳定到足以让 torch.compile 把整张图抓进 CUDA graph——
这就干掉了 baseline 里最大的那块 overhead:从 Python 一个一个地发射 kernel。
merged.generation_config.cache_implementation = "static"
fast = torch.compile(merged, mode="reduce-overhead")
warm = tok("预热", return_tensors="pt").to("cuda")
for _ in range(3): # 计时之前一定要先预热
fast.generate(**warm, max_new_tokens=8)
第一次调用 torch.compile 要花几十秒到一分钟量级的时间去 trace 和编译。
如果这段时间混进了计时,你会得出 compile "让它变慢了"的结论,而事实恰好相反。
notebook 的规矩是:正式开表之前至少空跑 3 轮,每一个 configuration 都一样,没有例外。
7.3 Stage 3b —— 手写约 60 行的 continuous batching
公式 3.2 告诉我们,读一趟权重才是那笔大成本——批处理就是让多个 token 去分摊同一笔成本。 但 static batching(凑够 个才开始,然后要等最长的那个跑完)会浪费掉巨量的空位, 所以 continuous batching 一有空位就立刻把新的 request 收进 batch——精髓就在这个循环里:
from collections import deque
queue, running, MAX_BATCH = deque(), [], 16
while queue or running:
while queue and len(running) < MAX_BATCH:
running.append(Sequence(queue.popleft())) # 中途就收进来,不等原来那批跑完
step(running) # 给所有 sequence 走一步 forward —— 读 1 趟权重,拿到 B 个 token
running = [s for s in running if not s.done] # 跑完的立刻退出,把位置还给队列
完整版本(约 60 行,含每条 sequence 的 position id 和 mask 处理)在 notebook 里。 它不是 vLLM——没有 paged memory,没有 prefix cache——但它用一屏就能读完的代码证明了这套机制, 而且它测出来的每一个百分点的提升都能解释来源,这在这堂课里比华丽更重要。
7.4 量化 —— int8 与 nf4,以及真实的代价
from transformers import BitsAndBytesConfig
int8 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(load_in_8bit=True),
device_map={"": 0},
)
nf4 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
),
device_map={"": 0},
)
Figure 10.4左:权重占用的 VRAM,按 config 真实计算得出(bitsandbytes 始终把 embedding 保留为 fp16)——右:T4 上的大致速度比例。ΔTH-KNOW 那一格是故意留成 ? 的,要等 notebook 来填
一句"nf4 省了 2.2 倍 VRAM!"如果没有附上质量分数,那就不是实验结果,那是广告。 因为把权重压到 4 位一定要拿某样东西去换,唯一有意义的问题是"换掉了多少"。 所以我们的 notebook 会对第 9 节表格中有行的每一个 configuration 都重跑一遍 KobEval-TH 上的 TH-KNOW—— fp16、int8、nf4 用的是同一套题目,并按本系列的惯例附上 Wilson CI。
再给一个坦率的事前预测:在 T4 上,bitsandbytes 的 int8 往往会比 fp16 更慢—— LLM.int8() 会把 outlier 拆出去用 fp16 算,于是两头都付 overhead。它是一个省 VRAM 的工具,不是一个提速的工具。 如果你的实测结果就是这样,那不是你的 bug,那是评测文章不太爱印出来的真相。
7.5 Stage 4 —— vLLM(可选,也是全系列最脆的一段)
vLLM 确实支持 SM 7.5,但在 Colab 的免费 T4 上有三层叠加的条件:
(1)必须指定 dtype="half"——它在 config 里读到 bf16 就会当场拒绝(读到这里,你应该在看之前就猜到了)
(2)不少版本还需要 enforce_eager=True,因为 CUDA graph 那条路径在老卡上有问题
(3)某些最新版本干脆砍掉了、或者根本编不出 sm_75 的 build——所以 notebook 把版本 pin 死,不要升到 latest
try:
from vllm import LLM, SamplingParams # 版本已在 notebook 的安装 cell 里 pin 死
llm = LLM(
model="qwen3-th-serve",
dtype="half", # T4 没有 bf16 —— 必须显式指定
enforce_eager=True, # 绕开在 sm_75 上闹脾气的 CUDA graph
gpu_memory_utilization=0.85,
max_model_len=4096,
)
VLLM_OK = True
except Exception as e:
VLLM_OK = False
print("vLLM 在这个运行时上用不了 —— 直接跳过即可:", e)
这个结构是刻意的:即便 vLLM 装不上、或者在 init 时崩掉,stage 1–3 的一切依然完整。 本章的主要结论完全不依赖 vLLM——它只是一份补充证据,说明 paged KV 加上 fuse 得当的 kernel, 相比我们那个 60 行的 scheduler 还能再多做到什么。
8. 结果(Results)
notebook 会把每一个数字写进 results.json,骨架就是从 datasheet 得到的理论值 vs 实测值:
| 量 | 理论(第 3 节) | 实测(notebook) |
|---|---|---|
单流 decode,naive generate | 上限 ≤ 266 tok/s | ? |
| 单流 decode,static cache + compile | 上限 ≤ 266 tok/s | ? |
| batch 16 的 aggregate(continuous batching) | 数倍于 batch 1 | ? |
| TTFT(prompt ~512 token) | 几十 ms(受算力限制) | ? |
该期待什么,以及怎么读它: naive 的数字会比上限低大约 10 倍——别慌,也别怪 T4。 这个差距的来源是可以一层一层追出来的:Python 逐 token 打转、每一步要发射几十次 kernel、 KV 的动态分配、sampling 时同步回 CPU——stage 3 会逐层拆掉它们,并且每拆一层就重测一次。 结果一步步逼近上限(但永远碰不到,因为上限没有算进 KV、activation 和剩下的 overhead), 这就是公式 3.2 能解释真实机器的实证依据——这也正是我把它称为本系列最扎实那次实验的原因。
在负载之下,第二张表抓的是图 10.3 的形状:
| λ(req/s) | p50 | p99 |
|---|---|---|
| 低(约 capacity 的 30%) | ? | ? |
| 中(约 60%) | ? | ? |
| 接近饱和(约 90%) | ? | ? —— 应当出现图 10.3 里那种膝盖式爆炸 |
还有一项数字替代不了的、要用眼睛做的质量检查——同一个 prompt,fp16 与 nf4 的回答对照:
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. 对比(Comparison)
全章的汇总表——任何一份换来的速度,都必须在同一行里把付出的代价亮出来:
| 配置 | tok/s @B=1 | tok/s @B=16 | p99 | 峰值 VRAM | 最大并发* | TH-KNOW |
|---|---|---|---|---|---|---|
fp16 + naive generate | ? | — | ? | ~1.5 GB | 1 | baseline |
+ static cache + torch.compile | ? | — | ? | ~1.7 GB | 1 | = baseline(权重分毫未动) |
| + continuous batching | ? | ? | ? | ? | 16(取决于 MAX_BATCH) | = baseline |
| int8(bitsandbytes) | ? | ? | ? | ~0.9 GB | ? | ? |
| nf4(bitsandbytes) | ? | ? | ? | ~0.7 GB | ? | ? |
vLLM dtype="half"(如果跑得起来) | ? | ? | ? | 取决于 gpu_memory_utilization | ? | = fp16 |
* 在 context 1,024 token 时,公式 3.1 给出的 KV 预算能撑住上百条 sequence(~14.8 GB ÷ 112 MiB ≈ 125) ——真正的限制来自 scheduler 和 prefill 的算力,而不是 VRAM,这本身就是一课。
你应该看到的模式是:
- compile 对 batch 1 的帮助最大(它干掉的是每一步的 overhead,而那正是单流场景的瓶颈)
- 批处理几乎不改善每个 request 的 tok/s,但会把 aggregate 成倍放大——并且在高负载下让 p99 变差
- int8 降低了 VRAM 但在 T4 上更慢,nf4 降得更多而且比 int8 更快——质量必须自己去看最右边那一列,不许从速度列下结论
- vLLM 如果活下来了,在高并发下应该明显赢过我们手写的 scheduler——如果没赢,说明
enforce_eager正在吃掉它的收益
需要提防的坑
1. 服务器因为 Colab 断线而悄悄消失 —— 测量分成短段,结果立刻写盘(第 7.1 节)。不要制定比 session 寿命更长的测量计划。
2. T4 上的 bf16 —— 都第十章了,你应该在 error 冒出来之前就猜到:transformers 里写 torch_dtype=torch.float16,
vLLM 里写 dtype="half"。这个贯穿全系列的梗到这一章为止,但世界上的 Turing 卡还会继续活着。
3. 不预热就开始测 —— 第一次 compile 要吃掉分钟级的时间,一旦混进计时,结论会当场反过来(第 7.2 节)。
4. 报告 tok/s 却不说 batch size —— 同一个数字,可能代表用户每个 token 等 40 ms 的系统,也可能是等 500 ms 的系统。 所以本章的每一个数字都随身带着 @B。
5. 把 prefill 和 decode 混在一起 —— 长 prompt 会让"平均 tok/s"虚高,因为 prefill 是受算力限制的、 每秒能吞掉上千 token(第 3.5 节)——只能把 TTFT 和 ITL 分开报告。
10. 小结(Summary)
- batch 1 的 decode 是在等权重赶路,不是在等计算 —— 芯片在"全速工作"的时候有 ~99.5% 是闲着的
- 速度上限可以直接从 datasheet 算出来:320 GB/s ÷ 1.2 GB ≈ 266 tok/s,一行代码都还没跑
- KV 缓存 112 KiB/token(和本站 test suite 里 assert 的是同一个数)——在 40,960 token 的满 context 下, 单条 sequence 就是 ~4.7 GB,接近模型权重的四倍——吃掉预算的是 context
- 批处理 = 卖延迟买吞吐量,而 continuous batching 是这笔买卖里亏得最少的卖法
- 量化减少的是要搬运的字节数 —— 理论上快一倍,实践中必须测,而且必须把质量并排一起测
- Little's law 把这一切串起来: 给出必须同时持有的 sequence 数,而它又反过来变成 KV 预算
- p99 在服务器满之前就先炸 —— 没有 headroom 的系统,就是被设计成在用的人最多时垮掉的系统
T4 是 2018 年的卡,320 GB/s 的带宽对上 H100 的 ~3.35 TB/s——差了整整一个数量级。 所以本章的每一个绝对数字都不可迁移到别的机器上。可以迁移的是比例关系和思考方式: 公式 3.1–3.5 对任何一张卡都成立,只要把常数换成新 datasheet 上的值。
而真正的 production 级服务还需要好几层本章完全没有碰的东西: autoscaling、health check 与 readiness probe、observability(metrics/logging/tracing)、 multi-tenancy 与用户隔离、rate limiting、身份认证、每个 request 的成本核算、 模型版本管理与 rollback——没有提到它们,不代表它们不重要。 只是一篇文章说不完,而我们选择讲那个作为一切之根的东西:瓶颈的物理学。
收尾
过去十章铺出的是一条完整的路:灌入知识(第 1 章)→ 教会格式(第 2 章)→ 用三种方法对齐 preference (第 3–5 章)→ 蒸馏变小(第 6–7 章)→ 加上护栏(第 8 章)→ 诚实地测量(第 9 章)→ 送上线并对照物理学定下的上限来测(本章)。 但我真正希望留在你身上的,不是其中任何一项技术,而是每一章都在反复强调的那些习惯: 写代码之前先列出公式;测什么都带上置信区间;并且永远在文章末尾那个黄框里,把自己的局限公开出来。 模型会换,库会换,显卡会快十倍——而这三个习惯,在这个系列里的一切都过时的那一天,依然管用。
这十个 notebook 都能在免费 Colab 上跑完——先别信我,去自己跑一遍,看看你的数字和我的在哪里不一样。 如果你是一头撞进这一章的:从第 1 章开始 —— Continue Pretraining, 然后沿着这条路一直走到这里。回头见。
参考文献(References)
- Kwon et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention — PagedAttention:vLLM 背后的 KV 缓存管理
- Yu et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI '22) — 连续批处理的原始论文,第 7 节手写了一个精简版
- Dao et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention——以及 T4 为何用不了
- Frantar et al. (2022). GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers — GPTQ:精确的训练后量化
- Dettmers et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale — LLM.int8():bitsandbytes 8-bit 模式的基础
- Pope et al. (2022). Efficiently Scaling Transformer Inference — 系统层面的推理瓶颈分析
- Williams et al. (2009). Roofline: An Insightful Visual Performance Model for Multicore Architectures — 第 3 节 266 tok/s 上限所依据的 roofline 模型
- Pipatanakul et al. (2023). Typhoon: Thai Large Language Models — Typhoon:另一条泰语 LLM 路线
- Nguyen et al. (2023). SeaLLMs -- Large Language Models for Southeast Asia — SeaLLMs:面向东南亚语言的模型
- Pairatsuppawat et al. (2025). SiamGPT: Quality-First Fine-Tuning for Stable Thai Text Generation — SiamGPT:以质量优先的泰语微调
本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。
