☰
EuroLLM-1.7B与Instruct对比:指令微调、SFT与LoRA实战
2026/11/2 7:06:35 网站建设 项目流程

EuroLLM-1.7B 和 EuroLLM-1.7B-Instruct 摆在一起看,参数量一模一样,词表一模一样,连权重的张量形状都对得上,但你把同一句中文指令丢进去,出来的东西完全是两个物种。前者像个刚读完海量语料的实习生,你说"帮我写个正则",它会接着你的话往下编,编到一半开始自言自语;后者会直接给你一段可以用re.compile跑起来的东西。很多人第一次接触指令调优(Instruction Tuning)就是从这种对比开始的,因为它足够直观地告诉你:大模型的"能力"和"听话"是两件独立的事。这篇内容适合三类人:手上有 base 模型想自己微调的、拿 instruct 模型做产品但搞不清它为什么偶尔"退化"的、以及想搞清楚 SFT(Supervised Fine-Tuning)到底改了模型哪个部分的。我会从两个模型的加载差异讲起,一路讲到怎么自己复现一次轻量指令微调,中间穿插我自己踩过的坑和排查链路,不讲空话。

1. 同一个底座两种人格:先弄明白它俩到底差在哪一层

1.1 预训练目标决定了它天生是个"续写机器"

要理解指令调优在做什么,得先接受一个有点反直觉的事实:base 模型从来就不是为"回答问题"而训练的。它的训练目标是标准的自回归下一 token 预测,给定前缀,最大化后续 token 的似然。这个目标本身不区分"用户说的话"和"模型该说的话",在它眼里全都是连续的文本流。所以当你在 EuroLLM-1.7B 上输入"请解释一下什么是 LoRA",它最可能的行为不是回答你,而是顺着这个句式继续编造——因为它在预训练语料里见过太多"请解释一下什么是 XXX"后面跟着"请解释一下什么是 YYY"的列表页、目录页、试题集。

这种"续写惯性"不是 bug,是训练目标的直接产物。它在很多任务上其实是优点:做文本补全、做数据增强、做风格模仿,base 模型往往比 instruct 版本更"自由",因为它没有被人为约束成"必须回答"。我见过有团队做古文续写,用 instruct 模型效果反而僵硬,换回 base 加上精心设计的 few-shot 前缀才自然。所以第一个结论要先立住:base 不是"能力弱",而是"目标错位"。它掌握的知识量、语言覆盖度跟 instruct 版本是同一套权重底座,差的是一层行为对齐。

放到 EuroLLM 这条线上看更明显。这个系列本身的设计重点之一是多语言覆盖,词表做过针对性的扩展,欧洲各种语言的 tokenization 效率都比直接套英文词表好不少。这个特性在 base 模型上是"隐性的"——你得自己设计 prompt 才能把某种语言的能力激发出来;在 instruct 模型上是"显性的"——你用该语言提问,它就用该语言回答。差别不在知识,在触发方式。

1.2 SFT 改的不是知识,是"什么时候该停、该给什么格式"

很多人以为指令调优是往模型里灌新知识,这是个挺常见的误解。SFT 阶段的数据量相比预训练小了几个数量级,靠这点数据根本塞不进新的事实性知识。它真正在做的事情,用一句话概括:在给定的对话前缀条件下,重新分配下一个 token 的概率分布,让"像回答的文本"概率上升,"像续写的文本"概率下降。

具体到参数层面,SFT 是拿 base 权重做初始化,用 (指令, 回答) 配对数据继续做梯度下降。全参微调的话所有权重都会动,但动的幅度很小,因为学习率低、步数少。这解释了一个现象:instruct 模型在通用知识问答上未必比 base 强,甚至在某些冷门知识上会略微退化(灾难性遗忘的轻度表现),但它在"遵循格式""理解角色""知道何时结束"这些行为维度上是碾压的。

"知道何时结束"这件事特别值得单独拎出来说。base 模型经常出现"停不下来"的情况,生成到max_new_tokens被硬截断,结尾半句话。原因是它在预训练时见到的文档结尾五花八门,没有统一的终止信号。而 SFT 数据在构造时,几乎每条样本的末尾都会被拼上 EOS token,模型被反复强化"回答完了要吐 EOS"。这个行为在推理时表现为:你设了eos_token_id,它真的会停。这个细节在后面讲生成参数时还会提到,因为它是排查"输出截断"问题的第一现场。

