衔言渡意:中英法,六个方向

引言:简历上缺一块

简历上有两个NLP项目。

韵染流光是序列生成,Encoder-Decoder,中文描述到颜色DSL的映射。数语觅类是多标签分类,Encoder-only,列名和样本到语义类型的识别。两个项目,两种架构,两类任务。但有个明显的空白:我没碰过跨语言。

机器翻译是NLP里最经典的seq2seq任务,用点力就能够到,做了能给简历多一个推送方向。

动机就这么简单。

但“为简历而做”不等于随便做。

韵染流光的Encoder-Decoder处理的是单语言映射——中文进,DSL出,分词用单字切分就够了。翻译不一样。中文是汉字,英法是拉丁字母,单字切分会把英文和法文炸成字母序列。所以要用SentencePiece在混合语料上训练多语言BPE词表——这是韵染流光没碰过的东西。

三语带来六个翻译方向:中→英、英→中、中→法、法→中、英→法、法→英。英语和法语共享拉丁字母,但“pain”在英语是痛苦,法语是面包;“chat”在英语是聊天,法语是猫——共享字形,不共享语义。而且BPE会在这两种语言间切出大量共享的subword,“-tion”、“inter-”、“com-”这些在英法里都高频出现,含义有时重叠有时不同。模型得从上下文学会区分。

同一个Encoder-Decoder架构,从单语到跨语言,工程决策完全不同。三个项目表面是三种任务,但底下是同一套东西——对架构的理解不在表面的应用,在本质。

这篇文章记录这个过程。

语料:从OPUS到联合国

找数据的弯路

翻译模型需要平行语料——一句中文对一句英文,一句中文对一句法文,一一对应。

OPUS(opus.nlpl.eu)是开放平行语料的聚合站。我一开始不知道有这个东西,是问Claude时得知的——在不知道"OPUS"这个词的情况下,自己搜很难搜出来。顺带一提,知道了也不好搜,搜“OPUS”这个词,结果要么是音频格式要么是Anthropic的旗舰模型Opus,加上“语料”两个字才搜到正主。

OPUS上按来源分了很多子集。一开始我挑了WikiMatrix——理由很直觉:维基百科什么都写,技术、历史、地理、日常,语料的领域覆盖够广。

然后翻数据的时候,发现了一些有意思的东西。

这是最初选的中日语言对里的几条(当时还打算做中英日三语):

かな 〔湖西・湖南〕《仮名》たやすい。

惟其點拍之法簡而易行。

日文是方言词典的条目,中文是文言文。两句唯一的共同点是都沾了个“容易”的语义。

还有更离谱的:

真主确是全聪的,确是全明的。

(例)四槓子の一向聴 単騎。

中文是古兰经,日文是麻将术语。

WikiMatrix的平行句对是用多语言句子嵌入从维基百科里自动挖掘的,不是人翻的——算法觉得两句话语义接近就配对。所以会出现这种“语义沾边但根本不是互译”的噪声。

这不是能清洗解决的问题。我没有能力对二十多万句对做人工筛选,知识量也不够——古兰经配麻将我能看出来,但有些似是而非的句对,我自己都判断不了对不对。

换数据源

问题不在OPUS,在于从OPUS里挑了什么。

OPUS上的数据集性质差异很大。WikiMatrix和CCMatrix是自动挖掘的,量大但噪声多。而有些数据集是人工翻译的——比如联合国平行语料库。

联合国平行语料库包含1990至2014年的联合国正式记录和会议文件,六种官方语言(中、英、法、俄、西、阿),人工翻译,句子对齐现成。语言风格是外交体——非常正式、非常书面——但对我来说这不是问题。语料是最容易换的一层,模型架构不变,训练流程不变,换个数据源重新跑就是了。

而且,最初选日语只是因为自己在学日语。但中日高质量平行语料极度稀缺,可靠的只找到五万句对,对翻译任务远远不够——翻译是开放域任务,每个句对贡献的信息量远不如精心设计的DSL样本,模型需要大量数据才能归纳出泛化的映射规则。联合国语料库里中英法三语的量级都在千万以上,数据量不是瓶颈。三语和六语没有本质区别——多语言BPE词表、语言标签路由、跨语言表征,三种语言就全有了。

六个文件,六个方向。

输入格式:让encoder安静地读

Encoder-Decoder架构天然地把输入分成两路。这个物理隔离意味着源和目标之间不需要分隔符——它们本来就不在同一个序列里。

我想要的是:encoder只看源句子,不知道要翻译成什么语言。它的工作是理解输入的“本源含义”,跟目标无关。语言路由的信号放在decoder端——encoder处理源语言,decoder生成目标语言,decoder序列的第一个token是目标语言标签:<zh><en><fr>

