搞 LLM 的人这两年大概都有同一种体感:容易刷的榜基本刷完了,剩下那些真正卡住前沿的问题,一个比一个贵。我们团队过去一年多一直在往科研边界上撞,不是把某个公开基座微调一下、跑几个公开基准、写份报告就完事的那种,而是从假设、数据、训练、评测一路搭到能自洽复现。这个过程里踩过的坑,比我在任何技术文档里读到的都多,也比任何一篇漂亮的结果汇报更值钱。
这篇东西想聊的就是这些坑。它不算综述,也不是教程,而是一个做大模型研究的人把自己走过的路摊开:选题怎么从工程痛点里长出来,数据配比为什么不能拍脑袋定比例,评测分数为什么会系统性虚高,长上下文和推理链这两条最挤的赛道上到底卡在哪,以及那些“实验跑完了但结果对不上”的夜晚该怎么排查。正在做或准备做大模型研究的同学可以直接拿去对照,工程侧想把模型能力真正用起来的开发者也能从中找到可操作的部分。不需要你手上已经有八卡机器,但需要你愿意把每一个数字的来源想清楚。
1. 先把“前沿”拆开:LLM 选题的三个判断标准
1.1 好问题通常长在工程痛点上
我刚进这个方向的时候,总想着从论文里找“没人做过”的空隙,后来发现这条路对绝大多数人来说是死路:真正没人做过的方向,要么是别人试过发现没价值,要么是缺一个关键工具还没到时候。反过来,最靠谱的选题来源是自己训练和推理过程中反复出现的异常现象。
比如我们在做长文档问答时发现,把参考资料从 8K 扩到 32K 之后,模型对开头和结尾的信息利用率明显高于中间段落,这个现象稳定可复现,而且换个模型、换个任务都还在。这就是一个典型的好问题胚子:它有明确的现象、有可测量的指标(不同位置的检索准确率)、有可干预的变量(位置编码方案、注意力实现、训练时的数据排布)。相比之下,“如何让模型更聪明”这种题目看上去宏大,实际上你连第一步该改什么都不知道。
把现象转成研究问题,我一般走四步:先把现象写清楚,包括触发条件和不触发条件;再提一个机制层面的假设,比如“中间遗忘主要来自位置编码的外推误差而不是注意力容量不足”;然后设计一个只改单一变量、其余全部锁死的干预实验;最后预先写下什么结果算支持、什么结果算推翻。这四步里最容易被跳过的是最后一步,而它恰恰是区分“研究”和“调参”的分水岭。
1.2 假设必须能被打脸
我见过太多实验设计成“改了个东西,分数涨了,说明有效”。这种结论没法证伪,也就没有信息量。一个能打脸的假设长这样:如果中间遗忘的主因是位置外推误差,那么在训练长度固定、只替换外推方案的情况下,超过训练长度的检索准确率应该出现一个明确的拐点,而训练长度以内的准确率几乎不动;如果换成只增加注意力头数,拐点不该消失。这个假设里的每一个分句都对应一个可以观察的数值方向,跑完实验你只有“支持”或“推翻”两种结果,没有“再看看”。
预注册指标这件事在工程团队里经常被嫌麻烦,但我强烈建议至少做到两条:评测脚本在开跑前冻结,任何修改都要留记录;每次实验开始前写下预期方向和失败判据。我们内部有个很土的做法,就在实验目录里放一个expect.md,写三行:我改了什么、我预期哪个指标往哪走、如果出现什么现象我就认为假设错了。半年下来,这个文件帮我们省掉的无效算力比任何调优技巧都多。
1.3 基线与对照组:别自己给自己当裁判
选题定完,第二个坑是基线。大模型实验最贵的成本不是算力,是对照组没对齐导致的整轮返工。我总结下来有三条硬对齐:参数量对齐、训练 token 预算对齐、评测脚本与解码参数对齐。少任何一条,你得到的提升都可能是别的因素带来的。
| 对齐项 | 常见跑偏方式 | 后果 | 处理方式 |
|---|---|---|---|
| 参数量 | 对照组用了更大的隐层但层数更少 | 容量差异被误读为方法差异 | 固定总参数量,只改结构 |
| token 预算 | 新方法多训了 20% 的步数 | 提升其实来自训练更久 | 固定总 token 数,早停对齐 |
| 解码参数 | 两组温度、top-p、最大长度不同 | 生成类指标差异不可比 | 评测脚本统一注入配置 |
| 评测版本 | 中途换了基准版本或提示模板 | 分数跳变无法归因 | 冻结脚本并记录哈希 |
还有一条更隐蔽的:数据顺序。同一批数据换个 shuffle 种子,小样本评测上抖一两个点是常态。所以我们在做小规模消融时会固定种子跑三次,报中位数和区间,而不是报单次最好成绩。这个习惯看着笨,但它能让你在汇报时心里有底,别人复现时也不容易翻车。
2. 数据与训练:决定上限的那一段
2.1 配比是带约束的优化问题,不是拍比例
数据配比这件事最容易被玄学化。我的理解很朴素:在固定 token 预算下,配比决定了模型在目标分布上的期望收益,所以它是一个带约束的优化问题,约束是总预算和各来源的可获取量,目标是下游任务的加权表现。既然是优化问题,就有两个推论:第一,配比必须和你的目标分布绑定,做代码能力的配比拿去做长文问答基本无效;第二,配比在训练的不同阶段应该变化。
我们现在的做法是分三段:前期用泛化性强、多样性高的混合数据把基础能力铺开;中期提高目标领域占比,同时保留一定比例的通用数据防止能力塌陷;最后 5% 到 10% 的 token 做退火,把高质量、高难度的样本集中放进去,学习率同步线性降到接近零。这个“退火阶段”的收益在我们几个任务上都很稳定,尤其是需要精确输出的场景,原因是模型在训练末期对数据分布的印象最深。
| 训练阶段 | 目标数据占比 | 通用数据占比 | 学习率 | 主要作用 |
|---|---|---|---|---|
| 前期铺底 | 20% | 80% | 峰值 | 建立通用表征 |
| 中期强化 | 45% | 55% | 缓降 | 提升目标领域密度 |
| 末期退火 | 75% | 25% | 线性降到近零 | 固化高质量模式 |
注意:退火阶段的数据质量必须比前面更严,因为这一段的样本对最终权重影响最大。我们有一次偷懒复用了中期没过滤干净的批次,结果模型在长输出上开始出现重复段落,排查了两天才定位到是退火数据里混进了模板化文本。
2.2 质量过滤流水线的四道闸
数据清洗最怕两种极端:不洗,噪声直接进模型;洗太狠,分布收窄,模型变得刻板。我的经验是搭四道闸,每道闸都留可回滚的记录。
第一道是规则层:去重、去样板、去超短和超长文本。去重我用的是近似去重,把文本切成词片做哈希,重合度超过阈值就丢掉。这一步能砍掉很大一块重复内容,而且成本极低。第二道是模型打分:用一个小的分类模型给每条样本打质量分,阈值不要定太高,宁可放过也不要错杀,因为高分样本本来就稀缺。第三道是困惑度筛:用基座模型算每条样本的困惑度,把明显异常的低困惑度样本挑出来单独看,这类样本往往是模板化文本或者和评测集高度相似的内容。第四道是领域保留:对稀有但重要的领域设配额,避免被整体过滤掉。
这四道闸的阈值设定有个实用原则:先看保留率曲线。把阈值从松到紧扫一遍,画出保留率和平均质量分的关系,找一个拐点,过了拐点质量提升变缓但保留率掉得很快,那个位置就是合适的阈值。整个过程半天能跑完,比凭感觉定一个数字靠谱得多。
2.3 训练稳定性:loss 尖刺的定位顺序
训练跑崩是家常便饭,关键是别一崩就重启。我的定位顺序基本固定:先看是不是某一批数据的问题,再看学习率和 warmup,再看归一化和残差,然后看混合精度有没有溢出,最后才怀疑通信。这个顺序是从概率高到概率低排的,能省下大量时间。
import torch gnorm_history = [] def check_spike(step, gnorm, window=50, ratio=8.0): gnorm_history.append(gnorm) if len(gnorm_history) <= window: return recent = sorted(gnorm_history[-window:-1]) median = recent[len(recent) // 2] if gnorm > ratio * median: print(f"[告警] step={step} 梯度范数={gnorm:.3f} 中位数={median:.3f}") # 此时建议保存当前 batch 的样本 id,便于回溯数据 # 训练循环里 for step, batch in enumerate(loader): loss = model(**batch).loss loss.backward() total = torch.sqrt(sum( p.grad.detach().pow(2).sum() for p in model.parameters() if p.grad is not None )) check_spike(step, total.item()) optimizer.step() optimizer.zero_grad()定位到尖刺之后,第一件事是把那一步的样本 id 记下来,第二个 epoch 跳过这批数据再跑一次。如果尖刺消失,基本就是数据问题,通常是某条样本文本里混了异常字符或者长度极端。如果没消失,就把学习率降一半、warmup 拉长一倍再试。这两招能解决我遇到过的大多数尖刺。剩下那些顽固的,多半出在混合精度上:某些层的激活值超出半精度表示范围,反向传播时梯度变成 NaN。解决办法是给这些层保留单精度,或者换用动态损失的缩放策略。
实操心得:训练日志里除了 loss,一定要记梯度范数、学习率、当前批次的最大长度和平均长度。这四个量放一起看,很多问题不用进代码就能判断方向。
3. 评测体系:分数是最容易骗人的东西
3.1 评测污染的三种来源与自查手段
分数虚高是大模型研究里最普遍的暗坑,而且往往是分析评测脚本时才发现的。污染我见过三种:训练数据里撞了测试题;提示模板在训练语料里出现过;少样本示例和测试集有重叠。第三种最隐蔽,因为示例是你自己挑的,挑的时候很容易无意识地选到和测试题同源的样本。
自查手段有三个,成本都不高。第一个是 n-gram 重叠率,把测试集的每条样本和训练集做片段级比对,重叠超过一定比例就标红人工看。第二个是困惑度异常检测,模型在某条测试样本上的困惑度如果显著低于同分布样本,要么是题太简单,要么是见过。第三个是换模板重测,把提示模板换一种表述、少样本示例全部换掉再跑一遍,如果分数掉得厉害,说明原来的分数里有模板红利。
我们内部有个不成文的规矩:任何对外汇报的分数,必须在两套不同的提示模板下都成立,且差距不超过两个点。这条规矩挡住过好几次“看起来很美的结果”。
3.2 小样本评测的置信区间
另一个常被忽略的问题是统计显著性。一千条样本上,两个方法差一个点,很多人就直接下结论了,但一个点在统计上很可能是噪声。用 Wilson 区间算一下就知道差距有没有意义。
import math def wilson_ci(k, n, z=1.96): p = k / n denom = 1 + z * z / n center = (p + z * z / (2 * n)) / denom half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom return center - half, center + half for k in (612, 620, 628): lo, hi = wilson_ci(k, 1000) print(f"准确率 {k/1000:.3f} 的 95% 区间: [{lo:.3f}, {hi:.3f}]")跑一下就能看到,61.2% 和 62.8% 的区间大面积重叠,这种情况下说“提升显著”是没有依据的。真要下结论,要么扩样本量,要么改用配对检验看逐题的一致性和差异分布。我们后来把评测样本扩到三千条以上,很多之前以为的提升直接消失了,虽然难受,但省下了后续大量无效投入。
3.3 消融表该怎么设计
消融实验最忌讳只报“加了什么、涨了多少”。合理的消融表应该包含变量、控制项、观察指标、预期方向和失败判据五列,写清楚每个实验想验证什么。
| 变量 | 控制项 | 观察指标 | 预期方向 | 失败判据 |
|---|---|---|---|---|
| 位置外推方案 | 训练长度、参数量、数据 | 超长检索准确率 | 拐点右移 | 拐点不动或提前 |
| 数据退火比例 | 总 token、学习率调度 | 精确输出类任务 | 稳定上升 | 通用任务掉超 1 点 |
| 注意力分块大小 | 显存、批大小 | 吞吐与长文准确率 | 吞吐升、准确率平 | 准确率掉超 2 点 |
这张表的价值在于,它强迫你在开跑前想清楚“什么算失败”。我见过不少实验,跑完之后发现结果和预期相反,然后开始找理由解释,最后把一个失败实验写成了成功故事。这种自我欺骗对长期研究的伤害远大于一次失败。
4. 长上下文与推理链:最挤的两条赛道
4.1 长上下文的三个瓶颈
长上下文是当前最拥挤的方向之一,但很多人把它简化成“把窗口开大”。实际做下来,瓶颈至少有三个,而且互相牵制。
第一个是位置外推。训练时只见过 8K 的位置,推理时硬塞 64K,模型对超出范围的位置几乎没有概念,表现是远端信息利用率断崖式下降。常见处理是位置插值或频率缩放,把超出范围的位置映射回训练分布内,代价是近距离的区分度会略微变差,需要在插值比例上做权衡。
第二个是注意力成本。注意力计算量随长度平方增长,64K 上下文的一次前向开销是 8K 的几十倍。工程上的解法主要是分块计算和稀疏化,只让每个位置关注一部分相关位置。这里的关键不是省算力,而是怎么保证被省掉的部分确实不重要,我们踩过的坑是稀疏策略过于激进,长文档里跨段落指代的信息被切断了。
第三个是信息稀释。上下文里塞的东西越多,单个信息在注意力分布里的权重越薄,模型容易抓大放小。缓解办法是让检索结果带层级结构,先给摘要再给细节,或者在提示里显式标注每段的来源和作用。这个技巧不需要改模型,纯提示层面的调整,收益却很直接。
4.2 推理链数据怎么造
推理链微调的效果高度依赖数据质量,而数据质量里最贵的是过程正确性标注。我们最早的做法是只保留答案正确的样本,结果模型学会了“答案对但过程胡说”,在需要中间步骤的任务上表现很差。后来改成三步:先用强模型采样多条推理路径;再用一个独立的验证器逐步检查,只保留每一步都能站得住的路径;最后人工抽检一小部分,校准验证器的判定标准。
拒绝采样是个好用的中间方案,但要注意分布偏移。采样出来的正确路径往往集中在模型已经擅长的模式上,长期只在这批数据上训练会让模型的解题风格越来越窄。我们的处理是保留一定比例的高难度失败样本,配上正确解法,让模型见到“自己不会做但存在解”的题。这部分数据量不大,但对能力边界的拓展很明显。
结果奖励和过程奖励的选择也值得说一句。结果奖励好实现,信号稀疏;过程奖励信号密,但标注成本高且容易引入验证器的偏好。我的建议是先用结果奖励把基础能力拉起来,再在难点任务上补过程奖励,别一上来就全量做过程标注,性价比很低。
4.3 推理成本控制:KV cache 与批处理
模型能力上去了,成本问题立刻浮出水面。长上下文推理的显存大头是 KV cache,粗略估算方式是:层数 × 2 × 注意力头数 × 每头维度 × 序列长度 × 批大小 × 精度字节数。一个 7B 级模型在 32K 长度、批大小为 8 的情况下,KV cache 很容易吃掉几十 GB,比模型权重本身还大。
| 优化手段 | 作用点 | 收益 | 代价 |
|---|---|---|---|
| 分页式缓存管理 | 显存碎片 | 显存利用率明显提升 | 实现复杂度上升 |
| 连续批处理 | 批内长度差异 | 吞吐提升显著 | 调度逻辑更复杂 |
| 缓存量化 | 缓存精度 | 显存占用下降 | 长文精度可能略降 |
| 前缀复用 | 重复提示 | 首 token 延迟下降 | 需要缓存命中管理 |
这套组合拳打下来,同样的硬件能扛的并发量能翻几倍。但我要提醒一句:缓存量化对长文任务的影响不是均匀的,短问答基本无感,需要精确回溯原文位置的检索类任务会掉点。上线前一定要在自己的业务分布上验证,别只看公开基准。
5. 算力账本:先算清再开跑
5.1 显存与时间的估算公式
我见过太多人上来就开跑,跑到一半显存溢出再回头改配置,一来一回浪费的时间比做预算多得多。显存大致分四块:模型权重、梯度、优化器状态、激活值。前三个好算,混合精度下权重每参数 2 字节,梯度每参数 2 字节,优化器如果存两阶矩加主权重,每参数大概 12 字节。7B 参数的模型,光这三块就是 100 GB 量级,单卡根本放不下,必须切分。
激活值是变量,和批大小、序列长度、是否启用梯度检查点强相关。启用梯度检查点能把激活显存压到原来的几分之一,代价是重算导致训练速度下降一到两成。这个取舍在长序列训练里几乎必选,因为不选就直接跑不起来。
def rough_memory_gb(params_b, seq_len, batch, layers, hidden): # 权重 + 梯度 + 优化器状态(混合精度,Adam 类) static = params_b * 1e9 * (2 + 2 + 12) / (1024 ** 3) # 激活粗略估计,系数受实现影响较大,仅作数量级参考 activation = layers * batch * seq_len * hidden * 2 * 2 / (1024 ** 3) return static, activation static, act = rough_memory_gb(7, 32768, 4, 32, 4096) print(f"静态显存约 {static:.1f} GB,激活约 {act:.1f} GB(未开检查点)")这个估算不精确,但足以判断“能不能跑”。如果算下来超出总显存,就按顺序考虑:开梯度检查点、减批大小、加序列并行、上更大规模的切分。顺序别乱,因为每往后一步,工程复杂度都是跳一级的。
5.2 并行策略怎么选
数据并行最简单,每个设备一份完整模型,适合模型能单卡放下、只是想把吞吐拉满的情况。张量并行把单层内部的矩阵切开放到多卡,适合单层就放不下的场景,但通信量大,要求设备间带宽足够。流水并行按层切分,通信量小一些,但会引入流水气泡,需要微批调度来填。序列并行是把序列维度切开,长上下文训练里几乎是必备的补充手段。
我的选择顺序一般是:先看模型能否单卡放下,能就用数据并行加梯度累积;放不下先试张量并行,规模到八卡以内通常还能接受;再大就叠流水并行;序列特别长的时候补序列并行。这个顺序背后的逻辑是通信成本,越往后的方案,跨设备同步越频繁,对互联带宽越敏感。
6. 排查实录:那些跑完但对不上的实验
6.1 复现失败的五步排查
复现失败比训练崩溃更折磨人,因为没有任何报错。我整理了一个固定顺序,按这个走能覆盖大部分情况。
| 现象 | 可能原因 | 验证方法 | 处理 |
|---|---|---|---|
| 两次跑分数差超过 2 点 | 随机种子未全固定 | 打印所有随机源状态 | 固定种子并记录 |
| 换机器后结果明显不同 | 硬件差异导致数值路径不同 | 同机重跑对照 | 记录硬件与精度配置 |
| 评测分数异常高 | 数据撞题或模板泄漏 | 换模板重测 | 清洗重叠样本 |
| 长文任务掉点 | 位置编码配置未同步 | 对比配置文件差异 | 统一推理配置 |
| 生成结果重复 | 解码参数不一致 | 检查温度与惩罚项 | 配置化注入 |
第一步永远是固定所有随机源,不只是 Python 的种子,还包括数据加载器的 shuffle、Dropout 的随机状态、分布式采样的种子。这些地方漏一个,两次跑的结果就对不上。第二步是把硬件和精度配置写进实验记录,换机器跑出来的数值差异很多时候来自浮点运算顺序不同,这类差异在小数点后几位,正常情况下不影响结论,但如果你的差异在指标上体现,那就不是数值问题。
6.2 几个反直觉的坑
第一个坑是分词器不一致。训练用的是一个分词器,评测脚本里加载的是另一个版本,词表差几个 token,同样的文本切出来长度不同,长文任务上直接导致截断位置偏移。这个问题我们查了整整一天,因为分数只是平滑地低了一点,没有任何异常信号。后来在评测脚本里加了断言,比对分词器哈希,才算彻底堵住。
第二个坑是混合精度下的数值溢出。某些注意力实现在序列很长时,中间计算会超出半精度范围,结果是输出变成无穷或者零,模型表现是“偶尔答非所问”。这类问题在小批量测试时几乎不出现,只有长序列大批量才暴露。处理方式是给关键计算保留单精度,或者在实现里加数值稳定的写法。
第三个坑是数据加载顺序对训练的影响。分布式训练里,如果每个进程的采样器没有正确设置分片,会导致部分数据被重复采样、部分数据从未被见到。这个 bug 不会报错,只会让模型在某些子任务上莫名其妙地弱。我们的检查办法是统计一个 epoch 内每条样本被采样的次数,正常情况下应该接近均匀,出现明显偏斜就说明分片逻辑有问题。
提示:排查这类问题,最有效的工具不是更复杂的调试器,而是更细的日志。把配置、种子、分词器哈希、数据采样统计都打进日志,出问题时对比两份日志的差异,比盯着代码看快得多。
7. 一点个人体会
做了这么久,我越来越觉得大模型研究里最难的不是想出一个新点子,而是把一个想法干净地验证掉。同样的实验,有人跑完能说清楚“为什么有效、在什么条件下有效、边界在哪里”,有人只能说“涨了”。这两种产出的差距,不在于算力,而在于有没有把对照组、评测、日志这些“不性感”的部分做扎实。
我自己踩得最深的坑,是早期太急着看结果,实验设计得很糙,跑出来一个不错的分就写进汇报,过两周复现不出来,只能悄悄撤掉。后来养成习惯,每个实验开跑前先写清楚预期和失败判据,跑完先看日志再看分数,反而快了。那些看起来拖慢节奏的流程,实际上是在帮你省掉返工的时间。
如果有人正准备往这个方向走,我的建议是先找一个小而明确的现象,把它彻底搞清楚,哪怕只是“模型在某类长文档上的中间段落利用率偏低”这么具体的问题。搞清楚一个,你就掌握了整套方法论,后面换什么题目都能用。反过来,一上来就盯着“通用能力提升”这种大题目,很容易做了半年还在原地打转。