解锁模型适配能力:Llama.cpp 框架下新架构开发全流程指南
前置条件
如今,很多人借助大语言模型辅助编码,即便超出自身专业领域也能完成不少工作。但编写模型架构仍需掌握以下核心技能:
● 大语言模型基础工作原理:了解张量和层的概念,以及数据在大语言模型中的编码和解码过程。
● 线性代数:这是必备技能。若从未接触过矩阵乘法,后续工作将举步维艰。入门要求不高,掌握基础矩阵运算即可,但需熟练运用。
● 指针运算与 C/C++ 基础:GGML 是底层库。编写运算逻辑时,需自行管理张量的内存布局并正确写入数值(后续将详细说明)。
● 耐心:大量的耐心。实现复杂模型时,可能会遇到代码异常却无从下手的困境。你需要对比张量输出日志,排查诸如 7.5251 与 7.4621 这类数值偏差的原因。经验越浅,面临的此类问题可能越多。
起步方向
显然,除非你打算从零开始在 GGML 中构建新模型架构(尽管极具挑战性,但欢迎尝试!),否则通常需基于参考实现进行开发。综合多方面因素,最优选择是优先使用 Hugging Face Transformers 的实现(若存在)。原因如下:首先,我们有成熟工具可便捷对比你的适配版本与 Transformers 实现的差异;其次,Transformers 生态对各类功能的实现方案更为完善,其他框架可能缺乏相关技术积累。若不得不选用其他参考实现,难度将大幅提升。此外,务必选择稳定的参考实现。有时,即便模型开发者自身也可能在初始实现中遗留漏洞,后续才会修复。因此,开始转换前,需确保参考实现稳定且能产出理想结果。
核心要素
convert_hf_to_gguf_update.py(新分词器适配)
若模型使用全新分词器(此前未被支持),需更新 convert_hf_to_gguf_update.py 脚本,从 Hugging Face 的参考模型中获取分词器,然后执行脚本。若分词器已被支持,可跳过此步骤。注意:若新分词器与现有分词器功能一致,无需重复添加。
这是所有模型转换的入口脚本,一个整合了所有转换器的大型文件。
但转换前并非仅需关注此文件。实际上,在编写 convert_hf_to_gguf.py 代码前,应先查看 gguf-py 包中的 constants.py 和 tensor_mapping.py。这两个文件包含了 GGML 目前支持的张量类型及参数名称。你需要查看原始模型的 model.safetensors.index.json(假设从 safetensors 格式转换)和 config.json 文件,分别获取层列表和参数列表。首要任务是制定检查清单:
● 模型中的所有张量在 GGML 中均有对应定义吗?若没有,无需担心 —— 可新增定义!但新增前,需确认不存在命名不直观的已有定义(至少在 tensor_mappings.py 中使用 Ctrl+F 搜索,检查是否有其他模型已处理过同名格式的张量)。
● 需用到哪些参数?请注意,除非目标明确,否则我们通常优先实现前向传播兼容性 —— 一般无需支持完整的反向传播(模型训练功能)。有两种方案可供选择:一是查看 Transformers 实现的 modeling_*.py 类,明确所需参数;二是根据需求实时添加参数。需注意,许多参数可由默认实现处理,无需手动逐一适配。
● 张量需要哪些预处理工作?一个关键要点是 GGML 对专家张量的处理方式 —— 标准做法是将多个专家张量合并为一个大型专家张量。若不确定具体实现,可参考已完成的 MoE(混合专家)架构实现。同时,需寻找可通过预处理优化的场景。若张量在使用前需固定执行预处理操作(例如 Transformers 代码中常出现的 exp (your_tensor) 或 1 + your_tensor 等),建议在准备阶段完成这些计算,既能简化图构建过程,又能提升性能(只需计算一次)。参考代码中有时不提前处理,是为了兼容反向传播,但如前所述,我们多数情况下仅需支持前向传播。
查看完参考代码、张量及参数后,即可开始编写代码。需修改以下文件:
● constants.py:添加模型架构常量、代号及所用张量列表。
● tensor_mapping.py:若模型张量名称不符合现有命名规则,需在此文件中添加映射关系。除非张量是模型专属的,否则建议添加到通用映射规则中。
● llama-arch.h 和 llama-arch.cpp:添加与 constants.py 对应的架构名称定义。
● convert_hf_to_gguf.py:实现转换类本身。
编写转换类时,建议继承最接近目标架构的基类(至少继承 TextModel)。通常可直接参考现有示例,复制粘贴额外参数处理、专家张量打包及其他张量转换代码。重点关注以下方法:
● set_gguf_parameters:转换所有非标准超参数。
● prepare_tensors:创建所需的新张量。
● modify_tensors:对现有张量执行处理(合并、忽略、转换等)
需注意:GGML 中包含权重和偏置的张量需遵循 “X.weight” 和 “X.bias” 的命名规范。若原始模型使用其他规范(如 “X_weight”“X_bias” 或 “X”“X_bias”),需修改名称以适配。
完成代码编写后,尝试执行模型转换。密切关注提示未处理张量的错误信息。若存在无需处理的张量(例如对应 MTP 等不打算转换的功能),需在 modify_tensors 中显式忽略。
这是最耗时的环节 —— 图实现。建议先查看 examples/model-conv
llama-model.cpp
ersion 目录,其中包含可同时运行参考实现与你的实现、获取并对比日志概率(logits)的脚本。但仅对比日志概率可能不够:若转换过程中遇到问题,需分析张量输出日志。run-org-model 脚本已支持输出 GGML 格式的张量日志,但你可能需要为模型自定义更多函数(脚本文件中包含带注释的示例)。
llama-model.cpp 本身仅包含张量和参数的加载代码。针对特定模型的图构建器,需在 src/models 目录下创建新文件(记得后续添加到 src/CMakeFiles.txt 中),并在 src/models/models.h 中添加类声明。
什么是图构建器?
开始前,需明确 Llama.cpp 的推理原理与 Transformers 的差异。在 Transformers 代码中,推理流程清晰:调用模型的主前向传播方法,该方法会链式调用各层及组件的前向传播函数。但 GGML / Llama.cpp 的推理方式完全不同。
由于 Llama.cpp 支持多种后端且需对用户透明,因此采用了特殊设计:模型架构构建的并非推理过程本身,而是推理图。
这意味着:首先,编写张量运算逻辑时,代码执行的效果是将对应运算添加到图中,而非立即执行。因此,在图构建阶段,除张量类型、维度和步长外,无法获取张量的其他信息 —— 尤其无法提取数据。
其次,图必须是静态的。图的结构可依赖模型超参数或层数量等元参数,但不能依赖推理时张量的具体数值。此类依赖需编码到运算逻辑中 —— 即通过 “运算(operation)” 实现。
此外,图的构建是惰性的:若某个节点被判定为不可达,则不会进行计算。因此,需通过 ggml_build_forward_expand 函数标记关键节点。该函数将图节点标记为计算必需节点,进而确保该节点及其所有前驱节点都会被计算(注意:这仍不会触发实际计算,图构建器中没有强制计算的方法,因为图本质上是推理的模板,而非单一推理实例)。
最后,图构建中不支持循环。即使循环逻辑与张量数值无关,例如对 512 个令牌进行循环,也会生成包含(512 × 循环内节点数)个子节点的子图,这会瞬间耗尽内存。若需实现循环,需在自定义运算中处理(后续将详细说明)。
GGML 的特性与注意事项
深入图构建代码前,需明确 GGML 与 Transformers 的两个核心差异:
1. 张量索引顺序:GGML 采用小端序(little-endian),与 Transformers 的大端序(big-endian)相反。两者均为 “行优先”(即维度为 (5, 4) 的张量表示 “5 行 4 列”)。但 Transformers 中维度为 (5, 4) 的张量,在 GGML 中需表示为 (4, 5)。简言之,需反转 Transformers 张量的维度顺序以适配 GGML。此外,GGML 张量严格限制为 4 维(实际上所有张量均按 4 维处理),因此若需表示 Transformers 中的 5 维(或更高维)张量,需手动打包。这也意味着,Transformer 模型中默认的 4 维张量顺序(通常为(序列数、令牌序列长度、维度 1、维度 2)),在 GGML 中需反转为(维度 2、维度 1、令牌序列长度(n_seq_tokens)、序列数(n_seq))。
2. 矩阵乘法:GGML 的矩阵乘法逻辑也不同。GGML 中的 matmul (A, B) 对应 Transformers 中的 transpose (B) @ A,或等价于 transpose (matmul (A, B)) = transpose (A) @ B。其他多数运算与 Transformers 一致,矩阵乘法是主要差异点。
模型准备
构建图前,需先加载模型。我们已完成张量转换,接下来需将其加载到分层模型结构中。首先,在 llama-model.cpp 的 load_hparams () 方法中,将超参数加载到 GGML 的 hparam 结构体中。然后,在 load_tensors () 方法的大型 switch 语句中加载张量本身。所有转换后的张量均需在此加载,否则加载器会抛出错误。同时,需根据超参数指定张量维度。这是转换过程的首次实际验证:确认转换后的张量能否以符合模型参数语义的正确维度加载。
最后,若模型有新的尺寸规格,需更新模型尺寸枚举(enum)!
图构建
这是最复杂的环节。首先需注意,GGML 已内置大语言模型前向推理的常用功能实现,如 RoPE(旋转位置编码)、专家路由、标准注意力机制、KV 缓存处理、输入位置嵌入等。除非确定目标架构的实现逻辑完全不同,否则建议直接使用这些内置函数。无需逐行复刻 Transformers 代码 —— 只需理解其核心构建模块即可。
多数典型张量运算在 GGML 中均有对应实现,以下为常用运算对照表:
● 矩阵乘法(@)→ ggml_mul_mat(需注意上述维度差异);若权重投影需支持 LoRA,可使用 build_mm_lora。
● 标准按元素乘法 → ggml_mul(支持广播,但仅单维度)。
● 重复 → ggml_repeat_4d(将张量广播至指定形状)。
● 填充 → ggml_pad(语义与 Transformers 不同:ggml_pad 仅支持后填充,需指定各维度填充值;而 Transformers 的 pad 通常仅对语义维度填充,且支持前填充和后填充。若需前 / 后填充,可参考 ggml_pad_ext)。
● 重塑 → ggml_reshape_(1|2|3|4) d(对应 1/2/3/4 维重塑)。
● 连续内存 → ggml_cont(也可使用 ggml_cont_(1|2|3|4) d,等价于重塑 + 连续内存处理)。
● 转置 → ggml_permute 或 ggml_transpose:前者接受维度排列元组,指定各维度的目标位置(如 ggml_permute (ctx, t, 3, 0, 1, 2) 表示维度 0→3、1→0、2→1、3→2);后者仅交换前两个语义维度。
● 标量乘法与加法 → ggml_scale(标量乘法)、ggml_scale_bias(标量乘加)。
● 指数运算 → ggml_exp。
● 新建零张量 → ggml_new_tensor_(1|2|3|4) d(默认初始化为零)。
● 新建全 1 张量 → ggml_exp (ggml_new_tensor)(利用 e^0 = 1 的特性)。
● 张量切片 → ggml_view_(1|2|3|4) d:需指定步长(每个维度上获取下一个元素所需的字节偏移量)和起始偏移。建议使用 ggml_element_size 和 ggml_nelements 动态获取参数,避免使用固定数据类型。除非是特殊切片场景,传递原始张量的步长(存储在 t->nb 数组中)即可。张量维度(对应 Transformers 的 .shape)可通过 t->ne 获取(完整维度为 (t->ne [0], t->ne [1], t->ne [2], t->ne [3]))。
新增运算
若所需运算未被 GGML 实现,可尝试通过等效转换规避,或直接新增运算。新增运算本身是一个独立话题,标准流程是先实现 CPU 后端作为参考版本,其他后端的优化可后续通过单独 PR 提交。运算逻辑是图计算和推理执行时的实际执行代码。编写运算时,需脱离抽象层,直面指针运算、内存分配、手动遍历张量维度等底层操作。注意:库中提供了部分抽象工具,例如针对纯一元运算(元素级独立运算,无额外参数,如 NEG 或 EXP)或二元运算(两个张量的元素级配对运算)的 C++ 模板。新增运算需执行以下步骤:
1. 实现运算方法(按规范命名为 ggml_):准备结果张量并传递参数(参数可存储在 t->src 数组中作为源张量,或存储在 t->op_params 专属参数数组中)。
2. 在 ggml.h 和 ggml.c 中添加运算定义,记得更新静态计数断言;若为纯一元运算,无需添加到 ops 枚举中,需添加到一元运算枚举(unary ops enum)。
3. 在 ggml-cpu.c 的 switch 语句中添加运算调用,并在 ops.cpp 或 unary-ops.cpp 中实现运算逻辑。
4. 在 test-backend-ops 中添加基础测试用例。
选择哪种 RoPE?
这是一个看似简单却易出错的问题。RoPE 主要分为两种类型:标准 RoPE 和 “NeoX 版 RoPE”,但区分方式并不直观。查看 Transformers 实现的 apply_rotary_pos_emb 函数,通常如下:
original_dtype = q.dtype
cos = cos.unsqueeze(unsqueeze_dim)
sin = sin.unsqueeze(unsqueeze_dim)
# 交错排列,非标准形状处理
cos = cos[..., : cos.shape[-1] // 2].repeat_interleave(2, dim=-1)
sin = sin[..., : sin.shape[-1] // 2].repeat_interleave(2, dim=-1)
q_embed = (q.float() * cos) + (rotate_half(q).float() * sin)
k_embed = (k.float() * cos) + (rotate_half(k).float() * sin)
return q_embed.to(original_dtype), k_embed.to(original_dtype)
关键判断依据是以下两行代码:
cos = cos[..., : cos.shape[-1] // 2].repeat_interleave(2, dim=-1)
sin = sin[..., : sin.shape[-1] // 2].repeat_interleave(2, dim=-1)
若存在这两行代码,则为标准 RoPE;若不存在,则为 NeoX 版 RoPE。在 llama-model.cpp 末尾的 switch 语句中标记对应的 RoPE 类型。
调试
至此,代码编写基本完成!接下来是繁琐的功能验证环节。至少需通过 examples/model-conversion 中的日志概率对比测试。还需验证模型能否生成连贯的长文本输出,以及正确处理长输入。若短提示处理时你的实现与参考版本完全一致,但生成阶段出现偏差,通常原因是:(a) 状态管理错误(在 mamba 或混合架构模型中尤为常见);(b) RoPE 配置错误(参考上述 RoPE 类型选择,同时确保 RoPE 超参数正确,尤其是使用 YaRN 时)。根据经验,数值偏差的主要原因是张量形状和 / 或转置处理错误,这类错误会快速导致输出结果偏离。对比张量日志时,可通过检查左上角和右下角元素(如 (2, 0)、(1, 0)、(0, 0)、(0, 1)、(0, 2) 及对应右下角元素)快速定位明显的转置或张量顺序问题。单令牌处理的张量日志可通过 llama-eval-callback 便捷获取;若需调试长序列,可将 llama-eval-callback 的回调代码复制到 llama-cli 中,并通过传递相应参数启用回调(详见 eval-callback.cpp)。
提示词模板
以为日志概率完全匹配就大功告成了?并非如此。若模型使用非标准思考标记、工具调用标记,或两者兼具,需在 chat.cpp 和 llama-chat.cpp 中添加对话模板支持。具体包括:
● 基于 Jinja 模板的独特片段,实现对话格式的自动识别。
● 为强制模型输出特定格式(如工具调用指令)添加语法规则(grammar)。
● 实现模型输出内容的解析器。
● 确保 Jinja 模板完全兼容:Llama.cpp 使用 Minja



