
前阵子做了一个内容质量控制的实验把一个 7B 参数的小模型微调成事实核查器fact-checker拿它做文本校验同时用 30B 的大语言模型当评审员reviewer跑同一批数据。结果相当反直觉——7B 的核查器不仅评判效果更好还几乎没删掉任何一条真实观点。deleted no true claims这句话在内容审核和知识库过滤场景里比“准确率提升几个点”更值钱。这篇就把整个项目的设计思路、数据构造、微调细节、对比结果和经验坑位都盘一遍适合正在做 RAG 质量、内容审核、事实校对的同学参考。1. 项目背景为什么需要“能删又能留”的事实核查器1.1 大模型reviewers的“宁可错杀”困局很多团队现在做内容质量管控已经不是靠正则和关键词了而是直接让大模型当裁判。流程大概是这样先生成一批文本然后用一个更大的模型去审问它“这里面有没有错误信息有就删掉”。30B 这种体量的模型确实能抓住不少问题但带来的副作用同样明显它经常把正确的说法也当成问题删掉。这种“宁可错杀不可放过”的行为在审核场景里很致命。你删掉一条虚假信息用户最多觉得平台变干净了你删掉一条真实信息用户立刻会质疑你的专业性和公信力。更麻烦的是大模型对“真实性”的判断标准并不稳定。同一个 claim换个 prompt 表述结果可能从“支持”变成“反对”。它不是不会判断而是过度响应了用户“找问题”的指令导致把手里的正确内容也搭进去。我做这个项目时给自己定了一个硬性指标不管模型能识别多少假话它必须做到deleted no true claims。也就是说准确性要看两点一是假话识别得准不准二是真话留得住留不住。后面这一步才是真正拉开差距的地方。1.2 把事实核查重新定义成“保留优先”的任务传统 fact-checking 任务的输出是“真/假/存疑”但实际业务里真正关心的是“这条内容能不能入库、能不能展示”。我重新把任务框定为只有证据明确与 claim 矛盾时才允许判 FALSE证据不足时一律判 UNVERIFIABLE不进入删除流程。这句话说起来简单做起来很难。模型天生有“找茬”倾向看到证据里没有直接出现关键数字就倾向于判“错误”。训练时必须反复灌输一个规则没有证据 ≠ 错误检索不到 ≠ 不存在。只有“证据明确反对”才叫错误。这也是我在后面数据构造部分反复强调的一点。这个立场很重要。它是整个系统设计的上游逻辑不是追求“删得准”而是追求“不误删”。一个 30B 的大模型如果只是做通用指令跟随它很难稳定坚持这个立场但一个专门微调过的 7B 小模型可以做到。2. 系统设计与数据构建小模型不是凭空变强的2.1 把核查拆成“抽取-检索-判定”三步一开始我也想过让模型对整段文本直接打分但效果很差。原因是一段 500 字的文本里往往混了多个事实点可能一句真一句假整体判断会把它们平均掉。后来我把流程拆成三步第一步是 claim 抽取。从待审文本里拆出原子化的事实主张比如“系统支持单点登录”“数据库采用 PostgreSQL 12”这类能够独立验证的句子。这一步我用的是轻量 NLP 规则加模型后处理没有单独训练模型。第二步是证据检索。对每个 claim 做向量化从本地知识库或业务文档里召回 top-k 相关片段。我用的嵌入模型是 bge-m3top-k 取 5。这里有个容易被忽视的细节检索召回的质量直接决定了判定上限。如果证据没召回后面模型再厉害也救不回来。第三步才是 7B 事实核查器的主场。把 claim 和证据片段拼成结构化 prompt让模型输出 SUPPORTED、REFUTED、UNVERIFIABLE 三种判定并附上解释和置信度。这个设计的好处是模型永远基于证据回答而不是凭自己的记忆“拍脑袋”。2.2 训练数据怎么构造的数据是这次实验的胜负手。我总共构造了 20000 条训练样本分布为SUPPORTED 约 7000 条、REFUTED 约 5000 条、UNVERIFIABLE 约 8000 条。注意 UNVERIFIABLE 的比例刻意偏高就是为了压制模型乱删真实 claim 的倾向。构造方法分为三类。第一类是从真实业务文本里抽取 claim再用成熟的知识库和检索系统做证据召回由人工标注最终标签。这是最干净的数据能保证真实世界分布。第二类是合成数据。用更强的模型生成一批“看起来像真的、实际上缺乏证据”的 claim再把规则里定义的矛盾证据片段拼接进去生成大量 REFUTED 样本。比如生成“该平台支持 IBM Db2”但证据里只写了“该平台支持 MySQL 和 PostgreSQL”这就是标准矛盾。第三类是难度样本。专门收集那些证据没有直接命中、但语义上近似支持的 claim强制模型学会区分“不支持”和“有矛盾”的边界。比如 claim 是“支持 OAuth2.0 登录”证据是“支持企业微信扫码登录”它显然不属于 REFUTED但也不完全等于 SUPPORTED判 UNVERIFIABLE 最合适。数据标注规范里有一条核心规则只有在证据明确出现与 claim 相反的表述时才允许标 REFUTED其他情况一律标 UNVERIFIABLE。这条规则被写进了标注工具的系统提示里。2.3 为什么选 7B 而不是更小或更大我把候选基座模型试了 1.5B、3B、7B、30B 四档。太小的问题不是不聪明而是学不会精细规则。1.5B 在“证据无关”和“证据反驳”之间反复摇摆经常把无关证据当成反驳证据。30B 能力确实强但它强过头了对证据的“完美匹配”要求极高反而容易误伤。7B 是那个平衡点。它参数量足够学会复杂的判定边界又不会像 30B 那样过度展开推理。实践里我用的是 Qwen2.5-7B-Instruct 作为基座单卡就能跑推理量化之后显存占用也只有 6GB 上下。对内容审核这种需要高频调用的场景这个成本压力完全可以接受。3. 微调与推理实现把 7B 调教成合格审查官3.1 LoRA 微调配置我没有做全量微调用的是 LoRA。原因很简单一是显存不够二是 LoRA 对这类窄任务足够而且可以保留基座模型的通用能力后面出问题还能快速切换分支。核心训练配置大致如下。from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-7B-Instruct, load_in_4bitTrue, device_mapauto ) model prepare_model_for_kbit_training(model) lora_config LoraConfig( r32, lora_alpha64, target_modules[ q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj ], lora_dropout0.05, biasnone, task_typeCAUSAL_LM ) model get_peft_model(model, lora_config) training_args TrainingArguments( output_dir./fact-checker-7b-lora, per_device_train_batch_size4, gradient_accumulation_steps8, learning_rate2e-4, num_train_epochs2, logging_steps20, save_steps500, fp16True, max_seq_length2048, report_tonone )关键参数是r32和learning_rate2e-4这是我在对比实验里跑出来最稳的一组。r太小会让模型记不住判定边界r太大又容易过拟合到训练数据。max_seq_length2048是因为证据片段加上 prompt 经常超过 1024短了会直接截断关键证据这是误删的隐形杀手。训练数据格式上我用了 Qwen 的 ChatML 模板在[INST]部分放任务描述、claim 和 evidence在[/INST]部分放 JSON 输出。每条数据都会强调“如果没有矛盾证据你必须回答 UNVERIFIABLE不要猜测”。3.2 核心技巧否定式标签与“证据矛盾才算删”这一节是整个项目最值钱的部分我单独拿出来讲。微调时如果只是在正常样本上做分类模型学到的决策边界会很“急”。它倾向于把相似但不等同的证据也判断成“支持”或“反对”。要解决这个问题得在训练样本里大量掺入“否定式标签”样本。什么意思比如一条 claim 是“支持 OAuth2.0”召回的证据是“支持微信扫码登录”。如果把这个样本标成 UNVERIFIABLE模型会学到“无关也可能不相关”但它不会理解“无关不等于矛盾”。更好的做法是给模型看一组证据链先是一条“通用登录支持说明”的无关背景再穿插一条“明确不支持 OAuth2.0 的文档”。这样模型才真正学会区分无关与相反。训练 prompt 里的一个典型示例如下请判断给定 claim 与证据之间的关系。 规则 1. 证据明确支持 claim输出 SUPPORTED。 2. 证据明确与 claim 矛盾输出 REFUTED。 3. 证据不充分或与 claim 无关输出 UNVERIFIABLE。 4. 宁可保留不可误删。不确定时必须输出 UNVERIFIABLE。 Claim: 系统支持 OAuth2.0 登录。 Evidence: 登录功能支持微信扫码、账号密码和企业微信认证未提及 OAuth2.0。 只输出 JSON {decision: UNVERIFIABLE, confidence: 0.9, reason: 证据未涉及OAuth2.0无法支持或反驳该claim}这个模板里的“规则 4”起到了很强的约束作用。我试过删掉规则 4模型对 UNVERIFIABLE 的召回率立刻掉了 8 个百分点。不要小看这一句话它就是业务里“不误删真实 claim”的兜底。3.3 推理时如何对齐输出微调完成后的推理阶段我做了三件容易被忽略的事。第一温度必须设为 0。温度一高模型会在“判断”这种低熵任务里乱漂移偶尔还出现“UNVERIFIABLE 但 confidence 很高”这种内部矛盾。我统一用 greedy decoding不采样。第二强制结构化输出。我用的是outlines库对 JSON schema 做约束生成。这样模型无论怎么“自由发挥”都会吐出一个合法 JSON。如果解析失败系统会把它当作 UNVERIFIABLE 处理而不是当作 FALSE。这一点很关键宁可让一条走私漏过去也不能误杀真实内容。第三后置规则兜底。模型输出的decision只是第一层判断我还会用一个简单规则检查reason里面是否出现明确的否定词比如“不支持”“没有”等。如果 decision 是 REFUTED 但 reason 里没有实质矛盾内容就降级为 UNVERIFIABLE。这个规则非常粗暴但实测能捡回 1% 左右被误删的真实 claim。4. 与 30B reviewer 对比测的不只是准确率4.1 评测集和指标对比实验用了一批独立的测试集共 1000 条 claim人工标注分布为真实 600 条、虚假 300 条、证据不足 100 条。对比对象是两个方案一个是直接用 30B 基座模型做零样本 reviewer给它同样的 claim 和证据让它按自己的理解输出“保留/删除”另一个就是我微调好的 7B 事实核查器。评测指标上除了常规的 precision/recall我还额外关注“真实主张误删率”。False-delete rate真实 claim 被判成 REFUTED 的比例。True recall真实 claim 最终被保留的比例。Precision判定为 REFUTED 的样本中人工确实认定为虚假的比例。4.2 对比结果7B 如何赢结果如下表所示方案PrecisionTrue recallFalse-delete rate说明30B 通用 reviewer69.4%84.9%15.1%删了很多也误伤很多7B 微调 fact-checker82.7%98.7%1.3%几乎没有误删真实 claim30B 方案的 Precision 只有 69.4%这意味着它每删除 100 条内容大约有 30 条是真实信息。这就是前面说的“reviewer 困局”它确实识别出了大量虚假信息但代价高到业务无法接受。7B 事实核查器的 True recall 到了 98.7%对应 false-delete rate 只有 1.3%。换句话说600 条真实 claim 里只误删了 8 条而 30B 方案误删了 91 条。这个差距已经能直接决定系统能不能上生产。4.3 为什么会赢分析很多人觉得大模型参数越多判断应该越准。这个直觉在通用任务里成立但在这种“窄且高风险”的事实核查任务里不成立。第一个原因是量级错配。30B 模型的知识覆盖广但它会过度依赖自己的“世界知识”来判断。它面对证据不足时会习惯性用一个内部先验概率补全缺失信息。比如证据里没写“是否支持 OAuth2.0”它倾向于根据“很多系统支持 OAuth2.0”这个先验直接判 SUPPORTED。这看起来是“自信”其实是未经证实的臆测。第二个原因是位置偏差。30B zero-shot reviewer 对长 prompt 的最后几句话特别敏感。如果业务 prompt 里写了“请把错误信息删除”它就会把“删除”的指令权重放大导致过删。而微调过的 7B 模型在训练时见过大量“证据不足必须保留”的约束已经在参数层面把这条规则固化了。第三个原因是推理深度。30B 会展开层层推理把一条本可以直接判断的证据绕到另一个维度去解释。比如证据里写“该版本不支持 SSO”claim 说“系统支持 SSO”它可能分不清“新版本支持”还是“所有版本支持”最终给出模糊回答。7B 因为参数量小反而更“老实”只做模式匹配加轻量推理判得反而更干净。5. 常见问题与排查技巧实录5.1 误删真实 claim 的三种典型原因即便模型整体效果不错我在复现和调优过程中还是遇到了几次集体误删翻了日志后发现原因基本集中在三类。第一类是证据召回阶段跑偏。embedding 模型把语义相近但实际无关的段落召回了模型又没识别出来就把原本真实的 claim 当成无人支持的假消息。解决办法是在 prompt 里明确加一句“如果证据与 claim 的核心语义不一致必须输出 UNVERIFIABLE”。第二类是序列截断。某些证据片段过长我的max_seq_length2048还是被截断了导致关键信息刚好落在截断区。后来我把证据切片改成“长片段分段召回”再对每段单独做判定最后用规则合并结果误删率又降了一截。第三类是模型把“无法判断”误解成“不支持”。这是微调数据里最难处理的部分。需要在训练数据里故意加入大量“证据为无关背景”的负样例让模型反复看到“无关 ≠ 矛盾”的映射关系。5.2 输出格式不稳怎么办微调模型和基座模型一样都有输出格式漂移的问题。尤其当 prompt 里的 evidence 太长时模型会牺牲 JSON 结构转而输出一段散文。我自己的解法是用约束生成工具强制吐 JSON不做传统正则解析。如果约束生成库和你的量化后端不兼容退而求其次的方案是让模型先输出“决定理由”再输出“decision”然后在文本正则里以最后出现的 decision 字段为准。用json.loads解析失败时我可以设置一个 retry 逻辑用更短的精简 prompt 再让模型跑一次。仍然失败就直接打回 UNVERIFIABLE绝不默认成 REFUTED。5.3 部署时的小坑量化、温度、批处理最后说部署阶段。我本地验证时先用了 Ollama 直接跑qwen2.5:7b快速看效果非常方便。但上生产我还是换成了 vLLM配 AWQ 量化版本吞吐稳定很多。这里有一个很容易被忽略的坑量化后模型行为会有轻微变化尤其是在confidence输出上。同一个样本AWQ 量化版本可能从 confidence 0.95 掉到 0.88。所以上线前一定要拿 100 条典型样本在 fp16 和 AWQ 上各跑一遍对比decision_accuracy是否一致。只看 macro-F1 是不够的要对真实 claim 的保留率单独核对。batch 推理也有讲究。长 prompt 和短 prompt 混在一个 batch 里vLLM 的 continuous batching 会把短请求提前结束但长请求的输出分布和单条推理偶尔会有细微差异。保险起见我在生产里按 prompt 长度分桶长度相近的一起跑。这个操作看起来多余但能显著减少偶发的 decision 漂移。还有一个经验是不要把温度调成 0 以外的任何值。我知道有人为了增加多样性把 temperature 调到 0.3但在这种判定任务里毫无收益只会让真实 claim 的保留率下降。审核系统要的是可复现性不是创造性。踩过这么多坑之后我个人的体会是模型参数大小不是事实核查效果的唯一决定因素任务设计、数据构造、推理规则这三件事合起来才决定了一条真实信息能不能活下来。7B 能赢 30B不是因为它更聪明而是因为它更“专一”。如果你也在做类似的内容质量系统建议先把自己的“保留优先”规则写清楚再看模型该选多大。