维度EuroLLM-1.7B(base)EuroLLM-1.7B-Instruct
训练目标下一 token 似然条件于指令的下一 token 似然
典型输入任意文本前缀带角色标记的对话模板
终止行为无稳定终止信号,易撞上限训练中强化过 EOS,通常能自然停
格式遵循需 few-shot 诱导零样本即可,但对模板敏感
适合场景续写、补全、领域继续预训练问答、工具调用、结构化输出

这张表不是让你背的,是让你在选型时有个判断依据。如果你要做的是"给一段代码补全后半段",别用 instruct;如果你要做的是"根据用户自然语言描述生成 SQL",别用 base。

2. 把两个模型同时拉起来:加载、模板与第一批对照输出

2.1 加载代码看着一样,模板处理完全不是一回事

从 transformers 的角度,两个模型的加载代码几乎一模一样,这恰恰是最容易让人放松警惕的地方。真正的分水岭在 tokenizer 的chat_template属性上。Instruct 版本通常会带一个 chat template,你能直接调apply_chat_template;base 版本要么没有这个属性,要么即使有也不该用——套上模板等于给模型喂了一种它训练时没见过的格式,输出质量会莫名其妙地崩。

先看 instruct 侧的标准写法:

import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_id = "utter-project/EuroLLM-1.7B-Instruct" tok = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.bfloat16, device_map="auto", ) model.eval() messages = [{"role": "user", "content": "用一句话解释什么是梯度累积。"}] prompt = tok.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) inputs = tok(prompt, return_tensors="pt").to(model.device) with torch.inference_mode(): out = model.generate( **inputs, max_new_tokens=256, do_sample=False, eos_token_id=tok.eos_token_id, pad_token_id=tok.eos_token_id, ) new_tokens = out[0][inputs["input_ids"].shape[-1]:] print(tok.decode(new_tokens, skip_special_tokens=True))

add_generation_prompt=True这个参数千万别漏。它的作用是在模板末尾追加"轮到助手说话了"的标记。漏掉它,模型看到的是"用户说完话,对话结束",于是它要么不吭声,要么开始自己扮演用户继续往下编。我在第一次跑的时候就是因为这个参数写在另一个分支漏了,对着输出困惑了半小时。

base 侧的写法思路完全不同,你要手工构造一个"看起来像训练语料"的前缀:

model_id = "utter-project/EuroLLM-1.7B" tok = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.bfloat16, device_map="auto" ) prompt = ( "问题:用一句话解释什么是梯度累积。\n" "回答:梯度累积是在显存受限时,把多个小 batch 的梯度累加后再统一更新参数的做法。\n\n" "问题:用一句话解释什么是混合精度训练。\n" "回答:" )

注意这里的 few-shot 结构。给一个示例,模型就能推断出"哦,这是在让我按'回答:'的格式产出内容"。给两个示例,格式稳定性会明显提升。这就是 base 模型的用法——你不教它规则,你演示规则。

2.2 chat template 缺失时的兜底写法

有些分发版本会裁掉 tokenizer 里的 chat template 字段,或者你手上是本地转换过的权重。这时候apply_chat_template会直接抛错。兜底方案是自己写模板函数,但前提是你得知道原模型的角色标记长什么样。最稳的做法是去翻模型的tokenizer_config.json,看chat_template字段里的原始字符串,把特殊 token 抄出来。

一个典型的兜底写法长这样:

def build_prompt(user_text: str) -> str: # 角色标记以模型 tokenizer_config 中的实际定义为准 return ( f"<|user|>\n{user_text}\n" f"<|assistant|>\n" )

写完一定要验证:把构造出的 prompt 打印出来,用tok.encode编一遍再decode回来,看特殊 token 有没有被当成普通文本切碎。如果<|user|>被切成了<、|、user、|、>五个 token,说明它不是词表里的特殊 token,模型根本认不出来,你前面所有工作白做。这个检查我建议做成习惯,每次换模型都跑一遍,成本十秒钟,能省掉一整天的怀疑人生。

另外提醒一句:base 模型不要硬套 instruct 的模板,反过来也一样。混用会导致模型进入一种"半懂不懂"的状态,输出既不像是回答也不像是续写,非常难排查,因为你会怀疑是采样参数的问题。