所以实际进模型的是:

  • Encoder:源句子的token序列
  • Decoder<zh>/<en>/<fr> + 目标句子的token序列

最初想过在语言标签和目标之间加一个 <sep>做分隔。但想了一下,<sep><zh>面临同样的问题——作为特殊token,它们在SentencePiece的词表里都是完整的单个token,但在原始文本里也可能作为字面字符串出现。加一个 <sep>不是多了一层保障,是多了一个也可能出现在目标文本里的token,还白白占一个位置。

语言标签的身份靠位置确认,不靠分隔符。decoder的第一个token永远是语言标签,模型从训练中学到这一点。后面即使出现同形的 <zh>字符串,也只是被SentencePiece切成了一个特殊token——上下文和位置足够让模型分辨。

文件层面的组织更简单。六个文件,每个文件本身就是一个翻译方向,目标语言信息在文件名里,不需要在内容里再存一次。文件内一行一句,空行分隔句对:

line1
第一行

sentence2
第二句

How are you?
你怎么样呀?

干净,没有多余的东西。

采样:从千万到百万

联合国语料库每个语言对有上千万句对,但小模型吃不下那么多。需要采样。

三个语言对,每对取60万不重复的句对,然后拆成双向各30万。比如中英:随机抽60万句对,前30万做中→英,后30万做英→中。双向用不同的句对,模型能接触到更多语言现象,不是简单地把同一批数据反过来喂一遍。

随机采样而不是顺序读取。联合国语料按时间和议题组织,顺序读意味着前几十万行可能全是九十年代初期的安理会决议。随机采样能让不同年份、不同议题的句对均匀分布。

最终六个文件,每个方向30万句对,总共180万。

合并:从六个文件到一个

六个文件分别存六个方向,但词表训练和模型训练都需要一个统一的入口。所以合并成一个 samples.txt,六个方向随机混在一起,每个句对由空行分隔:

<fr>Espagne
<zh>西班牙

<zh>在此框架内,突尼斯各部委、机构和其他有关单位举行了一系列协调会议,以确定执行这一任务的最佳方法。
<fr>Dans ce contexte, les ministères, les institutions et les entités tunisiens compétents ont tenu des réunions en vue de coordonner au mieux l'application des dispositions des résolutions du Conseil de sécurité.

<zh>53. 各种不同的实体都对暴力案件进行记录,这些实体包括妇女事务办公室、警察、阿富汗独立人权委员会、法律援助组织、医院、检察官办公室和一些其他组织。
<en>53. Various entities such as DoWAs, police, AIHRC, legal aid organizations, hospitals, prosecution offices and a number of other organizations record the cases of violence.

<fr>M. Blaise Campaore, Président du Burkina Faso, est escorté hors de la salle de l'Assemblée générale.
<en>Mr. Blaise Compaore, President of Burkina Faso, was escorted from the General Assembly Hall.

<en>36. The Russian Federation participates in programmes for the construction and operation of ISS and space systems for environmental monitoring, early warning of destructive natural phenomena and other emergency situations, search and rescue operations and programmes to control and reduce pollution in outer space.
<zh>36. 俄罗斯联邦参与建立和运行国际空间站及环境监测、破坏性自然现象和其他紧急情况预警、搜索和援救行动空间系统的各种方案以及控制和减少外层空间污染的各种方案。

<zh>第四,委员会应向内部监督事务厅提供必要资源和工具,将其建成能真正改善联合国管理的得力部门。
<fr>En quatrième lieu, la Commission devrait doter le Bureau des services de contrôle interne des ressources et moyens nécessaires pour qu'il permette des améliorations réelles en matière de gestion à l'Organisation des Nations Unies.

上面的示例里源行也带了语言标签,是为了一眼看出每个句对的翻译方向。实际文件中源行不带标签——encoder不需要被告知“这是什么语言”,中英法的字面差别足够大了。只有目标行以 <zh><en><fr>开头,作为decoder端的语言路由信号。

词表:三种书写系统的共同语言

为什么不用BPE

SentencePiece支持两种主流分词算法:BPE和UnigramLM。

BPE是自底向上的贪心合并。从单个字符开始,每一步找当前语料里最高频的字符对,合并成新token,重复这个过程直到词表达到目标大小。每一步只看“现在哪个对最频繁”,不回头。

问题出在三语场景。中文是汉字,英法是拉丁字母。拉丁字母的双字符组合——th、er、le、de——在语料里出现频率极高,会抢先占掉大量合并名额。而中文有几千个常用汉字,任意两个汉字组成的bigram频率都相对低。不是因为不重要,是因为太分散了。BPE按频率分配合并机会,结果就是32K或48K的词表预算里,大量名额被拉丁子词吃掉,中文那边还停在接近单字的状态。

