最近我在跑一个挺小的业务实验:让一个 0.6B 参数的开源模型,记住一小批完全不在它预训练语料里的“故障码问答”。手里只有一张 8G 显存的卡,所以从一开始就排除了全量微调。剩下两条路,一条是大家最熟悉的 LoRA,冻结基座权重、只训练低秩旁路;另一条更极端,叫 Engram 记忆表,模型权重一个数字都不改,纯粹靠外部记忆表在推理时把知识喂回去。同一个冻结的 Qwen3-0.6B、同一份训练数据、同一套评测脚本,我把两条路完整跑了一遍。结果两边各有胜负,而且暴露出的问题完全不同。这篇就把实验设计、实现细节、结果对比和踩过的坑一次性写清楚,给正在纠结“小模型到底怎么注入新知识”的人做个参考。
1. 为什么做这组对照实验
1.1 只有 8G 显存,小模型的微调困境
Qwen3-0.6B 这种量级的模型,半精度权重大概 1.4G 左右,光看权重好像很轻松。但一旦走上全量微调的路,优化器状态、梯度、激活值会迅速把显存吃干。我做过的经验是,batch size 稍微开大一点,8G 卡直接 OOM,根本跑不了几个 step。更要命的是,0.6B 的参数量本身就不大,全量微调很容易把模型原本会的基础能力冲掉,这就是常说的灾难性遗忘。模型变小之后,这个现象反而更明显,因为它没有足够的容量同时容纳“原有知识”和“新知识”。
所以现实的选择就只剩下“冻结主干、外部补丁”这一条路。LoRA 是参数侧的补丁,它往模型内部塞几个可训练的小矩阵;Engram 记忆表则是输入侧的补丁,它不碰参数,只靠改推理时的 prompt 内容。这次实验想搞清楚的问题很简单:当任务换成“知识记忆型”的小规模问答,这两类方案各自的真实表现到底差多少?
1.2 LoRA 和 Engram 本质上不是同一类方案
LoRA 和 Engram 虽然都不改基座权重,但它们的哲学完全不同。LoRA 想的是“让模型学会新知识”,它把训练数据里的规律压缩进低秩矩阵,推理的时候模型内部已经被修改过,一旦训练完成就不需要任何外部依赖,模型自己就能答。Engram 想的是“让模型查到新知识”,它把训练样本原封不动地存成一张键值表,推理时先检索相关问题,再把查到的问答对拼进 prompt,模型只需要做“阅读并复述”这件事。
一个是把规律内化成参数,一个把例子外挂到输入。这个区别在后面的实验结果里会体现得非常清楚:LoRA 擅长对没见过的问题做迁移,Engram 擅长精准复现见过的问法。严格来说,“不训权重”这个说法对 LoRA 并不完全严谨,因为 LoRA 毕竟训练了旁路矩阵;但对 LoRA 和 Engram 的共同点而言,它们都没有动 Qwen3-0.6B 最原始的 Transformer 权重,所以放在一起对照是成立的。
1.3 实验目标:量化“记忆捷径”和“规则学习”的差距
这次实验不追求把分数刷到多高,目标很朴素:在同一个冻结模型上,量化两条路线的能力边界。因此我特意把评测集分成三类。第一类是训练集本身,用来确认两边有没有“记住该记的东西”。第二类是改写问法后的相似题,比如训练集里问“研磨器卡豆怎么处理”,测试集改成“磨豆的时候一直响,屏幕显示 E7,怎么办”,考的是对语言变化的容忍度。第三类是组合题,需要把两条独立知识拼接后才能回答,比如同时出现两个故障码的现象,考的是泛化推理。
这个分类决定了后续所有数据的构造方式。我提前想到了一个关键点:Engram 的记忆表只允许放训练集里的问答对,绝不能把测试集答案也索引进去,否则分数虚高没有任何参考价值。这个坑在后面某次快速验证里真的踩到了,回头细说。
2. 两条技术路线的原理拆解
2.1 LoRA 的原理:冻结主干,只训练一条低秩旁路
LoRA 的出发点很简单:既然全量更新 ΔW 太贵,那就假设 ΔW 是低秩的,把它拆成两个小矩阵的乘积。前向计算从原来的 h = W0 x 变成 h = W0 x + BA x,其中 B 的维度是 d_out × r,A 的维度是 r × d_in,r 远小于隐藏层维度。训练时只有 A 和 B 参与更新,W0 保持冻结。
打个比方,这就像主干管道完全不动,在旁边接一根可以调节的辅助细管,细管的管径 r 决定了能流过多少新信号。r 太小,新知识装不下;r 太大,模型容易把训练集里那些表面句式也一起背下来,过拟合风险反而上升。Qwen3-0.6B 的隐藏层维度本身不大,我最后选了 r=8,alpha=16,这个配置在容量和泛化之间相对均衡,alpha/r 正好等于 2,也是社区里比较常见的缩放比例。
2.2 Engram 记忆表的原理:把训练样本变成外部可查的字典
Engram 记忆表的实现更直接。我把训练集做成一张键值表,key 是问题原文,value 是标准答案。推理时先用同一个模型把用户问题编码成向量,再和所有 key 的向量算余弦相似度,取 top-k 的问答对拼到 prompt 前面。这个流程和检索增强生成很像,但所有向量都来自 Qwen3-0.6B 自己的隐藏层,没有额外引入别的模型,这样能保证检索到的内容和基座模型的理解空间一致。
我之前试过用通用 embedding 模型来建索引,效果反而不好。原因是通用 embedding 模型和 Qwen3-0.6B 的语义空间不完全对齐,经常出现“检索结果看上去很像、但模型读起来很别扭”的情况。换成模型自带 hidden state 之后,至少能保证检索命中的问法在模型的语义理解里确实是相近的。
2.3 公平性设计:同一个冻结模型、同一份数据、同一套评分
对照实验最怕变量不干净。这次我把所有能控制的变量都定死了:同一个 Qwen3-0.6B 基座,加载时全部参数冻结;同一份 200 条训练问答对,LoRA 用它训练,Engram 把它存成记忆表;同一份测试集,包含 60 条改写题、40 条组合题、20 条无关干扰题。解码参数也固定下来,temperature 设为 0.2,top_p 设为 0.85,最大生成长度 128 token,避免采样随机性影响比较。
唯一保留的差异就是“是否训练参数”这件事本身。这里还想多提一句:Engram 虽然不做训练,但它也用了模型的能力来编码和检索,所以严格说它并非“零成本利用模型”,而是“零参数修改地使用模型”。这个定位要提前说清楚,免得被误读成玄学方案。
3. 搭建实验的过程
3.1 构造虚构故障码问答数据
为了让实验结果可信,训练数据必须选一个预训练语料里大概率不存在的内容,否则模型可能靠“背答案”或“常识联想”把分数混过去。我虚构了一套“商用咖啡机故障码”知识库,故障码和现象都是编的,比如“故障码 XK-204 表示研磨器卡豆,蜂鸣三声、屏幕显示 E7,处理步骤是关机、清理磨豆器、重新自检”。这类内容既贴近真实业务场景,又保证模型在预训练阶段几乎不可能见过。
数据集规模不大,200 条训练问答对,覆盖 40 个故障码,每个故障码配 5 种不同问法。测试集在此基础上构造:60 条改写题是新的问法,但故障码不变;40 条组合题会把两个故障码的现象拼在一起提问;20 条干扰题则是完全不相关的设备问题,用于测试两侧的“拒答能力”。数据格式我用 JSON 保存,结构很简单:
{ "fault_code": "XK-204", "question": "咖啡机磨豆的时候一直响,屏幕显示E7,是什么问题?", "answer": "故障码XK-204,研磨器卡豆。关机后清理磨豆器,重新开机自检。" }这里有个经验值得分享:训练问答对不要写成模板化的一句,要故意制造问法差异。LoRA 才更有机会学到“故障码和现象之间的语义关联”,而不是只背住固定句式;Engram 的检索也能更自然地遇到相似但不同的表达,从而更接近真实线上环境。
3.2 LoRA 微调的实现与参数选择
LoRA 这条路的实现比较常规,我用了开源的模型加载库和参数高效微调组件。加载模型后先把所有参数冻结,再挂载低秩适配器。Qwen3-0.6B 这类模型要挂载的模块一般是注意力里的 q/k/v/o 投影层,以及 MLP 里的 gate/up/down 线性层,不同模型命名可能略有差异,务必要先打印模型结构确认,不能只凭经验猜。
配置代码如下,基本可以直接照抄:
model = load_base_model("Qwen3-0.6B", half_precision=True) for p in model.parameters(): p.requires_grad = False lora_config = { "r": 8, "alpha": 16, "dropout": 0.05, "target_modules": [ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj" ] } model.attach_lora(lora_config)训练参数我选了学习率 1e-4、余弦退火、batch size 8、梯度累积 2、训练 5 个 epoch。这个学习率在 LoRA 里算是比较稳的起步值;epoch 没有再加大,因为小模型加小数据很容易在第 5 轮之后开始过拟合。实际训练峰值显存大约 6.2G,8G 卡能跑,但序列长度超过 512 就有可能爆显存,所以数据预处理时把问题和答案截断到了 256 token 以内。
3.3 Engram 记忆表的实现与检索细节
Engram 的实现反而更需要仔细处理。我用 Qwen3-0.6B 的倒数第二层隐藏状态做向量化,对每个 token 取平均池化,得到整个问题句子的向量。为什么不用最后一层?最后一层和生成任务耦合太深,直接拿来做检索容易偏向局部 token;倒数第二层相对更接近语义表示,实测检索效果也更稳。
建好索引后,推理时的主要流程是:先对用户问题算向量,和 200 条 key 全部做余弦相似度,取 top-3,然后把命中的问答对拼成 prompt 输入模型。参考信息放在用户问题前面,并显式要求模型优先采用参考信息作答。这是试过几次之后发现的结论,Qwen3-0.6B 这种小模型的指令遵循能力没有大模型强,如果不把参考信息放最前面,很容易被后面的问题带偏。
retrieved = memory_table.search(user_q, top_k=3) reference_block = "" for q, a in retrieved: reference_block += f"问:{q}\n答:{a}\n" prompt = ( "参考信息:\n" + reference_block + "\n当前问题:" + user_q + "\n请严格根据参考信息回答。如果参考信息中没有任何相关内容,请直接回答:不知道。" )3.4 评估脚本与指标定义
评测我没有只盯一个准确率,而是同时算了三个指标。完全匹配率要求模型生成的答案和标准答案逐字一致;关键实体召回则只看答案里有没有出现正确的故障码和处理动作,容忍措辞变化;幻觉率统计的是答案中出现了知识库里根本不存在的故障码或处理步骤。三者结合起来,才不会出现“答得好但全是编的”这种假阳性。
LoRA 那边用训练好的模型直接推理,Engram 这边用冻结模型查表后推理。两侧都跑同一份测试集,输出都存成 JSON 再统一打分。这一步虽然简单,但一定要把模型输出和标准答案对齐好,我第一次跑的时候少转了一次格式,导致完全匹配率虚低,浪费了不少排查时间。
4. 实验结果:训练集、测试集和典型案例
4.1 总体指标对比
先说结论,LoRA 和 Engram 的结果非常有意思,直接看表:
| 指标 | LoRA | Engram 记忆表 |
|---|---|---|
| 训练集完全匹配率 | 95.5% | 99.0% |
| 改写测试集完全匹配率 | 71.0% | 86.0% |
| 组合测试集完全匹配率 | 55.0% | 38.0% |
| 无关干扰题正确拒答率 | 60.0% | 85.0% |
| 幻觉率(全测试集) | 7.0% | 13.5% |
训练集上 Engram 接近满分不难理解,因为它本身就把训练集给存了下来,检索训练集原题等价于把标准答案直接放在 prompt 里。但拉到改写测试集,Engram 靠相似检索依然能赢,说明用户问法只要和训练样本足够接近,记忆表的容错能力其实很强。LoRA 的 71% 也不算差,它学到的是“故障码 XK-204 对应研磨器卡豆”这种关联,所以换一种问法也能认出来。
组合题就反过来了。LoRA 能通过训练时见过的多个故障码表述组合出答案,虽然正确率只剩 55%,但至少能给出完整处理建议;Engram 的检索经常只命中其中一条,漏掉另一条,模型拿不到完整参考就直接回答了一部分,正确率掉到 38%。干扰题上 Engram 明显更保守,检索不到相关内容时会倾向于回答“不知道”,而 LoRA 反而容易硬编一个故障码,这也直接抬高了 LoRA 的幻觉率。
综合下来,这两条路线的能力边界很清楚:记忆表擅长“记得准”,LoRA 擅长“推得动”。
4.2 三个典型回答案例
案例一,改写问法题。用户问:“机器启动两分钟就停,然后屏幕没报错,压缩机附近很烫,怎么回事?”训练集里原题问的是“压缩机过流停机”,Engram 检索到高度相似的问题后直接给出“故障码 QT-812,断电 15 分钟,检查电压”的答复,LoRA 也答对了,但措辞更偏向训练集里的固定模板。
案例二,组合题。用户问:“咖啡机一边磨豆响三声显示 E7,一边启动两分钟就停机,两个问题一起报怎么处理?”LoRA 给出的答案同时包含 XK-204 和 QT-812 两段处理步骤,虽然顺序有瑕疵,但关键信息都在;Engram 只检索到了 XK-204,因为两个故障码同时出现的组合问法在记忆表里不存在,top-3 里另外两条都不太相关,最终漏掉了压缩机过流。
案例三,干扰题。用户问:“冰箱冷藏室结冰很严重,要不要换温控器?”训练集和记忆表里完全没有冰箱相关内容,Engram 直接回答“不知道”,LoRA 则给出了一个泛泛的检查步骤,甚至编出了故障码“RF-077”。这个对比很直观地展示了参数微调在知识覆盖边界上的副作用。
4.3 显存、耗时、可维护性对比
除了效果指标,落地时更关心的其实是资源和维护成本。我把实测数据整理成了下面这张表:
| 项目 | LoRA | Engram 记忆表 |
|---|---|---|
| 训练阶段显存占用 | 约 6.2G | 无需训练 |
| 推理阶段显存占用 | 约 1.8G | 约 1.8G |
| 数据准备与索引构建 | 约 10 分钟 | 约 5 分钟 |
| 训练耗时 | 约 25 分钟 | 0 |
| 单条推理耗时 | 约 0.9 秒 | 约 1.3 秒 |
| 知识更新成本 | 重新训练 | 直接改表 |
Engram 单条推理多出来的 0.4 秒主要花在向量检索上,如果数据量从 200 条涨到上万条,还需要引入近似最近邻检索,否则耗时会更明显。不过考虑到它完全省掉了训练环节,知识更新时只需要改记忆表里的内容,不需要重新跑训练,对于频繁变动的业务场景优势很大。
5. 过程中踩过的坑和排查思路
5.1 LoRA 训练 loss 不降的排查
第一次跑 LoRA 时,我在 target_modules 配置上犯了错,只挂了 q_proj 和 v_proj,loss 一直在一个 1.7 左右的高位震荡,训练完测试集几乎全错。排查方式很简单:把模型结构完整打印出来,逐个确认注意力层和 MLP 层的投影层命名,把所有线性层全部挂上再重新训练。之后 loss 才顺利降到 0.3 附近。
另一个坑是序列长度设计。我把问题和答案分别截断到 128 token,导致答案被切掉一半,模型学到的是不完整的映射关系。最后改成以“问题+答案”整体为单位截断,最大长度 256,效果明显提升。类似的排查思路可以当成惯例:训练数据不管多少,先拿 10 条跑一个过拟合小测试,如果 loss 能在几个 step 内降到接近 0,说明配置没问题;如果降不下去,优先查数据拼接和 target_modules。
5.2 Engram 检索命中但模型不听话
Engram 这边最典型的问题是“检索到了正确答案,但模型不按照参考信息作答”。一开始我把参考信息放在 prompt 最后,Qwen3-0.6B 经常无视它,直接按自己的记忆生成。后来把参考信息整体前移,并在 prompt 里增加“请严格根据参考信息回答,如果参考信息中没有任何相关内容,请直接回答不知道”这样的硬约束,情况才好转。
另一个细节是 top-k 的选择。我最初用 top-5,命中的相关条目反而被不相关条目干扰,模型读着读着就跑偏了。降为 top-3 后准确率提升明显,再配合一个 0.55 的相似度阈值,低于阈值的给不到参考信息,直接输出“不知道”,能有效压制幻觉率。这个阈值要根据实际测试集调,不要照搬别人的数值。
5.3 幻觉和检索噪音的处理
幻觉问题两侧成因不一样。LoRA 的幻觉来自过度泛化,模型学会了“生成像标准答案的文本”这个行为,但知识边界不明,遇到陌生问题时会倾向于编造一个故障码。后处理阶段我加了故障码白名单校验,输出中如果出现不在知识库里的故障码就标记为幻觉,让业务侧决定是否拦截。
Engram 的幻觉更多来自检索噪音,查到的内容本身不相关,但 prompt 要求和参考信息一致,模型就被错误参考带偏。处理办法是在检索阶段就提高精度,把 top-3 里相似度明显偏低的条目直接丢弃。组合题上出现的漏答问题,工程上最有效的办法是在记忆表里增加常见组合问法的完整条目,本质上是把推理缺口用记忆来兜底。
6. 我的结论与使用建议
6.1 什么时候选 LoRA,什么时候选 Engram
跑完这组对照实验,我的判断标准变得很明确。如果业务的本质是“知识注入”,比如给模型一批它没见过的产品规格、故障码、政策条款,而且用户问法相对稳定、知识更新频率高,那么 Engram 记忆表是性价比最高的选择,改一行表就能上线新知识,完全不担心训练算力和遗忘问题。如果业务的本质是“行为对齐”,比如让模型学会某种固定的回复格式、执行多步骤流程、按特定语气生成,那 LoRA 更合适,它能把行为模式固化到参数里,推理时不依赖外部检索。
更常见的真实情况是业务既要知识又要行为。最优解不是二选一,而是先用 Engram 把事实类知识供上,再用 LoRA 微调模型的表达方式和决策逻辑,让模型学会“怎么更好地使用参考信息”。我在另外一个模拟项目上试过这个组合,整体效果比单独用任何一条路线都稳。
6.2 后续可以扩展的方向
后续我打算做三件事。第一是记忆表压缩,把长答案切成摘要和细节两层,检索时优先给摘要,降低 prompt 长度。第二是分层记忆,把高频规则放进固定前缀,把低频细节留给检索,既保证稳定性又保留扩展性。第三是结合检索失败日志做条目淘汰,把长期不命中的记忆定期清理,减少检索噪音。
最后再分享一点个人体会。这次实验让我最意外的不是 Engram 在改写题上赢过 LoRA,而是 LoRA 在组合题上能靠 0.6B 的容量完成部分推理。这说明小模型不是没有泛化能力,只是它擅长的不在“记住新事实”,而在“把已经理解的东西组合起来”。以后再做类似任务,我不会再执着于哪个方案更“高级”,而是先问自己一个问题:当前项目缺的是知识,还是行为?答案不同,路线完全不同。