从零开始造轮子:用C++实现大语言模型推理的核心逻辑
在大语言模型(LLM)普及的当下,亲手搭建一个高效的推理框架,能让我们更透彻地理解AI生成内容的底层逻辑。C++凭借其贴近硬件、低开销的特性,成为实现轻量LLM推理的优选语言。这篇文章将整合框架的核心设计思路与代码落地细节,用大白话拆解从原理到实现的完整路径,让你既能懂“为什么这么设计”,也能明白“代码里怎么实现”。
在大语言模型(LLM)普及的当下,亲手搭建一个高效的推理框架,能让我们更透彻地理解AI生成内容的底层逻辑。C++凭借其贴近硬件、低开销的特性,成为实现轻量LLM推理的优选语言。这篇文章将整合框架的核心设计思路与代码落地细节,用大白话拆解从原理到实现的完整路径,让你既能懂“为什么这么设计”,也能明白“代码里怎么实现”。
一、为什么选择C++?推理框架的语言选型逻辑
LLM推理的核心需求是“快”和“省”——快速处理海量矩阵运算,同时尽量节省内存和硬件资源。C++恰好契合这些需求:
- 无解释器开销,能直接操作内存,矩阵运算、循环等核心逻辑的执行效率远超高阶语言;
- 硬件适配灵活,可直接调用CPU指令集或GPU的CUDA接口,兼顾通用设备和高性能加速场景;
- 编译后体积小、依赖少,能打包成独立可执行文件,在不同设备上轻松部署。
简单说,C++让推理框架“跑得起来、跑得够快、随处能跑”,是轻量LLM推理的理想载体。
二、核心设计:四大模块撑起推理框架
一个能正常工作的LLM推理框架,无需复杂冗余的结构,核心由四个模块构成。每个模块各司其职,环环相扣完成“文本输入→数字转换→推理计算→文本输出”的全流程。
1. 分词器:语言与模型的“翻译官”
模型只认数字,不认人类语言,分词器的核心任务就是把文本翻译成模型能理解的“数字密码”(token ID),同时在推理后把数字转回文本。
核心原理:BPE分词法(高频组合优先)
分词本质是“拆词+合并”的游戏:先把文本拆成最小单位(汉字单字、英文字母+符号),再统计高频组合(比如“人工智能”“LLM”),将其合并为固定词汇,最终形成词汇表并分配唯一数字。比如“Hello, my name is”会被转换成[31373, 1175, 1403, 318]这样的token ID序列。
代码实现落地
- 数据结构:用
unordered_map存储词汇表(字符串→token ID)和反向词汇表(token ID→字符串),哈希表的O(1)查找速度能保证分词效率;用vector存储BPE合并规则,按优先级依次合并高频子串。 - 核心函数:
load加载词汇表和合并规则,encode完成文本到token ID的转换,decode实现反向转换。 - 关键优化:用正则表达式提前编译文本匹配规则,快速区分字母、数字、符号,避免分词混乱;全程复用内存,减少频繁分配释放的开销。
2. 模型加载器:参数的“搬运工”
我们从网上下载的LLM模型(如GPT-2、Llama系列),本质是存储着神经网络权重、偏置等参数的二进制文件。加载器的作用就是把这些“_raw数据”拆包、整理,转换成程序能直接运算的矩阵和向量。
核心原理:参数映射与精度适配
- 按模型结构映射:不同模型(如GPT-2和Llama)的层数量、神经元数不同,加载器需读取配置文件,按“嵌入层→Transformer层→输出层”的顺序,将参数对应到程序定义的结构体中。
- 精度灵活切换:支持FP32(全精度)、FP16(半精度)、BF16(脑半精度)等格式,其中FP16和BF16能在几乎不损失效果的前提下,减少一半内存占用,大幅提升推理速度。
代码实现落地
- 数据结构:定义
ModelParams结构体存储全局参数(词嵌入矩阵、位置嵌入矩阵),TransformerLayerParams存储单层Transformer的权重(注意力层、前馈网络层、归一化层参数)。 - 核心函数:
load函数负责读取二进制权重文件和JSON配置文件,解析后填充到对应的矩阵(Matrix)和向量(Vector)中,同时处理精度转换。 - 设计巧思:矩阵用自定义
Matrix类封装,内部基于vector存储数据,既保证内存连续,又支持基础运算(矩阵乘、加),比原生二维数组更灵活。
3. Transformer解码器:推理的“核心大脑”
这是框架的核心模块,负责接收token ID序列,通过逐字预测生成完整回答。其核心是Transformer解码器结构,核心逻辑是“上下文关联+逐token生成”。
核心原理:注意力机制与KV缓存
- 自注意力机制:相当于“放大镜”,预测下一个token时,能聚焦前面序列中最相关的内容(比如预测“法国首都”的后续token,会重点关注“法国”“首都”)。通过计算查询(Q)、键(K)、值(V)的矩阵运算,得到注意力权重,再加权求和得到上下文信息。
- 逐token生成:模型不会一次性输出整句话,而是从输入序列的最后一个token开始,每次只预测下一个最可能的token,循环往复直到达到最大长度。
- KV缓存优化:每次生成新token时,前面序列的Q、K、V计算结果不会重复计算,而是存储在缓存中,仅新增当前token的计算结果,将时间复杂度从O(n²)降至O(n),速度提升显著。
代码实现落地
- 核心函数:
scaled_dot_product_attention实现自注意力计算,包含QKV矩阵运算、因果掩码(避免未来信息泄露)、softmax概率转换和KV缓存更新;generate函数封装循环预测逻辑,每次将新生成的token加入序列,直到满足终止条件。 - 数据流转:输入token ID经词嵌入+位置嵌入转换为向量,送入多层Transformer层(注意力层→归一化→前馈网络→归一化),最后通过线性层转换为token概率分布,采样得到下一个token ID。
- 关键细节:用
concatenate函数实现KV缓存的拼接,复用已分配内存;激活函数(如GELU)、归一化操作采用内联函数,减少函数调用开销。
4. 硬件适配层:性能的“加速器”
推理速度的上限由硬件决定,这一模块的核心是让框架能适配CPU、GPU等不同硬件,最大化利用硬件性能。
核心原理:硬件接口封装
- CPU推理:直接使用C++标准库和自定义矩阵运算逻辑,适配普通设备,主打通用兼容性;
- GPU推理:通过CUDA核函数封装矩阵运算,利用GPU的并行计算能力(成千上万的流处理器同时运算),将大模型推理速度提升10倍以上。
代码实现落地
- 条件编译:通过
#ifdef CUDA等宏定义,实现CPU/GPU模式的切换,编译时可根据硬件环境选择对应的运算逻辑; - 统一接口:上层推理逻辑无需关注底层硬件,调用
matmul(矩阵乘)、add(矩阵加)等统一接口,底层自动适配CPU或GPU运算。
三、代码结构:轻量高效的工程设计
整个框架的代码结构遵循“单一职责”原则,无冗余依赖,核心文件不超过10个,没有冗余依赖,这也是“轻量”的体现:
src/
├── tokenizer.h // 分词器类声明
├── tokenizer.cpp // 分词器实现(BPE算法核心)
├── model.h // 模型结构定义(包含Transformer层)
├── model.cpp // 模型加载与参数解析
├── transformer.h // Transformer解码器核心逻辑
├── transformer.cpp // 自注意力、前馈网络实现
├── utils.h // 工具函数(矩阵运算、文件操作等)
├── main.cpp // 主程序入口(推理流程串联)
└── config.h // 配置参数(模型路径、推理长度等)
这种结构严格遵循“单一职责”原则:每个文件只干一件事,比如tokenizer只处理文本和token的转换,transformer只负责核心推理计算,后期想改某个模块(比如换个分词算法),直接动对应文件就行。
四、核心模块的代码实现细节
1. 分词器(tokenizer.h/cpp):把文本变成数字的“翻译官”
核心数据结构
// tokenizer.h 中定义的关键结构
class Tokenizer {
private:
unordered_map<string, int> vocab; // 词表:字符串→token ID(哈希表查得快)
unordered_map<int, string> vocab_inv; // 反向词表:token ID→字符串
vector<pair<string, int>> merges; // BPE合并规则(高频子串组合)
// 正则表达式:提前编译好匹配规则(字母、数字、符号等)
regex pattern;
public:
bool load(const string& vocab_path, const string& merges_path); // 加载词表和合并规则
vector<int> encode(const string& text); // 文本转token ID
string decode(const vector<int>& tokens); // token ID转文本
};
关键实现:BPE编码过程(encode函数)
BPE的核心是“先拆成单字,再按规则合并”,代码里是这么干的:
- 文本预处理:用正则表达式把文本拆成“原子单元”(比如“Hello!”拆成[“Hello”, “!”]),避免符号和文字混在一起。
- 拆分成字符级:每个单元再拆成单个字符(比如“Hello”→[“H”,“e”,“l”,“l”,“o”])。
- 按合并规则合并:循环查
merges表,把当前序列中出现的最高频组合合并(比如[“l”,“l”]→[“ll”]),直到没有可合并的组合。 - 查表转ID:把合并后的子串在
vocab哈希表里查到对应的数字,就是最终的token ID。
这里用unordered_map存词表,是因为它的查找复杂度是O(1),比数组遍历快得多——分词时每秒要查几千次,快一点很重要。
2. 模型加载器(model.h/cpp):把参数“搬进”程序的“搬运工”
模型文件(比如GPT-2的权重)本质是一堆二进制数据,model模块的作用就是把这些数据“翻译”成程序能直接用的矩阵。
核心数据结构
// model.h 中定义的模型参数结构
struct ModelParams {
// 嵌入层参数(把token ID变成向量)
Matrix<float> wte; // 词嵌入矩阵 (vocab_size, embed_dim)
Matrix<float> wpe; // 位置嵌入矩阵 (max_seq_len, embed_dim)
// Transformer层参数(多层堆叠)
vector<TransformerLayerParams> layers;
};
// 单个Transformer层的参数
struct TransformerLayerParams {
// 自注意力相关权重
Matrix<float> attn_c_attn_w; // 注意力查询/键/值的权重合并矩阵
Vector<float> attn_c_attn_b; // 偏置
Matrix<float> attn_c_proj_w; // 注意力输出投影权重
Vector<float> attn_c_proj_b; // 偏置
// 前馈网络相关权重
Matrix<float> mlp_c_fc_w; // 前馈第一层权重
Vector<float> mlp_c_fc_b; // 偏置
Matrix<float> mlp_c_proj_w; // 前馈输出投影权重
Vector<float> mlp_c_proj_b; // 偏置
Vector<float> ln_1_g; // 第一层层归一化的gamma
Vector<float> ln_1_b; // 第一层层归一化的beta
Vector<float> ln_2_g; // 第二层层归一化的gamma
Vector<float> ln_2_b; // 第二层层归一化的beta
};
关键实现:加载权重(load函数)
- 读配置文件:先从JSON配置里拿到模型的基本信息(比如词表大小、隐藏层维度、层数),知道要加载多少参数。
- 二进制文件解析:模型权重文件(通常是.bin格式)按固定顺序存储参数,程序按“嵌入层→第一层Transformer→第二层Transformer→…”的顺序读取,对应到
ModelParams的各个矩阵里。 - 数据类型处理:如果权重是FP16(半精度),会转成FP32存到
Matrix<float>里(C++原生支持float,处理起来方便);也可以保留FP16节省内存,这时候会用Matrix<half>(需要编译器支持)。
这里用自定义的Matrix类封装矩阵操作,里面其实是个vector存储数据,加了行/列属性和基础运算(矩阵乘、加等),比直接用二维数组更灵活。
3. Transformer解码器(transformer.h/cpp):推理的“核心引擎”
这部分是整个框架最复杂的地方,负责实现“根据上下文预测下一个token”的逻辑,核心是自注意力机制和KV缓存。
关键函数:自注意力计算(scaled_dot_product_attention)
自注意力的作用是“让每个词关注上下文里相关的词”,代码里是这么算的:
// 简化版自注意力实现
Matrix<float> scaled_dot_product_attention(
const Matrix<float>& q, // 查询矩阵 (batch, seq_len, head_dim)
const Matrix<float>& k, // 键矩阵 (batch, seq_len, head_dim)
const Matrix<float>& v, // 值矩阵 (batch, seq_len, head_dim)
Matrix<float>* kv_cache_k, // K缓存(可选,加速推理)
Matrix<float>* kv_cache_v // V缓存(可选,加速推理)
) {
int batch = q.rows();
int seq_len = q.cols();
int head_dim = q.depth(); // 假设Matrix支持三维(batch, seq, dim)
// 计算注意力分数:Q*K^T / sqrt(head_dim)
Matrix<float> attn_scores = matmul(q, k.transpose(1, 2)) / sqrt(head_dim);
// 掩码:让当前词只能看到前面的词(避免未来信息泄露)
attn_scores = apply_causal_mask(attn_scores);
// 转成概率(softmax)
Matrix<float> attn_probs = softmax(attn_scores);
// 加权求和:注意力概率 * V
Matrix<float> output = matmul(attn_probs, v);
// 更新KV缓存(推理时用,把当前的k和v存起来,下次不用重新算)
if (kv_cache_k != nullptr && kv_cache_v != nullptr) {
*kv_cache_k = concatenate(*kv_cache_k, k, 1); // 在序列维度拼接
*kv_cache_v = concatenate(*kv_cache_v, v, 1);
}
return output;
}
关键优化:KV缓存的实现
推理时每次生成一个新token,前面的序列是不变的。如果每次都重新计算所有词的K和V,会做很多重复工作。KV缓存就是把已经算过的K和V存起来,只算新token的K和V,再和缓存拼接:
- 第一次输入“法国的”,计算3个token的K和V,存在缓存里。
- 生成“首”时,只算“首”的K和V,和缓存拼接成4个token的K和V,再算注意力。
- 这样每次推理的计算量从O(n²)降到O(n)(n是当前序列长度),速度提升非常明显。
4. 推理流程串联(main.cpp):把所有模块“拧成一股绳”
主程序的逻辑很简单,就是按顺序调用各个模块:
int main() {
// 1. 初始化配置
Config config = load_config("config.json");
// 2. 加载分词器
Tokenizer tokenizer;
tokenizer.load(config.vocab_path, config.merges_path);
// 3. 加载模型参数
Model model;
model.load(config.model_path);
// 4. 输入文本编码
string input_text = "法国的首都是";
vector<int> input_tokens = tokenizer.encode(input_text);
// 5. 推理生成
Transformer transformer(model.params);
vector<int> output_tokens = transformer.generate(
input_tokens,
config.max_length, // 最大生成长度
config.temperature // 随机性控制(0= deterministic,1=随机)
);
// 6. 输出结果解码
string output_text = tokenizer.decode(output_tokens);
cout << output_text << endl; // 输出:法国的首都是巴黎...
return 0;
}
生成函数(generate)的核心是“循环预测”:每次把当前的token序列输入模型,得到下一个token的概率分布,采样出一个token加到序列里,直到达到最大长度或遇到结束符。
构建并运行
mkdir build
cmake -B ./build -DCMAKE_BUILD_TYPE=Release
cmake --build ./build --config Release
这将生成可执行文件并将资源复制到demo/bin目录中,然后你就可以运行演示了:
cd demo/bin
./TinyGPT_demo
[INFO] Load model ...
[INFO] Load model done.
[INFO] Generated Outputs:
[INFO] ------------------------------------------------------------
[INFO] Prompt: 'Hello, my name is'
[INFO] Output: ' Max! I am Phelan and I'm the world's greatest magician! I am the world's greatest magician! You are the world's greatest magician! You'
[INFO] ------------------------------------------------------------
[INFO] Prompt: 'The president of the United States is'
[INFO] Output: ' on a temporary trip to Asia, and the Pentagon has made several announcements about what's next for the war on terror.\n\nThe next day, General Martin Dempsey'
[INFO] ------------------------------------------------------------
[INFO] Prompt: 'The capital of France is'
[INFO] Output: ' located in the eastern part of the country, so it is very easy to find houses in this part of the country. The most important houses are in Paris, and'
[INFO] ------------------------------------------------------------
[INFO] Prompt: 'The future of AI is'
[INFO] Output: ' forever. Our time is now.\n\n\nSequel to the game, The Mighty Ducks is available on Android and iOS, and a new Android app is also coming'
[INFO] ------------------------------------------------------------
[INFO] Time cost: 1907 ms, speed: 83.90 token/s
If you need the complete source code, please add the WeChat number (c17865354792)
五、技术细节里的“效率密码”
这个框架能在普通设备上跑起来,靠的是这些细节优化:
- 内存复用:矩阵运算时尽量复用已分配的内存(比如KV缓存直接在原矩阵上拼接),避免频繁new/delete导致的性能损耗。
- 避免冗余计算:层归一化(LayerNorm)、激活函数(GELU)这些操作都用inline函数实现,减少函数调用开销;矩阵乘用循环展开(unroll)优化,让CPU流水线更高效。
- 轻量依赖:没有用PyTorch/TensorFlow这些重型框架,矩阵运算自己实现(或用轻量的Eigen库),词表解析用C++标准库的regex和unordered_map,整个程序编译后体积很小(几MB)。
- 条件编译支持硬件加速:通过
#ifdef CUDA宏定义,预留GPU加速接口——把矩阵运算换成CUDA核函数,就能在NVIDIA显卡上跑,速度能提升10倍以上。
总结:代码实现的核心思路
这个C++轻量LLM推理框架的实现,本质是“用最朴素的代码做最关键的事”:
- 只保留推理必需的模块(分词、加载、Transformer、生成),砍掉训练相关的冗余逻辑;
- 数据结构优先选“快”的(哈希表查词表、向量存矩阵),运算优先用“省”的(KV缓存、内存复用);
- 模块间接口尽量简单(输入输出都是向量或矩阵),方便后续改造成更复杂的框架。
如果想动手改一改,可以先从这两个方向试:一是给自注意力加个量化(比如INT8),进一步减小内存占用;二是完善GPU加速部分,让大模型也能跑起来。代码结构清晰,改起来门槛不高
Welcome to follow WeChat official account【程序猿编码】
更多推荐

所有评论(0)