第一章:底层基石 —— 主流大模型分词器详解
1.1 为什么分词器对LLM推理很重要
模型只能处理数字,文本必须先经过 Tokenizer 转化为 Token ID 序列。
原始文本 “Hello, world!” → Tokenizer 切分 + 编码 → Token IDs [15496, 995, 0] → Embedding 向量查表 → 语言模型 Transformer
有三种切分粒度:字符级、词级、子词级。现在的llm都用的子词级,字符级词表极小但是序列极长、并且字符本身没有语义;词级序列最短但是词表巨大并且新词是UNK。
理想的Tokenizer要满足三个目标:零 UNK 覆盖率(不要出现[UNK]);高压缩率 (token尽量少);多语言公平性(避免中文几个字就消耗大量token,而英文同样语义只消耗很少token)。
分词器直接决定 token 序列长短:token 越多,上下文窗口消耗越大,KV‑Cache 占用的显存就越高;出现[UNK]会丢失原始文本信息;而后面 vLLM 的约束解码能力,更是完全运行在 token 粒度之上。理解分词算法,是看懂 LLM 推理底层逻辑的第一步。
1.2 BPE 原始算法
BPE(Byte‑Pair Encoding,字节对编码)最早并不是为大模型发明的,它是一种无损数据压缩算法,后来被引入NLP领域做子词分词。
核心思想:自底向上贪心合并
- 先把训练集全部文本,按空格切分成独立单词;再把每个单词拆解成最基础的字符序列。
- 统计所有相邻字符对的出现频次。
- 选出出现频率最高的字符对,合并成一个新子词单元,加入词表。
- 重复上面统计‑合并的过程,直到词表达到预设大小。
⚠️关键前置约束:原始BPE必须依赖空格预先切分单词,单词边界是提前确定好的,算法本身不会处理句子。
优势
- 实现简单,训练、编码解码速度快;
- 高频片段会被合并为完整token,取得不错的压缩效果;
- 子词由训练数据统计得来,贴合真实文本分布。
劣势
- 强依赖预分词:需要依靠空格切分单词,对中文、日文这类没有天然空格分隔的语言处理很麻烦。
- 存在OOV/UNK问题:遇到训练中从未见过的生僻字符、特殊Unicode符号(比如这个表情:🤣),如果词表里没有对应单元,就只能输出未知token
[UNK],丢失信息。 - 单词边界被固化:切分行为高度依赖预分词结果,预分词出错,后续BPE合并结果也跟着出错。
早期部分开源模型使用原始BPE,现在生成式大模型基本不再直接使用,它更多是作为BBPE的理论原型。
1.3 BBPE(Byte‑Level BPE,字节级BPE)
BPE的缺陷:
- Unicode 覆盖不完全。边缘字符仍出现UNK
- 词表大小爆炸。多语言模型要覆盖所有的字符,难以管理
- 新 emoji ,在部署后会出现UNK
Unicode字符,不管是中文、日文、emoji还是生僻符号,最终都能编码成1~4个UTF‑8字节。而字节总共只有256种可能,全部在初始词表里。
BBPE就是在BPE的基础上,把初始词表换成了256字节值。实现了零UNK。
比如说同样语义的问候语,英文hello占 5 个 UTF‑8 字节,中文你好占 6 个 UTF‑8 字节,阿拉伯语问候则要 10 个 UTF‑8 字节,BBPE 会直接在这些原始字节之上执行 BPE 合并操作。
1.4 WordPiece
BPE、BBPE都是按共现频次选择子词对进行合并,而WordPiece走了另一条路线:它不再看单纯出现次数,而是以最大化训练集语言模型似然概率作为合并判断标准,由Google提出,最知名使用者就是BERT系列编码器模型。
WordPiece使用打分公式:
score(A,B)=freq(AB)freq(A)×freq(B)score(A,B)=\frac{freq(AB)}{freq(A) \times freq(B)}score(A,B)=freq(A)×freq(B)freq(AB)
分子越大(AB 常见),分母越小(A、B 单独罕见),说明合并这对更能减少不确定性。
1.5 SentencePiece
BPE / WordPiece 需要先按空格/标点预切分文本(预分词步骤),这一步依赖语言规则:中文无空格。
SentencePiece的做法是把空格替换为特殊字符 ▁(U+2581),文本变成纯字符流。算法直接在原始 Unicode 上运行,不需要任何语言特定预处理。比如输入“我 like 大模型”,子词切分结果示例:["我", "▁like", "▁大", "模型"]
1.6 Unigram
BPE 从小词表出发,不断合并(自下而上);Unigram 从大词表出发,不断剪枝(自上而下)
核心思想是优先删掉那些删掉之后,整体效果几乎没怎么变坏的子词。
1.7 分词算法横向对比小结
bbpe分词方法现在是主流。
| 对比维度 | BPE(Sennrich原版) | BBPE(字节级BPE) | WordPiece | Unigram |
|---|---|---|---|---|
| 初始词表 | 字符 | 256字节 | 字符 | 大词表剪枝 |
| 合并策略 | 频次最大 | 频次最大 | 似然最大 | EM + 剪枝 |
| UNK风险 | 极低(存在UNK) | 零UNK | 极低 | 极低(存在UNK) |
| 分词确定性 | 确定 | 确定 | 确定 | 可概率化 |
| 多语言友好 | 中 | 高 | 中 | 高 |
第二章:KV Cache——vLLM高吞吐背后的底层根基
2.1 推理性能的关键指标
| 指标 | 全称 | 含义 | 参考标准/说明 |
|---|---|---|---|
| TTFT | Time to First Token | 从发送请求到收到第一个token的时间 | < 500ms 为优秀,影响用户体验感知 |
| TPOT | Time Per Output Token | Decode阶段每生成一个token的平均耗时 | < 50ms 流畅体验 |
| Throughput | 系统吞吐量 | 单位时间内处理的总token数(所有并发请求之和) | 高并发部署核心指标,单位:token/s |
| MFU | Model FLOPs Utilization | GPU实际算力利用率 vs 峰值算力 | Decode通常 < 20%,Prefill可达40%+ |
核心矛盾:低延迟 / 高吞吐 / 低显存三者难以同时最优 —— 推理优化的本质是在三角约束中找最佳平衡点。
2.2 LLM自回归推理两个阶段:Prefill 和 Decode
推理的流程是这样:
Prefill 阶段:处理 prompt
输入:整段 prompt(n 个 token)一次性进入模型
计算:对所有 n 个位置并行做 Attention
输出:下一个 token 的概率分布
特点:算力密集(compute-bound)
Decode 阶段:逐 token 生成
输入:上一步生成的 1 个 token
计算:对当前位置做 Attention(看前面所有 token)
输出:下一个 token,循环到 EOS
特点:访存密集(memory-bound)
2.3 朴素推理的致命冗余:每一步重复计算K/V
朴素推理过程:
- 把已有的 t 个 token 重新输入模型
- 每一层都重新计算所有 t 个位置的 Q、K、V
- 做完整 Attention,取最后一个位置的输出
代价:前 t-1 个位置的 K、V 在上一步早就算过,这一步又算了一遍 → 纯粹浪费
2.4 KV Cache引入的问题
| 问题类型 | 具体表现 | 产生原因 | 影响 |
|---|---|---|---|
| 显存压力巨大 | KV Cache占用随序列长度线性增长,长上下文/高并发场景下显存占用甚至超过模型权重 | 每个请求都需要存储全部历史token的K、V向量 | 显存成为推理瓶颈,限制并发数和上下文长度 |
| 内部碎片 | 按max_seq_len预分配连续显存,但实际生成长度远小于最大值,大量空间闲置 | 传统实现为每个请求预留最大长度的连续内存块 | 显存利用率低,实际情况可能浪费一半的显存 |
| 调度低效(静态Batch) | 一批请求必须全部生成结束才统一释放KV缓存,短请求完成后原地等待长请求 | 传统静态批处理同进同出的调度机制 | GPU算力大量空转闲置,吞吐量上不去 |
2.5 vLLM如何改造KV Cache:PagedAttention 分页注意力
复刻OS虚拟内存分页机制,不再连续分配一整块的显存。先把显存按size划分,在需要用到显存的时候按需分配显存块。
2.6 Continuous Batching 连续批处理
一个token生成后,立刻开始运行新的请求
2.7 本章小结
- KV Cache解决自回归重复计算问题,是LLM推理基础;但原生连续分配+静态批处理带来严重碎片、低利用率、调度低效
- PagedAttention借鉴OS虚拟内存分页:解决显存碎片、利用率、前缀缓存复用,拉高并发上限
- Continuous Batching借鉴OS抢占式调度:解决GPU算力空转,拉高整体吞吐量
一句话分工:PagedAttention负责省显存,让更多请求装下;Continuous Batching负责吃满GPU,让请求跑得更快
第三章:vLLM实战——本地项目复现
3.1 环境搭建
我在autodl云平台租的5090显卡做的实验。用到社区镜像
3.2 吞吐量对比实验(bench_throughput.py)
[A] transformers 串行
逐条循环处理请求,每次只向模型输入 1 条 prompt,这条完整生成完毕之后,才处理下一条。
- GPU 同一时刻只在计算 1 个请求,大部分算力处于空闲状态。
- KV 缓存处理完一条就完整释放,没有批处理加速。
[B] transformers 静态 batch=24
把 50 条请求按每 24 条切分成若干个固定分组,一组一组送入model.generate()。
- 同一组最多 24 条同时推理;组内请求做 padding 补齐长度。
- 必须等待本组内全部 24 条请求全部生成结束,才释放显存、再执行下一组。
- 只要组内存在生成较慢的长请求,其余已经完成的请求会占用位置和 KV 缓存,GPU 算力空等,产生尾部算力空洞。
- batch 开到 24,已经充分利用 32GB 显存,相比串行有巨大提升,但依然受静态批处理机制限制。
[C] vLLM 批处理(Continuous Batching + PagedAttention)
把全部 50 条请求一次性提交给 vLLM。
- 连续批处理(Continuous Batching):不以整组请求为调度单位,以单步 token 迭代做调度。某条请求生成完成,立刻将它剔除,马上补入排队的新请求,不需要等待一整批全部跑完。
- PagedAttention 分页 KV 缓存:KV 缓存拆成独立小块 block 管理,请求结束立刻回收 block,消除内存碎片,显存利用率大幅提升。
- 内部动态调整同时活跃的请求数量,充分打满 GPU 算力。
结果对比
| 模式 | 总耗时 | QPS(请求/秒) | tokens/s | 相对 vLLM |
|---|---|---|---|---|
| [A] transformers 串行 | 43.10s | 1.16 | 87 | 0.02× |
| [B] transformers batch=24 | 4.76s | 10.51 | 775 | 0.18× |
| [C] vLLM 批处理 | 0.84s | 59.19 | 4134 | 1.00× |
3.3 约束解码实战:四大模式逐个拆解
3.3.1 guided_choice 枚举约束
每步解码时屏蔽非法 token 的 logits
输出了“查”之后,就只能输出“股价”、“财报”、“新闻”了。
INTENT_CHOICES=["查股价","查财报","查新闻","对比分析","其他"]defrun_with_guided_choice(user_msg:str)->tuple[str,float]:"""guided_choice 约束:底层 FSM 屏蔽非法 token"""t0=time.time()resp=client.chat.completions.create(model=MODEL,messages=[{"role":"system","content":SYSTEM_PROMPT},{"role":"user","content":user_msg},],temperature=0,max_tokens=10,extra_body={"guided_choice":INTENT_CHOICES},# ★ 关键参数)returnresp.choices[0].message.content.strip(),time.time()-t0运行结果
指标 裸 prompt guided_choice ---------------------------------------------------------------------- 输出合法(在枚举内) 248/257 (96%) 257/257 (100%) 预测正确 55/257 (21%) 57/257 (22%) 平均延迟(秒) 0.041 0.0423.3.2 guided_regex 正则约束
DATE_REGEX=r"\d{4}-\d{2}-\d{2}"defrun_generate(system:str,user:str,regex:str|None=None)->tuple[str,float]:t0=time.time()extra={"guided_regex":regex}ifregexelse{}resp=client.chat.completions.create(model=MODEL,messages=[{"role":"system","content":system},{"role":"user","content":user},],temperature=0,max_tokens=30,extra_body=extra,)returnresp.choices[0].message.content.strip(),time.time()-t0运行结果:
====================================================================== 输入 裸 prompt guided_regex --------------------------------------------------------------------------- 2024年5月12日 ✓ 2024-05-12 ✓ 2024-05-12 2023/12/1 下午开会 ✗ 2023-12-01 14:00:00 ✓ 2023-12-01 三月三号我去北京 ✓ 2017-03-03 ✓ 2017-03-03 2024.11.30 是截止日期 ✓ 2024-11-30 ✓ 2024-11-30 明天(假设今天是2026-05-11) ✓ 2027-04-12 ✓ 2027-04-12 2024 年 10 月的第一天 ✓ 2024-10-01 ✓ 2024-10-01 --------------------------------------------------------------------------- 格式合法率:裸 prompt 5/6 (83%) | guided_regex 6/6 (100%)3.3.3 response_format JSON 约束
保证输出json格式
defrun(user:str,mode:str)->tuple[str,float]:"""mode: 'raw' | 'json_object'"""kwargs={}ifmode=="json_object":kwargs["response_format"]={"type":"json_object"}t0=time.time()resp=client.chat.completions.create(model=MODEL,messages=[{"role":"system","content":SYSTEM_PROMPT},{"role":"user","content":user},],temperature=0,max_tokens=150,**kwargs,)returnresp.choices[0].message.content.strip(),time.time()-t0运行结果:
=========================================================================== 200 条测试结果 =========================================================================== 指标 裸 prompt response_format ------------------------------------------------------------ 合法 JSON 200/200 (100%) 200/200 (100%) 有 sentiment 字段 200/200 (100%) 200/200 (100%) sentiment 值合法 200/200 (100%) 200/200 (100%) 有 confidence 字段 200/200 (100%) 200/200 (100%) 有 keywords 字段 200/200 (100%) 200/200 (100%) ===========================================================================裸 prompt也是100%,但是依靠模型自觉
3.3.3 guided_json JSON Schema约束
比json约束更定制化,不光保证json格式,还能约束取值范围等内容
INTENT_SCHEMA={"type":"object","properties":{"company":{"type":"string","description":"公司全称,如 招商银行、贵州茅台",},"year":{"type":"integer","minimum":2015,"maximum":2025,},"metric":{"type":"string","enum":["营收","净利润","ROE","毛利率","总资产","经营现金流"],},},"required":["company","year","metric"],"additionalProperties":False,}defrun_generate(user_msg:str,mode:str)->tuple[str,float]:"""mode: 'raw' | 'guided_json' | 'response_format'"""extra={}kwargs={}ifmode=="guided_json":extra={"guided_json":INTENT_SCHEMA}elifmode=="response_format":kwargs={"response_format":{"type":"json_object"}}t0=time.time()resp=client.chat.completions.create(model=MODEL,messages=[{"role":"system","content":SYSTEM_PROMPT},{"role":"user","content":user_msg},],temperature=0,max_tokens=120,extra_body=extra,**kwargs,)returnresp.choices[0].message.content.strip(),time.time()-t0运行结果
指标 裸 prompt response_format guided_json ------------------------------------------------------------------------------ 合法 JSON 9/9 (100%) 9/9 (100%) 9/9 (100%) 字段齐全 9/9 (100%) 9/9 (100%) 9/9 (100%) year 在 2015~2025 8/9 (89%) 8/9 (89%) 9/9 (100%) metric 在枚举内 9/9 (100%) 9/9 (100%) 9/9 (100%) jsonschema 完全通过 8/9 (89%) 8/9 (89%) 9/9 (100%) ============================================================================== 结论: response_format 只保证是 JSON,不保证字段名、类型、枚举正确 guided_json 是唯一 100% 保证 schema 合法的方式 ==============================================================================vllm在function call的例子里很有用,可以做到约束模型给出合规的json输出
3.4 约束解码原理简述:FSM有限状态机
- 在每一步token采样时,用FSM过滤掉不符合规则的候选token
- 从生成源头杜绝非法输出
- 首次构建Schema有FSM初始化开销,缓存后速度恢复正常
第四章:总结
本文从分词器底层讲起,深入KV Cache与vLLM两大核心优化技术,并通过本地实验复现,验证了vLLM的两大工程价值:
第一,极致的推理吞吐性能。
PagedAttention借鉴操作系统虚拟内存分页思想,将KV Cache切分为离散的内存块,按需分配、即时回收,大幅缓解传统连续内存分配带来的显存碎片问题;Continuous Batching则以单步token迭代为调度单位,完成的请求立刻退出、新请求随时补入,避免静态批处理的算力空转。
本地实测数据显示,在Qwen2-0.5B模型、50条请求的场景下,vLLM的吞吐速度达到原生transformers串行的47倍,是transformers静态batch=24的5.3倍,充分体现了两项技术协同带来的性能收益。
第二,可靠的约束解码能力。
vLLM支持枚举、正则、JSON、JSON Schema等多种约束模式,通过FSM有限状态机在token生成的每一步屏蔽非法候选,从源头保证输出格式合规。
实验表明,裸prompt和OpenAI标准的response_format虽然能保证JSON语法合法,但在字段取值范围、枚举约束等细节上仍有89%左右的通过率;而guided_json可以做到100%符合Schema定义。对于Agent工具调用、结构化数据抽取等对输出格式有严格要求的场景,这是公有API无法提供的工程价值。
最后,回到一个实际问题:要不要自建vLLM?
- 如果你的业务有高并发、大批量、数据敏感、强结构化输出的需求,vLLM是非常值得投入的方案;
- 如果只是小流量原型验证、追求快速上线、不需要强格式约束,公有API依然是更省事的选择。
vLLM不是银弹,但它在"性能"和"可控性"两个维度上,给了开发者一个非常有竞争力的自建推理选项。