文章

如何识别 LLM 推理文本异常:它选择不看文本,只看概率

拆解 msprobe response_anomaly:不看输出文本,只靠 token 概率分布检测乱码、复读机与异常回复。

如何识别 LLM 推理文本异常:它选择不看文本,只看概率

深夜,线上模型的回复里突然吐出一片”𰻝𰻝▓Ω”,下一个请求又陷入”哈哈哈嗝”的无限复读。

开发把日志翻了个底朝天,靠肉眼判断每条回复到底病没病——又慢,又主观。

有没有更聪明的办法?我最近拆了 AISBench + msprobe 的推理文本异常检测模块,它给出了一条相当不一样的思路:

判断一条 LLM 回复是否异常,不需要读输出里的任何一个字。它看的不是文本,而是推理时每个 token 的概率数据。

一、核心设计思路

检测的核心设计思路就一句话:文本只是采样结果,概率分布才是模型的实时状态。

判断一条回复是不是”推理出了问题”——乱码、生僻字、复读机,依据不是文本内容本身,而是每个生成位置的概率分布。

它只有两个原始输入:

输入来源说明
topk_logprobs推理服务每个位置的 top-k 候选(同时含 token id 和 logprob)
model_config推理服务模型配置,包含模型名称和对应的配置文件(权重、Tokenizer等)

注意一个细节:输入里没有文本。这套检测的判断依据,从设计之初就绕开了”生成的内容长什么样”。

整体链路

数据从左到右流过四个阶段:两条并行的变换线,汇入三类检测:

数据变换链路:原始输入长什么样,经过哪些关键转换,变成了什么


二、【异常检测】生僻字检测

概念澄清

这里的”生僻字检测”,听起来是”看输出里有没有生僻字”。但它真正测的,是模型的”自信心”崩没崩。

模型每生成一个 token,本质上是在整个词表上掷一次骰子。

  • 正常情况下,骰子高度偏向几个合理选项——生成”是”的概率可能高达 90%。
  • 一旦模型内部出了故障(量化误差、算子 bug),骰子就会突然变均匀:前 5 名候选加起来不到 0.4,而且这 5 个候选竟然来自汉字、希腊字母、符号、英文四个互不相干的家族。

正常与异常的生成分布对比:检测的是骰子,不是字

判定是个两步漏斗,用一个例子走一遍:

推理服务吐出的原始数据里,每个位置有 5 个候选(top-5),每个候选同时携带三样东西:

  • 对应的原始文字(实际处理流程中没有原始文字,样例里写入仅为了辅助理解)
  • token id
  • logprob (next-token 的概率)

样例以 2 个位置的 next-token 举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
== 位置 A 的 top-5(正常,模型笃定) ==
id 9554   "是"    logprob -0.1   → 概率 exp(-0.1) ≈ 0.905
id 99523  "对"    logprob -3.1   → 概率 exp(-3.1) ≈ 0.045
id 10471  "我"    logprob -3.3   → 概率 exp(-3.3) ≈ 0.037
id 118277 "不"    logprob -3.5   → 概率 exp(-3.5) ≈ 0.030
id 559    "那"    logprob -3.7   → 概率 exp(-3.7) ≈ 0.025

== 位置 B 的 top-5(可疑,模型懵了) ==
id 108386 "𰻝"    logprob -2.8   → 概率 exp(-2.8) ≈ 0.061
id 119232 "β"     logprob -3.1   → 概率 exp(-3.1) ≈ 0.045
id 100230 "肉"    logprob -3.5   → 概率 exp(-3.5) ≈ 0.030
id 25      "%"    logprob -3.9   → 概率 exp(-3.9) ≈ 0.020
id 275     "the"  logprob -4.2   → 概率 exp(-4.2) ≈ 0.015

先扫一眼两张名单的字面,我们可以很清楚地通过原始文字看出:

位置 A 的候选清一色汉字,位置 B 的候选横跨汉字、希腊字母、符号、英文。

检测器看不到”是”或”𰻝”这些字,它眼里只有 id 和概率。它要靠两步计算,从数值里读出我们一眼看出的东西。

第一步:算”自信总分”——把每个位置 top-5 的概率加起来:

