通过强制LLM输出的每个事实字段都必须是输入原文的精确拷贝,配合代码层面验证来杜绝幻觉。核心做法:将限制放入Schema而非prompt文本,设置"无结果"分支,按复制场景调低温度。
你在从一份长文档中提取结构化事实:合同、支持工单、一份没人写过的规范。为了让结果可核查,你让模型返回每个事实来自的确切行号。
然后其中一个引用在用 Ctrl-F 对着源文档检查时没有找到。输出的其他部分看起来都没问题——这正是它能通过审查的原因。
提示词里写一句"不要编造"只能把模型指向它的输入,而当你要求的东西根本不在输入里时,这毫无帮助。这就是一个幻觉(confabulation)通常的真相:当诚实地说出真相没有出路时,剩下的就是那个答案。
比起更好的措辞,真正有效的是让每个事实字段都成为输入中某处的副本,并且用代码——而不是模型——来负责检查它是否真的是副本。如果需要给它起个名字,可以叫"基于证明的落地"(Proof-based grounding)。
TL;DR。让每个事实字段都成为输入中某处的副本。告诉模型那条会拒绝其答案的规则。把限制放到 schema 里而不是提示词 prose 里。给它一个说"没找到"的方式。选择用于抄写的温度而不是用于写作的温度。在代码中验证,并对失败的情况做路由。以上全部都打包成了一个 Claude Code skill:
git clone https://github.com/r-ms/prompt-construction.git ~/.claude/skills/prompt-construction
下面六个机制是我会按顺序应用的,每个都附有它的价值说明。
逐字段走一遍输出 schema,对每个字段问自己:模型是从输入中复制它,还是组合它。
任何必须是事实的东西,必须在输入中以你要求回的形式打印出来。给输入的行编号,然后要求返回行号:[#42] 实际的那一行,模型返回 {"line_id": 42, "quote": "..."}。
然后有些字段模型会不管你写什么都会编造:字符偏移量、页码、在文本中任何地方都没出现的 id。这些根本不要问。我的输入是转录文本,而流水线故意把词级时间戳对模型隐藏了,因为它很擅长伪造它们。代码之后通过匹配模型复制的文本来重新附加它们。
在责怪模型没有产生某个事实之前,先打印出组装好的提示词,找找你想要的东西回来。我的一个通道返回报告标题为裸数字,直到我打印出提示词,才发现我传入的列表里有 id 没有标题。
如果你的代码丢弃了不是源文档确切子字符串的引用,就把那句话原样写在提示词里,用结果来表述:
quote:源文档中逐字出现的措辞。未通过精确匹配的值将被自动丢弃。
这样写它就成了一份规范,而得到接受标准的模型会为此优化。
这一行这种写法在我的状态字段上值 +28.6 点准确率,那个 verdict 记录的是一个条款是否在文档中实际被满足。它现在是逐字节冻结的,带有一条注释命名了效果和测量它的条件,因为重新措辞的版本会失去这个收益而没有任何信号表明它丢失了。
"返回最多 25 项"是我提示词里的一行。在语料库中最长的文档上它返回了 134 条。
同样的限制写成 maxItems 会在边界处停止生成,因为解码器根本不被允许打开第 26 个元素。在 prose 里,那个数字只是模型在同一个提示词中要权衡的又一样东西。所以把真正重要的东西下沉到 schema 里:enum 覆盖一个固定的值集合让错误的值无法表示,maxLength 会阻止循环解码器——在我的案例里它曾经把一个 schema 字段名放进了面向客户的报告的要点里。
两个值得知道的限制。语法无法区分正确值和错误值,只能区分存在和缺失,所以代码验证仍然必要。而且语法保证的是有效前缀,不是完整文档:被 max_tokens 截断的输出是符合 schema 的、不可解析的。提高上限也修不了这个,我的输出达到了新的限制。
在严格的 schema 下每个字段都必须被填充。如果"我什么都没找到"没有表示方式,模型会用编造来填充那个空洞,而你已经在工程化你想要阻止的那种幻觉。
三个条件,缺一不可:schema 中存在 null 或专用值,提示词命名它为可接受的结果,代码对其进行处理。
永远不要让不确定性成为一个业务状态。"模型不确定"和"这事没发生"对看输出的人来说看起来一样,但意思正好相反。
Qwen 的模型卡用大写写着"不要使用贪婪解码",并推荐带思考时 0.6,不带思考时 0.7。
我的提取通道运行在 0.1。
在 0.6 时模型停止复制开始改写。引用保持 plausible,但不再是确切的子字符串,验证代码就会丢弃它。Qwen 的禁令针对的是退化和无限重复,这是生成工作的风险,而提取的最优解正好相反。针对重复我改用 frequency_penalty 0.3,还有那个有代价的供应商警告:"使用更高的值偶尔可能导致语言混合。"
永远不要让模型验证自己,也不要让验证器在遇到第一个不可验证的引用时就 raise,因为那样你会丢失整个运行,什么也学不到。
def check_grounding(items, source_lines):
ok, ungrounded = [], []
for it in items:
line = source_lines.get(str(it["line_id"])) # keys are strings; assert this once
if line and norm(it["quote"]) in norm(line): # norm: NFKC, collapse ws, unify dashes
ok.append(it)
else:
ungrounded.append(it) # route, don't raise
return ok, ungrounded
规范化在这里起的作用比看起来大。原始比较会因为一个多余的空格或一个 unicode 破折号而失败——这种情况比真正的编造更常发生。
这给你留下了一桶不可落地的声明去做路由,而运行得以存活。第二个信号告诉你哪些通过了验证的声明仍然值得怀疑:多次运行提取,然后观察哪些运行之间有分歧。
我每个跑三次。当三个答案不一致时,它们相对于我的参考集的误差率是 57.1%,而当它们一致时是 21.7%,所以只把分歧路由给人类就能捕获大约一半的错误。
有两件事欺骗了我。
第一个是噪声。我通过在固定的文档集上运行流水线并与我的参考答案比较来给一个改动打分。那次运行得分 64.84%。我什么都没改又跑了一次,同样的模型、同样的服务器、同样的配置、同样的文档,得到 57.45%。我的通过阈值是 60.05%,夹在两者之间,所以那个关卡报告的是噪声倾向哪边。在相信任何 delta 之前先把基准记录两次,因为在那之前你不知道自己能够检测到的效应大小。
第二个是一个什么都不做的开关。我给一个提取步骤启用了推理,测量没有改善,差点就把推理在这个任务上排除了。然后我打印出那次运行实际产生了多少推理 token,得到 19 个,整个运行 19 个——还不到一句话的思考量。vLLM 的 grammar 后端检查推理内容是否存在,并在存在时跳过结构化输出,所以没有 --reasoning-parser 标志时这两个功能冲突,被丢弃的是推理。一个未触发的机制和一个无用的东西都报告"无效果",所以在读任何结果之前先打印一个计数器来证明你打开的东西正在运行。
每个 schema 字段标注为复制或组合;组合的事实删除并在代码中重建。
输入打印时带上行 id,并且以你要求回的形式携带你问回去的每个事实。
拒绝规则在提示词中说明,用结果来表述。
限制在 schema 中(maxItems、enum、maxLength),代码验证仍然存在。
"无证据"可表示、允许被填充、并被处理。
采样配置为提取选择而不是继承自模型卡。
验证器在比较前先规范化,并对失败的情况路由而不是 raise。
基准记录两次,这样你在声称检测到效应之前就知道最小可检测效应。
以上效应大小属于我的技术栈,供应商引用是引用:我没有复现他们的实验。可以迁移的是,这六个机制每一个都可以在你的技术栈上用一个下午验证,用一个你在形成意见之前先打印的计数器。
以上全部都是一个 Claude Code skill。一条命令安装它:
git clone https://github.com/r-ms/prompt-construction.git ~/.claude/skills/prompt-construction
Claude 然后在你写下一个提示词或响应 schema 之前读取这些规则。仓库里包含供应商引用、一个症状到机制的调试表,以及发货前的检查清单。