我在整理一份技术选型文档时让大模型代笔,它写了一版看起来很有说服力的初稿,连版本号、发布年份、资源链接都像模像样。复制给同事后没几分钟,对方就来问:文档里那个工具真的存在吗?我去翻了官方仓库,整段引用链都是模型编出来的。更麻烦的是,我把它生成的错误结论原样丢回去做追问,它没有纠正自己,反而顺着错误往下圆。
这就是 LLM 一个非常隐蔽的坑:表面自信,但内部没有事实校验模块。它会基于自己前一轮生成的文本继续二次推断,错误不仅不会被发现,还会被强化,最后形成一篇“自己骗自己”的内容。后来我给自己所有 AI 生成流程加了一道关卡——用正则表达式做可信度闸门,在内容送出去之前先过一遍筛子。这个方案不依赖复杂算法,成本极低,效果却意外地稳定,拦截了大量会让作者尴尬的“伪精确表达”。
如果你也在做 AI 写作辅助、RAG 问答、Agent 自动生成报告,或者只是拿大模型当编辑工具,这篇文章应该能给你一个立刻能落地的防护思路。
1. LLM 为什么会在内容里“骗自己”
1.1 上下文污染:它把你给的错当成既定事实
LLM 本质上是文本概率模型,它没有“这句话是我上一步猜的”这种元认知。你丢给它一段含有错误的对话历史,它不会像人一样产生警觉,反而会认为“这是用户提供的事实背景”,所有后续推论都会建立在这个错误前提上。
我在测试里遇到过非常典型的情况:先让模型写一段产品介绍,它随口说“该模块支持 Kafka 3.2 之后的所有版本”,然后我在下一条消息里追问“那它是否兼容 3.6 的 Consumer API ?”模型立刻给出了一大段分析,讨论 3.6 的兼容细节。可问题是 3.6 这个版本本身就是它上一轮胡诌出来的。它无法区分“自己生成的假设”和“外部提供的资料”,因为对模型来说这两者都是上下文窗口里的 token。
这种机制造成的后果很直接:一个错误不是孤立出现的,它会被后续所有相关回答引用、扩展、包装成更“可信”的论述。只要源头没被卡住,错误就会像雪球一样越滚越大。
1.2 训练语料里的自我强化:错误被重复就成了刻板印象
现在的大模型训练流程里有一个很现实的问题:新一代模型会拿上一代模型或同代模型生成的文本当训练语料。如果这批语料里混入了大量“看起来合理但实际错误”的内容,模型在训练时学到的不只是一个错误事实,而是学到了一整套“用流畅句式包装虚假信息”的生成风格。
这个现象背后是研究者反复提到的模型坍缩风险。简单说,当模型生成的数据不断被机器筛选、加权、重新训练,小概率的统计数据会被放大,少见的异常表达会被抹平,最后剩下的往往是同类错误的平均值。模型会越来越“自信”,因为它的输出概率分布被训练得越来越单一、平滑,但那个概率中心对应的内容未必真实。
对我们这些使用者来说,这意味着一个残酷的现实:错误不是偶发噪声,而是模型的统计倾向。如果你完全信任输出,不设任何人工或程序化防线,错误会以极高的一致性反复出现。
1.3 长链路任务里的误差接力
Agent 类应用比单轮问答更危险。模型先把任务拆成子步骤,再为每个子步骤生成结果,然后把结果拼装成最终答案。这中间任何一步出现幻觉,后面所有步骤都会基于错误结果继续推理。
我见过一个比较典型的案例:让 Agent 做一个竞品分析报告,第一步要“生成竞品调研大纲”,模型列了一个文件夹结构。第二步它去填充内容时,把大纲里一个虚构的竞品名字当成了真实存在的公司,为此专门编造了市场份额和产品定位数据。最后一步做总结时,它甚至根据这个不存在的竞品得出了“该领域已经进入红海竞争”的结论。
这类问题特别难发现,因为最终输出看起来结构完整、逻辑连贯,每一步单独检查都像“那么回事”。可一旦你把中间步骤全部摊开比对,就会发现错误在很早就埋下了。这也是为什么不能只在最后人工看一眼,而要尽量在生成链路的输出端加一道自动化闸门。
1.4 提示词只能缓解症状,治不了根
有人会问:那我直接在提示词里写“请确保所有数据都有可靠来源”“不要编造内容”行不行?
实测下来,这类指令能减少一些夸张表述,但无法解决根本问题。因为模型输出的是概率分布,不是逻辑推理结果。你在提示词里反复强调“要诚实”,等价于把包含“诚实”语义的 token 在概率上略微上调,并不会触发任何外部事实校验机制。
更麻烦的是,如果模型在生成过程中“觉得”前面某个说法很有道理,它会倾向于补全一个符合上下文的论证。提示词约束的是宏观风格,管不了微观层面的错误引用、错误版本号和虚构数字。所以现实情况是:大模型负责产出的弹性,我们负责加一道刚性闸门,两者各有分工。
2. 给输出加闸门:为什么我选了正则而不是“再请一个 LLM 把关”
2.1 AI 幻觉留下的往往是“形式痕迹”
很多人以为 AI 幻觉是纯粹的语义问题,只能靠语义模型去抓。但在实际内容生产中,最容易让人社死的错误,其实是高度模式化的表达。
我整理了手头近百篇生成内容,发现一个规律:编造统计数据时,模型几乎必然用类似句式:“XX% 的用户认为”“超过 XX 的企业表示”“绝大多数受访者选择”。这些句式不是不能用,可问题在于,当文本缺少来源标记时,它们恰好是最容易误导读者的结构。编造论文引用时,情况也一样,模型最爱写“[12] 表明……”然后正文里却没有任何参考文献表;或者它会在正文中生成“[1]”,但到文末根本没有对应的 1 号条目。
这些错误并不是纯语义层面的“这句话内容有误”,而是结构层面的“这句话留下了可被追踪的可疑痕迹”。正则正好擅长捕捉这类痕迹:它不需要理解“62.4% 是否真实”,只需要判断“一个精确到小数点的比例后面,附近有没有数据来源标记”。如果连来源都不存在,那这句话就该被标出来人工复核。
进一步说,正则能做的是给内容加仪式感:让文本主动暴露它自己该有的证据结构。没有证据结构的华丽断言,就是高危险信号。
2.2 正则闸门的核心优势:确定性、低成本、可审计
对比方案是想让“另一个 LLM 当裁判”,给生成的文章逐条打分。从直觉上这可行,但真落地有四个麻烦。
第一是成本。一篇文章生成后,再调用一次大模型评判,token 费用直接翻倍。第二是不确定性。同一段文字,同一个 prompt,你让大模型评两次,结果可能有波动,尤其当阈值卡在中间地带。第三是一致性问题。评审模型可能被原文的权威口气带偏,产生一种“跟随性幻觉”,即它也觉得文章写得好,因为文本太流畅了。第四是可解释性差,评审模型说“这个段落不可信”,但它不会告诉你具体原因是缺引用还是数据来源存疑。
正则没有这些问题。同一段文本,跑一百次,结果完全一致。每次命中都能定位到具体规则、具体前后文、具体字符位置。如果客户或审核方问“为什么标记这条”,你可以直接给出规则名和匹配片段。这种可审计性在生产环境里极其重要。
2.3 先想清楚边界:它管形式,不负责验证事实
我必须强调,不要把正则闸门的定位搞成“事实核查器”。
正则能做的,是识别三类问题:格式硬错误,比如引用了不存在的参考文献编号、日期格式不合法;危险句式,比如无来源的百分比声称、绝对化断言语;结构可疑,比如正文提到了“最新版本”,却缺少版本发布说明的配套信息。
正则做不了的事情也一样清晰:它无法判断某个 API 在真实世界里是否存在,无法判断一段符合语法的陈述是否违背外部事实。比如模型写“某公司在2025年6月发布了财报”,句子结构无懈可击,正则不会拦截,除非你额外引入数据库或检索工具。
因此,最佳定位是把闸门当作“过滤漏斗”,而不是“真相审判官”。先让正则把所有高风险、低风险、需要人工留意的内容筛出来,再由人或其他事实校验模块处理。这个思路能让整个系统既高效又不至于误杀太多。
| 判别层次 | 正则适合? | 典型处理方式 |
|---|---|---|
| 格式错误:引用标记缺失、日期不合法 | 适合 | 直接拦截或发出硬警告 |
| 危险句式:比例无来源、绝对化过度表述 | 适合 | 标记为 review,提示人工确认 |
| 语义错误:全文通顺但事实错误 | 不适合 | 留给外部检索/事实核验模块 |
| 上下文矛盾:同一论述前后冲突 | 弱适合 | 先做模式匹配,再配合人工判断 |
有了这个边界,后面的规则库设计就不会跑偏。
3. 动手写第一版规则库:拦截 AI 最常“说谎”的几种模式
3.1 规则库骨架:规则、命中、动作
我的实现非常简单,没有引入复杂框架。先把规则拆成两层:第一层用正则定位候选片段,第二层写两行业务判断决定是否真的标记。正则负责“找得到”,业务判断负责“判得准”。
先看骨架:
import re class TrustIssue: def __init__(self, rule_name, risk_level, snippet): self.rule_name = rule_name self.risk_level = risk_level # "block" 或 "review" self.snippet = snippet def __repr__(self): return f"<TrustIssue rule={self.rule_name} risk={self.risk_level} snippet={self.snippet[:30]}...>" def locate(pattern, text, width=80): """返回每个命中的上下文片段""" results = [] for m in pattern.finditer(text): start = max(0, m.start() - width) end = min(len(text), m.end() + width) snippet = text[start:end].replace("\n", " ") results.append((m.start(), m.end(), snippet)) return results每条规则本质上是“把可疑点从长文本里捞出来”,后续所有命中都会汇总成审计报告。我用的是 Python 标准库 re,不需要额外依赖,任何环境都能直接跑。
3.2 第一类规则:百分比声明缺少来源标记
编造数据是最常见、也最容易人设崩塌的幻觉类型。我的思路不是“见到百分比就报警”,而是用正则定位百分比,再看该百分比前后有没有来源短语。
source_patterns = [ r"据[^。]*(?:统计|调查|报告)", r"数据来源[::]", r"根据[^。]*(?:机构|报告|研究|平台)", r"(?:Statista|Gartner|IDC|艾瑞|QuestMobile)" ] compiled_sources = [re.compile(p) for p in source_patterns] def audit_unverified_percent(text): findings = [] # 匹配百分比数字,排除“100%”这种明显的完整量 percent_pat = re.compile(r"(?<!\d)(\d{1,3}(?:\.\d+)?)\s?%") for pos, endpos, ctx in locate(percent_pat, text, width=150): # 上下文区域内如果出现来源词,则放过 if any(p.search(ctx) for p in compiled_sources): continue # 上下文里出现“约/大概/预计/估算”这类表述时,降低冲突 if re.search(r"(约|大概|预计|估算|超过?)", ctx) and len(ctx) < 120: # 仍然值得关注,只是风险等级降低 findings.append(TrustIssue("percent_without_source", "review", ctx)) continue # 没有来源也没有模糊限定词,直接提升风险 findings.append(TrustIssue("percent_without_source", "block", ctx)) return findings为什么这样的设计更稳?如果一篇文章写“根据 QuestMobile 报告,62.4% 的用户选择……”那这句是安全的,不需要打扰作者。如果一篇文档满屏都是“95% 的开发者认为”,却连一个“据分析机构统计”都找不到,那大概率是模型在自由发挥,值得直接拦截。这里的核心逻辑叫作“用上下文履约检查替代关键词匹配”,正则负责缩小范围,上下文检查负责下结论。
实际跑下来,这条规则能捞出一大批“AI 味很浓但完全不可考证”的句子。
3.3 第二类规则:版本号、日期与“最新状态”的模糊声明
AI 生成技术文档时疯狂爱写“最新版已经更新到 v2.3.0”。如果它生成时用的是旧版本知识库,这个断言就是错误的,而且会误导读者选错依赖版本。
这种论述的逃逸速度很快,因为形态千变万化。我给出一个保守做法:把“时间状语 + 版本规格”锁定起来。
latest_versions = [ r"(目前|当前|最新|现在|截止至?今?)" r"[^。\n]{0,30}?" r"(版本|框架|工具|库|更新)" r"[^。\n]{0,20}?" r"(?:是|为|已到|升级至|更新到|来到了)" r"\s*[vV]?(\d+(?:\.\d+){1,4})" ] latest_version_pat = re.compile("|".join(latest_versions)) def audit_latest_claims(text): findings = [] for pos, endpos, ctx in locate(latest_version_pat, text, width=120): # 如果周围已经出现明确的版本比较语义,我们仍然提示,但降级为 review findings.append(TrustIssue("latest_version_claim", "review", ctx)) return findings这条规则不适合做成 block,因为人类作者也可能会写“当前版本是 3.1.2”并且完全正确。但它非常适合做“强制人工确认”:凡是这种句式,哪怕内容可能没问题,也要确保背后有官方更新日志支撑。尤其在自动化生成内容聚合站、文档站这种场景,没有经过确认的“最新”描述会对 SEO 和用户体验造成双重伤害。
3.4 第三类规则:同段内绝对化表述与模糊限定词并存
模型偶尔会在同一句话里产生一种“逻辑分裂”的观感,典型的症状是先用无条件断言,再用退化限定词找补。例如:“绝对可以保证 100% 兼容,不过在部分环境下可能存在兼容问题。”这种句子常常出现,因为模型从一个推理分支切到了另一个分支,却没有意识到两者彼此冲突。
直接做严格的矛盾检测很难,正则更合适的方式是寻找“绝对化/高置信词”与“模糊限定词”在近距离内频繁组合的情况:
abs_words = r"(绝对|百分之百|必然|毫无例外|完全肯定|一定不会|绝对没有)" hedge_words = r"(可能|或许|有些情况下|部分场景|偶尔|不一定|例外)" def audit_absolute_hedge(text): findings = [] # 两个词在200字之内同处一个论述段落时,作为可疑矛盾 pattern = re.compile( f"({abs_words}).{{0,200}}?({hedge_words})", re.S ) for pos, endpos, ctx in locate(pattern, text, width=150): findings.append(TrustIssue("absolute_hedge_conflict", "review", ctx)) return findings这条规则的误报率比前两条高,因为有些文章会刻意使用“绝对不行,但某些场景有例外”这种合理的让步论述。但正因如此,它的风险等级我设成 review,而不是 block。它真正的作用是让审核人员一眼看到那些“建模时左右互搏”的段落,节省整篇通读的时间。
3.5 第四类规则:正文引用标记与文末参考文献脱节
AI 非常擅长生成看起来专业的伪引用。它会在正文里写 [12],但文章结尾根本没有参考文献列表。这时候用正则直接定位两边的引用编号做集合差,是一个极其干净可靠的方法。
def audit_broken_references(text): # 正文行内引用,例如 [1] [2] [12] inline_refs = re.findall(r"\[(\d{1,3})\](?=\s*[,。;、])", text) # 文末参考文献列表行首的编号,例如 1. 或 [1] 或 [1] list_refs = re.findall(r"(?:^|\n)\s*\[?(\d{1,3})\]?[.、.\s]", text) inline_set = set(map(int, inline_refs)) list_set = set(map(int, list_refs)) missing_inline = inline_set - list_set missing_list = list_set - inline_set findings = [] for ref in sorted(missing_inline): findings.append(TrustIssue(f"reference_{ref}_not_in_list", "review", f"正文引用了 [{ref}],但文末找不到该编号")) for ref in sorted(missing_list): findings.append(TrustIssue(f"reference_{ref}_not_inlined", "review", f"文末有 [{ref}],但正文中从未引用")) return findings这个判断极其直观:没有对应条目的引用就是“僵尸引用”。我在验证集里跑过很多次,AI 生成的文章对这个规则的命中率高得惊人,尤其是让它写综述类内容时,几乎每篇都会有一两个编造的引用编号。它不属于“语义判断”,而是结构完整性检查,正则在这里发挥的效力完全是确定性的。
4. 接入生成流水线:从单次筛查到自动化闭环
4.1 闸门放在生成管线的哪个位置
很多人的第一个直觉是“内容生成之后,执行下一步动作之前放一道钩子”。这个方向对,但我更建议具体理解一下位置差异。
如果你的应用是纯聊天机器人,每次输出都直接展示给用户,那在流式输出阶段做近乎实时的筛查就不现实。更好的做法是把闸门放在“完整生成结束但尚未呈现最终答复”的时机。有一种技巧是让流式结果先到一个临时缓冲区,只有当生成完毕、进入缓冲区的全文被审计通过后,才统一推送给用户端。虽然会增加显性延迟,但保证了内容在到达用户前已经过检。
另一种常见场景是批量生成,比如每天晚上定时用 Agent 生成一批SEO文章或商品描述。这里闸门应该放在批量落库之前:先把所有文章写入一个称为“待审核”的表单状态,跑完规则库后,命中高危规则的直接进入“审核驳回”状态,命中低危规则的进入“人工复核队列”,没问题的才正式发布。
def generate_with_gate(generate_func, prompt, audit_funcs): # 第一阶段:用模型生成原始文本 raw_text = generate_func(prompt) # 第二阶段:所有审计规则执行聚合 all_issues = [] for audit in audit_funcs: all_issues.extend(audit(raw_text)) # 第三阶段:根据风险级别决策 block_issues = [i for i in all_issues if i.risk_level == "block"] review_issues = [i for i in all_issues if i.risk_level == "review"] decision = { "status": "pass", "block_issues": block_issues, "review_issues": review_issues, } if block_issues: decision["status"] = "block" elif review_issues: decision["status"] = "review" return raw_text, decision这个函数是通用模板,可以根据业务改造成 REST API 或内部任务队列。要点在于:审计结果永远和原文绑定返回,不要只返回一个“是否通过”的布尔值。因为后续需要人工查看命中的上下文、决定是修订还是采纳。
4.2 分档处理:硬拦截、标记提醒、人工复审
在实践中,不同内容场景对错误的容忍度完全不同。面向用户的 C 端文案尽量硬拦截高风险项,内部工作流则可以容忍一部分。
我把处理策略分成三档:
- block(硬拦截):命中引用缺失、恶意格式错误、生成内容包含无效 API 引用等。输出不会进入正式渠道,而是原样退回给生成方或请求方。
- review(标记提醒):命中“无来源百分比声明”“最新版本断言”“绝对化表述”等。内容可以正常发布,但系统在管理后台或编辑器里给出一个醒目的提示条。
- 低置信提醒(优先级最低):比如某些绝对化和限定词组合的上下文很短,不一定是真矛盾,只会出现在日志里,供运营人员定期查看。
通过这种分级,我们不需要把正则闸门做成“一言堂”,而是让它在不同场景下扮演不同角色。对一个个人博客编辑器来说,它可以是温柔的校对员;对自动化金融摘要平台来说,它可以是强硬的合规检查员。
4.3 审计日志:让每条规则都处于可迭代状态
如果只是跑一把筛子,积累不了任何经验。我强烈建议把每次命中记录下来,尤其是人工之后判定为“确实有问题”还是“误报”的结果,这会直接决定下个版本规则怎么写。
我在实际项目里使用的日志格式很简单,每条命中记录一个 JSON:
{ "text_id": "doc_204812", "generated_at": "2025-06-21T10:05:00Z", "rule": "percent_without_source", "risk_level": "review", "is_suspicious": true, "human_verdict": null, "snippet": "95% 的企业已经将大模型服务接入生产环境" }人工审核完之后,把human_verdict更新为true或false。这样积累两周,你就可以统计每条规则在生产数据里的命中准确率。规则准确率 = 人工判定为误报的比例。当发现某条规则命中一百次,有八十次都是无害的,那就该放松阈值,或者直接关掉。这不是凭感觉调正则,而是靠真实数据做回归。
5. 实测效果和一些容易翻车的地方
5.1 在一组典型文本上的测试结果
为了演示这个闸门的效果,我拿一组模拟 AI 生成内容的文本做了测试。
| 测试样本 | 存在的问题 | 闸门动作 |
|---|---|---|
| 样本A:“62.4% 的开发者认为该方案的性能优于传统方法。” | 无任何来源标记,比例精确,高危 | block |
| 样本B:“目前该框架最新版本已更新到 v4.2.0,支持……” | 没有给出更新日志依据,版本状态存疑 | review |
| 样本C:“绝对没有任何兼容问题,但某些老版本环境可能有例外。” | 绝对化断言 + 模糊限定词并存 | review |
| 样本D:“Karpathy 在他的博客中明确说过这种架构……” | 这句话看起来自然但引用无法定位 | 无命中 |
| 样本E:正文出现 [7] 引用,文末却没有任何编号 7 的条目 | 僵尸引用 | block |
前面的 A、B、C 三类问题都能被规则库准确找出来。D 类问题的本质是“声称某个人说过某句话”,正则管不了,需要靠检索外部资料来核。E 类则属于结构性问题,落地到生产环境里几乎零误报。
5.2 正则库真正容易踩的坑:误杀和漏网
我刚开始把规则做得特别激进,比如用正则识别所有没有来源的百分比,结果把很多正常文本也拦截了。有的文档上一段已经写了“根据 Gartner 报告”,下一段只是延续数据讨论,不再重述来源,也被我的规则捞出来。
后来我把阈值从“前后 80 字符内缺少来源词”调整成“前后 200 字符内缺少来源词,且排除明显的延续性分析段”,误报率立刻降了很多。正则规则不是越严格越好,关键是先定义“什么样的上下文可以被视为有证据支撑”。
另一个非常实际的坑是全角半角不统一。中文文本里可能有一半百分号是半角“%”,另一半是全角“%”,数字也可能会夹着空格。如果不做规范化,正则会漏掉一大片。建议进入审计前先执行一个标准化函数:
import unicodedata def normalize_text_for_audit(text): # 统一把全角字符转半角,顺便把连续空格压缩成一个 normalized = unicodedata.normalize("NFKC", text) normalized = re.sub(r"\s+", " ", normalized) return normalized这个函数解决了我很多无谓漏报,让正则规则长得更“干净”。另外还要特别小心:正则里如果用了大量非贪婪量词.*?,在超长文本上的性能会变得很差,所以最好把locate函数里的上下文窗口限制在 80 到 200 字符之间,避免 `re