1
2
位置 A: 0.905 + 0.045 + 0.037 + 0.030 + 0.025 = 0.942  > 0.4 → 排除
位置 B: 0.061 + 0.045 + 0.030 + 0.020 + 0.015 = 0.171  < 0.4 → 可疑, 进入第二步

这一步逻辑很直白:

位置 A 模型九成把握押在”是”上,总分自然高;

位置 B 模型连前 5 名加起来都凑不出两成把握——骰子快变成均匀的了。

第二步:数”候选家族”(关键)——查家族字典,把 top-5 的 token id 翻译成语言家族(id “9554” 查到”汉字”,id “119232” 查到”希腊字母”……):

我们在一开始基于模型的词表,生成了一个家族字典。

把 15 万个 token 压缩成约 24 个家族标签(chinese_cjk、english_latin、gibberish_symbols、emoji 等)

1
2
3
4
5
6
7
{
  "9554":   "chinese_cjk",
  "32":     "english_latin",
  "108386": "chinese_cjk",
  "690":    "gibberish_symbols",
  ...
}

基于这个家族标签,我们再看位置 B 的 next-token 预测

1
2
3
4
5
6
位置 B(可疑,模型懵了)
id 108386 "𰻝"    logprob -2.8   → 家族:汉字
id 119232 "β"     logprob -3.1   → 家族:希腊字母
id 100230 "肉"    logprob -3.5   → 家族:汉字
id 25      "%"    logprob -3.9   → 家族: 符号
id 275     "the"  logprob -4.2   → 家族:英文

一次推理的 next-token top-5 居然来自 4 个不同的家族,超过设定的异常阈值 2 → 判定异常。

第二步为什么是关键?

因为模型正常的犹豫永远发生在同一家族内——纠结”是”还是”呢”(都是汉字),纠结 “cat” 还是 “cats”(都是英文)。

什么场景会让下一个 token 既可能是汉字又可能是希腊字母?几乎没有。

因此可以得出结论: 跨多个家族 = 分布坏了的高置信证据。


三、【异常检测】乱码检测

乱码检测并不是一套完全独立的算法:如果生僻字是”概率数据坏了”的轻度信号,乱码就是同一个信号变浓之后的重症形态

核心思想就是:在一个窗口内,高密度的”生僻字”就代表”乱码”出现。

同一个信号,两种浓度:稀疏分布是生僻字,连成一片是乱码

它不另起炉灶,直接把生僻字检测当子程序调用,自己只加一个密度门槛:

1
窗口判乱码 ⟺ 生僻字命中数 / 窗口长度 > 20%

用数字感受浓淡分界:

场景128 token 窗口内命中密度处理
稀稀拉拉2 个1.6%生僻字,只立标记
连成片30 个23.4%乱码,立即报警返回

一两个位置概率乱掉可能是偶然(专有名词、语言切换),但 20% 的位置同时乱掉,只能解释为系统性故障。

故障不会自愈,继续扫是白烧算力——所以乱码1 个窗口就急停

孤立命中各有成因,连片命中只能是分布坏了:20% 密度门槛的由来


四、【异常检测】重复检测

概念澄清

生僻字和乱码看的是单个位置的解码分布(骰子坏没坏),重复检测看的则是整条序列的轨迹(输出是否陷入循环)。

但轨迹层有个天然难题:部分场景下重复是合法的

  • 合法的重复:歌词副歌、法条引用、代码块;
  • 病态的重复:复读机——”哈哈哈嗝哈哈哈嗝……”直到把输出长度耗尽。

如果要把”病态重复”从”合法重复”里剥出来,凭单一指标做不到。

这套系统的答案是:两个算法,一个看内容单调度,一个看概率周期性 —— 同一个故障的两个不相关观测侧面

算法 1:distinct-n — 看”内容”

以 distinct-3 为例:就是把窗口切成一个个 3-gram,数有多少互不相同:

distinct_3 = 不同 3-gram 数 / 总 3-gram 数(0~1,越低越单调)

