1. 项目缘起当RAG遇上幻觉我们到底在解决什么问题最近在折腾大模型应用落地的朋友估计没少被“幻觉”问题折磨。你精心搭建了一个基于RAG检索增强生成的智能客服或者知识库问答系统指望着它能精准地从企业文档里找到答案。结果呢用户问“我们公司今年的年假政策是什么”它可能给你编一个“根据最新规定每位员工享有20天带薪年假并可叠加法定节假日”的完美答案听起来头头是道但实际情况可能是公司规定只有10天。这种一本正经地胡说八道就是大模型最让人头疼的“幻觉”。传统的RAG流程简单说就是“检索-拼接-生成”。系统先从向量数据库里捞出几篇最相关的文档片段把它们和用户问题一起塞给大语言模型说“喏这是参考资料请基于此回答。” 模型的任务是综合这些信息生成最终回复。这里的核心假设是模型会“乖乖地”主要依据你提供的参考文本来回答。但现实很骨感LLM大语言模型本质上是基于海量数据训练出的概率生成器它有极强的语言建模和逻辑推理能力但同时也保留了“自由发挥”的习性。当检索到的文档信息模糊、不全或者模型自身的“知识”与文档冲突时它就可能选择忽略或曲解你给的参考资料转而依赖自己训练数据中的记忆来“编造”答案。所以RAGognizer这个项目的目标非常明确它不是要取代RAG而是要给RAG系统加上一个“质检员”。这个“质检员”的任务就是在模型生成最终答案的同时实时地、自动地判断答案中的每一部分信息到底有多少是忠实于你提供的参考文档的有多少是模型自己“加戏”产生的幻觉。更进一步它不仅仅是一个事后的检测器而是将这种“幻觉感知”能力通过一个额外的“检测头”集成到模型微调过程中让模型在训练时就学会“自我审查”从而在根本上提升生成答案的可靠性和事实一致性。这相当于给模型装了一个“诚实度传感器”让它生成答案时自己心里有数知道哪句话是有据可查哪句话是推测甚至虚构的。2. 核心架构拆解检测头如何实现“幻觉感知”RAGognizer的核心创新在于它提出的“集成检测头”架构。要理解它我们得先抛开复杂的数学公式从功能模块的角度来看。想象一下一个标准的、用于RAG场景的微调后的大模型可以看作一个黑箱输入是问题Query和检索到的参考上下文Context输出是答案Answer。传统的微调目标是让这个答案在流畅度、相关性和事实准确性上尽可能好。但“事实准确性”是一个事后评估指标模型在生成时并没有一个内置的机制来量化自己对每个生成token的“信心来源”。RAGognizer的做法是在这个主模型我们称之为“生成主干网络”的旁边并联一个轻量级的“检测头”网络。这个检测头通常只有几层全连接层或小型Transformer层参数很少计算开销低。它的输入并不是原始的问题和上下文而是生成主干网络在生成答案过程中的“内部状态”——具体来说是每个解码步骤即生成每个答案token时对应的隐藏层表示Hidden States。这个设计非常巧妙。因为模型的隐藏层表示蕴含了模型在生成当前词时“思考”的全部信息包括它对问题、上下文的理解以及它即将生成的内容的倾向。检测头的任务就是学习分析这些隐藏状态并输出一个概率值对于当前正在生成的这个词它有多大可能是直接来源于参考上下文的即“可归因于上下文”又有多大可能是模型基于自身参数“凭空创造”的即“幻觉”或“不可归因”。从技术实现上看这个过程是并行的生成流主干网络接收[Query, Context]开始自回归地生成答案Token序列A1, A2, ..., An。检测流在生成每个答案TokenAi时主干网络会产生一个对应的隐藏状态向量Hi。这个Hi被实时地送入检测头。检测头计算检测头对Hi进行计算输出一个标量分数si例如通过Sigmoid函数映射到0到1之间这个分数就代表了TokenAi来源于上下文的置信度。联合输出最终系统不仅输出答案文本[A1, A2, ..., An]还同步输出一个对应的置信度序列[s1, s2, ..., sn]。你可以把它理解为答案的“可溯源分数”序列。那么这个检测头是如何学会区分“可归因”和“幻觉”的呢关键在于微调阶段的数据和损失函数设计。我们需要构造专门的训练数据对(Query, Context, Answer, Attribution Labels)。这里的Attribution Labels是一个与答案Token一一对应的0/1标签序列标注每个Token是否能在给定的Context中找到直接或强力的支持证据。构建这样的数据需要一定的人工或启发式规则例如使用文本匹配、命名实体识别工具进行对齐。在微调时我们有两个损失函数在同时工作生成损失L_gen就是标准的语言模型损失比如交叉熵损失确保生成的答案Answer本身是流畅、相关的。归因损失L_attr这是检测头的训练目标。通常使用二元交叉熵损失让检测头输出的置信度序列[si]尽可能接近真实的归因标签序列[li]。总的损失函数是两者的加权和L_total L_gen λ * L_attr。这里的λ是一个超参数用于平衡生成质量和归因准确性。通过这种多任务学习的方式主干网络在学习如何生成更好答案的同时其内部表示也被“塑造”得更容易让检测头区分信息的来源。这就是“幻觉感知微调”的本质——让模型在训练中内化对自身输出可溯源性的判断能力。3. 从零到一基于LLaMA-Factory的RAGognizer实战微调理论讲完了我们来点硬的。如何亲手实现一个RAGognizer风格的模型这里我们选择目前社区最活跃、对中文支持也较好的微调框架LLaMA-Factory作为实战平台并以Qwen1.5-7B-Chat模型为基础演示如何为其增加幻觉感知的检测头并进行微调。3.1 环境准备与数据构造首先你需要一个能够运行LLaMA-Factory的环境。强烈建议使用Python 3.10及以上版本并准备好足够的GPU资源例如单卡A100 40G对于7B模型的全参数微调是足够的如果资源有限可以使用QLoRA等高效微调方法。# 1. 克隆LLaMA-Factory仓库 git clone https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory # 2. 安装依赖 (推荐使用conda创建虚拟环境) conda create -n llama_factory python3.10 conda activate llama_factory pip install -r requirements.txt # 3. 安装PyTorch (根据你的CUDA版本) # 例如对于CUDA 11.8 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118接下来是最关键也最耗时的一步构造训练数据。RAGognizer需要四元组数据(query, context, answer, attribution_labels)。假设我们有一个简单的JSONL格式数据集train.jsonl每一行是一个样本{ query: 公司年假有多少天, context: 根据《员工手册》第三章第五条规定正式员工入职满一年后每年享有10天带薪年假。年假可分段休但最小请假单位为0.5天。, answer: 正式员工入职满一年后每年有10天带薪年假。, attribution_labels: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] // 对应answer每个token的标签 }这里的难点在于attribution_labels的生成。一个实用的方法是对answer进行分词使用与模型相同的tokenizer。对于answer中的每个token或token组使用字符串匹配、编辑距离或更高级的NLI自然语言推理模型判断其是否在context中出现或能被context所蕴含。将可以归因的token标记为1否则标记为0。对于中文可能需要以词或短句为单位进行判断而不是单个字。注意数据质量直接决定检测头的性能。如果条件允许最好能进行一定的人工校验和清洗。初期可以尝试用规则如关键词匹配生成粗糙标签再用小批量人工标注的数据进行微调。3.2 修改模型结构添加检测头LLaMA-Factory本身不直接支持这种“并联检测头”的架构我们需要对模型代码进行一些修改。这里以Qwen1.5模型为例展示核心的修改思路。我们创建一个新的模型类QwenWithAttnHead它继承自原始的Qwen2ForCausalLM。核心是重写forward方法在生成过程中插入检测头的计算。# 文件modeling_qwen_with_head.py import torch import torch.nn as nn from transformers import Qwen2ForCausalLM, Qwen2Config class AttributionHead(nn.Module): 轻量级归因检测头 def __init__(self, hidden_size): super().__init__() self.dense nn.Linear(hidden_size, hidden_size) self.activation nn.Tanh() self.classifier nn.Linear(hidden_size, 1) # 输出单个分数 def forward(self, hidden_states): # hidden_states: [batch_size, seq_len, hidden_size] pooled_output hidden_states[:, -1, :] # 通常取最后一个token的隐藏状态代表当前生成步的“思考” x self.dense(pooled_output) x self.activation(x) logits self.classifier(x) # [batch_size, 1] return torch.sigmoid(logits).squeeze(-1) # 输出概率 [batch_size] class QwenWithAttnHead(Qwen2ForCausalLM): def __init__(self, config): super().__init__(config) self.hidden_size config.hidden_size self.attribution_head AttributionHead(self.hidden_size) def forward( self, input_idsNone, attention_maskNone, position_idsNone, past_key_valuesNone, inputs_embedsNone, labelsNone, attribution_labelsNone, # 新增归因标签 use_cacheNone, output_attentionsNone, output_hidden_statesNone, # 必须设置为True我们需要隐藏状态 return_dictNone, ): # 调用父类forward获取输出 outputs super().forward( input_idsinput_ids, attention_maskattention_mask, position_idsposition_ids, past_key_valuespast_key_values, inputs_embedsinputs_embeds, labelslabels, use_cacheuse_cache, output_attentionsoutput_attentions, output_hidden_statesTrue, # 强制输出隐藏状态 return_dictreturn_dict, ) # 获取最后一个隐藏层的状态 [batch_size, seq_len, hidden_size] last_hidden_states outputs.hidden_states[-1] # 计算归因分数 # 我们需要为每个生成的位置计算分数。这里简化处理只计算labels不为-100的位置即需要预测的token if labels is not None: # 找到需要预测的token位置 pred_positions (labels ! -100).nonzero(as_tupleTrue) if pred_positions[0].numel() 0: selected_hidden last_hidden_states[pred_positions[0], pred_positions[1], :] attribution_logits self.attribution_head(selected_hidden) # [num_pred_tokens] outputs.attribution_logits attribution_logits else: outputs.attribution_logits None else: # 推理时可以为每个生成的token计算分数需要更复杂的逻辑 outputs.attribution_logits None # 计算归因损失 if attribution_labels is not None and outputs.attribution_logits is not None: # attribution_labels 形状应与 labels 中非-100的位置对应 attr_labels_flat attribution_labels[labels ! -100].float() loss_fct nn.BCELoss() attribution_loss loss_fct(outputs.attribution_logits, attr_labels_flat) # 将归因损失加到总损失上 if outputs.loss is not None: outputs.loss outputs.loss 0.5 * attribution_loss # lambda 设为 0.5 else: outputs.loss attribution_loss return outputs然后我们需要修改LLaMA-Factory的数据处理和训练循环使其能加载我们自定义的模型并正确处理attribution_labels这个新的字段。这涉及到修改dataset.py和trainer.py中的相关部分将四元组数据加载进来并在计算损失时传入attribution_labels。3.3 配置与启动微调在LLaMA-Factory中我们通常使用一个配置文件来定义训练参数。我们需要创建一个新的配置文件train_ragognizer.json并指定我们自定义的模型。{ model_name_or_path: /path/to/your/qwen1.5-7b-chat, custom_model: QwenWithAttnHead, // 告诉LLaMA-Factory使用我们的自定义类 data_path: ./data/train.jsonl, template: qwen, finetuning_type: full, // 全参数微调也可用 lora output_dir: ./output/ragognizer_qwen, overwrite_output_dir: true, per_device_train_batch_size: 4, gradient_accumulation_steps: 8, lr_scheduler_type: cosine, logging_steps: 10, save_steps: 500, learning_rate: 2e-5, num_train_epochs: 3, max_length: 1024, fp16: true, report_to: none }然后使用LLaMA-Factory的脚本启动训练CUDA_VISIBLE_DEVICES0 python src/train_bash.py \ --stage sft \ --do_train \ --model_name_or_path /path/to/qwen1.5-7b-chat \ --custom_model QwenWithAttnHead \ --dataset train_data \ --template qwen \ --finetuning_type full \ --output_dir ./output/ragognizer_qwen \ --overwrite_cache \ --per_device_train_batch_size 4 \ --gradient_accumulation_steps 8 \ --lr_scheduler_type cosine \ --logging_steps 10 \ --save_steps 500 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --max_length 1024 \ --fp16 \ --plot_loss这个过程会同时优化语言模型损失和归因损失。训练完成后你就得到了一个具有“幻觉感知”能力的Qwen模型。3.4 推理与结果解读训练好的模型在推理时会有两个输出生成的答案文本和每个token的归因置信度。from transformers import AutoTokenizer, pipeline from modeling_qwen_with_head import QwenWithAttnHead model QwenWithAttnHead.from_pretrained(./output/ragognizer_qwen/checkpoint-final) tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen1.5-7B-Chat) pipe pipeline(text-generation, modelmodel, tokenizertokenizer, device0) query 公司年假有多少天 context 根据《员工手册》第三章第五条规定正式员工入职满一年后每年享有10天带薪年假。 prompt f根据以下上下文回答问题\n上下文{context}\n问题{query}\n答案 # 注意需要修改generate函数以获取隐藏状态和归因分数这里仅为示意 output pipe(prompt, max_new_tokens50, return_dict_in_generateTrue, output_hidden_statesTrue) answer output[0][generated_text][len(prompt):] # 假设我们通过某种方式获取了每个token的归因分数 attribution_scores # attribution_scores [0.95, 0.98, 0.12, ...] print(f答案{answer}) print(归因置信度逐词) for token, score in zip(tokenizer.tokenize(answer), attribution_scores): print(f {token}: {score:.3f})输出可能类似于答案正式员工入职满一年后每年有10天带薪年假。 归因置信度逐词 正式: 0.992 员工: 0.987 入职: 0.978 满: 0.965 一年: 0.953 后: 0.941 : 0.120 每年: 0.989 有: 0.125 10: 0.998 天: 0.995 带薪: 0.991 年假: 0.993 。: 0.110你可以看到像“正式”、“员工”、“10”、“天”、“带薪年假”这些直接从上下文中抽取或高度相关的词置信度非常高接近1。而“”、“有”、“。”这类功能性词语或连接词置信度较低因为它们并非来源于上下文的具体事实而是模型语言习惯的一部分。如果模型在“10天”后面自己加了一句“并且可以折现”那么“折现”这个词的置信度会非常低例如0.05这就被成功标记为潜在的幻觉。4. 关键挑战与实战避坑指南理想很丰满但实现一个可用的RAGognizer系统路上坑不少。结合我自己的实验经验分享几个最关键的挑战和应对策略。挑战一高质量归因标签数据的匮乏与噪声这是最大的瓶颈。自动生成的标签如基于字符串匹配噪声极大。例如答案中的“可以”可能对应上下文里的“允许”严格匹配会判为0但语义上应判为1。反之“公司”这个词可能在上下文高频出现但答案中的“公司”指代可能不同严格匹配会判为1实则可能是幻觉。应对策略采用“分阶段训练”和“数据蒸馏”。阶段一冷启动使用规则精确匹配、模糊匹配或轻量级NLI模型如DeBERTa生成一批带有噪声的标签数据先训练一个初版检测头。这个版本的精度可能只有60-70%但足以作为一个起点。阶段二数据蒸馏用初版模型对大量未标注的(query, context, answer)三元组进行推理得到模型预测的归因分数。设定一个高阈值如0.95和低阈值如0.05筛选出高置信度的正负样本加入到训练集中。这相当于用模型自己的判断来清洗和扩充数据。阶段三主动学习与人工校验针对模型预测置信度在中间模糊区域如0.3-0.7的样本进行小批量的人工标注。这批高质量数据对提升模型在困难案例上的判别能力至关重要。挑战二检测头与生成主干的耦合与干扰检测头是附加组件如果设计不当或训练不充分可能会“带偏”主干网络的生成能力。例如模型可能为了获得高的归因分数倾向于生成一些非常保守、完全照抄上下文的、不流畅的答案。应对策略精细调整损失权重λ和训练策略。动态权重在训练初期可以设置较小的λ如0.1让模型先专注于学习生成任务。随着训练进行逐步增大λ如到0.5或1.0加强归因任务的训练。课程学习先使用简单的、归因清晰的样本进行训练再逐步引入模糊、困难的样本。共享层冻结可以考虑只微调主干网络的最后几层而让前面的层保持冻结减少检测头对底层语言理解能力的干扰。挑战三推理阶段的性能与延迟在推理时每生成一个token都需要调用一次检测头来计算归因分数这会增加额外的计算开销。对于延迟敏感的应用这可能成为瓶颈。应对策略工程优化与近似计算。检测头轻量化确保检测头结构足够简单如2层MLP。缓存与并行利用Transformer的KV缓存机制检测头的输入隐藏状态计算可以和下一token的生成计算部分重叠。稀疏计算不必对每个token都计算归因分数。可以每隔2-3个token计算一次或者只在生成名词、实体、数字等关键信息点时计算。后处理模式对于某些不要求实时归因的应用可以先让模型快速生成完整答案然后再用检测头对整个答案序列进行一次性的归因分析。但这失去了在生成过程中进行引导的可能性。挑战四归因粒度的选择是以词token为粒度还是以短语phrase或句子sentence为粒度词级粒度最精细但标签最难标注且输出对用户不友好一串数字。句子级粒度更易理解但可能掩盖句子内部的幻觉。应对策略根据应用场景折中。对于高可靠性要求的领域如医疗、法律建议采用词级或子词级粒度并在前端对低置信度部分进行高亮预警。对于普通问答可以采用“句子关键实体”的混合粒度即输出整个句子的置信度并对句中识别出的关键实体如日期、金额、人名单独给出置信度。5. 效果评估与未来展望不止于检测训练完成后如何评估RAGognizer的效果不能只看生成答案的BLEU或ROUGE分数更需要一套针对“幻觉感知”能力的评估体系。归因分类准确率这是最直接的指标。构建一个测试集其中每个答案token都有真实的人工归因标签。计算检测头预测的置信度二值化后与真实标签的准确率、精确率、召回率和F1分数。幻觉检测的F1分数将“幻觉token”标签为0视为正例计算模型检测幻觉的能力。生成质量保持度在引入归因任务后需要评估模型原本的生成能力流畅性、相关性、事实准确性是否下降。可以使用GPT-4等大模型作为裁判对微调前后的模型生成答案进行盲评打分。端到端RAG系统指标将微调后的模型接入一个完整的RAG系统在真实业务数据集上评估其最终答案的准确率Answer Correctness和可归因率Attribution Rate。RAGognizer的思想打开了LLM可靠性提升的一扇新窗。它的潜力远不止于事后检测。一个很自然的延伸是“幻觉抑制生成”。既然我们能在生成每个token时实时得到其“幻觉风险分数”那么就可以在解码阶段引入这个分数作为约束。例如在束搜索Beam Search或采样时给那些归因置信度低的候选token施加惩罚引导模型生成更多有据可查的内容。这相当于把检测头变成了一个“生成引导器”。更进一步这种“感知-控制”的框架可以泛化。检测头不仅可以检测“是否源于上下文”理论上可以训练它检测任何我们关心的属性比如“是否符合安全规范”、“是否带有特定风格”、“是否包含敏感信息”。通过多任务学习我们可以让一个模型在生成的同时具备多种“自我审查”能力。当然这条路还很长。如何构建大规模、高质量的细粒度归因标注数据如何设计更高效、更精准的检测头架构如何平衡感知能力与生成效率都是需要持续探索的问题。但RAGognizer无疑指出了一个明确的方向让大模型变得更“自知”和“可控”是将其可靠地应用于关键领域的必经之路。从被动地事后纠错到主动地过程控制这才是提升LLM可信度的治本之策。
RAGognizer实战:为LLaMA-Factory模型添加幻觉感知检测头
发布时间:2026/6/22 9:54:04
1. 项目缘起当RAG遇上幻觉我们到底在解决什么问题最近在折腾大模型应用落地的朋友估计没少被“幻觉”问题折磨。你精心搭建了一个基于RAG检索增强生成的智能客服或者知识库问答系统指望着它能精准地从企业文档里找到答案。结果呢用户问“我们公司今年的年假政策是什么”它可能给你编一个“根据最新规定每位员工享有20天带薪年假并可叠加法定节假日”的完美答案听起来头头是道但实际情况可能是公司规定只有10天。这种一本正经地胡说八道就是大模型最让人头疼的“幻觉”。传统的RAG流程简单说就是“检索-拼接-生成”。系统先从向量数据库里捞出几篇最相关的文档片段把它们和用户问题一起塞给大语言模型说“喏这是参考资料请基于此回答。” 模型的任务是综合这些信息生成最终回复。这里的核心假设是模型会“乖乖地”主要依据你提供的参考文本来回答。但现实很骨感LLM大语言模型本质上是基于海量数据训练出的概率生成器它有极强的语言建模和逻辑推理能力但同时也保留了“自由发挥”的习性。当检索到的文档信息模糊、不全或者模型自身的“知识”与文档冲突时它就可能选择忽略或曲解你给的参考资料转而依赖自己训练数据中的记忆来“编造”答案。所以RAGognizer这个项目的目标非常明确它不是要取代RAG而是要给RAG系统加上一个“质检员”。这个“质检员”的任务就是在模型生成最终答案的同时实时地、自动地判断答案中的每一部分信息到底有多少是忠实于你提供的参考文档的有多少是模型自己“加戏”产生的幻觉。更进一步它不仅仅是一个事后的检测器而是将这种“幻觉感知”能力通过一个额外的“检测头”集成到模型微调过程中让模型在训练时就学会“自我审查”从而在根本上提升生成答案的可靠性和事实一致性。这相当于给模型装了一个“诚实度传感器”让它生成答案时自己心里有数知道哪句话是有据可查哪句话是推测甚至虚构的。2. 核心架构拆解检测头如何实现“幻觉感知”RAGognizer的核心创新在于它提出的“集成检测头”架构。要理解它我们得先抛开复杂的数学公式从功能模块的角度来看。想象一下一个标准的、用于RAG场景的微调后的大模型可以看作一个黑箱输入是问题Query和检索到的参考上下文Context输出是答案Answer。传统的微调目标是让这个答案在流畅度、相关性和事实准确性上尽可能好。但“事实准确性”是一个事后评估指标模型在生成时并没有一个内置的机制来量化自己对每个生成token的“信心来源”。RAGognizer的做法是在这个主模型我们称之为“生成主干网络”的旁边并联一个轻量级的“检测头”网络。这个检测头通常只有几层全连接层或小型Transformer层参数很少计算开销低。它的输入并不是原始的问题和上下文而是生成主干网络在生成答案过程中的“内部状态”——具体来说是每个解码步骤即生成每个答案token时对应的隐藏层表示Hidden States。这个设计非常巧妙。因为模型的隐藏层表示蕴含了模型在生成当前词时“思考”的全部信息包括它对问题、上下文的理解以及它即将生成的内容的倾向。检测头的任务就是学习分析这些隐藏状态并输出一个概率值对于当前正在生成的这个词它有多大可能是直接来源于参考上下文的即“可归因于上下文”又有多大可能是模型基于自身参数“凭空创造”的即“幻觉”或“不可归因”。从技术实现上看这个过程是并行的生成流主干网络接收[Query, Context]开始自回归地生成答案Token序列A1, A2, ..., An。检测流在生成每个答案TokenAi时主干网络会产生一个对应的隐藏状态向量Hi。这个Hi被实时地送入检测头。检测头计算检测头对Hi进行计算输出一个标量分数si例如通过Sigmoid函数映射到0到1之间这个分数就代表了TokenAi来源于上下文的置信度。联合输出最终系统不仅输出答案文本[A1, A2, ..., An]还同步输出一个对应的置信度序列[s1, s2, ..., sn]。你可以把它理解为答案的“可溯源分数”序列。那么这个检测头是如何学会区分“可归因”和“幻觉”的呢关键在于微调阶段的数据和损失函数设计。我们需要构造专门的训练数据对(Query, Context, Answer, Attribution Labels)。这里的Attribution Labels是一个与答案Token一一对应的0/1标签序列标注每个Token是否能在给定的Context中找到直接或强力的支持证据。构建这样的数据需要一定的人工或启发式规则例如使用文本匹配、命名实体识别工具进行对齐。在微调时我们有两个损失函数在同时工作生成损失L_gen就是标准的语言模型损失比如交叉熵损失确保生成的答案Answer本身是流畅、相关的。归因损失L_attr这是检测头的训练目标。通常使用二元交叉熵损失让检测头输出的置信度序列[si]尽可能接近真实的归因标签序列[li]。总的损失函数是两者的加权和L_total L_gen λ * L_attr。这里的λ是一个超参数用于平衡生成质量和归因准确性。通过这种多任务学习的方式主干网络在学习如何生成更好答案的同时其内部表示也被“塑造”得更容易让检测头区分信息的来源。这就是“幻觉感知微调”的本质——让模型在训练中内化对自身输出可溯源性的判断能力。3. 从零到一基于LLaMA-Factory的RAGognizer实战微调理论讲完了我们来点硬的。如何亲手实现一个RAGognizer风格的模型这里我们选择目前社区最活跃、对中文支持也较好的微调框架LLaMA-Factory作为实战平台并以Qwen1.5-7B-Chat模型为基础演示如何为其增加幻觉感知的检测头并进行微调。3.1 环境准备与数据构造首先你需要一个能够运行LLaMA-Factory的环境。强烈建议使用Python 3.10及以上版本并准备好足够的GPU资源例如单卡A100 40G对于7B模型的全参数微调是足够的如果资源有限可以使用QLoRA等高效微调方法。# 1. 克隆LLaMA-Factory仓库 git clone https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory # 2. 安装依赖 (推荐使用conda创建虚拟环境) conda create -n llama_factory python3.10 conda activate llama_factory pip install -r requirements.txt # 3. 安装PyTorch (根据你的CUDA版本) # 例如对于CUDA 11.8 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118接下来是最关键也最耗时的一步构造训练数据。RAGognizer需要四元组数据(query, context, answer, attribution_labels)。假设我们有一个简单的JSONL格式数据集train.jsonl每一行是一个样本{ query: 公司年假有多少天, context: 根据《员工手册》第三章第五条规定正式员工入职满一年后每年享有10天带薪年假。年假可分段休但最小请假单位为0.5天。, answer: 正式员工入职满一年后每年有10天带薪年假。, attribution_labels: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] // 对应answer每个token的标签 }这里的难点在于attribution_labels的生成。一个实用的方法是对answer进行分词使用与模型相同的tokenizer。对于answer中的每个token或token组使用字符串匹配、编辑距离或更高级的NLI自然语言推理模型判断其是否在context中出现或能被context所蕴含。将可以归因的token标记为1否则标记为0。对于中文可能需要以词或短句为单位进行判断而不是单个字。注意数据质量直接决定检测头的性能。如果条件允许最好能进行一定的人工校验和清洗。初期可以尝试用规则如关键词匹配生成粗糙标签再用小批量人工标注的数据进行微调。3.2 修改模型结构添加检测头LLaMA-Factory本身不直接支持这种“并联检测头”的架构我们需要对模型代码进行一些修改。这里以Qwen1.5模型为例展示核心的修改思路。我们创建一个新的模型类QwenWithAttnHead它继承自原始的Qwen2ForCausalLM。核心是重写forward方法在生成过程中插入检测头的计算。# 文件modeling_qwen_with_head.py import torch import torch.nn as nn from transformers import Qwen2ForCausalLM, Qwen2Config class AttributionHead(nn.Module): 轻量级归因检测头 def __init__(self, hidden_size): super().__init__() self.dense nn.Linear(hidden_size, hidden_size) self.activation nn.Tanh() self.classifier nn.Linear(hidden_size, 1) # 输出单个分数 def forward(self, hidden_states): # hidden_states: [batch_size, seq_len, hidden_size] pooled_output hidden_states[:, -1, :] # 通常取最后一个token的隐藏状态代表当前生成步的“思考” x self.dense(pooled_output) x self.activation(x) logits self.classifier(x) # [batch_size, 1] return torch.sigmoid(logits).squeeze(-1) # 输出概率 [batch_size] class QwenWithAttnHead(Qwen2ForCausalLM): def __init__(self, config): super().__init__(config) self.hidden_size config.hidden_size self.attribution_head AttributionHead(self.hidden_size) def forward( self, input_idsNone, attention_maskNone, position_idsNone, past_key_valuesNone, inputs_embedsNone, labelsNone, attribution_labelsNone, # 新增归因标签 use_cacheNone, output_attentionsNone, output_hidden_statesNone, # 必须设置为True我们需要隐藏状态 return_dictNone, ): # 调用父类forward获取输出 outputs super().forward( input_idsinput_ids, attention_maskattention_mask, position_idsposition_ids, past_key_valuespast_key_values, inputs_embedsinputs_embeds, labelslabels, use_cacheuse_cache, output_attentionsoutput_attentions, output_hidden_statesTrue, # 强制输出隐藏状态 return_dictreturn_dict, ) # 获取最后一个隐藏层的状态 [batch_size, seq_len, hidden_size] last_hidden_states outputs.hidden_states[-1] # 计算归因分数 # 我们需要为每个生成的位置计算分数。这里简化处理只计算labels不为-100的位置即需要预测的token if labels is not None: # 找到需要预测的token位置 pred_positions (labels ! -100).nonzero(as_tupleTrue) if pred_positions[0].numel() 0: selected_hidden last_hidden_states[pred_positions[0], pred_positions[1], :] attribution_logits self.attribution_head(selected_hidden) # [num_pred_tokens] outputs.attribution_logits attribution_logits else: outputs.attribution_logits None else: # 推理时可以为每个生成的token计算分数需要更复杂的逻辑 outputs.attribution_logits None # 计算归因损失 if attribution_labels is not None and outputs.attribution_logits is not None: # attribution_labels 形状应与 labels 中非-100的位置对应 attr_labels_flat attribution_labels[labels ! -100].float() loss_fct nn.BCELoss() attribution_loss loss_fct(outputs.attribution_logits, attr_labels_flat) # 将归因损失加到总损失上 if outputs.loss is not None: outputs.loss outputs.loss 0.5 * attribution_loss # lambda 设为 0.5 else: outputs.loss attribution_loss return outputs然后我们需要修改LLaMA-Factory的数据处理和训练循环使其能加载我们自定义的模型并正确处理attribution_labels这个新的字段。这涉及到修改dataset.py和trainer.py中的相关部分将四元组数据加载进来并在计算损失时传入attribution_labels。3.3 配置与启动微调在LLaMA-Factory中我们通常使用一个配置文件来定义训练参数。我们需要创建一个新的配置文件train_ragognizer.json并指定我们自定义的模型。{ model_name_or_path: /path/to/your/qwen1.5-7b-chat, custom_model: QwenWithAttnHead, // 告诉LLaMA-Factory使用我们的自定义类 data_path: ./data/train.jsonl, template: qwen, finetuning_type: full, // 全参数微调也可用 lora output_dir: ./output/ragognizer_qwen, overwrite_output_dir: true, per_device_train_batch_size: 4, gradient_accumulation_steps: 8, lr_scheduler_type: cosine, logging_steps: 10, save_steps: 500, learning_rate: 2e-5, num_train_epochs: 3, max_length: 1024, fp16: true, report_to: none }然后使用LLaMA-Factory的脚本启动训练CUDA_VISIBLE_DEVICES0 python src/train_bash.py \ --stage sft \ --do_train \ --model_name_or_path /path/to/qwen1.5-7b-chat \ --custom_model QwenWithAttnHead \ --dataset train_data \ --template qwen \ --finetuning_type full \ --output_dir ./output/ragognizer_qwen \ --overwrite_cache \ --per_device_train_batch_size 4 \ --gradient_accumulation_steps 8 \ --lr_scheduler_type cosine \ --logging_steps 10 \ --save_steps 500 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --max_length 1024 \ --fp16 \ --plot_loss这个过程会同时优化语言模型损失和归因损失。训练完成后你就得到了一个具有“幻觉感知”能力的Qwen模型。3.4 推理与结果解读训练好的模型在推理时会有两个输出生成的答案文本和每个token的归因置信度。from transformers import AutoTokenizer, pipeline from modeling_qwen_with_head import QwenWithAttnHead model QwenWithAttnHead.from_pretrained(./output/ragognizer_qwen/checkpoint-final) tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen1.5-7B-Chat) pipe pipeline(text-generation, modelmodel, tokenizertokenizer, device0) query 公司年假有多少天 context 根据《员工手册》第三章第五条规定正式员工入职满一年后每年享有10天带薪年假。 prompt f根据以下上下文回答问题\n上下文{context}\n问题{query}\n答案 # 注意需要修改generate函数以获取隐藏状态和归因分数这里仅为示意 output pipe(prompt, max_new_tokens50, return_dict_in_generateTrue, output_hidden_statesTrue) answer output[0][generated_text][len(prompt):] # 假设我们通过某种方式获取了每个token的归因分数 attribution_scores # attribution_scores [0.95, 0.98, 0.12, ...] print(f答案{answer}) print(归因置信度逐词) for token, score in zip(tokenizer.tokenize(answer), attribution_scores): print(f {token}: {score:.3f})输出可能类似于答案正式员工入职满一年后每年有10天带薪年假。 归因置信度逐词 正式: 0.992 员工: 0.987 入职: 0.978 满: 0.965 一年: 0.953 后: 0.941 : 0.120 每年: 0.989 有: 0.125 10: 0.998 天: 0.995 带薪: 0.991 年假: 0.993 。: 0.110你可以看到像“正式”、“员工”、“10”、“天”、“带薪年假”这些直接从上下文中抽取或高度相关的词置信度非常高接近1。而“”、“有”、“。”这类功能性词语或连接词置信度较低因为它们并非来源于上下文的具体事实而是模型语言习惯的一部分。如果模型在“10天”后面自己加了一句“并且可以折现”那么“折现”这个词的置信度会非常低例如0.05这就被成功标记为潜在的幻觉。4. 关键挑战与实战避坑指南理想很丰满但实现一个可用的RAGognizer系统路上坑不少。结合我自己的实验经验分享几个最关键的挑战和应对策略。挑战一高质量归因标签数据的匮乏与噪声这是最大的瓶颈。自动生成的标签如基于字符串匹配噪声极大。例如答案中的“可以”可能对应上下文里的“允许”严格匹配会判为0但语义上应判为1。反之“公司”这个词可能在上下文高频出现但答案中的“公司”指代可能不同严格匹配会判为1实则可能是幻觉。应对策略采用“分阶段训练”和“数据蒸馏”。阶段一冷启动使用规则精确匹配、模糊匹配或轻量级NLI模型如DeBERTa生成一批带有噪声的标签数据先训练一个初版检测头。这个版本的精度可能只有60-70%但足以作为一个起点。阶段二数据蒸馏用初版模型对大量未标注的(query, context, answer)三元组进行推理得到模型预测的归因分数。设定一个高阈值如0.95和低阈值如0.05筛选出高置信度的正负样本加入到训练集中。这相当于用模型自己的判断来清洗和扩充数据。阶段三主动学习与人工校验针对模型预测置信度在中间模糊区域如0.3-0.7的样本进行小批量的人工标注。这批高质量数据对提升模型在困难案例上的判别能力至关重要。挑战二检测头与生成主干的耦合与干扰检测头是附加组件如果设计不当或训练不充分可能会“带偏”主干网络的生成能力。例如模型可能为了获得高的归因分数倾向于生成一些非常保守、完全照抄上下文的、不流畅的答案。应对策略精细调整损失权重λ和训练策略。动态权重在训练初期可以设置较小的λ如0.1让模型先专注于学习生成任务。随着训练进行逐步增大λ如到0.5或1.0加强归因任务的训练。课程学习先使用简单的、归因清晰的样本进行训练再逐步引入模糊、困难的样本。共享层冻结可以考虑只微调主干网络的最后几层而让前面的层保持冻结减少检测头对底层语言理解能力的干扰。挑战三推理阶段的性能与延迟在推理时每生成一个token都需要调用一次检测头来计算归因分数这会增加额外的计算开销。对于延迟敏感的应用这可能成为瓶颈。应对策略工程优化与近似计算。检测头轻量化确保检测头结构足够简单如2层MLP。缓存与并行利用Transformer的KV缓存机制检测头的输入隐藏状态计算可以和下一token的生成计算部分重叠。稀疏计算不必对每个token都计算归因分数。可以每隔2-3个token计算一次或者只在生成名词、实体、数字等关键信息点时计算。后处理模式对于某些不要求实时归因的应用可以先让模型快速生成完整答案然后再用检测头对整个答案序列进行一次性的归因分析。但这失去了在生成过程中进行引导的可能性。挑战四归因粒度的选择是以词token为粒度还是以短语phrase或句子sentence为粒度词级粒度最精细但标签最难标注且输出对用户不友好一串数字。句子级粒度更易理解但可能掩盖句子内部的幻觉。应对策略根据应用场景折中。对于高可靠性要求的领域如医疗、法律建议采用词级或子词级粒度并在前端对低置信度部分进行高亮预警。对于普通问答可以采用“句子关键实体”的混合粒度即输出整个句子的置信度并对句中识别出的关键实体如日期、金额、人名单独给出置信度。5. 效果评估与未来展望不止于检测训练完成后如何评估RAGognizer的效果不能只看生成答案的BLEU或ROUGE分数更需要一套针对“幻觉感知”能力的评估体系。归因分类准确率这是最直接的指标。构建一个测试集其中每个答案token都有真实的人工归因标签。计算检测头预测的置信度二值化后与真实标签的准确率、精确率、召回率和F1分数。幻觉检测的F1分数将“幻觉token”标签为0视为正例计算模型检测幻觉的能力。生成质量保持度在引入归因任务后需要评估模型原本的生成能力流畅性、相关性、事实准确性是否下降。可以使用GPT-4等大模型作为裁判对微调前后的模型生成答案进行盲评打分。端到端RAG系统指标将微调后的模型接入一个完整的RAG系统在真实业务数据集上评估其最终答案的准确率Answer Correctness和可归因率Attribution Rate。RAGognizer的思想打开了LLM可靠性提升的一扇新窗。它的潜力远不止于事后检测。一个很自然的延伸是“幻觉抑制生成”。既然我们能在生成每个token时实时得到其“幻觉风险分数”那么就可以在解码阶段引入这个分数作为约束。例如在束搜索Beam Search或采样时给那些归因置信度低的候选token施加惩罚引导模型生成更多有据可查的内容。这相当于把检测头变成了一个“生成引导器”。更进一步这种“感知-控制”的框架可以泛化。检测头不仅可以检测“是否源于上下文”理论上可以训练它检测任何我们关心的属性比如“是否符合安全规范”、“是否带有特定风格”、“是否包含敏感信息”。通过多任务学习我们可以让一个模型在生成的同时具备多种“自我审查”能力。当然这条路还很长。如何构建大规模、高质量的细粒度归因标注数据如何设计更高效、更精准的检测头架构如何平衡感知能力与生成效率都是需要持续探索的问题。但RAGognizer无疑指出了一个明确的方向让大模型变得更“自知”和“可控”是将其可靠地应用于关键领域的必经之路。从被动地事后纠错到主动地过程控制这才是提升LLM可信度的治本之策。