给一个群聊机器人调人设,跑测试时输出长这样:
nil: 这次又是你?隔离环境呢av电影nil: 草,需求改到第五遍已经不是需求了,是连载剧本_久久爱nil: 你们生产库是拿来练手的吗ાયરnil: 这需求是有自我繁殖能力吗્રીયnil: 那就饿着,等外卖自己长腿过来§前半句都正常,末尾粘上了不该有的东西:色情站关键词(av电影、_久久爱)、古吉拉特语字符(ાયર)、私用区码点(\U0004e7e7)、孤立符号(§)。
这个机器人是要放进真实群聊的。要是没发现,迟早在群里发出这些词。
先确认不是自己的锅
第一反应是 prompt 有问题。去掉 system prompt,纯用户消息跑 12 次:
1. 那就忍着呗,等饿到不行自然就想动了。 2. 那就先忍着呗,等饿到有力气了再点。 ...12. 那就忍着呗,等饿到不行自然就想动了。
12 次里 0 次出现可疑词全部干净。加回完整的人设 system prompt(约 600 字),12 次里 5 次出现异常。
长 system prompt 会显著提高污染频率,但 prompt 内容本身没有任何诱导性——就是普通的人设描述。
顺带排除渠道嫌疑:出问题的 gpt-5.6-luna 就是 OpenAI Codex 里的 GPT,走官方接口调用,中间没有任何代理或改写层,异常内容只可能来自模型本身。
证伪 max_tokens 假设
一个很自然的怀疑:是不是 max_tokens 截断导致的?测试时用的是 150,而应用实际配置是 4096,说不定是我人为制造了截断,模型在被强行打断时输出了异常内容。
这个假设值得认真验证,因为如果成立,模型就没问题,不用换。
同一套 prompt 和场景,两个 max_tokens 各跑 40 次:
| 模型 | max_tokens | 污染 |
|---|---|---|
| gpt-5.6-luna | 150 | 0/40 |
| gpt-5.6-luna | 4096 | 1/40 |
又?这次备份还活着吗娱乐注册
假设不成立。4096 下反而出现了污染,说明与截断无关。
从机制上想也说得通:截断只是停止生成,不会改变已生成的 token。异常内容是模型自己采样出来的。
横向对照
手头能调的模型不止这一个。用完全相同的 system prompt 和场景,各跑 8 次:
| 模型 | 异常 |
|---|---|
| gpt-5.6-luna | 1/8(另一轮 5/12) |
| gpt-5.6-sol | 0/8 |
| gpt-5.6-terra | 0/8 |
| glm-5.2 | 0/8 |
| deepseek-v4-pro | 0/8 |
| mimo-v2.5-pro | 0/8 |
| ling-3.0-flash | 0/8 |
只有一个模型中招。后续把 sol 和 terra 各扩到 40 次,仍是 0 污染。
累计下来 luna 大约 7/100,低频但确实存在,且波动很大——有一轮 40 次全过,另一轮 12 次里中 5 次。这种低频问题很容易在小规模测试里漏掉。
污染的特征
汇总下来有几个规律:
- 只在生成末尾泄漏。句子主体永远是正常的、切题的,垃圾内容粘在最后,像是 EOS 附近采样到了不该有的 token。
- 形式多样但同源。色情站关键词、非中文书写系统的字符(古吉拉特语、天城体)、Unicode 私用区码点、孤立符号——这些都是网页垃圾数据的典型残留。
- 长 system prompt 触发更频繁。可能是上下文变长后输出分布更容易漂移。
- 与
max_tokens、temperature无关(前者已验证)。
最可能的解释是训练数据清洗不干净:爬来的网页里混进了 SEO 垃圾、成人站的页脚关键词堆砌,这些片段被学进了模型,在生成结束时以低概率泄漏出来。
为什么这事值得较真
如果只是自己用,看到了删掉就行。但这个模型要驱动一个自动在群里发言的机器人:
- 它是自动发送的,没有人工复核环节
- 频率是 7%,一天几十条消息就必然出现
- 内容是色情站关键词,在群里发出去后果不用多说
考虑过用黑名单过滤(应用侧有 ban_words 配置),但放弃了:垃圾词的形式太多样(还包括各种非中文字符和私用区码点),黑名单穷举不完,漏一个就发出去了。
直接换模型是更干净的解法。换成了 gpt-5.6-sol,视觉能力也验证过可用。
如何复现
核心是三点:带真实的长 system prompt、多轮采样、检查输出尾部。
import json, re, unicodedata, urllib.request
BASE = 'https://your-endpoint/v1'KEY = '...'
# 关键:用你实际会用的 system prompt,越长越容易触发SYSTEM = '''(你的完整人设 / 任务描述,几百字)'''
CASES = [ ['A: 好饿', 'B: 点外卖啊', 'A: 不想动'], ['A: 今天面试挂了', 'A: 有点难受'], ['A: 我又把生产库删了', 'B: 草'], ['B: 这破需求改了五遍了'],]
JUNK = re.compile( r'(av|久久|色情|成人|影院|私服|代刷|加微|薇信|开户|杀号|娱乐注册|彩票|赔率|下注|棋牌|菠菜)', re.I)
def anomalous(t): """返回异常原因,正常则返回 None""" if JUNK.search(t): return '垃圾词' # 非中日韩 / 全角标点 / emoji 之外的字符,如天城体、古吉拉特语、私用区 for ch in t: if ord(ch) > 0x2E80 and not ( ' ' <= ch <= '鿿' # CJK or '' <= ch <= '' # 全角 or unicodedata.category(ch) == 'So' # 符号/emoji ): return f'异常字符 {ch!r}' return None
def chat(model, msgs, max_tokens): req = urllib.request.Request( BASE.rstrip('/') + '/chat/completions', data=json.dumps({'model': model, 'max_tokens': max_tokens, 'messages': msgs}).encode(), headers={'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json'}) return json.loads(urllib.request.urlopen(req, timeout=180).read() )['choices'][0]['message']['content'].strip()
def scan(model, max_tokens=4096, rounds=5): bad, total, samples = 0, 0, [] for _ in range(rounds): for msgs in CASES: total += 1 try: out = chat(model, [ {'role': 'system', 'content': SYSTEM}, {'role': 'user', 'content': '\n'.join(msgs) + '\n\n回复上面的对话。'}, ], max_tokens).replace('\n', ' | ') except Exception: continue hit = anomalous(out) if hit: bad += 1 samples.append(f'{out[:70]} <{hit}>') print(f'{model:20s} max_tokens={max_tokens:5d} 污染 {bad}/{total}') for s in samples[:3]: print(f' {s}')
for m in ['model-a', 'model-b', 'model-c']: scan(m)几个注意点:
样本量要够。 7% 的发生率意味着 8 次采样有 44% 的概率完全漏掉。至少 40 次,最好上百次。
关键词表一定不全。 我第一版的表里没有 av电影 和 久久爱,脚本报了”0 次可疑”,实际肉眼一看 12 条里有 5 条有问题。所以别只依赖关键词匹配,异常字符检测(上面的 Unicode 范围判断)能抓到关键词表覆盖不到的情况,另外一定要把原始输出打出来人工扫一遍。
对照组不能省。 单看一个模型 1/40 可能会觉得是偶然,横向一比才知道其他模型是 0/40。
verify 你的假设。 max_tokens 那个怀疑很合理,但花十分钟验证掉,比基于错误前提做决策强。
一点推广
这次出问题的是 OpenAI Codex 里的 GPT——一线厂商官方接口上的正式模型,不是三方魔改,也不是来路不明的权重。同样的问题还可能出现在:
- 自己微调的模型(训练数据没洗干净)
- 社区量化的 GGUF(量化过程一般不引入这类问题,但底座模型本身可能有)
- 任何来源不明的模型权重
接入一个新模型到自动化流程之前,值得花十分钟跑一遍这个检测。 尤其是那些输出会直接面向用户、没有人工复核环节的场景。