3. 六组对照实验:把"魔法"拆成可观测的差异

3.1 实验设计:控制变量比堆样本重要

做模型对比最容易犯的错是变量不控制。温度不同、max_new_tokens不同、prompt 长度不同,最后得出的结论全是噪声。我建议固定这几项:do_sample=False(贪心解码,可复现)、同一份 prompt 集合、同样的max_new_tokens、同样的精度(统一 bf16)。贪心解码的缺点是输出可能偏死板,但对比实验要的是可复现,不是好看。

样本不用多,六组足够看出趋势。我一般按这几个维度切:单轮事实问答、多轮指代消解、结构化输出(要求返回 JSON)、多语言混合、指令冲突(要求"只输出数字不输出别的")、以及边界拒答。每组跑两个模型,人工看输出。

下面是我在某次实测中记录的结果形态,具体输出内容因随机性和版本差异会有出入,这里描述的是差异的类型而不是逐字结果:

测试维度base 表现特征instruct 表现特征
单轮事实问答常以"这个问题……"开头自我复述直接给答案,偶有冗长开场
多轮指代无法稳定维持"它"指代前文实体基本能正确解析,长对话后偶有漂移
JSON 输出需 3 个以上示例才勉强稳定零样本约七成稳定,加一句"只输出 JSON"可达九成
多语言混合倾向切换到 prompt 中占比最高的语言能按"用 A 语言回答 B 语言问题"的指令执行
严格格式约束经常附加解释性文字遵守率明显更高,但仍会漏
边界拒答无拒答概念,会顺着编有一定拒答倾向,但阈值不稳定

值得强调的是 JSON 那一行。很多人以为 instruct 模型"天然会输出 JSON",其实不是。它只是被训练过"遵循格式指令",而"输出合法 JSON"仍然是一个需要明确说出来的约束。我实测下来,加一句"只输出 JSON,不要 markdown 代码块,不要任何解释",成功率能提升一大截。不给这句,它八成会给你包一层json,然后你的解析器就挂了。

3.2 哪些差距是"魔法",哪些其实只是模板在起作用

这是本节最想说的一个点。你看到 instruct 模型表现好,有一部分功劳确实来自 SFT 带来的行为对齐,但还有一部分,纯粹是因为它被喂了正确的对话模板。如果你把 base 模型也用同样的模板包一遍(前提是它有对应特殊 token),差距会缩小一部分——不会完全消失,但会明显缩小。

我做过一个对照:同一批问题,base 用纯文本前缀跑一遍,用伪造的对话模板再跑一遍。后者在"是否直接回答"这个指标上能追回不少。这说明什么?说明模板本身携带了强信号,它告诉模型"现在是对话场景,你该扮演助手"。SFT 做的事情之一,就是把这个信号和"输出有帮助的回答"这个行为绑定起来。

所以当你的 instruct 模型表现不佳时,第一个要排查的不是模型能力,而是模板有没有对齐。三个检查点:模板里的角色标记是否与训练时一致、add_generation_prompt是否开启、特殊 token 是否被正确编码。这三点任何一点出问题,模型都会"变笨",但你会误以为是模型不行。

反过来,当你发现 base 模型加上模板后表现接近 instruct,也别急着下结论说"指令调优没用"。把测试集换成需要多步推理、需要遵循否定约束("不要提到 X")、需要拒答的样本,差距会重新拉开。SFT 真正的价值在这些"反直觉行为"上,而不在"能不能答"上。

4. 指令调优真正在做的事:数据配方、损失掩码与超参取舍

4.1 只对回答部分算 loss,这一步错了全盘皆输

自己动手做 SFT 时,最容易出错且最难发现的地方就是损失掩码。标准的做法是:把 prompt 部分的 label 全部设成-100(PyTorch 的 ignore_index),只对回答部分计算交叉熵。原因很直接——你希望模型学会"给定指令生成回答",而不是"学会生成指令"。如果 prompt 部分也参与 loss,模型会花容量去拟合用户提问的措辞分布,这在数据量小时会导致回答质量下降,而且会让模型在推理时倾向于自己生成问题。

手写掩码的逻辑大概是这样:

def build_labels(input_ids, prompt_len, pad_id): labels = input_ids.clone() labels[:prompt_len] = -100 # 屏蔽指令部分 labels[input_ids == pad_id] = -100 # 屏蔽 padding return labels