这不算“错”——中文单字本身有语义,切成单字也能用。但词表的分配是被语料的表面频率驱动的,不是被“什么切分方式让整个语料的编码效率最高”驱动的。

UnigramLM反过来。它从一个远大于目标的初始词表开始,自顶向下,每一步移除对整体语料似然影响最小的token。优化目标是全局的——整个语料在当前词表下的对数似然最大化。它不会因为某类字符对频率高就盲目保留,而是在三种语言之间找到全局更优的词表分配。

选择很明确:三种书写系统差异大,语料量均衡(每个方向30万),需要词表在三种语言间合理分配。UnigramLM做的就是这件事。

巧的是,SentencePiece的默认值就是UnigramLM。

训练参数

spm.SentencePieceTrainer.Train(
    input=sample_file,
    model_prefix=f"{data_dir}/vocab/word_ferry",
    model_type="unigram",
    vocab_size=VOCAB_SIZE,          # 48000
    max_sentencepiece_length=256,
    max_sentence_length=8000,
    pad_id=PAD_TOKEN_ID,            # 3
    pad_piece=PAD_TOKEN,            # "<pad>"
    user_defined_symbols=[ZH_TOKEN, EN_TOKEN, FR_TOKEN],  # "<zh>", "<en>", "<fr>"
    shuffle_input_sentence=True,
    character_coverage=0.9995,
    hard_vocab_limit=False,
    normalization_rule_name="identity",
    remove_extra_whitespaces=False,
    split_digits=True,
    add_dummy_prefix=False,
)

几个关键选择:

词表大小48K。最初想的是32K,但三种书写系统各有各的字符空间,给宽裕一些。

normalization_rule_name="identity"——不做NFKC归一化,保留原始字符。默认的NFKC会把一些“看起来相似”的字符合并成同一个(比如全角和半角),但翻译任务里原始字符的差异可能是有意义的。

split_digits=True——把数字拆成单个数位。UN文档里满是年份(1990、2014)、条款编号(第53条)、统计数字。如果不拆,“2014”和“2013”是两个完全不同的token,模型学不到它们之间的关系。拆成单个数位后,它们共享 201,只有最后一位不同。

add_dummy_prefix=False——SentencePiece默认会在句首加一个空格,这是为了让英文“hello”和“ hello”(出现在句中有前导空格)被切成相同的token。但中文不需要这个——中文没有词间空格的概念,加了反而多一个无意义的token。三语混合场景下,关掉它更干净。

user_defined_symbols——<zh><en><fr>作为特殊token加入词表。它们在训练时就被SentencePiece识别为不可拆分的整体,不会被切成 <+zh+>

评估:从拍脑门到压缩率

数语觅类里我写过一个tokenizer评估函数,用“平均token数”判断词表大小是否合理——超过20建议增大词表,低于5可能词表过大。

搬到衔言渡意,直接不适用。翻译任务的句子天然比数据库列样本长得多,平均token数跑出来62.5,数语觅类的标准会说“建议增大词表”,但实际上词表完全没问题。

问题在指标本身。“平均token数”把数据长度和编码效率混在一起了——句子长,token自然多,跟词表大小没关系。换个任务,这个数字就失去意义。

真正任务无关的指标是压缩率:每个token平均覆盖多少原始字符。这个比值只反映编码效率,和句子长短无关。压缩率高说明token粒度粗(每个token承载更多字符),低说明粒度细。不同书写系统有不同的健康范围:中文大约1.5-2.0字符/token,拉丁语系大约3.0-5.0字符/token。

这个思路足够通用,所以我把它抽成了独立的评估工具,单独写了一篇文章

衔言渡意的评估结果:

语言 压缩率 (chars/token) 参考范围 词表使用率
中文 2.04 1.5-2.0 37.92%
英文 4.87 3.0-5.0 28.48%
法文 4.53 3.0-5.0 32.06%

三种语言都落在健康范围内,UnigramLM在三语间的分配很均衡。总词表使用率85.86%——没有大量死token浪费embedding参数,也没有紧张到不够用。

架构:从数据反推维度

不再拍脑门

韵染流光的参数规模是试出来的。一开始想做500M的多任务路由,后来砍到60.6M,发现反而更好。过程有效,但方法不可复制——每个新项目都从头试一遍,太慢了。

数语觅类时开始有了方向感。从200M砍到4.2M不是盲目缩小,是发现32类语义识别的信息量远比预期少,不需要那么多参数。但“发现”还是事后的——先训了大的,发现过拟合,再缩小,再训。

两个项目做完,我开始想:能不能在训练之前就估出合理的参数规模?

思路是这样的:数据决定模型需要学多少东西,架构决定模型怎么学。如果能从数据特征估算出“需要多少参数”,再从任务特性推断出“参数怎么分配到层和维度”,那模型规模就不用猜了。

