ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AISTransforemr数据代码:从语料到权重的训练链路

AISTransforemr数据代码:从语料到权重的训练链路

简介:这份面向人工智能与机器学习开发者的AISTransformer数据代码包,围绕原始数据的清洗、特征工程与格式转换展开,适合具备一定Python基础、希望理解数据预处理管道的学习者参考。压缩包共257个文件,约189KB,主体为233个CSV数据表,可用于观察分批次数据样本与转换前后结构;另含12个Python源码、9个pyc字节码、1个xls表格、1个txt文本和1个pkl序列化文件,分别承担处理逻辑、运行缓存、参数说明与中间对象保存等用途。已有799人学习。读者可据此梳理模块划分、函数与类的组织方式,结合CSV样本和脚本理解缺失值处理、特征编码、数据集构建等环节,并参考配置与测试内容复现数据转换流程;pkl与xls等文件也有助于对照中间结果和参数记录,适合用作数据预处理项目的结构范本与排错参照,也能帮助理解软件或插件形态的数据工具如何落地。

1. AISTransforemr数据代码是什么:一条从语料到权重的完整训练链路

解压包里那份东西,乍看像论文复现代码,实际上是围绕「数据」和「Transformer」两块拼起来的一整套工程件:清洗脚本管语料,分词器配置管词表,打包脚本把变长样本压成定长张量,再往上接训练入口和推理插件。包名里的 Transforemr 多了个字母,不影响它想解决的问题——让你手上几十 GB 的原始文本,经过一条固定管线变成能喂进模型的 token 流。它适合两类人:手上有语料但不知道怎么做成训练集的,以及模型结构已经会了、卡在数据环节反复踩坑的。软件层面它是一个可改的骨架,插件层面它留了推理侧的挂载点,两头的代码风格一致,改起来不用来回切思维。真正值钱的不是模型定义那几十行,而是数据侧的清洗阈值、去重策略和掩码拼装逻辑,这些才是决定你训出来的东西是好是坏的隐形分水岭。

2. 拆包与运行环境:三条命令验证这套数据代码能不能跑

环境这关最容易出问题,不是装不上,是装上了但版本组合不对,跑起来不报错、结果全乱。这套代码涉及三个互相耦合的部分:张量计算框架、分词器工具链、数据加载库。任意一个版本偏了,症状都是训练 loss 不动或者直接 NaN,看起来像模型问题,实际是环境问题。先花二十分钟把依赖钉死,比后面调试三天划算。

2.1 先看目录再装依赖:认准四个入口

拿到包之后不要急着 pip install,先把目录摊开看一遍。常见做法是分成数据、模型、配置、脚本四大块,我一般先确认这四块是否齐全,缺哪块说明这个版本还没做完。

目录/文件承担的角色改动频率
data/或scripts/prepare_*.py清洗、去重、分词、打包的产出与脚本高,几乎每个项目都要改
configs/模型结构、训练超参、数据路径的集中配置中,主要调超参
model/Transformer 主体、注意力实现、位置编码低,除非改结构
train.py/infer.py训练循环与推理入口,插件挂载点通常在这里低到中

目录结构决定你后面改哪里。我见过有人直接把清洗逻辑写进train.py的数据加载函数里,第一次跑没问题,换一份语料就彻底乱了——清洗和训练耦合在一起,中间没有可检查的产物,出了问题只能靠猜。这套代码把环节拆开,好处是你可以在每一步停下来看输出;坏处是路径配置分散在几个文件里,第一次跑通要先理一遍。

判断一份数据代码靠不靠谱,有个很便宜的方法:看它有没有中间产物落盘。清洗完有没有clean.jsonl,分词后有没有vocab.json和merges.txt,打包后有没有.npy或.bin的索引文件。有中间产物,说明作者考虑过调试和复用;全是内存里一条流水线冲到底,那基本是 demo 级代码,用之前要有心理准备。

2.2 依赖版本与显存门槛:别拿消费卡硬扛