看着简单,坑在prompt_len怎么算。如果你用了 chat template,最稳的方式是先单独编码"只有 prompt"的序列拿到长度,再编码"prompt + 回答"的完整序列,用两者长度差反推。不能靠字符串查找回答起始位置,因为回答文本可能在 prompt 里出现过(比如少样本示例里的相似句式),一查找就错位。

另一个高频错误是 padding 方向。decoder-only 模型做训练时必须右侧 padding,做批量生成时反过来要左侧 padding。搞反了,训练时 attention mask 会把有效 token 遮掉一部分,loss 曲线看起来在降但模型学不到东西;生成时会因为 pad 在中间导致输出莫名其妙。这个坑我在两个不同项目里各踩过一次,每次都排查了很久,因为症状不明显——loss 就是慢慢降,你根本想不到是 padding 方向的问题。

4.2 数据配比:多语言模型做指令微调要额外小心

EuroLLM 这条线的定位决定了它天然是多语言模型,这给指令微调带来一个额外维度:语言配比。如果 SFT 数据里英文占比过高,模型会养成"默认用英文回答"的习惯,即使用户用其他语言提问。这个现象在小参数模型上尤其明显,因为容量有限,语言间的行为迁移不如大模型稳。

我的做法是把数据按语言分层,保证每种目标语言至少有足够样本覆盖"问答"这个基本形态,然后再按比例混入。比例不需要精确到小数点,但要有意识地控制——比如目标语言各占一定份额,剩下留给高资源语言。同时保留一部分"跨语言指令"样本,比如用 A 语言提问、明确要求用 B 语言回答,这类样本对多语言模型的指令遵循能力提升很明显。

数据质量比数量重要得多。我试过用自动生成的数据扩充规模,结果模型学会了生成数据的那些坏习惯——冗长的开场白、重复的套话、格式化的"首先其次最后"。清洗的时候要重点盯这几类:回答过长且信息密度低的、包含无法验证的具体数字的、格式标记混乱的、以及同一条指令有多个互相矛盾答案的(这类会让模型输出变得摇摆)。

还有一个容易被忽略的点:EOS 的拼接。每条训练样本的回答末尾必须补上 EOS token,而且要和推理时使用的eos_token_id保持一致。如果训练时补的是 A token、推理时按 B token 停,模型就永远不会停。这个不一致在跨版本迁移时特别容易出问题。

5. 复现一次轻量指令微调:从数据到验证的完整链路

5.1 LoRA 配置与显存预算

1.7B 全参微调在单卡上不是不能做,但没必要。LoRA 冻结主干、只训练低秩旁路,显存占用小一个量级,效果在指令微调这种"行为对齐"任务上通常够用。关键参数有三个:秩r、缩放系数alpha、以及挂载的目标模块。

from peft import LoraConfig, get_peft_model, TaskType lora_cfg = LoraConfig( task_type=TaskType.CAUSAL_LM, r=16, lora_alpha=32, lora_dropout=0.05, target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], bias="none", ) model = get_peft_model(base_model, lora_cfg) model.print_trainable_parameters()

r=16是个稳妥起点,经验上alpha取2r比较常见。目标模块我倾向全挂——attention 的四个投影加上 MLP 的三个投影。只挂 q/v 是老做法,省显存但效果上限低一些。指令微调要改的是"输出行为",这涉及 FFN 层的表示变换,只动 attention 往往不够。

显存粗算:1.7B 参数 bf16 存权重约 3.4GB,LoRA 旁路参数量大概在千万级,优化器状态和梯度加起来不到 1GB,剩下的是激活值。序列长度 1024、batch size 4、梯度累积 4 的配置下,24GB 卡跑起来比较宽松,16GB 卡把 batch 降到 1 也能动。如果开了梯度检查点,显存还能再省一截,代价是速度慢一些。

超参上,学习率我用1e-4到2e-4之间,cosine 衰减,warmup 比例 3% 左右。epoch 数不要多,1 到 3 轮通常就够,多了会过拟合到训练数据的措辞上——表现是模型开始复读训练集里的句式,泛化明显变差。

5.2 训练完必须做的三项回归验证

训练 loss 降下去不代表模型变好了,必须做回归。我固定跑三项。