我把这个思路写成了一个工具:d_model_calculator——输入数据量、信息密度、层数,输出维度。完整的推导过程单独写了一篇文章,这里只说衔言渡意的具体参数怎么来的。

一个参数需要多少token

核心概念是token_per_param:一个参数需要从多少个token中学习,才能充分训练。

这个值取决于语料的信息密度。韵染流光的DSL是精心设计的形式语言,每个token都承载精确的结构信息——修饰关系、嵌套层级、颜色参数——一个参数只需要0.0875个token就能充分学习。反过来说,一个token就能让11个参数学到东西。

数语觅类的语料完全相反。列名和样本数据的模式重复度高,phone_number|138...mobile|139...教给模型的东西高度重叠。一个参数需要25.5个token才能充分学习。

翻译呢?联合国文档是自然语言,每个句对教模型一组跨语言的对应关系——词汇映射、短语结构变换、语序调整。信息量比DSL稀疏得多。但外交体有大量套话和固定搭配,“各会员国”、“Member States”、“États membres”反复出现,单个token的边际信息量不算高。

取10。比DSL稀疏两个数量级,比数据库列样本密集两倍多。

模型需要思考几次

维度决定模型一次能处理多少信息,层数决定模型能思考几次。每过一层,就是一次特征提炼——把低层特征组合成更高层的抽象。

Encoder需要:

  1. 子词拼接——UnigramLM切出的subword拼成完整词义
  2. 句法解析——主谓宾、修饰关系、短语结构
  3. 语义抽象——从具体词汇到"这句话在说什么"
  4. 语言无关表征——抹掉源语言的特性,留下纯语义

四层抽象,四层encoder。

Decoder需要:

  1. 语言路由——从 <zh>/<en>/<fr>标签确定生成方向
  2. 跨语言映射——通过cross-attention把encoder的表征对齐到目标语言
  3. 结构重组——目标语言的语序、语法、搭配
  4. 表面生成——具体的措辞和用词

四层抽象,四层decoder。

128维

把这些数字送进 d_model_calculator

  • 词表大小:48,000
  • 总token数:111,600,000(180万句对 × 平均62 token)
  • token_per_param:10
  • Encoder层数:4
  • Decoder层数:4

输出:128维。

48K词表 × 128维 = 6.1M参数,光embedding层就占了总参数量的一半以上。留给4+4层transformer的预算很紧。

但这不一定是问题。翻译模型的核心能力之一就是词汇映射,embedding空间本身就是“知识”存放的地方。至于128维够不够让transformer层做好特征提炼——loss会告诉我。

工程:跑通训练循环

行偏移缓存:文本模式的坑

180万句对不能全部加载到内存里。数据集按需读取,靠文件偏移量定位到每个样本。

建缓存的逻辑很简单:扫一遍文件,记录每个样本(两行文本 + 一个空行)的起始字节位置。训练时按偏移量 seek到对应位置,读出来就是一个样本。

但Python的 tell()seek()在文本模式下不可靠——tell的返回值可能包含解码器的内部状态,不是真实的字节偏移。seek回去,读出来的可能不是你存的那个位置。

韵染流光时我没做偏移缓存,每次都重新扫描,绕过了这个问题。数语觅类时做了缓存,但数据量大、训练样本多,偶尔的偏移错误被淹没在正确的样本里,没暴露出来——或者暴露了,被当成正常的噪声忽略了。

这次才真正碰到。解法很直接:用二进制模式打开文件,tell()返回的就是真实的字节偏移,读出来后手动 decode('utf-8')

序列长度:2048的代价

第一次跑训练,max_len设成了2048——为什么是2048?我想翻译长文。

22.4G内存,1.86it/s。

Attention矩阵是 [batch_size, n_heads, seq_len, seq_len],序列长度的平方。2048²就是400多万,乘以batch_size、乘以注意力头数、乘以8层transformer,内存就是这么吃掉的。

但数据的平均token数是62。2048意味着大量的padding——给一堆平均62 token的句子分配2048长度的attention矩阵,95%以上的计算都花在了空气上。我想翻译长文,但语料里没有那么长的文本。而且,翻译长度对任务特性而言没有本质区别——模型学的是跨语言映射规则,不是“怎么处理长句子”。那就先调整到“联合国会议记录单行长度”级别。

回头看了一眼实际分布:

百分位 token数
P50 54
P75 84
P90 120
P95 150
P99 231
P99.9 479

P99才231。max_len改成256,截断0.74%的样本,内存从22.4G降到6.4G,速度从1.86it/s到31.66it/s。

这和架构章节的逻辑是同一件事——先看数据,再定参数。不只是模型维度,序列长度也是。拍脑门给2048,就是没看数据的后果。