依赖上最容易翻车的是分词器库和张量框架的版本错配。分词器训练出来的词表格式、特殊 token 的 id 分配,在不同大版本之间可能变。我的习惯是先建独立环境,再装框架,最后装分词器和数据处理库,装完立刻打一次版本快照。

# 独立环境,避免和机器上已有的框架版本互相污染 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate python -m pip install -U pip # 框架先装,C 扩展会跟着编译 pip install torch --index-url https://download.pytorch.org/whl/cu121 # 再装分词器与数据侧依赖 pip install tokenizers datasets numpy tqdm pyyaml tensorboard # 打完快照,以后换机器直接照这个装 pip freeze > requirements.lock.txt

--index-url这一行是按你机器的 CUDA 版本来的,显卡驱动新不代表能跑最新的 CUDA 包,装之前用nvidia-smi看一眼右上角的版本号,再去对应版本号找安装源。这一步写死以后,换机器复现的成本会降很多。requirements.lock.txt不要写成requirements.txt,前者是精确版本,后者通常是范围约束,两种都要留,但复现时只认前者。

显存这块给个粗略口径:参数规模乘 2 是权重占的字节数(16 位精度),再乘 4 左右是优化器状态加梯度的开销,最后加上激活值,激活值跟 batch size 和序列长度成正比。一个 1 亿参数级别的模型,序列长度 1024、batch 8 的情况下,常见配置在 16GB 卡上是能跑的;序列长度拉到 2048 就要开梯度检查点或者减小 batch。不要按「参数量除以多少等于显存」这种口诀算,激活值的波动很大,实测一次比算十次可靠。

提示:先在配置里把seq_len调到 256、batch 调到 2,跑通再往上加。直接按目标配置起跑,报错信息经常是 OOM,掩盖了真正的其他问题。

2.3 三条最小验证命令:从 import 到跑通一个 batch

装完先别急着训练,用三条命令把风险点逐个掐掉。第一条验环境,第二条验数据,第三条验一步前向。任何一条失败,问题范围都缩得很小。

# 1) 环境自检:框架版本 + 显卡是否可见 + 分词器能否加载 python -c " import torch, tokenizers print('torch', torch.__version__) print('cuda', torch.cuda.is_available(), torch.cuda.device_count()) print('tokenizers', tokenizers.__version__) " # 2) 数据自检:走一遍清洗+打包,产出中间文件但不训练 python scripts/prepare_data.py \ --input data/raw/corpus.jsonl \ --output data/processed \ --vocab_size 32000 \ --seq_len 512 # 3) 单步自检:只跑一个 batch 的前向+反向,不写日志不存盘 python train.py --config configs/tiny.yaml --dry_run --max_steps 1

第一条命令输出里cuda后面是False的话,八成是装成了 CPU 版,回去看pip freeze里框架包名带不带+cu后缀。第二条命令跑完,去data/processed里数一下文件:词表、打包后的样本、样本索引,三个都应该有,样本数应该等于清洗后语料条数除以 packing 之后的聚集程度,明显偏少说明有个环节在静默丢数据。第三条命令是我最看重的一步,--dry_run这个开关如果代码里没有,自己加一个也不费事,它的价值在于把「配置错」和「训练崩」这两种完全不同的故障分开——一步能过,后面崩就是训练逻辑或数据分布的问题;一步都过不去,就是配置和形状对不上。

注意:--dry_run跑完记得检查有没有残留的 checkpoint 目录,有些实现不管开关状态都会存一次盘,把干净的输出目录污染掉。

3. 数据管线:从原始语料到 token 张量的五个环节

数据这条链子是整份代码里最值得逐行读的部分。模型结构大差不差,能不能训出东西取决于进来的是什么。这条管线拆开看是五步:归一化、过滤、去重、分词、打包。每一步都有阈值,阈值不是拍脑袋定的,得看你的语料是什么类型。网页抓取的语料和领域文档的语料,短句比例差一个量级,用同一套过滤规则,前者会被砍掉一大半,后者会漏进一堆噪声。

