自托管推理模型在生成长文本时会出现「解码腐化」,输出突然变成无关内容而非真正的幻觉。该团队在流式输出阶段实时检测,而非等生成结束后,发现流式检测是唯一有效窗口。
我们在生产环境运行着一个 ~300B 的推理模型。它用阿塞拜疆语和英语撰写宏观经济台式报告。
偶尔,它会停下来。
不是崩溃。不是拒绝回答。而是写到一半,一篇合格的经济分析突然变成了一篇捏造的中文新闻文章。或者一篇软件 README,附带 pip install 说明。或者一个波斯语名字,重复七次。或者一面以 #REF! 开头的电子表格单元格墙。
用户正在目睹这一切,一个 token 一个 token 地发生。
我们称之为解码腐化(decoding corruption)*,这不是幻觉。幻觉是模型对世界的认知是错误的。而这是模型根本不再产生答案。第一种有大量文献研究如何检测。第二种几乎找不到任何资料,这很奇怪,因为任何运行自托管模型的人都见过这种情况。
这篇文章讲的是我们构建一个检测器学到了什么。最重要的教训不是出发前我预期的那一个。
为什么事后检测在这里毫无用处
大多数幻觉检测器在生成完成后才运行。对于事实性检测这没问题。对于这种腐化,不是。
两个原因。第一,流式输出:用户已经眼睁睁看着模型脱轨了。等结果出来再下结论已经失败了。第二,成本:在推理模型上,一次腐化生成会烧掉几分钟的 GPU 时间。我们测到一个 13,200 字符的表格回声,生成了 269 秒才完成。每一秒都是浪费。
所以检测器必须运行在流式过程中间,足够轻量以至于和解码相比可以忽略不计。
对字符流的一次遍历。一个状态对象逐字符摄入,为下游所有需要的东西增量维护各项统计量。检测器是对该共享状态的只读查询,仅在检查点评估。
这个约束比听起来重要得多。这意味着加第六个检测器零额外遍历开销。所有操作都是每字符 O(1) 分摊内存有界,所以失控的生成在常量空间内处理。
五个检测器坐落其上:
规则。对快照的确定性阈值。数字比例、符号密度、最长相同字符连续串。第一天上线就有保护,零训练,每个告警都携带人类可读的原因,比如 numeric dump digit_frac=0.57。
字符 n-gram 惊异度。在流自身干净前缀上训练的在线 n-gram 模型,在观察之前对每个输入字符打分。无预训练,无需访问生成器的 logits。流自身解释自身。签名很漂亮:重复循环使惊异度趋向零,而漂移和回吐会使其飙升。
Count-Min Sketch 重复检测。字符碎片在 4×2048 的 sketch 中计数。无论流多长都是常量内存。读取重复率,因为真正的循环会击穿大量碎片,而非一个。
滚动 SimHash。对滑动词窗口的 64 位指纹,与干净前缀末尾冻结的指纹比较。这是捕捉最难类别的那一个:流畅、同脚本的回吐,对字符统计不可见,但在指纹空间中落在归一化汉明距离 ~0.5 处。
字符熵。表格转储和退化循环会压平分布。
五个检测器都读同一个状态。吞吐量在单核 CPU 上约每秒 50,000 字符,比模型生成文本的速度快两到三个数量级。不用任何 GPU。
检测从来不是瓶颈。我试过的每种配置都捕捉到了每条腐化流。真阳性率 1.000,立即生效,无需费力。
全部难度在于不误报正常文本。
我们的正常文本是经济文章,充满数字、百分比、变量代码和 markdown 表格。统计学上,这是你能想到的最像腐化的合法文本。
我从 noisy-OR 融合开始。这是组合独立证据的教科书规则:
p = 1 - Π(1 - sᵢ)
它在干净生产文本上产生了 42.9% 的误报率。
想想这在操作层面意味着什么。每十份健康的台式报告中就有四份被杀掉并重新生成。完全不能用。
原因如下。合法文章会轻微激发多个检测器。数字密集的句子轻推规则层。公式化的段落轻推可压缩性。章节标题轻推 sketch。单独看没有一个告警。但 noisy-OR 把生存概率相乘,所以五个检测器各 0.3 就会产生自信的 0.83。
真正的腐化不像五个弱信号。它看起来像一到两个饱和信号。跨语言漂移不会温和地抬高五个统计量;而是把外语句比例撞到天花板,其他几乎不动。
所以我们把组合规则替换为最强信号主导,加上当两个或更多检测器独立跨过 0.5 时的小型佐证加分:
p = min(1, max(max(sᵢ), w·ℓ) + b·[#{i : sᵢ ≥ 0.5} ≥ 2])
同样的五个检测器。同样的数据。同样的协议。
误报率从 0.429 降到 0.024。
然后各检测器的专项修复把它带到了 0.000:
SimHash 在长报告中合法的子话题游移上触发了。修复:把它的贡献上限封顶在 0.60,低于中止阈值。它可以指控,但永远不能单独定罪。
惊异模型的低惊异度分数在流畅的公式化文章上触发了。修复:把它放在重复跟踪器后面门控。
Sketch 的最大计数在反复出现的章节标题上触发了。修复:用比率打分,不用最大计数。
这个教训可以泛化到我们的系统之外:如果你在风格多变的文本上构建多信号护卫,你的组合规则可能比检测器名单更重要。
只有生产环境才能教会的两件事
抽象来看明显的阈值实际是错的。我把数字比例的硬阈值设在 0.18,感觉已经很宽松了。真实的定量答案维持着接近 0.3 的数字比例。调到了 0.28。另外,markdown 分割线是二十多个相同字符,这会在每个带 --- 的文档上触发连续串检测器。现在排除分隔符字符。
两个修复都来自在真实语料上回放检测器。我要说这一步对任何打算上线的检测器都是非可选的。
中止是一个集成风险。我们把中止实现为从 token 接收器内部抛出的异常。第一个版本触发了 HTTP 断路器,因为传输层把中止解读为上游故障。三次 catch 就会让整个助手下线。传输层必须把它当作调用方取消。
协议有三个阶段。在 hold 阶段,前 350 个字符被缓冲并检查,不转发任何内容。这里腐化判决会杀死请求,零字符泄露。这很重要,因为从一开始就腐化是生产中的主要模式。在干净判决下,缓冲冲刷,自校准基线冻结,流上线,每 400 字符复查,两次命中滞后后才中止。
代价是首个可见 token 前一次性约 350 字符的延迟,按常规解码速度大约一秒。这是权衡:用一秒延迟换取永远不让用户看到腐化答案。
校准阈值而不是猜
手工设置中止阈值感觉不对,所以我们用分片保角校准(split-conformal calibration)。取 n 个留出干净流的最终融合分,设置 τ 为 ⌈(1−α)(n+1)⌉-th 最小值。这保证了在交换性假设下有限样本中 Pr[flag | clean] ≤ α。
实际收益:操作者的误报预算 α 成为系统中唯一有意义的旋钮。你说"我容忍 2% 的误中止",阈值就跟着走。
我们在数据库中把完成的护卫反向跑过 121 条历史消息,作为健全性检查期待一个干净的结果。
它恰好提了两个告警。两条都是几个月前已经发给用户的真正腐化、没人注意到:一个完整用中文写的台式报告,和一个用中文加 Python 代码脱轨并中途停止的英语回答。
其他 119 条零误报。
所以它也是一个审计工具,这不是计划内的。
保持流畅、同脚本、同主题、统计上毫无异常的腐化对这个检测器是不可见的。微妙错误但格式良好的分析需要grounding或事实性方法,我们在上游单独处理。
我们的正例是合成的,通过在干净文本中以受控起始点注入四类腐化生成的。它们以真实脱轨为模型,捕捉到了我们有的每一条真实历史案例,但规模化生产流量可能包含我们尚未建模的表面形式。
而且完美分数说明的是类别分离度,不是难度。观察到的腐化类别离干净流形很远。我完全预期 adversarially 精细的腐化——比如低于检查点分辨率的慢速漂移——会侵蚀那些边界。
检测器、基准生成器和训练权重是开源的:https://github.com/doofzoff/SIMURG
检测器注册表采用三行协议——名字、一个返回分数的 evaluate(state)、和一个原因集。你可以加检测器而不碰 sentinel,融合层会选取所有注册的东西。如果你在跑非拉丁字母脚本的部署,预期脚本先验需要翻转,我很想要这方面的帮助。
如果你自托管模型并且见过它脱轨:你的发生了什么?我在收集失败模式,目前分类有四个类别,但我不认为这是完整的列表。