位置编码:可学习就够了

Transformer需要位置编码——attention本身不区分顺序,位置信息得额外注入。

两种主流方案:固定的正弦位置编码(sinusoidal),和可学习的位置编码。固定编码的一个常被提到的优势是“可外推”——理论上能泛化到训练时没见过的序列长度。

但我已经设了 max_len。超出最大长度的输入本来就会被截断,“泛化到更长的序列”这个优势在有最大长度限制的场景下没有意义。

可学习编码能起作用的原因很直觉:不是每个样本的位置信息都随机,而是位置0永远是位置0。无论哪个样本,无论输入输出,第一个token都在位置0,第二个都在位置1。模型通过训练学到的是“位置0应该有什么样的特征”,这个信号在所有样本里是一致的。

实现上用 nn.Embedding而不是 nn.Parameter。两者都能做可学习编码,但Embedding的语义更明确——它就是“给一个索引,返回一个向量”,和位置编码的功能完全对应。

损失函数:token粒度的优化

翻译是自回归生成——decoder每个位置预测下一个token。所以训练时,目标序列需要错位:

  • Decoder输入:[<zh>, t1, t2, t3]
  • Labels:[t1, t2, t3, <eos>]

模型看到 <zh>应该预测 t1,看到 t1应该预测 t2,看到 t3应该预测 <eos>

损失函数就是token粒度的cross entropy:

loss = cross_entropy(
    logits.view(-1, vocab_size),
    labels.view(-1),
    ignore_index=PAD_TOKEN_ID,
    label_smoothing=0.05,
)

ignore_index=PAD_TOKEN_ID让padding位置不参与损失计算。

这里没有序列级别的指标。分类任务可以在每个batch上廉价地算precision、recall、F1,但翻译不行——评估翻译质量需要完整的自回归生成(逐token解码出一整句),再和参考译文比较。这个过程比一次forward慢得多,不适合每个batch都做。

回头看韵染流光,当时认为DSL里有随机小数,exact match不适用,直接放弃了序列级评估。三个项目做下来才发现,其实可以拆层——结构部分做精确匹配,数值部分设容差阈值。一个看似不能用exact match的任务,拆开后结构维度反而最适合。不同任务用什么指标、为什么用、怎么拆——这些思考我整理成了一篇单独的文章

序列级别的评估交给BLEU,在每个epoch结束时做。

评估:BLEU

BLEU是机器翻译的标准评估指标。它做的事情是:拿模型生成的译文和参考译文比较n-gram重叠率——1-gram看单词对不对,2-gram看两个词的搭配对不对,3-gram和4-gram看更长的短语结构。取1到4-gram精确率的几何平均,再乘以一个简短惩罚(防止模型只说“有把握的话”来刷高精确率)。

每个epoch结束时,从验证集上取1000句,跑完整的自回归生成,收集所有的模型译文和参考译文,用 sacrebleu一次性算一个corpus-level的BLEU分数。分数比之前高就存checkpoint。

训练过程中只盯loss。Loss管优化,BLEU管评估,各管各的。

推理的显存陷阱

训练跑起来了,BLEU评估却在第150个token左右OOM。训练时batch_size=25没问题,推理时batch_size=7就爆。

第一个问题很明显:generate()每一步都调 forward(),意味着每个时间步都重跑一次encoder。Encoder的输出只取决于源句子,和decoder的状态无关——150步里算了150次完全相同的结果。把 forward拆成 encodedecode两个方法,generate里encoder只跑一次,循环里只调decode。

拆完之后,还是OOM。

回头看decode的最后一步——output_projection把decoder输出从 [batch, t, 128]映射到 [batch, t, 48000]。训练时t是固定的目标序列长度,一次前向一次反向,没问题。但自回归生成里,每步都把整个已生成序列喂进decode——t从1递增到max_len——每步都对完整序列做48K维投影,而实际只用最后一个位置 [:, -1, :]的结果。

到t=256、batch=7时,单个logits张量就是 7 × 256 × 48000 × 4字节 = 344MB。加上循环中的中间态累积,12G显存挤爆。

理论估算时我按 [batch, 1, 48000]算的——以为每步只投影一个位置。没意识到decode内部是对整个t维度做投影。

修复分两步。

第一步:decode加一个 last_only参数。投影前先切 decoder_output[:, -1:, :]——这本身只是个view,不释放内存。但接下来 output_projection对这个切片做矩阵乘法,产生的结果是 [batch, 1, 48000]而不是 [batch, t, 48000]。计算完成后,原始的 decoder_output不再被引用,才真正释放。关键不在切片,在切片之后的投影只对一个位置计算。

