如何识别 LLM 推理文本异常:它选择不看文本,只看概率
拆解 msprobe response_anomaly:不看输出文本,只靠 token 概率分布检测乱码、复读机与异常回复。
深夜,线上模型的回复里突然吐出一片”𰻝𰻝▓Ω”,下一个请求又陷入”哈哈哈嗝”的无限复读。
开发把日志翻了个底朝天,靠肉眼判断每条回复到底病没病——又慢,又主观。
有没有更聪明的办法?我最近拆了 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 个窗口就急停。
四、【异常检测】重复检测
概念澄清
生僻字和乱码看的是单个位置的解码分布(骰子坏没坏),重复检测看的则是整条序列的轨迹(输出是否陷入循环)。
但轨迹层有个天然难题:部分场景下重复是合法的。
- 合法的重复:歌词副歌、法条引用、代码块;
- 病态的重复:复读机——”哈哈哈嗝哈哈哈嗝……”直到把输出长度耗尽。
如果要把”病态重复”从”合法重复”里剥出来,凭单一指标做不到。
这套系统的答案是:两个算法,一个看内容单调度,一个看概率周期性 —— 同一个故障的两个不相关观测侧面。
算法 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 | 检出 |
算法 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): 对不齐 → 值低
三道防线
光有两个算法还不够,”病态”与”合法”之间还隔着三道门:
- 置信度门(两算法共享):窗口内每个位置的 top1 概率都要 > (exp(-0.2) ≈ 0.82)。
- 病态重复是”高笃定地卡死”(循环惯性让每一步都确定),而低质量乱吐的表面重复是低置信的——这道门是”卡死”与”乱撞”的分界;
- 谐波门:真周期在 2/3 倍 lag 处应有回声峰,防止单峰噪声碰巧过线;
- 跨窗口投票(最重的一堵墙):
1
2
双算法同时检出 ≥ 3 个窗口 → 报 (快车道, ≈256 token 持续循环)
仅单算法检出 ≥ 15 个窗口 → 报 (慢车道, ≈1k token 持续循环)
逻辑很朴素:两个不相关视角同时指认,证据强度是相乘不是相加;孤证必须用 5 倍的持续性来偿还。
这套冗余设计的价值,在三个失效场景里看得最清楚:
| 场景 | distinct-n | ACF | 结果 |
|---|---|---|---|
| 长句循环(26 token 整句反复) | 漏检(循环一长,比值升高超过阈值 0.2) | 检出 | 慢车道,15 窗后报 |
| 同 token 复读(”哈哈哈…”全是同一个 id) | 检出 | 跳过(序列恒定无波动,无从谈周期) | 慢车道,15 窗后报 |
| 低质量乱吐(表面重复但概率全低) | 被置信度门拦下 | 同样被拦 | 沉默,归生僻字/乱码通道 |
任何一种故障形态,至少有一个算法能看见;两个都看见时,系统知道可信度足够高,可以立刻报警。
用冗余换鲁棒——代价是慢车道 1k token 的确认延迟,收益是单算法的盲区不再是漏检的洞。
五、总结
拆解完这套检测系统,真正值得带走的不是三个检测算法或函数,而是三个可以迁移的思考范式:
文本会骗人,分布不会。 同样的”哈哈哈”,可能是欢乐,也可能是卡死;概率分布是模型的实时状态,它不演。logprobs 是推理服务每个请求都在免费生成的、却被默默扔掉的可观测性数据。
单次证据越薄,越需要时间来补。 这套系统里其实藏着一条可信度阶梯:乱码扫 1 个窗口就敢急停,因为 20% 密度没有第二种解释;重复要 3~15 个窗口投票,因为它有合法形态;生僻字最容易误伤,只能扫完全程才兜底上报。越往下证据越薄、等待越久——不可信的证据用时间补偿,换来的是不错认类型。
冗余优于精调。 两个各带盲区的算法,加一条”孤证需 5 倍持续性”的规则,比一个”全能”算法抗造得多。
最后留一个问题:你们的推理服务,现在是怎么做文本异常检测的? 靠人工抽检、关键词规则,还是也有概率层面的方案?欢迎评论区聊聊你的做法。
【附录】
文中这套检测的机制在 AISBench 与 msprobe(MindStudio)两个仓库里都能找到,资料链接在此,欢迎大家上手实践:
- msprobe 使用文档:response_anomaly_instruct.md(gitcode)
- AISBench 使用文档:response_anomaly_detection.md(GitHub)