第一项是格式回归:用训练时同款模板构造 prompt,看输出是否能正常终止、有没有重复、有没有退化成续写模式。这一项挂了,说明模板或 EOS 有问题,先别往下看。

第二项是能力保持:拿一批 base 模型原本就做得不错的任务(比如语言续写、简单翻译)跑一遍,确认没有明显退化。LoRA 的秩设太高、训练轮数太多,容易出现"变听话了但变笨了"的情况。

第三项是负样本检查:故意给一些超出能力范围的、含错误前提的、格式刁钻的输入,看模型是硬编还是合理应对。这一步最能暴露数据质量问题——如果训练数据里全是"有问必答"的样本,模型就会对任何输入都强行给答案。

导出的时候注意 LoRA 权重和基座的对应关系。merge_and_unload合并成完整权重再存,推理时就不用加载 PEFT 了,部署更省事。合并后一定要重新跑一遍上面三项,因为合并过程偶尔会有精度损失。

6. 实测中反复踩到的坑与完整排查链

6.1 输出重复、停不下来、半句截断:按顺序排查

这三类症状看着不同,根因经常是同一个。我的排查顺序是固定的,从便宜到贵。

第一步,看eos_token_id。打印 tokenizer 的eos_token和eos_token_id,再看模型的generation_config.json里的配置,两边对不上就是它。base 模型还有一个变体问题:它可能根本不输出那个 token,因为训练时没被强化过,这时候你只能靠max_new_tokens兜底。

第二步,看pad_token_id。很多模型没有专门的 pad token,代码里直接用 eos 顶替。如果生成时 batch size 大于 1 而 pad 设置不当,会触发警告甚至输出错乱。单条推理时把pad_token_id显式设成eos_token_id能消掉大部分警告。

第三步,看 chat template 里的add_generation_prompt。前面提过,漏掉它模型不知道该开口。

第四步,看采样参数。repetition_penalty设太高(比如超过 1.3)会导致输出变得支离破碎,模型为了避开重复而强行换词。temperature太低配top_p太低,容易陷入循环。我一般对比实验用贪心,正式使用用temperature=0.7、top_p=0.9、repetition_penalty=1.05这组保守值。

第五步,看上下文长度。如果 prompt 接近模型最大上下文,注意力会被挤占,输出质量断崖下跌。这时候缩短 prompt 或换长上下文方案,别怀疑模型坏了。

6.2 你的对比结论可能是错的:评测失真的几个来源

最后一节聊评测,因为这是最容易自欺欺人的环节。

第一个来源是样本太少且挑过。我自己就干过这事:挑了十个模型表现好的例子截图,得出"效果不错"的结论。后来扩到五十个样本,发现有一半场景是崩的。样本要提前定好,不能边跑边加。

第二个来源是用困惑度评 instruct 模型。困惑度衡量的是模型对文本的拟合程度,而 instruct 模型的目标是生成有用的回答,这两件事没有必然的正相关。我见过 instruct 版本困惑度比 base 高但实际体验好得多的情况,因为它的输出分布更集中、更"像回答",反而不符合原始语料的统计特征。

第三个来源是用大模型当裁判。图省事的时候确实好用,但偏置很明显:它偏爱长回答、偏爱和自己风格接近的输出、对格式漂亮的答案给分虚高。要用的话必须做位置交换(同一组答案换个顺序再评一次),只取一致的结论。

第四个来源是忽略随机性。同一份 prompt 跑三次,instruct 模型的输出可能差异不小,尤其在开放生成任务上。做对比时至少跑三次取多数,或者干脆用贪心解码消除这个变量。我现在的习惯是:对比实验一律贪心,产品体验评估才用采样。

第五个来源最隐蔽——模板不一致。你在 A 测试里用了官方模板,在 B 测试里手写了个差不多的,两个结果根本不可比。把 prompt 构造逻辑抽成一个函数,所有测试走同一个入口,这个工程习惯能省掉大量返工。

最后分享一个我觉得挺有用的小技巧:给两个模型建一个共享的"失败案例库"。每次发现一个反例,不管是哪个模型出的,都存进去,标注上模型版本、prompt、参数。跑一段时间后你会发现,很多所谓的"模型能力问题"其实是同一类模板或参数问题在不同场景下的重复表现,改一处就能修掉一片。这个库比任何评测脚本都更贴近真实使用,因为它记录的是你实际遇到的坑,而不是人造的测试集。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询