1. 项目概述:从零构建一个可对话的微型大语言模型
最近几个月,我把自己关在实验室里,干了一件在很多人看来有点“自讨苦吃”的事情:从零开始,亲手训练一个参数规模在1B(十亿)级别的小型大语言模型(LLM),并让它最终具备基础的对话能力。这个项目的名字就叫build_MiniLLM_from_scratch。我的初衷很简单,就是想用可控的成本,完整地走一遍现代大语言模型从“出生”到“学会说话”的全过程:预训练、指令微调、奖励模型训练,再到强化学习对齐。目前,我已经完成了前两个核心阶段,得到了一个能进行简单聊天的MiniLLM。
为什么非要自己从头搞?市面上不是有那么多开源的LLaMA、ChatGLM可以用吗?原因在于,直接使用现成的大模型,就像开一辆别人调校好的超级跑车,虽然快,但你不知道引擎盖下每个零件是怎么工作的,遇到坑洼路面(比如业务场景的特定需求)该怎么调整悬挂。而亲手从数据清洗、模型结构搭建、到损失函数调试走一遍,你获得的不仅仅是几个模型权重文件,更是一种对模型“性格”和“能力边界”的深刻直觉。这种直觉,是你在未来做模型优化、领域适配甚至故障排查时,最宝贵的财富。
这个项目基于我维护的bert4torch训练框架,代码力求简洁高效。最大的特色是,训练出来的模型检查点(Checkpoint)可以无缝转换为transformers库的格式,这意味着你不需要任何额外的包装代码,就能直接用 Hugging Face 那套成熟的生态进行推理、部署甚至继续微调。无论是刚入门想理解LLM训练全貌的新手,还是有一定经验想在小规模数据上快速验证想法的研究者,这个项目都能提供一个清晰、可复现的参考路径。接下来,我就把这几个月“炼丹”过程中的设计思路、实操细节、踩过的坑以及收获的经验,毫无保留地分享给你。
2. 核心思路与方案选型:为什么是Llama 2和BERT4Torch?
当我们决定要“造一个轮子”时,第一个问题就是:造一个什么样的轮子?是独轮车、自行车还是汽车?对应到LLM领域,就是选择哪种模型架构。我最终选择了基于Meta 开源的 Llama 2结构来构建我的 MiniLLM。这背后有几个非常实际的考量。
首先,Llama 2 的架构在业界经过了充分的验证,它在效果和效率之间取得了很好的平衡。其核心是 Transformer Decoder 结构,采用了 RMSNorm 预归一化、SwiGLU 激活函数以及旋转位置编码(RoPE)。这些设计使得它在同等参数量下,通常比早期的 GPT 架构表现更好,尤其是 RoPE 对于长文本的建模能力更有优势。对于我们要构建的“微型”模型来说,选择一个高效且强大的基础架构是成功的起点。
其次,生态支持完善。Llama 2 的开源协议相对友好,并且有庞大的社区支持。这意味着有大量的相关工具、优化技术和实践经验可以借鉴。例如,Hugging Face 的transformers库对其有原生支持,这直接满足了我们项目“无缝转换”的核心需求。
那么,框架选什么呢?我选择了用自己的bert4torch。你可能会问,为什么不直接用 PyTorch Lightning 或者 DeepSpeed 这些更流行的框架?原因在于“可控性”和“轻量级”。bert4torch是我自己编写的训练框架,它封装了训练循环、分布式训练、混合精度训练、梯度累积等常用功能,但代码结构非常清晰,没有过多的抽象层。在调试模型,尤其是需要深入查看数据流、梯度变化时,这种透明性带来了巨大的便利。同时,它的设计目标就是让BERT、GPT等模型的训练代码变得极其简洁,通常一个训练脚本的核心代码不到200行,这让我能更专注于模型和数据本身,而不是框架的复杂配置。
注意:关于“重复造轮子”:在工程领域,有时为了深刻理解一个轮子为何这样造,亲手造一遍比单纯使用它价值更大。
bert4torch的诞生源于我早先对BERT模型训练的各种定制化需求,它可能不适合所有人,但在这个特定项目中,它提供了无与伦比的灵活性和调试便利。
在硬件层面,我使用了多张 A800/A100 80GB GPU 进行训练。对于预算有限的研究者或开发者,我也验证了在单张 RTX 4090(24GB)甚至更小的显卡上运行 0.2B 参数版本的可能性。关键在于采用梯度累积(Gradient Accumulation)技术。简单来说,就是当显卡内存不足以放下大的批次(batch)时,我们可以用小的批次多次前向传播,累积梯度后再一次性更新模型参数。这样,在效果上近似于使用了大批次,但显存占用却大大降低。例如,目标批次大小是32,但单卡只能放下8,那么我们可以设置梯度累积步数为4,每次用批次8跑4次,再更新参数。
3. 第一阶段:预训练——给模型“喂”海量文本
预训练,顾名思义,就是让模型通过“阅读”海量的无标注文本,学会语言的统计规律和世界知识。这是大模型之所以“大”和“智能”的基石。这个过程就像教一个婴儿识字和理解基本的语法,但不教他任何具体的任务。
3.1 语料准备:质量与多样性的权衡
我使用的预训练语料总计约634亿个Tokens(可以粗略理解为词语数量)。这些数据来源于多个渠道,以确保知识的多样性:
- Wiki中文百科 & BaiduBaiKe:提供高质量、结构化的百科知识,是模型事实性知识的主要来源。
- C4_zh:来自互联网的大规模通用文本,语言风格多样,覆盖范围极广,能让模型学习到更“接地气”的语言表达。
- WuDaoCorpora:中文悟道开源数据集,数据量巨大,进一步扩充了模型的见闻。
- 医学领域数据:引入特定领域(医学)语料,旨在让模型在该领域具备一些先验知识,虽然在小模型上效果可能不明显,但这是一个有益的尝试。
所有语料都统一使用ChatGLM2-6B 的分词器(Tokenizer)进行处理。这里有一个关键点:分词器的选择会直接影响模型训练的效率和对中文的理解能力。ChatGLM2 的分词器对中文更加友好,词汇表大小适中,能较好地平衡编码效率和语义单元粒度。
实操心得:数据清洗与预处理:原始的网络文本包含大量噪声,如HTML标签、乱码、重复内容、不相关广告等。直接使用会严重污染模型。我的流程包括:去重、基于规则的关键词过滤(去除过于成人或暴力的内容)、语言检测(确保以中文为主)、以及长度过滤(去除过短无意义或过长难以训练的文档)。这个过程极其耗时,但至关重要,是模型质量的“第一道防线”。
3.2 训练配置与超参数选择
我训练了两个版本的基座模型:MiniLLM-0.2B-Base和MiniLLM-1.1B-Base。以 0.2B 版本为例,其核心训练配置如下:
- 模型结构:基于 Llama 2,隐藏层维度为 1024,注意力头数为 16,层数为 22,总参数量约 2亿。
- 序列长度:
maxlen=1024。这是单次训练时模型能看到的上下文长度。更长的序列能更好地建模长距离依赖,但也会显著增加显存和计算开销。 - 批次大小:
btz=32 per GPU,在4张A800上采用数据并行(DDP)训练,全局批次大小即为 32 * 4 = 128。 - 学习率:
lr=1.5e-4,这是一个比较标准的初始学习率。采用了余弦衰减学习率调度器,并设置了warmup_steps=5000。Warmup 是指在训练初期,从一个很小的学习率线性增加到预设值,这有助于训练稳定性,避免初期梯度爆炸。 - 优化器:使用 AdamW 优化器,这是目前训练 Transformer 模型的标准选择,能较好地处理权重衰减。
为什么是这些参数?这些超参数并非凭空想象,而是参考了 Llama 2 原始论文及其他开源小规模模型训练的经验。对于学习率,通常遵循“模型越大,学习率可以越小”的规律,所以 1.1B 模型也使用了 1.5e-4。批次大小的设置主要受限于 GPU 内存,在内存允许的情况下,更大的全局批次大小通常有利于训练的稳定性和最终性能。序列长度 1024 是一个在效果和效率之间的折中选择,足以覆盖大多数段落级的文本。
3.3 训练过程监控与问题排查
训练一个模型动辄数天,不可能一直盯着屏幕。我主要依靠TensorBoard来监控训练过程。你需要重点关注两条曲线:训练损失(Training Loss)和验证损失(Validation Loss)。
- 理想的曲线:训练损失平滑下降,验证损失也随之下降,并且两者之间的差距(泛化间隙)不会过大。这表示模型正在有效地学习知识,且没有严重过拟合。
- 常见问题与排查:
- 损失出现NaN或突然飙升:这通常是梯度爆炸的迹象。首先检查学习率是否过高,可以尝试降低学习率或增加梯度裁剪(Gradient Clipping)的阈值。其次,检查数据中是否有异常值(如无穷大的数字)。
- 验证损失不降反升,而训练损失持续下降:这是典型的过拟合。意味着模型只是记住了训练数据,而没有学会泛化。解决方案包括:增加更多的训练数据、使用更强的正则化(如 Dropout)、或者提前停止训练。
- 损失下降非常缓慢:可能是学习率设置得太低,或者模型架构/初始化有问题。可以尝试适当提高学习率,或者检查模型参数初始化是否正确。
在我的训练中,MiniLLM-0.2B-Base在 4 张 A800 上训练了约 3.79 天。整个过程中损失曲线平滑下降,没有出现异常波动,表明训练过程是稳定的。
踩坑记录:分布式训练中断:在初期使用 PyTorch 的 DDP 进行多卡训练时,偶尔会遇到训练中途进程崩溃的问题,错误信息可能与 NCCL(NVIDIA 集合通信库)有关。一个有效的解决方法是设置环境变量
export NCCL_IB_DISABLE=1,这禁用了 InfiniBand,强制使用 PCIe 进行通信,虽然可能损失一些速度,但极大地增强了稳定性。对于小规模集群训练,稳定性远比那一点速度提升重要。
4. 第二阶段:指令微调——教会模型“听懂人话”
经过预训练的模型就像一个“知识渊博但不会交流的学者”,它满腹经纶,却不知道如何回答你的问题。指令微调(SFT, Supervised Fine-Tuning)的目标,就是通过高质量的“指令-输出”配对数据,教会模型遵循人类指令的格式进行回应。
4.1 指令数据集的构建与混合策略
我收集并整合了多个开源的高质量指令数据集,总计超过1157万条样本。数据来源的多样性至关重要:
- 通用指令:如
alpaca-zh,Belle-0.5M/1M-cn,提供了大量的日常问答、创作、分析等任务。 - 多轮对话:如
moss-002/003-sft-data,Belle-multiturn_chat_0.8M,专门训练模型进行连贯的多轮对话。 - 特定领域:如
Belle-school_math_0.25M(数学)、firefly-train-1.1M(诗歌、对联、文言文等文化任务)、CodeChat(代码)、medical(医学)。这有助于提升模型在垂直领域的能力。 - 自我认知数据:我手动整理了一个小数据集
self_cognition,包含约100条数据,用于告诉模型“你是谁”、“你叫什么名字”、“谁创造了你”等。这能有效防止模型在回答自身相关问题时产生幻觉或胡言乱语。
数据格式的统一:不同来源的数据格式各异,有的用Human: ... Assistant: ...,有的用### Instruction: ... ### Response: ...。在训练前,必须将它们统一成一种格式。我采用了类似ChatGLM2的格式:<human>{query}<robot>{response}。在推理时,只需要将用户输入包装成<human>{query}<robot>,模型就会自动续写出助理的回复部分。
4.2 微调技巧与参数设置
指令微调是在预训练模型的基础上进行的“精雕细琢”,因此训练强度要小得多。
- 学习率:
lr=2e-5。这是一个典型的小学习率,因为模型权重已经在大规模语料上学习得很好,我们只需要对其进行微小的调整。 - 训练轮数:
epoch=5。对于百万级的数据量,3-5个epoch通常足够让模型学会指令跟随,同时避免过拟合。 - 批次大小:根据GPU内存调整。对于 0.2B 模型,单卡 RTX 4090 可以使用
btz=8;对于 1.1B 模型,在 A800 上使用btz=8。 - 仅微调部分参数?:一种常见的节省显存和计算量的方法是 LoRA(Low-Rank Adaptation),即只训练注入到模型中的少量低秩矩阵,而不动原始庞大的预训练权重。在这个项目中,为了追求最佳效果和流程的完整性,我选择了全参数微调。如果你的计算资源有限,强烈建议尝试 LoRA,它通常能以极小的参数量达到接近全参数微调的效果。
一个关键的训练细节:只计算答案部分的损失。在指令微调中,我们的输入是<human>问题<robot>,我们希望模型生成的是答案。因此,在计算损失时,需要将问题部分对应的 token 的损失掩蔽掉(即设置为0),只让模型根据答案部分的 token 来更新权重。这能确保模型学习的核心是“如何回答问题”,而不是“如何复述问题”。
4.3 模型效果评估与对比
指令微调后,模型展现出了质的飞跃。以下是一些对比示例:
预训练模型(续写模式):
- 输入:
“床前明月光,疑是地上霜。举头望明月,” - 输出:
“低头思故乡。”这诗不仅描述了中秋夜月夜的温馨与宁静,还写出了作者对故土深情的眷恋和思乡之情。“月上柳梢头”一语,是写月下所见。“欲将心事付瑶琴”,指欲诉别情; “举头望明月”,写中秋之夜,月上高挂、皓月当空、群星闪耀的景象;“低头思故乡”,写思念故土的深情厚意。这首诗在写作手法上,主要运用象征... - 分析:模型在续写,它根据前文“举头望明月”,本能地接上了“低头思故乡”,并开始对整首诗进行赏析和扩展。这体现了其强大的语言生成和知识联想能力,但它没有“对话”的意识。
- 输入:
指令微调模型(对话模式):
- 输入:
<human>介绍下诗人李白<robot> - 输出:
李白是唐朝著名的诗人,被誉为“诗仙”,被誉为“诗仙”。他的诗歌以豪放、奔放、自由著称,对后世诗歌产生了深远影响。李白的诗歌风格独特,以豪放、奔放、清新、浪漫著称。他的代表作品有《将进酒》、《庐山谣》、《静夜思》等... - 分析:模型明确识别了这是一个指令(介绍李白),并给出了一个结构清晰、信息准确的回答。它不再进行无目的的续写,而是尝试完成一个具体的任务。
- 输入:
可以看到,经过指令微调,模型成功地从“续写机器”转变为了“任务执行者”。当然,受限于模型规模(仅0.2B/1.1B参数)和训练数据量,它无法与千亿参数的 ChatGPT 相提并论,在处理复杂逻辑、数学推理或需要深度世界知识的任务时仍会力不从心,但对于简单的问答、内容生成、摘要等任务,已经具备了相当不错的可用性。
5. 工程实现与代码解析
项目的代码结构非常清晰,主要分为pretrain(预训练)和sft(指令微调)两个目录。这里我挑几个核心的工程实现点进行解析。
5.1 基于BERT4Torch的高效训练循环
bert4torch框架将训练循环抽象得非常简洁。以下是一个简化版的核心训练逻辑:
import torch from bert4torch.models import build_transformer_model from bert4torch.snippets import Trainer, DDPTrainer from bert4torch.optimizers import get_linear_schedule_with_warmup # 1. 定义模型 config_path = ‘config/bert4torch_config.json’ checkpoint_path = None # 从头训练 model = build_transformer_model( config_path=config_path, checkpoint_path=checkpoint_path, model=‘lm’, # 语言模型 application=‘unilm’ # 这里用于因果语言建模 ) # 2. 准备数据 class MyDataset(ListDataset): def __init__(self, data_path): self.data = self.load_data(data_path) def __getitem__(self, index): text = self.data[index] # 使用tokenizer将文本编码为token ids token_ids = tokenizer.encode(text, max_length=maxlen, truncation=True)[‘input_ids’] # 因果语言建模的标签就是输入向右偏移一位 return token_ids[:-1], token_ids[1:] def __len__(self): return len(self.data) # 3. 定义训练器 trainer = DDPTrainer( model=model, train_dataloader=train_dataloader, optimizers=[optimizer], scheduler=scheduler, max_grad_norm=1.0, # 梯度裁剪 use_amp=True, # 混合精度训练,节省显存加速训练 ) # 4. 开始训练 trainer.fit(epochs=num_epochs, steps_per_epoch=steps_per_epoch)关键点在于DDPTrainer自动处理了多卡分布式训练、混合精度、梯度累积和梯度裁剪,你只需要关注模型和数据本身。use_amp=True启用自动混合精度(AMP),这是用较少的显存训练大模型的必备技术,它让模型权重和部分计算保持在低精度(float16)以节省内存和加速,同时在关键部分(如梯度更新)保持高精度(float32)以保证稳定性。
5.2 内存优化:流式读取与动态批处理
处理数百GB的文本数据,不可能一次性全部加载到内存。我采用了流式读取的方式。具体来说,我将所有语料文件路径列在一个清单文件中,训练时,每个进程(每张GPU)独立地、随机地从清单中选取一个文件,读取其中一部分数据进行处理。这种方式几乎不占用额外内存,并且通过随机化保证了数据分布的均匀性。
此外,由于文本长度差异巨大,使用固定的maxlen进行填充会造成大量的计算浪费(短文本填充了大量无意义的 [PAD] token)。我采用了动态批处理策略:在组 batch 时,尽量将长度相近的样本放在同一个 batch 中,然后以该 batch 中最长样本为准进行填充。这显著减少了无效计算,提升了训练效率。
5.3 模型转换:无缝对接Transformers生态
为了让训练好的模型能被广泛使用,我编写了转换脚本(docs/convert.py),将bert4torch保存的模型权重转换为标准的 Hugging Facetransformers格式。转换后的模型可以直接用AutoTokenizer和AutoModelForCausalLM加载。
转换的核心是权重名称映射。bert4torch和transformers对模型层(Layer)的命名约定可能不同。例如,bert4torch中的transformer.h.0.attn.q_proj.weight可能对应transformers中的model.layers.0.self_attn.q_proj.weight。转换脚本需要建立一个准确的映射字典,然后遍历权重文件,进行重命名和格式调整(如维度转置,如果框架间存储方式不同的话)。
# 转换脚本核心逻辑示意 state_dict_bert4torch = torch.load(‘pytorch_model.bin’) state_dict_transformers = {} for key, value in state_dict_bert4torch.items(): new_key = key_mapping_dict[key] # 根据映射字典转换key # 可能还需要进行 value = value.T 等操作 state_dict_transformers[new_key] = value # 保存为 transformers 格式 model_hf = LlamaForCausalLM.from_pretrained(‘meta-llama/Llama-2-7b-hf’, config=config_hf) # 先加载一个空结构的HF模型 model_hf.load_state_dict(state_dict_transformers) model_hf.save_pretrained(‘./minillm_hf’)成功转换后,你就可以像使用任何其他 Hugging Face 模型一样使用 MiniLLM 了,这极大地降低了部署和集成的门槛。
6. 常见问题与实战排坑指南
在长达数月的训练和调试过程中,我遇到了无数个“坑”。这里把最具代表性的几个问题及其解决方案记录下来,希望能帮你节省大量时间。
6.1 训练不稳定,Loss出现NaN
- 现象:训练初期或中期,损失值突然变成 NaN。
- 可能原因与排查:
- 学习率过高:这是最常见的原因。尤其是在训练刚开始的 warmup 阶段,如果学习率增长过快,可能导致梯度爆炸。解决方案:降低初始学习率,或延长 warmup 的步数。
- 数据中存在异常值:比如文本中包含了无穷大或非数字字符,经过分词和嵌入后产生异常值。解决方案:加强数据清洗,在数据加载时加入断言检查。
- 混合精度训练(AMP)不稳定:float16 数值范围较小,在某些极端情况下(如梯度非常大)可能导致溢出。解决方案:尝试使用
torch.cuda.amp.GradScaler并调整growth_interval参数,或者暂时关闭 AMP 进行调试。 - 模型权重初始化问题:某些自定义层的初始化方式不当。解决方案:检查模型初始化代码,确保其符合标准实践(如使用 Xavier 或 Kaiming 初始化)。
6.2 模型输出重复或无意义
- 现象:在推理时,模型不断重复同一个词或句子,或者生成完全乱码。
- 可能原因与排查:
- 采样策略问题:在生成文本时,如果使用的采样温度(Temperature)过低(接近0),模型会倾向于选择概率最高的 token,容易导致确定性重复。如果温度过高,则随机性太强,可能产生乱码。解决方案:调整温度参数(如设为0.7-0.9),或使用 Top-p(核采样)和 Top-k 采样来平衡生成的质量和多样性。
- 重复惩罚(Repetition Penalty)未启用:许多生成库(如
transformers的generate函数)提供了repetition_penalty参数,用于降低已生成 token 的概率,可以有效抑制重复。解决方案:在推理时设置repetition_penalty=1.2。 - 训练数据质量差:如果训练数据中本身就有大量重复或无意义内容,模型会学到这些模式。解决方案:回顾和检查你的训练数据,特别是从网络爬取的数据。
6.3 多轮对话历史处理混乱
- 现象:在进行多轮对话时,模型无法正确引用历史上下文,或者将不同轮次的信息混淆。
- 可能原因与排查:
- 历史拼接格式错误:在将多轮对话拼接成单个序列时,格式必须与训练时保持一致。例如,训练时使用的是
<human>Q1<robot>A1<human>Q2<robot>A2,那么推理时也必须严格按照这个格式拼接历史。解决方案:仔细检查数据预处理和推理前拼接的代码逻辑。 - 上下文长度超限:模型有固定的最大序列长度(如1024)。如果历史对话太长,超出部分会被截断,导致模型丢失早期关键信息。解决方案:在推理时实现一个滑动窗口机制,只保留最近 N 个 token 的历史,或者对过长的历史进行摘要。在训练阶段,也可以尝试增加
maxlen。 - 模型未经过足够的多轮对话数据训练:如果指令微调数据集中单轮问答占比过高,模型自然不擅长处理多轮交互。解决方案:在 SFT 数据中混合足够比例的高质量多轮对话数据。
- 历史拼接格式错误:在将多轮对话拼接成单个序列时,格式必须与训练时保持一致。例如,训练时使用的是
6.4 显存不足(OOM)
- 现象:训练或推理时出现
CUDA out of memory错误。 - 可能原因与排查:
- 批次过大或序列过长:这是最直接的原因。解决方案:减小
batch_size或maxlen。 - 未使用梯度累积:当单卡无法放下目标批次时,必须使用梯度累积。解决方案:在训练器中设置
gradient_accumulation_steps。例如,目标全局批次为32,单卡只能放8,则设置gradient_accumulation_steps=4。 - 未使用混合精度训练(AMP):AMP 可以显著减少显存占用。解决方案:确保训练代码中启用了 AMP(在
bert4torch中是use_amp=True)。 - 激活值占用显存:在前向传播过程中产生的中间变量(激活值)会占用大量显存。解决方案:使用梯度检查点(Gradient Checkpointing),这是一种用时间换空间的技术,它只保存部分层的激活值,在反向传播时重新计算其余部分,可以大幅降低显存消耗,尤其适用于深层模型。在
bert4torch中,可以在构建模型时传入use_gradient_checkpointing=True参数。
- 批次过大或序列过长:这是最直接的原因。解决方案:减小
7. 总结与未来展望
回顾整个MiniLLM从零构建的过程,它更像是一次深度的大模型原理与实践的“沉浸式体验”。从准备数 TB 的原始语料,到设计模型结构、调试超参数,再到处理各种棘手的训练问题,每一步都充满了挑战,但也带来了无与伦比的成就感。
目前项目已经稳定完成了预训练和指令微调两个阶段,产出的 0.2B 和 1.1B 模型虽然能力有限,但作为一个完整的教学范例和轻量级应用的基础,已经足够。你可以用它来学习 LLM 的训练流程,也可以在其基础上,用自己的领域数据(如客服日志、专业文档)进行进一步微调,打造一个专属的领域小模型。
关于后续的“对齐”阶段(奖励模型和强化学习),这确实是让模型行为更符合人类价值观和偏好的关键一步,也是当前研究的热点。我将其列为 Todo,是因为它涉及到更复杂的技术栈(如 PPO 算法)、更敏感的数据标注(需要人类对模型输出的偏好排序)以及更不稳定的训练过程。对于一个小规模实验性项目,在资源有限的情况下,优先把 SFT 做扎实是更务实的选择。如果你对此感兴趣,可以参考 LLaMA-Factory、TRL 等开源库,它们提供了完整的 RLHF 实现。
最后,我想分享一个最深的体会:在大模型时代,动手实践的价值远远大于纸上谈兵。无论你看了多少篇论文,听了多少场讲座,都不如亲手训练一个模型,看着它的损失曲线下降,并与它进行一场笨拙但真切的对话来得深刻。这个项目所有的代码、配置和训练日志都已开源,希望它能成为你探索大模型世界的一块坚实的垫脚石。训练过程中遇到任何问题,也欢迎在项目的 GitHub 仓库中提出,我们一起探讨解决。