一、这个项目到底是干嘛的?

简单来说,这是一个用纯 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/                 # 模型文件(不纳入版本控制)

设计哲学:分层清晰,不藏逻辑

  1. 入口极简:main.cpp 只做三件事——解析命令行参数、加载模型和分词器、决定是"单次推理"还是"服务模式"。
  2. 模型组件化:把 GPT 模型拆成标准流水线:
    输入 → Embedding → [Self-Attention + MLP] × N 层 → LayerNorm → 输出投影 → 采样
    
    每一层都是一个独立的类,想看 Attention 怎么算的直接去 src/models/ 找对应文件。
  3. 两种运行模式:
    • 单次模式(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【程序猿编码】

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