如果只是把一个大模型的所有权重塞给分类层,那它永远只是一个“看起来能用的编码器”。真正让大语言模型变成文本编码器的,是两件事:让因果注意力能回头读上下文,以及把表示空间里的语义坐标摆正。这个结论是我在一个文档检索项目里撞了两个月南墙才彻底想明白的。
当时我用老牌的双向预训练编码器做召回,召回率怎么调都卡在某个值上不去。后来看到 LLM2Vec 这类方案,把 decoder-only 的大模型拉过来当编码器用,确实在检索、句子相似度、聚类这些任务上硬生生把指标拉高了一截。但复现过程中最大的麻烦反而不是模型怎么改,而是改完之后的“语义对齐”——模型大了,表示空间里的坑也更大。这篇文章把我从论文到落地这段路里踩过的坑、调过的参数、想清楚的逻辑全部写出来,适合正在做语义检索、RAG 召回、文本嵌入,又对“拿 LLM 做 encoder”感兴趣的人参考。
1. 为什么要把大模型塞进“编码器”这个模具
很多人第一反应是:大语言模型不是已经很能理解文本了吗?直接用它的输出做嵌入不就行了。问题在于,decoder-only 模型设计出来是为了“接龙”,不是为了“总结全文”。这两件事看起来只差一个注意力掩码,实际差了一个表示空间的根本逻辑。
1.1 老一代编码器的软肋:不是参数不够,是目标本身跑偏
BERT 那一代双向编码器,靠的是 masked language modeling(MLM)。训练的时候随机遮住一部分 token,让模型根据上下文猜被遮住的词。这个目标本身是“局部理解”,它逼出来的表示更擅长做句法、词义层面的判断,却天然不擅长“这段文本和那段文本是不是同一个意思”这种全局语义排序。
更现实的问题是它规模太小。那个年代的主流编码器参数都在 1 亿到 3 亿这个量级,放到今天做大规模语料预训练确实吃亏。不是没有更大版本的,但训练成本摆在那,开源社区里能直接拿来用的编码器,基本都停留在“够用但不够强”的阶段。
所以在语义检索、小样本分类这类任务里,老编码器表现出一个典型症状:语法通顺、词义相近的句子得分很高,但真正的语义改写、跨领域同义表达,得分往往是错的。我那个检索项目里最典型的一个例子是,query 里出现“续费流程”,库里一篇讲“自动扣款不成功怎么办”的文章,语义上完全相关,但 BERT 系的打分却不如一篇强行包含“续费”两个字的低质文章。这就是局部匹配压过了全局语义。
1.2 Decoder 模型的“半个编码器”本质:因果注意力到底挡住了什么
Decoder-only 模型的默认配置是因果注意力,也就是每个 token 只能看到它自己和它前面的 token。生成文本时这是天然正确的设计,因为生成时你本来就不能偷看后面的答案。但做文本编码时,这个特性就成了一种自废武功。
举个具体的例子。给模型输入“苹果发布了新款手机”,因果注意力下,“手机”这个词的表示只能汇聚“苹果”“发布”“了”“新款”的信息,完全看不到它自己后面有什么。但句子里并没有别的词了,所以它还算是看全了整句话。可一旦句子是“苹果发布了新款手机,虽然价格不便宜,但销量依然火爆”,“手机”这个词就被切断了跟“价格”“销量”的联系。人在理解这句话时,“手机”的相关语义显然要跟后面这些内容融合。因果掩码挡掉的,正是这种“回头看得更多”的能力。
直接把 decoder-only 模型的最后层输出拿来做嵌入,效果通常很差。原因就在这里:每个位置的表示都被限制在左侧上下文的局部窗口里。句子长一点、信息分布分散一点,嵌入的语义完整度就崩了。我见过不少初学者把某个十几B的对话模型直接拿来做相似度计算,结果跑出来的效果还不如一个 300M 的旧编码器,以为是大模型“不会做检索”,其实只是没解决“它根本看不全句子”的问题。
1.3 三条常见的“硬蹭”路线,为什么都不太能打
想白嫖大模型的文本理解能力,社区里其实流行过几类做法。第一种是用人工构造的 prompt 去问模型“这段话是什么意思”,再把模型的回答向量化。这个方案效果不稳定,而且回答的向量里混进了模型自身的生成偏好。第二种是拿模型输出的最后一个 token 的隐藏状态,因为很多基座模型在最后一个 token 上做了过特殊处理,包含一定的全句信息。但实践中这个表示的偏置很强,句子里的高频词会严重干扰它。第三种才是真正的正路:把模型内部的注意力掩码改掉,让 decoder 在编码时也能全局可见,然后用继续训练的方式把模型“调”成编码器。
LLM2Vec 方法的核心就是这个逻辑。它不靠 prompt 打补丁,也不硬取最后一个 token,而是直接动模型结构里最根本的那个掩码。这个思路本质上是在告诉模型:你现在不是在生成文本,而是在阅读理解。方向对了,剩下的问题就是怎么训、怎么对齐。
2. LLM2Vec 的三步改造:从 next token 到“看一眼全文”
LLM2Vec 方案在业界被称为“给因果模型装双向可见性”,一共分三步:Masked Next Token Prediction(MNTP)、对比学习、无监督对比学习的进一步扩展。这三步不是三选一,而是有严格次序的,跳过任何一步,效果都会明显打折。
2.1 MNTP:双向注意力手术,以及“预测被遮住的词”为什么有效
MNTP 的做法,是把原来的 causal attention mask 替换成全可见的双向 mask,同时把训练目标从“预测下一个 token”改成“预测被遮住的 token”。听上去只是换个掩码,但有一个关键细节:模型原始词表里没有专门用来占位的 [MASK] token,你需要自己往词表里加一个,并随机初始化它的嵌入向量。
为什么必须加专门的 mask token?因为原来的模型在预训练时从来没处理过“输入里有一块内容是未知占位符”的情况。你直接把某个位置的 token 删掉或者用特殊字符替换,模型会把它当成一个普通的新词来理解。加一个专门训练的 [MASK],模型才能学会“这个位置的信息我需要根据上下文去脑补”这样一种新的输入模式。
具体到实现,我建议分三步走。第一步,加载一个开源 decoder-only 模型,把注意力掩码改为全 1 矩阵,同时关掉因果掩码逻辑。第二步,扩展词表嵌入矩阵,在尾部追加若干 special token 的 embedding。第三步,在训练时随机选取大概 15% 的 token 位置,把原始 token 替换成 [MASK],然后要求模型输出层预测出原始 token。
MNTP 阶段的训练数据不需要人工标注,直接用大规模原始语料跑就行。但我实际测试中发现,训练语料的领域分布会影响后续检索任务的偏向。比如你用一堆代码语料做 MNTP,模型对代码类文本的理解会变好,但对一般新闻、社交文本的检索不一定有明显提升。所以最好还是先想清楚下游任务需要什么领域的表示。
2.2 SimCSE 式对比学习:用随机 dropout 制造语义正样本
MNTP 训出来的模型已经具备双向编码能力,但它的表示空间还不是为语义相似度设计的。这时候需要第二阶段的对比学习,让模型学会“把语义相关的文本拉近”。
这里有个很巧妙的 trick。训练时同一个句子输入两次,但由于模型里有随机 dropout 的存在,两次前向传播得到的隐藏状态会有细微差异。这两个有差异的向量恰好构成一对语义一致的正样本,而 batch 里其他句子的向量都是负样本。这个思路本质上来自 SimCSE,简单却极其有效——不需要任何人工标注,正样本对的质量还比很多规则匹配高得多。
我当时第一次跑的时候心里犯嘀咕:两次前向传播的差异那么小,模型会不会直接躺平靠“记住输入”来让两个向量完全一致?后来想明白了,Dropout 是按神经元随机屏蔽的,模型不可能把两次输出完全变成同一个向量,它只能学会一件事:在表示层面忽略那些会随 Dropout 抖动的细枝末节,保留下真正稳定的语义内容。这一步做完,嵌入空间的语义结构会明显清晰很多。
实际操作中,对比学习的正样本还可以做更多花样。比如修改输入模板、加一句指令前缀、做轻微的文本增强,都能提高正样本区分度。但要注意别过度增强,改得太多就成了语义不完全一致的伪正样本,模型会被带偏。
2.3 把无监督信号继续榨干:词频加权与文档级正样本的取舍
到了这一步,模型的表示能力其实已经不错了。但还有一个问题:对比学习阶段使用的正样本都是“同一句子通过不同 Dropout 得到的”,它的范围很局限,只适合短句级别的相似度。如果在短文本上测试已经足够,直接收手就行。如果下游任务是段落级检索、长文档召回,那建议再往上走一步,用无监督信号构造文档级别的正负样本。
一个比较稳妥的方法是借助词频信息。把句子里的高频词和低频词做加权组合,或者对一段文档做随机遮罩,再用另一段包含相似主题词的文档构建正样本。这种做法的直觉是:TF-IDF 这样的词频统计虽然粗糙,但在“文档主题是否相关”这个维度上,它比纯向量距离要稳定得多。把词频信号作为“弱监督”叠加进对比学习,可以让编码器学到更多检索导向的语义。
不过这里有个边界要注意:无监督信号构造的正样本,误报率可能不低。两篇文档可能因为都提到“苹果”而被构造成正样本,但一个讲手机、一个讲水果。这种伪正样本如果占比过高,会反向拉坏模型。我自己的做法是严格控制正样本对的数量比例,建议每个 batch 里无监督构造的正样本对不超过 10%,其余继续用 Dropout 扰动生成的句子对,这样既能借到词频信号的力,又不容易被噪声带偏。
3. 语义对齐是真正的分水岭
很多人复现到 MNTP + 对比学习这一步,跑几个测试集发现指标还行,就以为大功告成了。但其实这一步得到的是一个“能用的编码器”,还不是一个“可信的编码器”。前者的表示空间可能是扭曲的、各向异性的,分数高只是恰好在那几个测试集上没翻车;后者的表示空间才是稳定的、可解释的、迁移得动的。这中间差的,就是语义对齐。
3.1 表示空间里的隐性病灶:高位锥形分布与共性方向抑制
几乎所有未经对齐的语言模型嵌入,都存在一个通病:向量在高维空间里并不是均匀分布的,而是挤在一个很窄的锥形区域里。这个锥形意味着所有向量彼此之间的相似度都很高,相对差异很小。你算出来的相似度分数,大部分来自一个“共性方向”的贡献,而不是真正的语义差异。这就是常说的各向异性(anisotropy)问题。
为什么会这样?一个很直觉的解释是:语言模型训练时,高频功能词(比如“的”“了”“是”)几乎出现在每个句子里,模型为了让这些词好用,会把它们的表示往一个集中的方向推。其他词的表示为了跟它们区分开,又会在整体坐标里叠加上一个跟这个方向相关的偏置。结果整个空间的坐标里混进了一个巨大的、和具体语义无关的“公共分量”。当你用余弦相似度去衡量两个句子时,这个公共分量被重复计算了,真正的语义差异就被稀释了。
另一个隐患是 token 位置编码的影响。decoder-only 模型的位置编码会随着距离线性漂移,句子越长,位置信息对向量范数的贡献越大,语义信息占比越小。这也意味着同一句话放在不同长度的上下文里,嵌入结果可能产生莫名其妙的偏移。这些都是表示空间层面的“系统性噪声”,不靠语义对齐手段是压不下去的。
3.2 后处理对齐手段的适用顺序:均值消减、白化、谱方法
处理各向异性问题,业界有一套从轻到重的后处理手段。
最轻的是均值消减(centering)。把整个语料库里所有句子的 embedding 算出平均向量,然后每个向量减去这个均值。这么做等于把共性方向上的偏移抹掉了。实现成本极低,推理时只需要存一个向量,效果却往往立竿见影。我实测过,在一些相似度数据集上,光做 centering 就能让 Spearman 相关系数涨零点几个点。
再进一步是白化(whitening)。白化不只是做一次均值消减,它会进一步对协方差矩阵做特征分解,把表示空间的坐标轴重新缩放,让各个维度的方差回归到同一起点。这样做的好处是,不再让少数几个方差极大的维度主导相似度计算。代价是需要拿一批有代表性的句子统计协方差矩阵,统计样本和推理样本如果分布差异太大,白化效果会变差。
更激进的是谱方法。通过谱聚类或者主成分分析(PCA)找出表示空间里对应“语义结构”的低维子空间,只在这个子空间里做相似度比较。这种方法对于处理长尾语义很有帮助,但计算量也最大,而且需要反复调试保留的维度数。我一般只在白化之后效果仍不理想时,才会考虑谱方法。
3.3 对齐不当反而掉点的场景与判定法
别把对齐当成无脑加分的操作。我在实际项目里遇到过几次“白化后效果反而下降”的情况,后来总结出两个典型场景。
第一个场景是检索目标文档本身就混杂了大量的领域噪声。如果文档集合里同一个主题下的内容相差很大,白化这种全局统计手段会把“领域共性”也当成是应该消掉的噪音,结果主题相关的文档反而被打散。第二个场景是向量要用于打分之后再做排序融合,比如你已经有了 BM25 分数,又想加向量的语义分数。这时候白化后的向量分数量纲变了,和 BM25 加权的比例就需要重新调,不调就直接用,效果往往不如不白化。
判定一套对齐手段适不适合,我建议看一个指标:在少量人工标注的验证集上,对比“对齐前”和“对齐后”的负例得分差。负例是指那些语义明显不相关、但模型给分偏高的样本。如果对齐后这类样本的得分明显下降,说明手段有效;如果下降微弱甚至反而上升,说明这个对齐策略对当前数据分布不适用,赶紧撤掉。
4. 从论文到可复现的训练管线
很多人看论文觉得什么都懂,一到自己写代码就卡壳。LLM2Vec 改造的难点不在损失函数,而在几个容易写错、写错后还特别难排查的结构性细节。我把这套训练管线里最关键的几个坑先拎出来。
4.1 改造模型时最容易写错的三个地方
第一个坑是注意力掩码。很多人以为把attention_mask传成全 1 矩阵就行,但实际上不少开源模型在底层仍然会强制叠加一个 causal mask。最稳妥的做法是下载模型后,手动把模型 config 里的_attn_implementation和is_causal相关字段改掉,然后在加载模型后检查model.model.layers[0].self_attn的掩码逻辑,确认它不会再自动生成上三角掩码。
第二个坑是词表扩展后的 embedding 对齐。给词表加了 [MASK] 这类新 token 之后,不仅要扩充lm_head的输出维度,还要确保tie_weights逻辑不会把已经扩展的 embedding 又回写。如果你用现成的 transformers 库,加完 token 之后建议强制验证一遍 embedding 矩阵的第一行是不是还是原来的向量,免得权重被悄悄重置,前几个 batch 的表现全部白费。
第三个坑是位置编码的长度限制。decoder-only 模型通常有固定的训练长度,比如 2048 或 4096。在做编码任务时,输入的长度可能经常逼近上限,超过长度就会被截断,导致长文档的信息大量丢失。我发现不少复现脚本完全没考虑这一点,输入一长、效果骤降。要么在数据阶段主动做滑窗切分,要么用支持长文本的模型基座,别指望截断之后靠模型力挽狂澜。下面是一个简化但能跑的 MNTP 数据构造片段:
def build_mntp_batch(input_ids, mask_token_id, mask_prob=0.15): # 复制一份输入,避免修改原始张量 masked_ids = input_ids.clone() labels = input_ids.clone() # 随机生成需要 mask 的位置,只对非特殊 token 生效 prob_matrix = torch.rand_like(input_ids, dtype=torch.float) mask_mask = (input_ids != 0) & (input_ids != 2) # 0 和 2 按你的词表定义调整 masked = (prob_matrix < mask_prob) & mask_mask # 目标位置只对被 mask 的 token 计算 loss,其余位置给 -100 labels[~masked] = -100 masked_ids[masked] = mask_token_id return masked_ids, labels4.2 训练循环与 InfoNCE 损失的一个精巧写法
对比学习的损失函数最常用的是 InfoNCE。它把每个 batch 内的所有负样本一起拉进来计算,比二元交叉熵效率高得多,也稳定得多。实现上有一个很关键的细节:不要对每个样本逐条算相似度再手动遍历负样本,而是构造一个完整的相似度矩阵,一次性算完,再用交叉熵做损失。
def info_nce_loss(query_emb, doc_emb, temperature=0.05): # 归一化,方便后续用余弦相似度 query_emb = F.normalize(query_emb, dim=-1) doc_emb = F.normalize(doc_emb, dim=-1) # 完整相似度矩阵 logits = torch.matmul(query_emb, doc_emb.T) / temperature # 对角线位置就是正样本对 labels = torch.arange(logits.size(0), device=logits.device) loss = F.cross_entropy(logits, labels) return loss这段代码的精妙之处在于,query 和 doc 在同一个 batch 里一一对应,对角线就是正样本对。只要你的 batch 构造保证query_emb[i]和doc_emb[i]对应同一条文本的两次前向,这个损失就是干净的 InfoNCE。不过要注意,如果 batch 里存在两条语义非常相近的样本,其中一个会被当成负样本,导致模型学歪。稳妥的办法是调大 batch size,负样本越多,单条假阴性造成的干扰越小。
我在实验里也用过对称版本的 InfoNCE,也就是让 doc 的方向也去对齐 query。效果会比单向略好一点,但训练成本翻倍。如果资源有限,单向已经够用,对称版本作为锦上添花即可。
4.3 评测指标的选择与“看起来涨点”的假象
评测是最容易自欺欺人的环节。最常见的坑是只用 Spearman 相关系数去做句子相似度评测,却忘了自己下游任务是检索。句子相似度数据集(比如 STS 系)衡量的是“两句话是不是同一个意思”,检索评测衡量的是“query 跟哪篇文档最相关”。这两个高度相关,但并不是一回事。
我建议至少同时看三类指标。第一类是句子相似度指标,用 Spearman 或者 Pearson,看表示空间是否连续平滑。第二类是检索召回指标,比如 Recall@10 或者 nDCG@10,看真实排序好不好用。第三类是聚类评估,比如在领域文本上跑 k-means 之后看 purity 和 NMI,看表示空间是否形成天然的分簇结构。只有三类指标都涨,才说明这套编码器在语义理解上全面可用。
还有一个“看起来涨点”的假象要特别提醒。如果你在后处理阶段做了白化或者谱降维,测试时必须保证用训练时的统计量去变换测试集,不能用全量测试集重新估计协方差矩阵。很多人在这里偷偷用了测试集的信息,导致指标虚高,部署到线上立刻崩盘。这个属于典型的“数据泄漏”,自查方法是把测试集切成两半,分别用各自估计的统计量去变换,如果两次结果差异巨大,说明统计量过度拟合了特定数据。
这是我在真实项目中经常见到的错误,尤其是那些刚从论文里拼出一个 pipeline 的团队,特别喜欢在评测上“无意识作弊”。
我把整套训练的超参建议写成一个表,方便按图索骥。
| 超参数 | 建议区间 | 我的理由 |
|---|---|---|
| 温度系数 temperature | 0.02 ~ 0.1 | 温度太高会均匀化负样本梯度,太低容易训练不稳 |
| batch size | 128 ~ 1024 | 对比学习非常吃负样本数量,小了效果断崖 |
| MNTP 学习率 | 2e-5 ~ 5e-5 | 动全量参数时偏高会破坏原有语言能力 |
| 对比学习学习率 | 1e-5 ~ 1e-4 | LoRA 加成时可尽量取上限 |
| LoRA rank | 16 ~ 64 | 过低表达不足,过高显存压力大 |
| mask 比例 | 0.1 ~ 0.2 | 15% 是分类任务的常见经验值 |
5. 我落地这套方案的血泪教训
这节写点不一定能在论文里看到、但会直接影响你 deploy 成败的内容。全部来自我自己项目的真实经历。
5.1 温度和 BatchSize:对比学习里最容易被忽略的一对
对比学习是最“吃负样本”的算法。同一个 batch 里如果没有足够多、足够难的负样本,模型会学到一个很偷懒的解决方案:把所有人都拉到一起,反正区分不出来就不区分了。这时候 loss 会快速下降,但嵌入质量一塌糊涂。所以 batch size 一定要尽可能大,实在不行就上梯度累积。我第一次跑 7B 模型做对比学习的时候,单卡只能塞下 32 条样本,效果怎么调都上不去,后来换了个投机取巧的方式,每步把前四轮的 hidden state 缓存起来,凑出 128 条负样本,效果立刻起来了。
温度系数也是同期需要调的关键。太高了,各个负样本的梯度被平均得很平,模型学不到精细差异;太低了,少数几个极相似的负样本主导梯度,训练波动剧烈。我的经验是 0.05 是个很好的起点,如果你用 7B 级别的大模型,0.02 往往更强,因为模型本身容量够大,能吸收更尖锐的梯度信号。
5.2 到底该冻结主模型还是全量微调
很多人不敢动主模型的权重,担心把它原本的语言能力训没了。我的实测结论是,如果只做 MNTP,建议对全量参数做微调,但用很小的学习率。因为 MNTP 保留了“预测被遮住词”的自监督目标,它和原来的 next token prediction 目标高度兼容,不太容易出现灾难性遗忘。
到了对比学习阶段,我强烈建议冻结主干,只训练 LoRA 适配层。原因有二。第一,对比学习的目标是压缩表示空间,这个目标很容易“过度优化”,把模型推向一个空间形态很完美但对原始语义遗忘严重的极端状态,LoRA 把这个极端状态限制在一个小维度子空间里,安全性高得多。第二,工程上冻结主干之后,梯度的显存开销小得多,batch size 可以大大提升,而 batch size 对对比学习的效果影响远大于参数量全量更新带来的收益。我用 7B 模型做实测,冻结主干加 LoRA 的总效果要好于全量微调。
5.3 生产环节的推理速度与维度压缩
训练完一套模型,真正上线时还有两个绕不开的问题。第一是推理速度:decoder-only 模型做编码时,虽然不需要自回归生成,但每一层 transformer 的 forward 计算量仍然是双向的,比同等规模的 BERT 系要慢不少。我当时的优化办法是把模型转成半精度,再做 kernel 融合,推理延迟降到了可接受范围内。如果你的服务对延迟极其敏感,建议直接选一个较小参数规模的基座模型,7B 够用,34B 很容易把成本推到失控。
第二是嵌入维度。大模型输出的 hidden feature 动辄几千维。如果直接把这么多维的向量存进向量数据库,存储成本非常大。常见做法是用 PCA 把维度压到 256 维或者 512 维。但要注意:PCA 必须在训练集上拟合并保存降维矩阵,同时降维之后再跑一遍检索指标,确认信息没有过度丢失。不要想当然地“压到 256 维一定差不多”,我测试过某些数据上压到 256 维会丢掉 2% 左右的 Recall@10,这个损失在业务上可能不可接受。
最后再说一个小经验。我后来在项目里习惯先把 MNTP 基础模型训好,再拿它去生成一个较小模型的蒸馏训练集,把大模型的嵌入能力蒸馏给一个小得多的专用编码器。这样做之后,线上推理用蒸馏出来的小模型,速度和成本都能兼顾。如果你也在纠结大模型嵌入效果虽好、但成本压不下来,可以认真考虑这个方向。至于“多大模型算大”其实没有标准答案,关键是先把双向可见性和语义对齐做扎实,再谈规模缩放。