从零看懂 LLM 推理:一个极简 C++ 实现框架
一、这个项目到底是干嘛的?
简单来说,这是一个用纯 C++ 写的、专门用来"看懂"大模型推理流程"的教学项目。
现在市面上开源的大模型推理框架(比如 vLLM、llama.cpp)功能很强大,但代码量动辄几万行,各种优化技巧叠在一起,初学者刚进去就懵了——“我想知道模型是怎么从输入一句话变成输出一句话的,结果满眼都是 CUDA kernel 和内存池管理”。
这个项目反其道而行之:不求快,只求看得懂。它把大模型推理的完整链路(从读取配置文件、加载权重、分词、预填充、解码、采样到最终输出)全部用清晰的 C++ 代码实现出来,每一行都在告诉你"现在到了哪一步"。
默认适配的是 Qwen2.5-0.5B 这个小模型,普通电脑就能跑起来,不需要显卡也能玩。
二、为什么要做这件事?——先搞清楚"推理"到底在推什么
很多人学了大半年 Transformer,背了 Self-Attention 的公式,但真要问"输入一句’你好’,模型内部到底经历了什么",还是说不清楚。其实推理流程可以拆成几个固定的"工序":
用户输入 → 加载配置 → 加载权重 → 分词 → 预填充 → 逐字解码 → 采样 → 输出文本
这个项目就是把这 7 个工序全部摊开给你看,每一步都有对应的代码文件,没有隐藏逻辑。
三、完整推理流程拆解(大白话版)
第 1 步:加载配置(config.json)
模型在训练时有很多"超参数":有多少层、每层多少个头、词表多大、用的是什么激活函数……这些信息都存在 config.json 里。程序启动时第一件事就是读这个文件,告诉后面的代码"我们要处理的是一个什么样的模型"。
第 2 步:加载权重(model.safetensors)
训练好的模型本质上就是一堆数字(权重矩阵)。model.safetensors 是把这些数字按名字存起来的二进制文件。程序会按照配置里写的层数、维度,把这些权重"对号入座"地加载到内存里。
这一步有个容易踩坑的地方:不同模型的权重命名规则不一样。比如 Qwen 的注意力层权重可能叫 model.layers.0.self_attn.q_proj.weight,而 Llama 可能叫别的名字。项目里专门做了"名字前缀匹配"的逻辑,来兼容不同模型家族。
第 3 步:加载分词器(tokenizer.json)
模型看不懂汉字或英文单词,它只看数字。分词器的作用就是把"你好世界"变成 [101, 2345, 6789, 102] 这样的数字序列。tokenizer.json 里存着每个词(或字)对应的数字 ID。
项目里还会读取 tokenizer_config.json,因为有些模型有特殊的"聊天模板"(chat template),比如 Qwen 的模板会自动给对话加上 <|im_start|> 和 <|im_end|> 这样的标记。
第 4 步:预填充(Prefill)
这是推理的"热身阶段"。假设你输入了"请介绍一下北京",分词器把它变成 10 个 token。预填充就是把这 10 个 token 一次性送进模型,算一遍前向传播,得到每个位置的隐藏状态。
关键操作:建 KV Cache
在预填充过程中,模型会计算每一层的 Key 和 Value 矩阵。这些矩阵会被存起来(就是所谓的 KV Cache),因为下一步解码的时候还要反复用,不用每次都重新算。
第 5 步:解码(Decode)
预填充结束后,模型已经"读完了"你的问题。接下来它要一个字一个字地"写回答"。
解码阶段每次只输入最新生成的那一个 token,然后利用 KV Cache 里存好的历史信息,快速算出下一个 token 的概率分布。每生成一个新 token,就把它追加到 KV Cache 里,供下一步使用。
这一步有个细节:左填充(Left Padding)。因为不同输入的长度不一样,短的句子要在左边补零,让所有输入对齐,这样才能批量计算。
第 6 步:采样(Sampling)
模型解码出来的不是确定的文字,而是一个概率分布——“下一个词有 30% 的概率是’北京’,20% 的概率是’中国’,5% 的概率是’烤鸭’……”
采样就是从这个概率分布里"抽奖",决定到底选哪个词。项目支持三种采样策略:
| 策略 | 原理 | 用途 |
|---|---|---|
| 贪心解码(Greedy) | 每次都选概率最高的词 | 确定性输出,适合测试 |
| Top-K | 只从概率最高的 K 个词里选 | 控制多样性,避免选到太离谱的词 |
| Top-P(Nucleus) | 从累积概率达到 P 的最小词集合里选 | 更灵活的多样性控制 |
温度(temperature)参数也很直观:温度越高,概率分布越"平",模型越爱"瞎猜";温度越低,越倾向于选最稳的词。
第 7 步:终止与输出
模型生成到遇到特殊的"结束标记"(EOS,End of Sequence)时,或者达到用户设置的最大长度时,就停止生成。然后把生成的数字序列扔回分词器,转回人类能看懂的文本。
四、代码架构与核心设计思路
整个项目的代码组织非常"直白",看目录结构就能猜出每个文件是干嘛的:
include/ # 头文件,定义接口
models/ # GPT 各组件的接口(Embedding、Attention、MLP 等)
continuous_batch_server.hpp # 服务模式的入口
src/ # 具体实现
models/ # 模型组件的实现
main.cpp # 程序入口,解析命令行参数
continuous_batch_server.cpp # 连续批处理服务的实现
cuda/ # CUDA 加速算子(可选)
test/ # 单元测试和回归测试
data/ # 模型文件(不纳入版本控制)
设计哲学:分层清晰,不藏逻辑
- 入口极简:
main.cpp只做三件事——解析命令行参数、加载模型和分词器、决定是"单次推理"还是"服务模式"。 - 模型组件化:把 GPT 模型拆成标准流水线:
每一层都是一个独立的类,想看 Attention 怎么算的直接去输入 → Embedding → [Self-Attention + MLP] × N 层 → LayerNorm → 输出投影 → 采样src/models/找对应文件。 - 两种运行模式:
- 单次模式(CLI):输入一句话,输出一句话,适合调试和学习。
- 服务模式(–serve):启动一个持续运行的服务,可以连续接收多个请求,用"连续批处理"来调度,适合理解生产环境中的请求调度逻辑。
连续批处理是怎么回事?
生产环境里不可能来一个请求就启动一次模型。服务模式下,系统会维护一个"请求池":
- 预填充轮次(Prefill Round):每轮选一批新进来的请求,一起做预填充。
- 解码轮次(Decode Round):每轮给所有"还在生成中"的请求各跑一步解码,生成下一个 token。
- 有的请求先结束了(比如只生成了 5 个字),有的还在继续(比如要生成 100 个字),系统会自动把已完成的请求移出批次,让 GPU/CPU 资源专注服务还在跑的请求。
五、推理流程原理图
下面是整个推理流程的简化示意图:
┌─────────────────────────────────────────────────────────────┐
│ 启动阶段 │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 读取config │→ │ 加载safetensors│→ │ 加载tokenizer │ │
│ │ (模型配置) │ │ (权重矩阵) │ │ (词↔数字映射) │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 单次推理模式 │
│ │
│ 用户输入 "请介绍北京" │
│ ↓ │
│ 分词器 → [101, 2345, 6789, ...] │
│ ↓ │
│ ┌─────────────┐ │
│ │ 预填充阶段 │ ← 一次性处理全部输入token,建立KV Cache │
│ │ (Prefill) │ │
│ └─────────────┘ │
│ ↓ │
│ 循环直到结束或达到最大长度: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 解码一步 │ → │ 采样下一个词 │ → │ 更新KV Cache │ │
│ │ (Decode) │ │ (TopK/TopP) │ │ (追加新Key/ │ │
│ │ 只输入最新 │ │ │ │ Value) │ │
│ │ 生成的token │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ │
│ 遇到EOS或长度上限 → 停止 │
│ ↓ │
│ 分词器反向解码 → "北京是中华人民共和国的首都..." │
│ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 服务模式 (--serve) │
│ │
│ stdin 接收请求 → [接受请求 id=1] │
│ → [接受请求 id=2] ... │
│ ↓ │
│ ┌─────────────────────────────────────────┐ │
│ │ 调度循环 │ │
│ │ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ 预填充轮次 │→ │ 解码轮次 │ │ │
│ │ │ (批量处理 │ │ (所有活跃请求 │ │ │
│ │ │ 新请求) │ │ 各走一步) │ │ │
│ │ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ ↓ │
│ 请求完成 → [request 1] 北京是... │
│ │
└─────────────────────────────────────────────────────────────┘
模型内部的前向传播流水线:
输入 Token IDs
↓
┌─────────────┐
│ Embedding │ ← 把数字变成向量
│ 层 │
└─────────────┘
↓
┌─────────────────────────────────────────┐
│ 重复 N 次(N = 模型层数) │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ LayerNorm │ → │ Self-Attention│ │
│ │ (归一化) │ │ (自注意力) │ │
│ └─────────────┘ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 残差连接 │ → │ MLP │ │
│ │ (Skip Conn) │ │ (前馈网络) │ │
│ └─────────────┘ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ │
│ │ 残差连接 │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────┘
↓
┌─────────────┐
│ LayerNorm │ ← 最后一层归一化
└─────────────┘
↓
┌─────────────┐
│ 输出投影 │ ← 映射到词表维度,得到每个词的概率
│ (LM Head) │
└─────────────┘
↓
┌─────────────┐
│ 采样 │ ← TopK / TopP / Greedy
└─────────────┘
↓
输出下一个 Token ID
If you need the complete source code, please add the WeChat number (c17865354792)
六、相关领域知识点梳理
1. Transformer 架构
这是目前大模型的"标准骨架"。核心就是 Self-Attention(让模型能看到输入中不同位置的关系)加上 MLP(对每个位置独立做非线性变换)。项目里的 src/models/ 就是按这个结构一层层实现的。
2. KV Cache
推理优化的"入门级技巧"。训练时模型是一次性看完整句话,所以 Key 和 Value 都是现算现用。但生成文本时,前面的 token 已经算过了,把它们的 K、V 存下来,下次直接用,能省大量计算。这个项目把 KV Cache 的"追加"和"复用"逻辑写得非常显式,适合学习。
3. 分词(Tokenization)
模型处理的是数字,不是文字。分词器负责把文本切成"词片段"(token)。注意:一个汉字不一定对应一个 token,可能是半个、一个或两个 token。项目里直接用了标准的 BPE 分词器实现。
4. 采样策略
大模型生成文本不是"查表",而是"概率抽样"。Top-K 和 Top-P 是控制输出多样性的两种经典方法。温度参数则是对概率分布做"软化"或"硬化"处理。
5. 连续批处理(Continuous Batching)
这是生产环境的核心技术。传统的"静态批处理"要等一批请求全部完成才释放资源,而连续批处理允许"先完成的先走,新请求随时加入",大幅提高吞吐量。项目里的服务模式实现了简化版,足以理解核心思想。
6. 精度与性能权衡
项目默认使用 BF16(Brain Float 16)精度,这是目前大模型的主流选择——比 FP32 省内存,比 INT8/INT4 精度高,且现代 CPU/GPU 都支持。
七、怎么编译运行?
环境准备
- C++17 编译器(g++ 或 clang++)
- CMake(>= 3.10)
- (可选)OpenMP:多线程加速,默认开启
- (可选)CUDA 工具包:如果你想跑 GPU 版本
编译
CPU 版本(推荐初学者):
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=g++
cmake --build build --target main_llm -j8
CUDA 版本(需要本地有 NVIDIA 环境):
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DMAIN_LLM_ENABLE_CUDA=ON \
-DCMAKE_CUDA_ARCHITECTURES=<你的显卡架构,比如 80> \
-DCMAKE_CXX_COMPILER=g++
cmake --build build --target main_llm -j8
准备模型文件
默认跑 Qwen2.5-0.5B,需要把下面 4 个文件放到 data/model/ 目录下:
data/model/
├── config.json
├── model.safetensors
├── tokenizer.json
└── tokenizer_config.json
这些文件可以从 Hugging Face 下载 Qwen2.5-0.5B 的原始权重。
运行示例
查看帮助:
./build/main_llm --help
单次推理(交互式):
./build/main_llm --max-steps 128 --temperature 0.7 --top-p 0.9 --top-k 40 "Hello"
从文件批量推理:
./build/main_llm -f test/data/test_batch.txt --max-steps 256 --temperature 0.1
输出会自动写到同目录的
test_batch_output.txt。
启动服务模式:
./build/main_llm --serve
然后每行输入一个提示词,输入 /quit 或 :quit 退出。你会看到类似这样的输出:
[accepted 1]
[request 1] 这是模型生成的回复内容...
常用参数说明
| 参数 | 作用 |
|---|---|
-f / --prompt-file | 从文件读取多行提示 |
-m / --max-steps | 最多生成多少个 token |
--temperature | 采样温度(默认 1.0) |
--top-p | 核采样阈值 |
--top-k | 只从概率最高的 K 个词采样 |
--seed | 随机种子,保证可复现 |
--greedy | 贪心解码,每次选概率最高的词 |
--serve | 启动连续批处理服务 |
--serve-max-active | 服务模式下最大并发请求数 |
--serve-prefill-batch | 每轮预填充最多处理多少请求 |
八、怎么测试?
项目自带回归测试和不变量测试,用来验证"改了代码之后,核心逻辑有没有被破坏"。
编译测试目标
cmake --build build --target main_llm_regression_gates -j8
运行测试
基础不变量测试:
ctest --test-dir build --output-on-failure -L "^invariant_gate$"
CUDA 相关测试(仅在编译时开启 CUDA 后可用):
ctest --test-dir build --output-on-failure -L "^invariant_gate_cuda$"
一键运行(脚本封装):
bash scripts/run_regression_gates.sh
# 如果编译了 CUDA 版本:
bash scripts/run_regression_gates.sh --with-cuda
这些测试会检查:数据管理器的填充逻辑、KV Cache 的更新是否正确、生成结果是否符合预期等。如果你打算改代码做实验,建议先跑一遍测试,确认基线通过。
九、总结:这个项目适合谁?
| 人群 | 能学到什么 |
|---|---|
| 在校学生 | 把课本上的 Transformer 公式变成可运行的代码,理解每一步的数据流 |
| 算法工程师 | 快速搭建推理原型,验证新想法(比如改采样策略、加新模块) |
| 想转推理方向的开发者 | 从 CPU 基线开始,逐步理解为什么需要 CUDA、为什么需要 KV Cache、为什么需要连续批处理 |
| 面试官/面试者 | 一个很好的"手写 LLM 推理"参考实现 |
它的价值不在于"跑得有多快",而在于把黑盒打开,让你看清楚光是怎么从一头传到另一头的。当你把这个项目的代码吃透了,再去看 vLLM、SGLang 这些工业级框架,就会有种"看懂了骨架,再学招式"的通透感。
如果你正在学习大模型推理,建议先跟着"单次推理模式"走一遍完整流程,把断点打在 forward 函数里,观察每一层的输入输出形状变化。等单条流程理顺了,再去看服务模式里的调度逻辑,会轻松很多。
Welcome to follow WeChat official account【程序猿编码】
更多推荐

所有评论(0)