一、前言
在大语言模型(LLM)时代,理论学习固然重要,但亲自实践从头构建一个模型的过程,才是真正掌握其核心原理的关键。无论是预训练(Pre-training)还是监督微调(SFT),数据准备还是策略设计,只有通过实际操作,才能深刻理解模型的每一个环节是如何运作的。
目前,Python生态在LLM领域占据了绝对主导地位,各种成熟的库和框架层出不穷。然而,对于C#开发者来说,相关资源却几乎不见。尽管如此,我们仍然可以参考Python生态中的优秀轻量级项目(如MiniGPT、MiniMind等),尝试在C#环境中实现一个简化版的LLM训练框架。
本文将详细介绍如何从零开始,在C#中构建并训练一个轻量级语言模型,包括完整的实现流程、数据处理流程以及训练效果评估。项目的所有代码已开源。另外本项目关于模型设计方面的95%代码都是AI生成的,尽管我们对关键合理性错误进行了检查纠正,但难免存在疏漏,供大家参考,实现效果如下。
希望本系列能以深入浅出的方式,帮助C#开发者快速掌握LLM的实现原理。相较于理论算法,希望更注重工程实践,能让大家看到数据信息数据流转变换的节点、熟悉模型能力的形成过程、了解各种trick的实现意义。
二、环境准备
2.1 项目依赖
本文的MiniLLM项目使用c# .NET10开发。
模型的构建、训练等核心功能使用TorchSharp-cuda-windows(0.105.1)实现,这一版运行比较稳定,新版本我这CUDA会莫名报错。在实现模型的构建、训练等核心功能方面,C#中使用TorchSharp与在Python中使用Torch区别不是很大,表达语法都很相似。由于C#和python语言不同,反射读取与类型转换方面有一些差异,需要额外注意下。
Tokenizer则使用LumTokenizer(1.0.7),在上一篇文章中已经介绍过。
2.2 数据来源
我们直接使用MiniMind(https://github.com/jingyaogong/minimind, Apache-2.0 license)相关数据,包括MiniMind训练好的BPE词表,大小为6400;预训练集 pretrain_hq.jsonl (1.6GB)、SFT训练集:sft_mini_512.jsonl (1.2GB)、SFT训练集:sft_1024.jsonl (5.6GB)
三、实现一个具有对话能力的语言模型的大致步骤
先在预训练过程基于文本预测,先实现一个简单的文本补全模型,然后再加入SFT风格调整,从而使其具备对话功能。
主要功能模块:
- Tokenizer:将文本转换为tokens序列
- Transformer(状态可存盘与加载):模型的核心计算单元,负责处理序列数据的语义关联和特征提取
- DataLoader(状态可存盘与加载):用于加载和批量处理数据,支持并行处理和数据增强
- 预训练:基于文本预测,先实现一个简单的文本补全模型
- SFT-LORA训练:使用设计好的格式来微调模型,使其具有风格并产生对话能力
数据转换过程示意如下:
3.1 预训练
预训练时候,我们给模型的输入是一条条的文本:然后经由Tokenizer转换为tokens序列,模型的输入就是这些tokens序列。- [32,34,11,223,22,11,223,33,44,55,66,77] //瞎填的
复制代码 模型通过对这些token进行自回归预测,学习到了文本的语法、语义和上下文关系。
3.2 SFT-LORA
在SFT过程中,我们使用设计好的格式来微调模型,使其具有风格并产生对话能力:- "<|im_start|>user 今天天气很不错,请萤火初芒作者喝杯咖啡吧?<|im_end|><|im_start|>assistant 好的,请收下!<|im_end|>"
复制代码 模型通过LORA下训练,可以学习到对回答的风格、意图的编码,从而生成符合要求的文本。
3.3 预测
此时对模型再输入:- "<|im_start|>user 今天天气很不错,请萤火初芒作者喝杯咖啡吧?<|im_end|>"
复制代码 对模型输出的tokens进行解码后,我们将获得:- "<|im_start|>assistant 好的,请收下!<|im_end|>"
复制代码 四、模型设计简要介绍
充分参考了MiniMind,本项目MINILLM 中的 Transformer 采用 Llama 风格设计,是模型的核心计算单元,负责处理序列数据的语义关联和特征提取。其结构由多个 LlamaBlock 堆叠而成,通过 ModuleList 组织以支持潜在的 LoRA 微调扩展。
4.1. LlamaBlock 结构
每个 LlamaBlock 采用 Pre-RMSNorm 设计,包含两个主要子模块:
- 自注意力机制 (SelfAttentionRoPE):处理序列内部的依赖关系
- 前馈网络 (FeedForwardSwiGLU):增强模型的非线性表达能力
前向传播流程 :
- 输入张量经过 _attn_norm (RMSNorm)归一化
- 归一化结果输入自注意力模块,输出注意力特征
- 注意力特征与原始输入进行残差连接
- 连接结果经过 _ffn_norm (RMSNorm)归一化
- 归一化结果输入前馈网络,输出特征
- 前馈网络输出与上一步结果进行残差连接
- 返回最终特征
4.2. 自注意力机制(SelfAttentionRoPE)
实现了带 RoPE (旋转位置编码)的自注意力,核心流程:
- 投影层 :将输入通过 _q_proj 、 _k_proj 、 _v_proj 分别映射为查询(Q)、键(K)、值(V)
- RoPE 编码 :对 Q 和 K 应用旋转位置编码,注入位置信息
- 注意力计算 :使用 torch.nn.functional.scaled_dot_product_attention 计算注意力权重并加权求和
- 输出投影 :通过 _o_proj 将注意力输出映射回原始维度
注意 :代码中预留了 GQA(分组查询注意力)的实现框架(通过 _n_kv_head 和 _n_rep 参数),但实际计算中未完全启用分组逻辑。
4.3 前馈网络(FeedForwardSwiGLU)
采用 SwiGLU 激活函数,相比传统 ReLU 具有更强的表达能力:
- 门控机制 :通过 _gate_up 一次性计算门控向量和上投影,减少计算量
- 激活与融合 :门控向量通过 SILU 激活后与上投影结果相乘
- 下投影 :通过 _down_proj 将特征映射回原始维度
- Dropout :在输出前应用 dropout 正则化
4.4. 归一化层(RMSNorm)
实现了 RMSNorm (均方根归一化),相比 LayerNorm 计算更高效:
- 仅计算输入的均方根,无需均值中心化
- 支持序列长度为 0 时的鲁棒性处理
五、模型效果一览
模型大小0.03~4B,权重文件31mb。每次提问都有一定概率胡言乱语(这里就不放出来了),大致提问回答效果见视频和如下:
[code]user 熊猫是猫吗assistant 熊猫是一种哺乳动物,属于食肉目熊科。它们主要分布在中国的四川、陕西和甘肃等地方。熊猫通常被认为是非常可爱的动物,因为它们喜欢吃竹子,而竹子是以竹子为食的。user 为什么白马不是马?assistant 我不知道。user 狮子和老虎谁厉害?assistant 狮子和老虎是两个不同的猫科动物,它们都是哺乳动物。狮子是肉食性动物,主要以竹子为食,而老虎则是食肉动物,以其独特的视力、爪子和牙齿等特征著称。user 如果老鼠变大10倍能打得过猫吗?assistant 不,老鼠不能打得过猫。user 熊猫是什么?assistant 熊猫是一种哺乳动物,属于食肉目熊科。它们主要分布在中国的四川、陕西和甘肃等地方。熊猫通常被认为是非常可爱的动物,因为它们喜欢吃竹子,而竹子是以竹子为食的。user 中国的首都在哪?assistant 中国首都是北京,是中国的首都。user 1+3等于几?assistant 一加三等于3。解题过程:1. 将两加三和三相加,得到:2. 5 + 3 = 4因此,这两个数的乘积为:3. 5 + 3 = 6。所以,这两个数的乘积是:2. 5 - 3 = 7因此,这两个数的乘积为:user 能否写一个Add函数实现加法?assistant 当然可以,以下是一个简单的Python代码示例:```pythondef add_numbers(num): return num * num: if num |