第二步:generate里,逐步 torch.cat拼接每次的预测结果会产生内存碎片——每一步创建比上一步更大的新张量,旧张量释放后的空洞装不下新的。换成预分配一个 [batch, max_len]的张量,每步往对应位置写入,不再分配新内存。

修完之后,batch_size=25推理peak 2.2G。8M参数的模型,这才是正常水平。

崩溃:从TDR假设到硬件根因

训练跑了大约20分钟,36k步附近,进程被杀。退出码 0xC0000005——Windows的ACCESS_VIOLATION。第二次崩在36976步,位置不同。显存稳定在8.4G/12G,温度正常。

nvidia-smi抓到关键线索:崩溃前GPU利用率突降至0%,功率从90W降到3W,进程挂着约10秒后被杀,功率读数跳到593W——TDP才120W的卡。593W是假的,GPU被重置后功率传感器寄存器的脏值。

这套时间线指向Windows TDR(Timeout Detection and Recovery)。WDDM驱动定期向GPU发心跳,默认2秒内没响应就判定GPU失联,强制重置。当时的假设是:8M小模型kernel极轻但提交频率极高,GPU一直在密集执行,心跳排在队列后面得不到响应。韵染流光60.6M参数没触发过TDR——模型大,单步kernel重,大kernel之间有自然的preemption point。搬大石头的人喊一声就停了,疯狂搬砖的人喊了也听不见。

基于这个假设,设计了 TDRGuard。最初是固定步数间隔(每5000步 synchronize一次),但TDR看的是墙钟时间,不是步数——动态padding下短句batch极快、长句batch较慢,固定步数无法覆盖所有密度分布。于是改为基于墙钟时间的自适应同步,后来又发现验证和BLEU生成循环同样需要保护(验证62 it/s,没有backward的开销反而kernel密度更高),最终抽成可复用的类:

python

class TDRGuard:
    def __init__(self, interval=1.0):
        self.interval = interval
        self.last_sync = time.time()

    def sync_if_needed(self):
        if time.time() - self.last_sync > self.interval:
            torch.cuda.synchronize()
            self.last_sync = time.time()

训练、验证、BLEU generate三个循环共用,1秒间隔给2秒的TDR阈值留一半余量。

加了TDRGuard之后,TDR消失了。以为解决了。

然后出现了更严重的崩溃。0xC0000374(HEAP_CORRUPTION),0xC0000005反复出现,最终甚至触发了黑屏重启(Kernel-Power 41)。三种现象呈递进关系。

逐一排除软件假设:Python 3.13切到3.12,崩溃依旧;PyTorch 2.10切到2.9 + Studio驱动595.79清洁安装,短暂稳定后再次崩溃。CUDA_LAUNCH_BLOCKING=1同步执行后定位到 loss.backward()中的 cudaErrorInvalidValue——但这是CUDA异步错误报告的典型表现,真正出错的kernel在更早的地方。

随后使用 transformersTrainer 训练,崩溃依旧——

关键点:停止训练后,仅浏览器播视频 + IDE改代码时依然触发黑屏重启。 这排除了训练代码本身的问题。笔记本曾1.5年未清灰,长期高温运行,BGA焊点、VRM等可能存在不可逆的热损伤。

最终将训练迁移至AutoDL服务器(5090),用自己的训练循环跑了4个epoch,零崩溃。确认崩溃并非代码问题。

回头看TDRGuard:nvidia-smi的时间线既可能是TDR超时,也可能是硬件故障触发驱动重置,两者外部表现完全一致。在本地硬件本身不可信的前提下,无法区分。TDRGuard的设计过程——从固定步数到墙钟时间、从单循环到全覆盖——作为工程推理是成立的,但它到底解决了一个真实的TDR问题,还是恰好与硬件故障的间歇期重合,不知道。

迭代:从 8.1M 到 51.7M

第一轮:128维的天花板

8.1M模型(128d×2h×4+4L)训了14个epoch,BLEU峰值9.99(epoch 6),val loss在3.64走平,早停触发。

BLEU 9.99意味着模型已经在做翻译——不是随机输出,是能看出源句子影子的、带结构的目标语言文本。但val loss几乎不再下降,模型在这个容量上学到了它能学的一切。

48K词表 × 128维 = 6.1M参数在embedding层,剩下2M分给8层transformer。2M参数要完成三语六方向的跨语言映射,不够。

诊断:哪里不够?

模型需要更大,但往哪个方向扩?加层还是加宽?

层数和维度对应不同的能力瓶颈。层数管串行依赖深度——每一层是一次特征提炼,嵌套从句需要逐层解开,层不够就解不动内层。维度管并行信息负载——同一层里能同时追踪多少东西,实体一多就记不住。