文本样例3-gram 情况distinct_3结果
健康文本 “今天天气真好适合出门散步”滑窗共计滑出 10 个,全不同10/10 = 1.0放行
歌词副歌 “一句副歌”×32复用有限几种值偏低,但通常 > 阈值放行
复读机 “哈哈哈嗝”×32只有 4 种4/126 ≈ 0.03检出

distinct-3:健康文本 3-gram 全不同,复读机只有 4 种 3-gram

算法 2:ACF — 看”周期”

复读机有个 distinctive 的物理特征:循环

例如:”哈哈哈嗝”每 4 个 token 循环一次,对应的 top1 概率序列也呈周期 4 的起伏。

ACF(自相关函数)是量周期性的标准工具,它把序列和”平移 k 位后的自己”逐位对齐打分,k 恰好等于循环周期时对得最齐:

1
2
3
4
5
6
7
概率序列:  [高高低中][高高低中][高高低中]...   (周期 4)

ACF 开始 "全量扫描 + 找峰值"
=================================
ACF(k=5):  对不齐 → 值低
ACF(k=4):  每次平移 4 格都完美对齐 → 峰值 ~0.97  (阈值 0.65)
ACF(k=3):  对不齐 → 值低

ACF:top1 概率序列周期 4 起伏,平移 k=4 完美对齐得高分

三道防线

光有两个算法还不够,”病态”与”合法”之间还隔着三道门:

  1. 置信度门(两算法共享):窗口内每个位置的 top1 概率都要 > (exp(-0.2) ≈ 0.82)。
    • 病态重复是”高笃定地卡死”(循环惯性让每一步都确定),而低质量乱吐的表面重复是低置信的——这道门是”卡死”与”乱撞”的分界;
  2. 谐波门:真周期在 2/3 倍 lag 处应有回声峰,防止单峰噪声碰巧过线;
  3. 跨窗口投票(最重的一堵墙):
1
2
双算法同时检出 ≥ 3 个窗口 → 报   (快车道, ≈256 token 持续循环)
仅单算法检出   ≥ 15 个窗口 → 报   (慢车道, ≈1k token 持续循环)

逻辑很朴素:两个不相关视角同时指认,证据强度是相乘不是相加;孤证必须用 5 倍的持续性来偿还。

这套冗余设计的价值,在三个失效场景里看得最清楚:

场景distinct-nACF结果
长句循环(26 token 整句反复)漏检(循环一长,比值升高超过阈值 0.2)检出慢车道,15 窗后报
同 token 复读(”哈哈哈…”全是同一个 id)检出跳过(序列恒定无波动,无从谈周期)慢车道,15 窗后报
低质量乱吐(表面重复但概率全低)被置信度门拦下同样被拦沉默,归生僻字/乱码通道

任何一种故障形态,至少有一个算法能看见;两个都看见时,系统知道可信度足够高,可以立刻报警。

用冗余换鲁棒——代价是慢车道 1k token 的确认延迟,收益是单算法的盲区不再是漏检的洞。


五、总结

拆解完这套检测系统,真正值得带走的不是三个检测算法或函数,而是三个可以迁移的思考范式:

  • 文本会骗人,分布不会。 同样的”哈哈哈”,可能是欢乐,也可能是卡死;概率分布是模型的实时状态,它不演。logprobs 是推理服务每个请求都在免费生成的、却被默默扔掉的可观测性数据。

  • 单次证据越薄,越需要时间来补。 这套系统里其实藏着一条可信度阶梯:乱码扫 1 个窗口就敢急停,因为 20% 密度没有第二种解释;重复要 3~15 个窗口投票,因为它有合法形态;生僻字最容易误伤,只能扫完全程才兜底上报。越往下证据越薄、等待越久——不可信的证据用时间补偿,换来的是不错认类型。

  • 冗余优于精调。 两个各带盲区的算法,加一条”孤证需 5 倍持续性”的规则,比一个”全能”算法抗造得多。

最后留一个问题:你们的推理服务,现在是怎么做文本异常检测的? 靠人工抽检、关键词规则,还是也有概率层面的方案?欢迎评论区聊聊你的做法。

【附录】

文中这套检测的机制在 AISBenchmsprobe(MindStudio)两个仓库里都能找到,资料链接在此,欢迎大家上手实践:

本文由作者按照 CC BY 4.0 进行授权