3.1 语料清洗与去重:先把脏数据拦在门口

归一化先做,去重后做,顺序反了会白白多做一轮计算。归一化的核心是把视觉上一样但编码不同的字符统一掉:全角半角、零宽字符、连续空白、Unicode 兼容形式。这一步看着琐碎,但漏了它,去重阶段的命中去重率会虚高,分词阶段会出现大量低频变体。

import unicodedata import re import hashlib ZERO_WIDTH = dict.fromkeys(map(ord, "\u200b\u200c\u200d\ufeff"), None) def normalize(text: str) -> str: # NFKC 把全角字母、兼容字符统一成标准形式 text = unicodedata.normalize("NFKC", text) # 去掉零宽字符,这类字符肉眼不可见但会拆碎 token text = text.translate(ZERO_WIDTH) # 连续空白压成一个空格,换行统一成 \n text = re.sub(r"[ \t\u00a0]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def exact_dedup(lines, seen=None): """精确去重:按归一化后的内容做 sha1 指纹""" seen = seen if seen is not None else set() for line in lines: norm = normalize(line) if len(norm) < 32: # 太短的样本对语言建模贡献低 continue h = hashlib.sha1(norm.encode("utf-8")).hexdigest() if h in seen: continue seen.add(h) yield norm

normalize里三条正则的取舍值得说:空白压缩能显著降低 token 浪费,但如果你训的是代码语料,\t有语义,压掉会破坏缩进,那种场景要单独开分支。len(norm) < 32这个阈值是经验值,网页语料里短片段占比很高,砍掉它们能让有效 token 密度明显上升;领域文档里短句往往是关键结论,这个阈值就要往下调或者干脆关掉。

精确去重只能处理完全一致的重复,改一个标点就绕过去了。语料规模上到几十 GB,必须补一层近似去重。常见做法是 MinHash 加局部敏感哈希,把每段文本切成 n-gram 集合,算签名后按 band 分组,同一组里落进两条以上的就判为近似重复。参数上,n-gram 取 5 到 9 之间,签名长度 128 或 256,band 数一般取 16 到 32。band 越多召回越高、误杀也越多,实际调的时候先在小样本上人工看二十对判为重复的文本,能接受再全量跑。

过滤环节容易被忽略的是语言识别和符号比例。一份多语种混在一起的语料,不做语言过滤直接训,模型会在几种语言之间反复横跳,效果被摊薄。符号比例指的是标点、数字、特殊字符占字符总数的比值,超过某个比例(我一般设 0.35)基本是噪声页或者表格残渣,直接丢。

3.2 分词器训练与词表对齐:特殊 token 是最容易翻车的地方

分词器这块,血泪经验大多集中在特殊 token 的 id 上。训练脚本、推理插件、评测脚本三处如果对特殊 token 的 id 理解不一致,症状极其隐蔽:训练正常收敛,推理输出开头多一段乱码,或者句子被截断。