两种瓶颈的失败模式不同:串行不足是结构性错误(从句关系乱、嵌套层级丢失),并行不足是遗漏和混淆(漏实体、张冠李戴)。这套分析框架——包括怎么从任务特性出发设计诊断用例、怎么读失败模式——整理成了一篇单独的文章

这里只说衔言渡意的具体操作。

第二轮:15.5M 和它的诊断

先扩了一版。token_per_param从10降到5(给模型更多参数预算),层数从4+4展开到6+6(原来“句法解析和语义抽象”、“跨语言映射和结构重组”各拆成两步)。d_model_calculator输出192维,3头。15.5M参数。

BLEU到15.6(epoch 26)后上限,val loss再次走平。比8.1M好了一截,但还没到目标范围。

这时候不能再拍脑门了,设计了诊断测试集:串行轴和并行轴各分三级难度,关键是控制变量——测串行时保持并列项少(最多两个),只递增嵌套深度;测并行时保持结构简单(主谓宾,无嵌套),只递增并列实体数。混在一起就分不清哪个轴出了问题。

每级3句,2轴 × 3级 = 18句,六个翻译方向随机覆盖。

并行轴的结果(目标语言均为中文):

2项并列,全对:

China and France submitted proposals on economic sanctions. → 中国和法国提出了经济制裁的建议。

4项并列,全对:

La Chine, la France, le Royaume-Uni et la Fédération de Russie ont soumis des propositions concernant les sanctions économiques et la coopération militaire. → 中国、法国、联合王国和俄罗斯联邦提出了经济制裁和军事合作的建议。

6项并列,丢实体:

China, France, the United Kingdom, the Russian Federation, the United States and Japan submitted proposals on economic sanctions, military cooperation, humanitarian assistance, diplomatic negotiations, technology transfer and environmental protection. → 中国、法国、美利坚合众国和日本提出了经济制裁、军事合作、外交谈判、技术转让和环境保护的建议。

丢了“联合王国”和“俄罗斯联邦”,丢了“人道主义援助”。6个国家只记住4个,6个议题只记住5个。192维装不下这么多并行信息。

串行轴更严重:

无嵌套,过:

Le Conseil a adopté la résolution à l'unanimité. → 理事会未经表决通过了决议。

结构对,“à l'unanimité”(全票通过)译成“未经表决”是词汇映射错误,不是结构问题。

一层嵌套,丢外层从句:

The Committee recommends that Member States adopt measures in accordance with resolution 1325. → 会员国根据第1325号决议采取措施。

“The Committee recommends that”整个主句蒸发了。模型只译出了内层从句,好像委员会推荐这件事从来没有发生过。

三层嵌套,全崩:

L'Assemblée générale a prié le Secrétaire général de lui présenter un rapport sur les mesures prises par les États Membres pour donner suite aux recommandations formulées par le Comité dans le rapport soumis à sa soixantième session. → 秘书长要求各会员国报告 suite委员会第六十届会议的建议。

结构散架,混入未翻译的法语词“suite”。三层嵌套对6层encoder来说太深了。

结论很清晰:两边都到瓶颈,串行更紧急——S2一层嵌套就崩,P3六项才开始丢。

第三轮:384维,8+8层

层数从6+6加到8+8。encoder加两层给短语结构和句级依赖更多处理空间,decoder加两层给结构映射和语法适配更多余地。

token_per_param从5降到2,d_model_calculator输出384。6头,每头64维。

总参数量51.7M。从8.1M到51.7M,涨了6倍多。

训练判据的修正

51.7M第一轮训练撞了一个认知问题:BLEU到19.96(epoch 26)时早停触发,但val loss还在降——从2.461降到2.422,明明还在学。

问题出在我用BLEU做早停判据。BLEU是离散指标,在10000句上算,小模型生成的译文质量波动大。val loss稳步下降的同时,BLEU可以从20跳到16再跳回19,这种噪声触发了早停。

修正后:训练阶段的一切决策只看val loss。早停看val loss,checkpoint判据看val loss,dropout触发看val loss。BLEU在刷新最佳loss时顺带记录,但不参与任何训练决策。

Loss管优化,BLEU管评估。训练时只听loss的。此时 BLEU 最佳(实用最佳)和 val loss 最佳(训练最佳)不再必然重叠,所以 best checkpoint 另存两份,最近三个 epoch 滚动保留,用于实用最佳不如训练最佳时可以继续训练。

两个藏了很久的 bug

改完判据继续训之前,加了翻译日志追踪异常输出,意外发现了两个一直存在的问题。

词表定义少了个字符。 FR_TOKEN = "<fr" ——少了 >。后果是SentencePiece把 <fr>当成未知序列来切,法语方向的路由信号从一个token变成两个,三分之一的训练方向路由模糊,模型带着一只瞎眼跑了所有之前的训练。

