Decoder-only Transformer:为什么AI圈都押注“只剩解码器”的架构
Decode-Only Transformer:为什么整个AI圈最后都押注在"只剩解码器"的架构上
2020年我刚接触Transformer时,周围人讨论最多的是BERT,面试官最爱追问"CLS向量到底该怎么用"。风向变得比想象中快,如今GPT系列、LLaMA、Qwen这些站在前沿的模型,清一色是Decoder-only架构——也就是题目里说的Decode-Only Transformer。我最初对这个转向是有些抵触的,毕竟教科书上把"编码器-解码器"称为标准结构,一个连编码器都不要的模型凭什么通吃所有任务?后来自己动手跑模型、调参、部署推理服务,才把这笔账慢慢算明白。这篇文章想把整套逻辑完整梳理一遍:Decode-Only为什么能赢过另外两支血脉,它的因果注意力内部到底在算什么,KV Cache怎么让推理提速几十倍,以及真到自己落地时,显存、参数量、各种坑分别该怎么算、怎么避。适合已经对Transformer有基础概念、想往GPT类模型内部再走一步的读者,也适合那些准备拿这套架构做实际项目的人。
1. 为什么"只剩解码器"反而赢了:三支血脉的路线之争
1.1 三种架构的血缘与分工差异
Transformer模型按结构可以粗略分成三支:Encoder-Decoder、Encoder-Only 和 Decoder-Only。它们不是随便剪出来的,而是按照不同的任务目标演化的。
Encoder-Decoder是原版论文的形态,左侧编码器吃完整输入,右侧解码器自回归吐输出。BART、T5都属于这类,天然适合翻译、摘要这种"一种序列变另一种序列"的任务。Encoder-Only把编码器单独拎出来用,代表作是BERT,它通过掩码语言模型的方式预训练,擅长做分类、抽取、语义相似度这类"理解"型任务。Decoder-Only则只保留解码器,代表作是GPT系列,它的训练目标极度简单:给定上文,预测下一个token。
我之前画过一张对比表,基本可以概括三者的差异:
| 架构类型 | 注意力可见范围 | 代表模型 | 强项任务 | 预训练目标 |
|---|---|---|---|---|
| Encoder-Decoder | 编码器双向 / 解码器因果 | BART、T5 | 翻译、摘要、生成 | 去噪自编码 + 自回归 |
| Encoder-Only | 双向 | BERT、RoBERTa | 分类、抽取、匹配 | 掩码语言模型 |
| Decoder-Only | 因果(单向) | GPT、LLaMA、Qwen | 生成、对话、推理 | 自回归语言模型 |
仅从这张表看,三者分工明确,Decoder-Only似乎只是"负责生成"的专用组件,没什么特别。但过去几年发生的事是:Decode-Only不仅把自己的主场拿下了,还把理解、分类、甚至多模态任务也一并收拾干净。理解能力表面积反而变小了,内部却变深了。
1.2 自回归的本质:把"理解"改写为"预测"
Decoder-Only真正颠覆性的地方,在于它重新定义了什么叫"理解"。
传统观点认为,理解一道阅读理解题,需要把文章和问题全面编码,然后输出类别或片段。BERT路线就是这么干的,它给每个token一个双向的上下文表示,最后用特殊标记的向量去做分类。这套思路在GLUE时代确实统治过一阵。
但Decode-Only走的是另一条路:不显式建模"理解"这个中间状态,而是直接建模"在给定所有上下文的情况下,下一个token是什么"的条件概率。你说它没"理解"?不对,如果它能在看完一份病历后准确写出诊断结论,能在看完整段代码后接上正确的下一行,那它对上下文的理解已经被压缩进参数里了,只是没有以"向量+分类头"的形式展示给你看。
这种设计的精妙之处在于,几乎所有任务都能被重新组织成预测任务。分类可以变成"这段评论的情感是:正面?负面?",抽取可以变成"文中提到的人名是:张三、李四",生成更不用说了,骨架就是自回归。训练目标始终只有一个——下一个token预测。所以Decode-Only天然具备"一个模型,到处可调"的优势。
1.3 行业从BERT转向GPT的深层原因:算力效率
行业集体押注Decode-Only,除了任务统一性之外还有一个更现实的原因:训练效率。
Encoder-Only的BERT类模型虽然能并行计算所有token,但它的掩码语言模型目标有个尴尬之处——15%的掩码token里,只有部分参与损失计算,大量计算其实花在了"重建被遮住的词"这一人为任务上。而Decode-Only的每个token都参与预测下一个token的损失,训练信号密度更高。
更重要的是,只要预训练架构选定了,部署阶段的取舍就逃不掉了。生成任务天然是自回归的,你让Encoder-Decoder来做生成,解码器部分照样是因果注意力,那前面那个编码器就变成了生成时的负担——每次都要把完整输入重新编码一遍。Decode-Only则干脆利落:输入和输出共用一套参数,KV Cache一套到底,推理时内存增长几乎是线性的。我在后面第4节会详细算这笔账。
2. 因果注意力的内部世界:Decode-Only的基石
2.1 掩码矩阵的构造:上三角里藏着的秩序
因果注意力是Decode-Only和另外两支架构最本质的差别。理解它,需要回到注意力公式:
[ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]
这个公式本身是双向的——第i个token可以和序列里任意一个token做内积。要想让它变成"只能看自己和前面",最直接的做法就是在softmax之前把注意力分数矩阵的上三角部分改成负无穷:
import torch def build_causal_mask(seq_len): # 上三角置为1,其余为0 mask = torch.triu(torch.ones(seq_len, seq_len, dtype=torch.bool), diagonal=1) return mask # 示例:seq_len=4时mask长这样 # [[False, True, True, True], # [False, False, True, True], # [False, False, False, True], # [False, False, False, False]]构造出掩码后,加到注意力分数上,再执行softmax。由于负无穷经过softmax后趋近于0,那些"未来位置"的注意力权重会被完全压制。这就是因果注意力的全部秘密。我第一次手撕这段代码时觉得这也太简单了,后来才意识到,简单背后是数学上优雅的"强制时间有序"——它把序列建模中"不能偷看答案"的约束硬编码进了计算图。
训练阶段,torch的FlashAttention等实现会把掩码和注意力融合起来优化,省得先算完整矩阵再塞负无穷浪费显存。但原理层面,你手动构造mask永远不会错。
2.2 并行训练的假象与串行推理的现实
Decode-Only有一个让人困惑的点:训练时可以一次性把一整段文本喂进去,所有位置的输出同时算出来,看起来完全是并行的;但推理时却必须一个token一个token地蹦出来。为什么会有这种错位?
因为训练时每个位置的正确答案是已知的。你预测第i个token时,用的"上文"是语料里真实的token,而不是模型自己生成的。这种范式叫"教师强制"。既然正确答案都在手里,模型当然可以同时学习所有位置的预测,互不干扰。
推理时就完全不一样了。模型输出端只有一个位置有初始输入(通常是bos标记),要拿到第i个token的概率分布,必须先把前面第0到第i-1个token全部计算一遍。第i+1个token又要重新从第0开始。如果每次都全量重算,复杂度是O(T^2)——这就是我下一节要讲KV Cache的动机所在。
很多新手会问:训练和推理行为不一致,模型不会懵吗?答案是会,这就是为什么大模型训练后期要用"序列级损失"或引入特殊的训练技巧来弥合差距。不过在标准实践中,大家默认接受这个不一致,然后用工程手段去补。
2.3 位置信息怎么算:从绝对位置到旋转位置编码
Decode-Only还有一个必须解决的问题:注意力本身是不认识顺序的,输入"我打你"和"你打我"在无位置信息时算出的向量一模一样。因此必须把位置注入进去。
早期GPT用的是可学习绝对位置编码,给每个位置配一个可训练向量,直接加到token embedding上。这个方法简单,但有两个短板:一是没见过的序列长度就没有对应的位置向量,需要外推或截断;二是位置信息是"加"到语义里的,模型很难把"某个位置的token是什么"和"这个位置在哪"解耦。
后来RoPE(旋转位置编码)成了主流,LLaMA、Qwen、DeepSeek等都用它。RoPE的思路是:把token的Q和K向量按维度两两分组,每组看作复数平面里的一个点,然后根据位置差做一个旋转变换。旋转后的Q和K做内积时,自然携带了"相对位置"的信息——两个token距离越远,旋转角度差越大,内积里多出来的相位差就可以表示相对位置。
写代码时需要手动给query和query注入旋转矩阵:
import torch import torch.nn as nn import math def precompute_rope_freqs(dim, max_seq_len, base=10000.0): freqs = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) t = torch.arange(max_seq_len).float() angles = torch.outer(t, freqs) # [max_seq_len, dim/2] return torch.stack([angles.cos(), angles.sin()], dim=-1) # [max_seq_len, dim/2, 2] def apply_rope(x, pos, rope_cache): # x: [batch, num_heads, seq, head_dim] # 将x两两一组,与旋转角度相乘 x = x.view(*x.shape[:-1], -1, 2).float() cos = rope_cache[pos][..., 0].unsqueeze(0).unsqueeze(0) sin = rope_cache[pos][..., 1].unsqueeze(0).unsqueeze(0) x1 = x[..., 0] x2 = x[..., 1] rotated = torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1) return rotated.flatten(-2).to(x.dtype)RoPE如今在Decode-Only里几乎成了默认配置,不少中文资料讲"位置信息怎么计算"也都在围绕RoPE展开。它对长序列更加友好,而且天然支持相对位置理解,这对"预测下一个token"这种顺序性极强的任务非常重要。
3. 手写一个能跑的最小Decode-Only Transformer
理论知识说了一堆,不如直接动手写一个玩具规模的模型。我在"transformer手写"这个方向上也踩过不少坑,这里按我实际能跑通的路径给你拆一遍。目标不是复刻LLaMA,而是让你看到每个模块的真实形状,跑通一个"从字符序列到loss 下降"的完整闭环。
3.1 基础组件:RMSNorm与SwiGLU
现代Decode-Only通常不再用LayerNorm,而是用RMSNorm。区别在于RMSNorm去掉了均值的计算,只做按均方根归一化。实现很简单:
import torch import torch.nn as nn class RMSNorm(nn.Module): def __init__(self, dim, eps=1e-6): super().__init__() self.weight = nn.Parameter(torch.ones(dim)) self.eps = eps def forward(self, x): rms = torch.sqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) return x / rms * self.weight为什么用RMSNorm而不是LayerNorm?一个原因是计算量小一截,另一个原因是在Decode-Only这种大量堆层数的场景里,去均值未必带来收益,反而省掉中心化步骤能减少数值不稳定的来源。很多大模型实验发现,RMSNorm在深网络上一样稳,速度还快。
FFN部分,主流选择是SwiGLU激活。它本质是门控线性单元的变体,把原来的单层线性+ReLU换成了三个权重矩阵:
class SwiGLU(nn.Module): def __init__(self, dim, hidden_dim): super().__init__() self.w1 = nn.Linear(dim, hidden_dim, bias=False) self.w2 = nn.Linear(dim, hidden_dim, bias=False) self.w3 = nn.Linear(hidden_dim, dim, bias=False) def forward(self, x): # 先经过w1走激活,再和w2的门控相乘,最后w3降维 gate = torch.nn.functional.silu(self.w1(x)) hidden = self.w2(x) return self.w3(gate * hidden)SwiGLU比普通ReLU FFN更强的经验证据来自很多开源模型的消融实验。直观理解是,门控机制能让网络对每个token的"信息流入量"做精细化控制,相当于多了一个可学习的开关。
3.2 单层Block:注意力与FFN的完整搭配
有了RMSNorm和SwiGLU,可以开始堆Block。每个Block内部顺序是:先对输入做RMSNorm,再进多头因果注意力,残差连接;再做RMSNorm,进SwiGLU FFN,再残差连接。
class DecoderBlock(nn.Module): def __init__(self, dim, num_heads, hidden_dim): super().__init__() self.norm1 = RMSNorm(dim) self.attn = CausalSelfAttention(dim, num_heads) self.norm2 = RMSNorm(dim) self.ffn = SwiGLU(dim, hidden_dim) def forward(self, x, mask, rope_cache, positions): x = x + self.attn(self.norm1(x), mask, rope_cache, positions) x = x + self.ffn(self.norm2(x)) return x残差连接在浅层模型里看不出什么,但堆到十几层后你会发现没有残差根本训不动。它让梯度可以直接从输出层回传到任意一层,不必穿过所有非线性模块。
CausalSelfAttention内部也不复杂,核心步骤如下:输入先过三个Linear得到Q、K、V;在Q和K上注入RoPE位置信息;用flash attention或手动方式计算带掩码的注意力分数;最后过输出投影。
证明一下因果掩码的作用:假设输入长度是5,QK^T形状是[5,5],我们把上三角置为负无穷后再做softmax,第4个token只能和自己以及前面3个token交互,彻底看不到未来。
3.3 训练一个小模型:从代码到loss变化
把多个Block串起来,前面接token embedding,后面接LM head,一个玩具Decode-Only就成型了:
class TinyDecoder(nn.Module): def __init__(self, vocab_size, dim=128, num_heads=4, num_layers=4, max_seq_len=256): super().__init__() self.embed = nn.Embedding(vocab_size, dim) self.blocks = nn.ModuleList([ DecoderBlock(dim, num_heads, dim * 4) for _ in range(num_layers) ]) self.norm_final = RMSNorm(dim) self.lm_head = nn.Linear(dim, vocab_size, bias=False) self.lm_head.weight = self.embed.weight # 参数共享 self.max_seq_len = max_seq_len self.rope_cache = precompute_rope_freqs(dim // num_heads, max_seq_len) def forward(self, idx): B, T = idx.shape x = self.embed(idx) positions = torch.arange(T, device=idx.device) mask = build_causal_mask(T).to(idx.device) rope_cache = self.rope_cache.to(idx.device) for block in self.blocks: x = block(x, mask, rope_cache, positions) logits = self.lm_head(self.norm_final(x)) return logits训练时用交叉熵损失,标签就是输入右移一位后的结果。下面这段代码可以拿一份英语文本直接跑。我自己试过在一个3MB的莎士比亚语料上训练,大约100步后就能看到loss从4.5左右降到2.8,模型开始能拼出像样的单词。
def train(): model = TinyDecoder(vocab_size=100) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) loss_fn = nn.CrossEntropyLoss() for step in range(500): x, y = get_batch() # x: [B, T], y: [B, T],y是x右移一位 logits = model(x) # [B, T, vocab] loss = loss_fn(logits.view(-1, logits.size(-1)), y.view(-1)) optimizer.zero_grad() loss.backward() optimizer.step()这里有个容易被忽略的细节:embedding和lm_head参数共享能省大量显存,尤其是vocab_size很大时。小模型里效果也好,因为共享后模型被迫让表示空间同时兼顾"输入语义"和"输出预测",起到隐式正则的效果。
3.4 生成阶段的采样逻辑
训练完这个玩具模型,就可以让它生成文本了。初始化输入放一个起始token,然后反复做前向传播,每次从最后一个位置的logits里采样下一个token,把它拼到序列末尾,继续下一轮。这就是自回归生成的雏形。
def generate(model, prompt, max_new_tokens=50, temperature=0.8, top_k=40): idx = prompt for _ in range(max_new_tokens): logits = model(idx)[0, -1, :] # 只取最后一个位置 logits = logits / temperature # top-k 截断 topk_vals, topk_idx = torch.topk(logits, top_k) logits = torch.full_like(logits, float('-inf')) logits[topk_idx] = topk_vals probs = torch.softmax(logits, dim=-1) next_idx = torch.multinomial(probs, 1) idx = torch.cat([idx, next_idx], dim=-1) return idx这是理解推理阶段一切的起点。你在这个demo里看到的"每生成一个token都要喂入全部历史",正是第4节KV Cache要解决的核心矛盾。
4. KV Cache:推理阶段的显存换时间账本
4.1 朴素自回归的浪费全在重复计算
如果一个7B模型在生成长度为1000的序列时,每一步都把完整历史重新forward一遍,总计算量和序列长度的平方成正比。原因在于第i步的隐藏状态是逐步依赖的,前几百步算出的隐藏状态,实际上在第1000步又要原封不动再算一回。
注意力部分的浪费最直观:第i步生成时,对第j个历史位置(j < i)计算K_j和V_j,第i+1步又会对同样位置重新算一遍完全相同的K_j和V_j。我实际测试过,不缓存KV的情况下,生成一个50token的短句也还能忍,一旦生成几百上千token,每步的延迟会线性恶化,因为K和V矩阵会越来越宽。
解决思路非常朴素:反正历史位置的K、V不会变,那就把它们存在内存里,下一步直接拼接使用,只计算当前token的Q、K、V。
4.2 缓存实现:预分配还是动态拼接
KV Cache的简易实现是动态拼接:
def forward_with_kv_cache(self, x, past_kv=None, position_ids=None): B, T, _ = x.shape q, k, v = self.q_proj(x), self.k_proj(x), self.v_proj(x) if past_kv is not None: past_k, past_v = past_kv k = torch.cat([past_k, k], dim=2) # 沿序列维度拼接 v = torch.cat([past_v, v], dim=2) # 这里再注入RoPE时要注意: # 对新增token(q、k) 使用当前位置的旋转角 # 对拼接后的完整k 只需旋转新增部分,历史部分已经编码过位置实际工程里,纯拼接带来大量内存拷贝,所以像vLLM、TensorRT-LLM这类推理框架会把KV缓存预先分配成固定大小的显存块,用位置索引去填充,避免反复重新分配。
还有一个关键细节:RoPE的旋转注入,必须只对新token做。如果你把历史K取出来再整体旋转一遍,就等于是把已经旋转过的向量再旋转一次,位置信息直接串台。
4.3 KV Cache的显存代价
KV Cache把计算复杂度降到了O(T),但显存开销上升得非常惊人。我常跟同事算这笔账,公式如下:
[ \text{KV Cache显存} = \text{batch} \times \text{seq_len} \times \text{layers} \times 2 \times \text{num_heads} \times \text{head_dim} \times \text{bytes_per_elem} ]
以LLaMA-7B举例:32层、32个注意力头、head_dim=128、FP16存储。假设推理时batch=32,上下文长度2048:
(32 \times 2048 \times 32 \times 2 \times 32 \times 128 \times 2 \approx 34.4\text{GB})
34GB只是一个batch 32、2K上下文的KV Cache。这时候你才会理解为什么开源社区要疯狂研究GQA和MQA——因为在服务长上下文时,KV Cache可能比模型权重还贵,甚至贵好几倍。
4.4 GQA与MQA:压缩KV的工程实践
标准的MHA(多头注意力)是每个头都有独立的K、V。GQA(分组查询注意力)把多个query头共享一组K、V;MQA(多查询注意力)更极端,所有query头共享一组K、V。
以LLaMA-2 7B为例,如果从MHA改成8组GQA,KV Cache的显存直接降到原来的1/4左右。代价是理论上模型的表达能力略有下降,但实测中GQA在绝大部分任务上几乎不掉点,这也是为什么近两年新发布的模型大部分都标配GQA。
我在自己项目里尝试过这样的分组实现:
class GQA(nn.Module): def __init__(self, dim, num_heads, num_kv_heads): super().__init__() assert num_heads % num_kv_heads == 0 self.num_heads = num_heads self.num_kv_heads = num_kv_heads self.group_size = num_heads // num_kv_heads self.head_dim = dim // num_heads self.q_proj = nn.Linear(dim, num_heads * self.head_dim, bias=False) self.k_proj = nn.Linear(dim, num_kv_heads * self.head_dim, bias=False) self.v_proj = nn.Linear(dim, num_kv_heads * self.head_dim, bias=False)实际部署时,GQA的K、V缓存量比MHA小很多,服务端的并发能力会成倍提高。以我的经验,如果你要部署一个7B模型做高并发对话服务,直接上GQA或MQA是性价比最高的优化手段,因为它的收益不靠任何花哨算法,只靠砍掉重复KV。
5. 现实项目里的选型、算账与踩坑
5.1 显存估算:动手之前先算这笔账
做Decode-Only项目,最常见的问题不是算法跑不通,而是显存爆了。训练、微调、推理三阶段的显存占用逻辑完全不同,需要分开估算。
训练阶段,模型权重、梯度、优化器状态、激活值四者都要占显存。以AdamW优化器为例,一个7B模型用FP16训练,权重占14GB,梯度占14GB,优化器状态占42GB(因为AdamW会存一阶动量m和二阶动量v,都是FP32),激活值另算。这就意味着,如果你只有一张24GB的显卡,想全量训练7B模型纯属做梦。LoRA这类参数高效微调技术之所以火爆,核心原因就是它把优化器状态和梯度从训练开销里砍掉了绝大部分。
推理阶段则是权重、KV Cache、激活值三者的博弈。7B模型FP16权重14GB,加上KV Cache和激活值,一张A100(80GB)能塞下batch 32、4K上下文的推理服务,这已经是比较理想的状态了。
5.2 训练稳定的经典坑:warmup与loss尖刺
Decode-Only模型在训练过程中最让我头疼的问题之一,是loss尖刺。长时间训练loss平稳下降,突然某一batch出现巨大损失值,然后整个训练节奏都被打乱。
我的经验里,loss尖刺最常见的两个来源:一个是数据里出现了异常长或异常重复的样本,导致梯度过大;另一个是学习率过高,随着模型规模增大,最优学习率反而需要下降,而且AdamW的beta2对长上下文场景往往需要从0.999调到0.95或0.98。
实操建议是把warmup步数从1000提到5000以上,尤其是在2B以上规模的模型训练中,用3%左右的全局步数做warmup是比较稳的选择。同时设置梯度裁剪,clip值设为1.0,能直接把尖刺问题压掉一大半。
还有一个很容易被忽略的细节:positional embedding或RoPE的base参数,如果直接沿用10000而训练时上下文长度比推理时短,很多模型会在长文本上出现性能跳水。LLaMA把RoPE base从10000调到500000才支持32K上下文,核心思路就是让高频旋转变得更慢,给外推留出余地。
5.3 推理时最容易忽视的三个参数
模型服务上线后,问题往往不在模型本身,而在于解码策略的配置。我在多个项目里总结出三个最容易被忽视的推理参数。
第一个是temperature。很多新手以为越接近0越"聪明",结果发现模型只会复读最高频的词。推荐值一般在0.6到0.9之间,要看业务对随机性的容忍度。严格打分场景可以用0,内容创作场景0.8左右体验最好。
第二个是max_new_tokens。如果你给模型一个很大的上限,它可以在回答结束后开始编造内容,这在开放式生成中尤其明显。实践中建议给一个合理的截断上限,同时设置停止符检测,别让模型一口气把上下文窗口填满。
第三个是EOS token的处理。你在自回归生成时必须显式判断是否需要停止。有些微调模型对EOS的敏感度不高,生成完回答后还会继续输出,这时候你可以在解码器里加入"连续N个相同token时断开"的自定义逻辑,而不是死等EOS。
5.4 Decode-Only之外:它如何沿用到了视觉和多模态
最后说一个方向性问题。很多人以为Decode-Only只关心文本,但Vision Transformer(ViT)和Swin Transformer的火热,以及"transformer图像分类"这类话题的出现,本质上是同一套架构在不同模态上的复制。
图像分类任务中,通常的做法是把图片切成patch序列,用Encoder-Only或Decode-Only的底层模块提取特征。而图像生成任务,如自回归图像生成,则直接对齐到了Decode-Only:把像素或patch当作token,按顺序预测。甚至医学图像分割领域里出现的"MissFormer"这类工作,也正是把Transformer在局部和全局特征提取上的能力做了重新组合。
这说明一个趋势:模型架构正在收敛。不管输入是文本、图片还是序列数据,预训练目标越来越统一为"预测下一个元素",而载体大概率就是Decode-Only。这让我想起早年看《The Illustrated Transformer》时的感觉——作者用大量图示解析注意力,依然是在回答"信息如何流动"的问题。Decode-Only把这个答案收敛得越来越简洁:只留一条自回归的流,让所有任务都向它对齐。
我在多个项目中的体会是,架构选型不是追赶时髦,而是要从数据形态、任务目标、推理成本三个维度去倒推。如果你要做一个生成式产品,哪怕业务里还夹着分类和抽取,优先考虑Decode-Only也是对的,因为在统一架构里微调多个任务的成本,远低于维护三套模型。
最后分享一个小技巧:如果你在现有开源模型上做微调,训练和推理的上下文长度最好保持一致,即便你的训练数据里大部分样本都比较短,也要把训练时的序列长度pad到推理长度附近。很多微调模型在长文本场景下表现崩盘,不是因为模型本身不行,而是因为训练时的位置信号分布和推理时不一致。这个坑我至少碰了三次,每次都是重新审视数据填充策略才解决的。