from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders, processors SPECIAL = ["<pad>", "<unk>", "<bos>", "<eos>"] tok = Tokenizer(models.BPE(unk_token="<unk>")) tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) tok.decoder = decoders.ByteLevel() trainer = trainers.BpeTrainer( vocab_size=32000, special_tokens=SPECIAL, # 顺序即 id 顺序,<pad> 为 0 min_frequency=2, # 只出现一次的子词不进词表 initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), show_progress=True, ) tok.train_from_iterator(iter_corpus("data/processed/clean.jsonl"), trainer) tok.post_processor = processors.TemplateProcessing( single="<bos> $A <eos>", special_tokens=[("<bos>", tok.token_to_id("<bos>")), ("<eos>", tok.token_to_id("<eos>"))], ) tok.save("data/processed/tokenizer.json")

special_tokens的传入顺序决定 id,<pad>排第一意味着 id 0 是填充位——这一点必须和模型里的padding_idx=0对上,对不上就是梯度从填充位回传,训练早期 loss 下降然后卡住。min_frequency=2是控制词表噪声的,语料小的时候设 1 会让词表塞满只出现一次的子词,模型记不住这些 id,等于浪费嵌入维度。initial_alphabet保留字节级字符集,作用是让任何 UTF-8 输入都能被编码,不会因为遇到生僻字整句变成<unk>。

TemplateProcessing这段是往每条样本头尾加起止符,很多人在这里犯的错是训练时加了、推理时忘了加。模型在训练时见到的分布永远是「以 bos 开头」,推理时不给 bos,它就得自己猜开头,输出质量下滑得很明显。我的做法是把模板相关逻辑封装成一个函数,训练和推理都调这一个,不在两处各写一遍。

提示:词表训完,用几条中文、英文、数字混合的句子手工 encode 再 decode 一遍,看能不能原样还原。还原不了说明字节级回退没配好,后面会出现大量<unk>。

3.3 样本打包与注意力掩码:短样本拼车省一半算力

变长样本直接按 batch 喂,短样本会把整个 batch 的有效计算拉低。打包(packing)的思路是把多条短样本首尾相接填满定长序列,再用注意力掩码把不同样本隔开。打包做得好,同样的硬件能多跑将近一倍的样本;做得不好,模型会学到跨样本的虚假依赖,症状是训练 loss 降得比不打包还慢。

import numpy as np def pack_sequences(seqs, seq_len, pad_id=0, bos_id=2, eos_id=3): """把变长 token 序列拼进定长窗口,同步产出 segment id""" out_ids, out_seg, buf, seg = [], [], [], [] for s in seqs: # 每条样本补上起止符,保证拼接后边界可识别 piece = [bos_id] + s + [eos_id] if len(buf) + len(piece) > seq_len: # 放不下就先把当前窗口封口 pad = seq_len - len(buf) out_ids.append(buf + [pad_id] * pad) out_seg.append(seg + [-1] * pad) # -1 表示填充位,不算损失 buf, seg = [], [] buf.extend(piece) seg.extend([len(out_ids)] * len(piece)) # 同一条样本共享 segment id if buf: pad = seq_len - len(buf) out_ids.append(buf + [pad_id] * pad) out_seg.append(seg + [-1] * pad) return np.asarray(out_ids, dtype=np.int32), np.asarray(out_seg, dtype=np.int32)

这段代码的关键在seg数组:每条原始样本分配一个 segment id,被拆到不同窗口的部分用窗口序号区分。有了它就能构造块对角注意力掩码,让 token 只能看到同一 segment 内的内容。-1是填充位标记,算交叉熵时用ignore_index=-1跳过。这两处约定要和模型代码、损失函数保持同步,三处任意一处不一致,模型就会在填充位上做无用计算,或者跨样本串味。

seq_len和打包率是一对矛盾。窗口开大,打包率高、吞吐好,但显存压力大;窗口开小,显存友好,短样本的浪费会重新出现。实操里我会先统计清洗后语料的长度分布:如果 90 分位数远小于目标窗口,说明窗口开大了,改用更小的窗口或者按长度分桶打包。分桶打包比顺序打包多一步排序,但能把浪费压得更低,代价是训练样本顺序被打乱,需要在 epoch 之间重新 shuffle 分桶。

4. 训练脚本:配置项、混合精度与断点续训

模型那部分代码读起来反而轻松,结构都是标准件:多头注意力、前馈、残差、归一化。真正需要花时间理解的是配置项之间的耦合关系,以及训练循环里那些不起眼但决定成败的细节——学习率预热多少步、梯度裁剪阈值设多少、什么时候存盘。

4.1 配置项逐个看:哪些能动,哪些动了必崩

配置集中在一个 yaml 里,好处是实验可追溯,坏处是很多项改一个就得连带改另一个,改漏了报出来的错指向性很差。

配置项含义调整建议
d_model隐藏维度必须是n_head的整数倍,改完要同步改注意力头数
n_head注意力头数单头维度低于 32 时效果明显变差,宁可减头数也别减维度
n_layer层数与d_model在算力预算内权衡,深层窄模型和浅层宽模型表现不同
seq_len训练窗口长度改这个必须重跑打包,否则索引全错
lr峰值学习率与 batch 大小正相关,batch 翻倍时通常乘 1.4 左右
warmup_steps预热步数取总步数的 1% 到 5%,太短容易早期发散
grad_clip梯度裁剪阈值1.0 是稳妥起点,训练不稳时先降它再查数据

d_model和n_head的整除关系是最常见的启动失败原因,报错通常是一句形状不匹配,看不出根因。seq_len改了必须重跑打包这一步,很多人只改配置不重跑数据,训练照常启动但样本和位置编码对不上,训出来的模型位置感知是错乱的。这类问题不会报错,只会让你在评测时发现长文本表现异常,回头找原因成本极高。

lr和 batch 的联动也要留意。单卡 batch 8 的配置直接搬到 batch 64 上,学习率不动,训练早期会明显不稳;反过来 batch 变小不改学习率,收敛会慢到让人怀疑代码有问题。我一般会在配置里写两个相关字段batch_size和lr,旁边留一行注释标注两者的基准比例,下次改的时候有个参照。

4.2 混合精度、梯度累积与有效 batch

单卡显存不够时,梯度累积是最直接的解法:把一个大 batch 拆成若干 micro-batch 顺序前向反向,梯度累加够步数再更新一次参数。这里有个容易忽略的点——损失要除以累积步数,否则等效学习率会被放大。

import torch accum_steps = 8 # 等效 batch = micro_batch * accum_steps * 卡数 scaler = torch.amp.GradScaler("cuda", enabled=True) optimizer.zero_grad(set_to_none=True) for step, batch in enumerate(loader): with torch.autocast("cuda", dtype=torch.float16, enabled=True): logits = model(**batch) # 除以累积步数,保证等效学习率和单步大 batch 一致 loss = criterion(logits.view(-1, logits.size(-1)), batch["labels"].view(-1)) loss = loss / accum_steps scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.unscale_(optimizer) # 先反缩放再裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) if step % 50 == 0: # 记录的是未除过的真实 loss,方便和单卡结果对比 writer.add_scalar("loss/train", loss.item() * accum_steps, step)

scaler.unscale_这一行不能省。混合精度下梯度被放大了若干倍,直接裁剪相当于把阈值按缩放倍数缩小,梯度会被过度裁剪,训练变得极慢且难以收敛,而且不报错。set_to_none=True相比清零是省一次显存写入,长训练里累积效果不小。日志里乘回accum_steps是为了让曲线可读,否则你会看到 loss 只有实际值的八分之一,误判收敛情况。

还有一个细节:autocast的dtype用float16还是bfloat16,取决于硬件。前者需要梯度缩放兜底,后者动态范围大、一般不用缩放,但老卡不支持。代码里我一般写成可配置,跑之前按硬件选,比写死一种然后换环境就崩要稳。

4.3 断点续训与日志:给自己留一颗后悔药

长训练最怕的是跑到一半机器重启,或者跑了三天发现学习率设错。断点续训的价值不只是省时间,更重要的是让你能回到某个中间状态做对比实验。存盘要存全:模型参数、优化器状态、学习率调度器状态、当前步数、随机数状态。

def save_ckpt(path, model, optimizer, scheduler, step, best_loss, rng_state): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict() if scheduler else None, "step": step, "best_loss": best_loss, "rng": rng_state, # 保证续训后数据顺序可复现 }, path + ".tmp") # 先写临时文件再重命名,避免断电时留下半个坏文件 import os; os.replace(path + ".tmp", path) def load_ckpt(path, model, optimizer, scheduler): ckpt = torch.load(path, map_location="cpu") model.load_state_dict(ckpt["model"]) optimizer.load_state_dict(ckpt["optimizer"]) if scheduler and ckpt.get("scheduler"): scheduler.load_state_dict(ckpt["scheduler"]) return ckpt["step"], ckpt["best_loss"], ckpt.get("rng")

先写.tmp再os.replace这个习惯,是从一次断电事故之后养成的——直接覆盖写,写到一半断电,文件长度是对的但内容残缺,加载时抛一个来源不明的反序列化错误,比丢一个文件还难查。存盘频率别太密,大模型一次存盘能卡住训练十几秒,按时间间隔存比按步数存合理,比如 30 分钟一次,另外在验证指标刷新时单独存一份 best。

日志方面,至少要记这几个量:训练 loss、学习率、梯度范数、每步耗时、有效 token 数。梯度范数是最灵敏的健康指标,它在训练早期突然飙高,往往意味着某个 batch 的数据异常,比等 loss 发散再回头找要早很多。每步耗时能帮你判断瓶颈在数据加载还是计算,耗时忽高忽低基本是数据加载器没开多进程或者预处理太重。

5. 排查清单:六个把训练跑挂的坑

下面这些是我在这类数据代码上反复遇到的,按启动阶段和数据阶段分两类。每一条都按现象、原因、解决写,症状对得上就直接照做,不用从头推。

5.1 启动与显存类

现象一:启动不到十步就 OOM,减了 batch 还是 OOM。

原因通常不是 batch 本身,而是序列长度和梯度检查点没开。打包之后所有窗口都是定长的,seq_len从 512 提到 1024,激活值按平方级别增长,batch 减半根本不够抵消。还有一种可能是验证集前向没关梯度,验证阶段显存占用和训练差不多,第一次验证就崩。

解决:先把seq_len降回 512 确认能跑,再开梯度检查点(代价是训练慢 20% 左右),最后才考虑加卡。验证循环外面套torch.no_grad(),这一句漏掉的情况我见过不止一次。

现象二:--dry_run能过,正式训练第一次验证就报形状错误。

原因是验证集的构造和训练集走了两条不同路径,常见于验证集没经过打包,仍是变长张量,而模型期待定长输入;或者验证集的pad_id和训练集不一致。这种错误只在验证阶段暴露,因为训练阶段的 collate 函数被单独实现过。

解决:验证集复用训练集的打包函数,只是不 shuffle。在 dataloader 外面加一次断言,检查 batch 里最大长度是否等于seq_len,早失败比晚失败好。

现象三:多卡启动后 loss 曲线和单卡完全不一样,收敛慢很多。

原因是梯度累积和多卡并行叠加时,等效 batch 算错了,或者分布式采样没有去重,每张卡都看到了全量数据的一部分但 epoch 定义错位。另一种情况是混合精度的缩放器每张卡各自维护,没有同步。

解决:先把卡数设为 1 验证配置本身正确,再逐级加卡;打印每次参数更新的实际样本数,和配置里的等效 batch 对照。分布式采样器要设set_epoch,每个 epoch 换一次种子,否则每轮顺序完全一样。

5.2 数据与收敛类

现象四:loss 平稳下降一段后突然 NaN,重启后能继续,但过一阵又 NaN。

先查数据,不要先怀疑模型。常见原因是某个 batch 里全是填充位,交叉熵在ignore_index覆盖不到的位置算出了 log(0);或者清洗阶段漏掉了超长文本,分词后长度爆掉,位置编码越界。

解决:在 loss 计算前加一个断言,检查有效 token 数大于零;对输入长度做硬截断,截断阈值设成seq_len减特殊 token 数量。定位时可以保存出问题那一步的 batch 到本地,单独跑一遍复现,比在完整训练里猜快得多。

现象五:训练 loss 一路降,验证 loss 从第一步就开始涨。

这是典型的词表和数据分布不匹配。分词器在一个域的数据上训练,训练集和验证集来自不同域,验证集的 token 大量落到<unk>,模型等于在看噪声。另一种可能是验证集里有训练集没有的语言或格式。

解决:把验证集用分词器编码一遍,统计<unk>占比,超过 1% 就要重新审视词表覆盖。词表大小在 32000 这个量级时,中英混合语料通常够用,纯代码语料或者小语种要单独训词表。

现象六:模型能生成,但输出重复、结尾停不下来。

位置编码的实现和训练长度不一致是最常见的原因。训练用 512,推理时直接喂 2048,外推之后注意力分布偏掉,模型开始循环输出。另一种是eos的 id 在推理时被错误映射,模型永远见不到终止符。

解决:推理长度不超过训练长度,需要更长就改训练窗口重训,或者换支持外推的位置编码方案。生成时加重复惩罚只是缓解,根因还在位置编码。检查eosid 的办法很简单,手工构造一条带eos的序列喂进去,看模型在对应位置给的 logit 是不是最高的几个之一。

注意:以上六条里,有四条的根因在数据侧而不是模型侧。遇到训练异常,先把最近一个 batch 的内容 decode 出来读一遍,这个动作花两分钟,能省掉大半的排查时间。

6. 进阶技巧:权重导出、量化与上线前的那次自检

训练跑通只是中间态,真正要交付的是能挂到推理插件上的权重。导出这一步有两个坑:一是训练时的模型包装(比如分布式封装、混合精度封装)会往state_dict的键名里塞前缀,直接加载到干净的模型上会全部 key 不匹配;二是分词器文件和权重文件要一起走,单拎权重出去,对方拿到的 id 映射是错的。

我的做法是导出时剥掉前缀并顺手做一次等价性校验:

import torch def export_weights(ckpt_path, out_path, model, prefix="module."): sd = torch.load(ckpt_path, map_location="cpu")["model"] # 剥掉分布式/编译封装加的前缀,只留模型本身的键 clean = {k[len(prefix):] if k.startswith(prefix) else k: v for k, v in sd.items()} model.load_state_dict(clean, strict=True) # strict=True 才能暴露漏键 torch.save({"model": clean, "meta": {"dtype": "fp16"}}, out_path) def sanity_check(model, tok, prompts, max_new=32): """上线前跑一遍:编码-生成-解码,看输出是否可读""" model.eval() for p in prompts: ids = tok.encode(p).ids with torch.no_grad(): out = model.generate(torch.tensor([ids]), max_new_tokens=max_new) print(p, "->", tok.decode(out[0].tolist(), skip_special_tokens=True))

strict=True这个参数别图省事改成False。改成 False 之后漏掉的键会被静默忽略,模型照常能跑,只是某几层还是随机初始化,输出质量掉一截但没有任何报错——这是最难查的一类问题。导出后立刻用几条覆盖不同长度的 prompt 跑一遍生成,中文、英文、带数字的各一条,输出能读通再往下走。

量化是压缩显存的常规手段。8 位量化的收益最直接,显存占用大致减半,代价是精度损失,对生成任务来说通常体现在长文本后段的一致性上。量化后必须重新跑一遍上面的自检,而且要对比量化前后的输出,不能只看能不能跑。我一般会准备一组固定的测试 prompt 和对应的参考输出,量化前后各跑一次,人工读几条,重点看有没有突然开始重复、突然换语言、突然截断。

插件侧还有一个容易忽略的点:并发下的批处理。推理插件通常会被多个请求同时调用,如果内部维护了共享的缓存状态而没有做隔离,两个请求的键值缓存会互相污染,表现为输出串味。检查方法很简单,单请求跑一次拿到结果,再并发发两条不同请求,看第一条的输出有没有变化。有变化就是状态没隔离好,把缓存改成随请求传入的对象,不要挂在模型实例上。

从那以后我每次导出权重,都会强制走一遍「剥前缀、strict 加载、三条 prompt 生成、并发一致性」这四步,不管训练日志看起来多正常。这套动作加起来不到十分钟,省下的是上线之后半夜被叫起来回滚的时间。这套 AISTransforemr数据代码里的训练入口和推理插件照着这个流程改一遍,基本能覆盖从本地实验到实际使用的落差。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进