BOS和语言标签的冲突。 训练时,目标序列以BOS(ID=1)起头,语言标签在位置1,这意味着模型首先在学的是“从源语言推测要翻译的语言类型”,而不是第一个单词的翻译。模型在推理时,首先在预测“我要翻译的目标语言是什么”,前1-2个token是懵的,输出 <zh><zh><en>s<fr>ee这种东西。

两个bug同一个本质:训练和推理的隐含假设不一致。代码能跑,loss能降,但模型一直在带伤学习。

修复很简单。<fr>补全,BOS去掉,语言标签直接做decoder首token。改完重训。

最终结果

BLEU 34.16(epoch 47),val loss 2.395。

拿15.5M模型全崩的那些句子重新跑:

三层嵌套(15.5M 输出“秘书长要求各会员国报告 suite 委员会...”的那条):

法→中:大会请秘书长向各会员国提出关于为执行委员会第六十届会议提交的报告提出的建议而采取的步骤的报告。

三层嵌套结构完整保留——大会请秘书长、会员国采取措施、落实委员会建议、第六十届会议报告,层层关系都在。

两层嵌套(15.5M输出“秘书长要求各会员国报告 suite委员会...”的那条):

中→英:The Committee recommends that Member States take appropriate measures, as set out in resolution 1325, adopted by the Security Council under Chapter VII of the Charter.

嵌套结构完整保留,委员会推荐、会员国采取、安理会通过、宪章授权——四层关系都在。

六项并列(15.5M丢了“联合王国”“俄罗斯联邦”“人道主义援助”的那条):

英→中:中国、法国、联合王国、俄罗斯联邦、美国和日本提出了经济制裁、军事合作、人道主义援助、外交谈判、技术转让和环境保护的提案。

六国六议题全部保留,零遗漏。

8+8层解决了嵌套结构的串行依赖,384维解决了多实体的并行负载。

尾声

三个项目

数语觅类证明了能力可以迁移——从颜色生成到列语义识别,问题不同,底层通用。

衔言渡意证明了迁移不是复制。

训练框架确实复用了,数据加载、checkpoint管理、梯度监控这些搭过一次就搬过来。但跨语言翻译在每一层都逼出了新的工程判断:多语言词表的分配策略,三语六方向的路由设计,序列长度和模型容量的匹配,串行与并行瓶颈的诊断和扩容方向。

三个项目走下来,最大的变化不是技能列表变长了,而是面对新任务时的动作变了。第一个项目是试——500M砍到60.6M,靠直觉和运气。第二个项目开始有方向感——从数据量反推参数规模,虽然还是事后验证。到第三个项目,有了可操作的方法:d_model_calculator给维度,任务分解给层数,诊断测试集告诉你哪个轴到了瓶颈。每一步都有依据,每一步的依据都可以被修正。

不是说方法论已经完善了——token_per_param的取值仍然靠经验,层数的任务分解仍然是“有方向的猜”。但从“拍脑门”到“有方向的猜”,这一步本身就是进步。

训练控制

这个项目里踩的另一类坑不在模型本身,在训练控制。

Dropout触发的窗口过敏、BLEU噪声误杀早停、val loss阈值在收敛末期失灵——这些问题有一个共同特征:在训练早期和中期都不会暴露,只在模型接近能力上限、loss变化量级很小的时候才冒出来。

它们的解决也有共同思路:从固定规则到统计判断。用线性回归的t检验替代单调上升判定,用loss曲线的统计特征替代patience计数器,用对loss量级自适应的阈值替代固定比例。

这些思考和韵染流光、数语觅类的经验合在一起,整理成了一篇关于训练控制的文章

KV Cache:拆开才算懂

推理优化是衔言渡意的最后一项技术任务。KV Cache 的概念很简单——把每层算过的 K/V 缓存起来,新 token 只算自己的。但 PyTorch 的 TransformerDecoderLayer 不暴露 K、V 的中间产物,想做真正的 cache 就得手写 decoder layer,把 MultiheadAttention 拆开。

实际拆开之后发现,MHA 内部并没有什么魔法——几个线性投影、一个 reshape、一次 scaled_dot_product_attention。真正咬人的是那些全量推理时不存在、cache 模式下才暴露的适配点:mask 的序列维度要累加历史长度,位置编码要从 cache 长度而不是 0 开始。

在我的模型规模下(384d、8 层、序列长度 30 左右),cache 的绝对速度反而更慢——手写 MHA 的固定开销大于省下的计算量。但增长模式变了:无 cache 时逐步变慢,有 cache 时恒定。这个优势要在更大的模型和更长的序列上才能真正体现。

完整的实现过程单独写了一篇:KV Cache 实现手记——高估了,低估了,然后搞懂了


💾 项目资源

项目代码:GitHub - Word Ferry