上周有位做内容审核的朋友丢过来一段文字问我这段话被同义替换过一遍还能不能查出来是模型写的这个问题背后其实就是文本隐形水印和改写后还能不能检出两件事。现在不少对话产品在输出文本时悄悄做了标记普通用户看不见、复制粘贴也带不走但检测工具拿着一把密钥就能算出这段文字像不像自家模型写的。我这段时间正好把开源实现翻了一遍也在自己的小模型上从零复现了一套打水印加检测的流程踩了不少坑。这篇就把文本水印的原理、代码、实测结果和排查经验一次讲清楚。适合三类人看做内容风控和审核的、做模型应用开发想加溯源的、以及单纯好奇改写一遍还能查出来吗的普通用户。读完你至少能判断什么样的改写能洗掉痕迹什么样的改写纯属心理安慰。1. 整体设计与思路拆解1.1 这个需求到底要解决什么问题先说清楚问题边界。我们要的不是给文档加个版权页而是让文本在脱离生成环境、经过复制、排版、局部修改之后仍然携带一个可被密钥持有者读出的统计信号。这个信号的三个硬指标是人读不出来、机器检得出、第三方伪造不了。人读不出来好理解加个不可见字符、塞个零宽空格也是隐形但那种方案一贴到别的编辑器就被吃掉或者被正则一洗就没了属于自欺欺人。机器检得出指的是检测过程要能给出一个可量化的置信度而不是看着像。第三方伪造不了靠的是密钥同一段文本换个密钥去检测结果应该回到随机水平否则攻击者可以反向利用规则去规避。所以这个需求本质上是一次生成侧的熵操控在不破坏语义和可读性的前提下把模型原本均匀随机的采样选择悄悄偏置到一个只有持钥方知道的子集上。检测方不需要看全文语义只需要数一数落在偏好子集里的词有多少个用统计检验判断这个数量是否显著超出随机预期。这个思路的妙处在于它去掉了对模型本身的重依赖——检测时不需要跑一遍大模型只要分词器和词表对得上一个统计脚本就够了。1.2 三类主流方案选型对比市面上讨论得比较多的做法大致三类我在选型阶段把它们放在一起对比过结论差异挺明显。方案类型核心做法抗轻度编辑抗深度改写抗翻译实现成本主要短板生成时词表分区打标采样时偏好密钥决定的绿名单词较强弱很弱低改采样即可短文本不显著语义层模板注入按密钥选择句式、连接词、标点习惯中等中等中等中需要构造模板库风格受限易被察觉生成后隐写嵌入同义替换、插入特殊符号、调整空格弱无无低任何清洗都可能抹除分布指纹无嵌入检测用困惑度、曲率判断是否机器生成中等较差差中只能判断像AI不能溯源我最后选的是第一类。原因很直接它嵌在解码循环里生成过程本身就是打标过程没有额外后处理也没有肉眼可见的风格痕迹。第三类那种事后嵌入看着简单但它把水印放在文本表面任何一次格式规范化都可能把它清掉等于白做。第四类的分布指纹我不把它当水印它是另一条技术路线后面第 5 章会讲清楚两者的区别很多人把这两个概念混为一谈。至于语义模板方案它在抗改写上确实有优势因为改写往往保留句法骨架。但它的问题是可用的模板空间有限密钥能控制的自由度不够大长文本还好短文本几乎无法形成统计显著性。而且模板一旦被逆向出来整套方案就废了。1.3 强度、可读性、鲁棒性的三角拉扯这三个指标天生互斥做工程的人必须做取舍。水印强度由两个参数决定绿名单占比 γ 和 logits 偏置 δ。γ 决定随机基线下命中绿名单的概率δ 决定模型被推向绿名单的力度。把 δ 从 0 加到 2.0绿名单词的采样概率会明显上升检测时 z 值涨得很快但文本质量会同步下降。我自己测下来δ 超过 3.0 之后模型开始偏爱某些高频虚词长句里出现重复用词的概率变高读起来有一种绕来绕去的别扭感。δ 在 1.5 到 2.0 之间是比较舒服的区间人基本看不出来检测统计量也够用。γ 的选择更微妙。γ 0.5 时绿名单占半个词表基线下命中率就是 0.5方差相对稳定但偏置的辨别力弱一些。γ 0.25 时命中率基线降到 0.25一旦被推高相对偏离更明显统计上更敏感。代价是可选的绿名单词变少了遇到需要精确表达的场合模型可能被迫选一个语义稍差的词文本质量掉得更多。我的经验是短文本输出用 γ 0.25 换取敏感度长文本输出用 γ 0.5 保质量因为长文本靠长度就能堆出显著性不需要那么激进的偏置。还有第三个维度很多人忽略文本长度。200 个 token 的文本和 50 个 token 的文本检出能力差了不止一个量级。50 个 token 时即使 γ 0.25、δ 2.0z 值也很难稳定超过 3。这意味着任何声称一句话就能百分百判定的工具可信度都要打问号。我在实际项目里定的规则是少于 80 个 token 的输出不做判定只做记录80 到 200 个 token 给出置信区间200 以上才给明确结论。2. 核心机制拆解2.1 哈希分区水印的墨水从哪来整个方案的核心是一段确定性的哈希。给定前一个 token或者前 h 个 token 组成的上下文窗口把它和一个密钥拼在一起做哈希把哈希值当随机种子对词表做一次排列取前 γ 比例作为绿名单。因为是同一个密钥加同一段上下文生成时和检测时算出来的绿名单完全一致。这里有个设计要点为什么用前一个 token而不是整段前缀用整段前缀当然更随机但生成时每走一步都要重算成本高而且检测时一旦中间有个别 token 被改了后面全部错位鲁棒性反而差。用固定窗口通常是前 1 个或前 4 个 token的好处是局部错误只影响局部不会连锁崩塌。这也正是它能抗轻度编辑的根源——你改掉一个词只影响它后面那一两个位置的绿名单判定前面的统计量照样有效。密钥在这里扮演的角色不能省。没有密钥攻击者也能算出绿名单那他就可以故意挑红名单词来写或者把文本里的绿名单词全换成红名单词水印瞬间归零。有密钥的情况下攻击者不知道哪一半是绿名单他只能随机改随机改的期望效果是把绿名单命中率拉回 γ也就是把 z 值往下压但压不干净剩下多少取决于他改了多少词。代码层面这段逻辑不复杂关键是哈希要稳定跨进程、跨机器、跨语言版本都得算出一模一样的结果import hashlib import numpy as np VOCAB_SIZE 50257 GAMMA 0.25 KEY bdemo-secret-key-v1 def green_mask(prev_token_id, vocab_sizeVOCAB_SIZE, gammaGAMMA, keyKEY): # 上下文 密钥 生成确定性种子 digest hashlib.sha256(key str(prev_token_id).encode()).digest() seed int.from_bytes(digest[:8], big) rng np.random.default_rng(seed) perm rng.permutation(vocab_size) n_green max(1, int(vocab_size * gamma)) mask np.zeros(vocab_size, dtypebool) mask[perm[:n_green]] True return mask注意这里用的是numpy.random.default_rng不要用老式的np.random.seed加np.random.permutation后者在不同 numpy 版本之间有行为差异的风险。生产环境更稳妥的做法是自己实现一个简单的伪随机置换或者把排列结果缓存起来——同一个 prev_token 反复算排列是浪费实测在批量生成时这部分能占到总耗时的 8% 左右加个 LRU 缓存就下去了。2.2 检测公式与阈值标定检测阶段做的事情很朴素把文本分词成 token 序列从头到尾扫一遍每遇到一个位置就用它前一个 token 算绿名单看当前 token 在不在里面命中就加一。最后统计命中数。在原假设文本不是这个密钥的模型生成的下每个位置命中的概率就是 γ总命中数服从二项分布。所以可以直接算 z 值z (命中数 − T × γ) / sqrt(T × γ × (1 − γ))其中 T 是参与统计的位置数。举个具体数字T 200γ 0.25随机期望命中 50 个标准差是 sqrt(200 × 0.25 × 0.75) sqrt(37.5) ≈ 6.12。如果实际命中 78 个z (78 − 50) / 6.12 ≈ 4.58。单侧 p 值大概是 2.3×10⁻⁶这个显著性已经足够下结论了。阈值怎么定取决于你更怕漏检还是更怕误报。z 4 对应单侧 p 3×10⁻⁵是我常用的初筛线。如果你要做批量判定比如一天几十万条那必须做多重检验校正否则误报绝对数量会很难看。假设一天判 10 万条即使 p 3×10⁻⁵也期望有 3 条误报这个数量在人工复核可承受范围内但如果你把阈值放到 z 3p ≈ 1.3×10⁻³一天就是 130 条误报审核团队会被淹没。我在项目里的做法是双阈值z 4 直接判为高置信命中3 z ≤ 4 判为疑似转人工z ≤ 3 判为未检出。同时对同一批次做 Bonferroni 校正把有效阈值按批量大小调高。这套组合跑下来误报率压到了万分之一以下漏检率在 200 token 以上的文本里控制在 10% 以内。注意z 值只能说明这段文本和这把密钥存在统计关联不能说明这段文本由某个具体的人写的。这两件事差得很远后面第 5 章会专门讲这个边界。2.3 改写为什么杀不死全部信号这是标题里那个问题的正面回答。改写分几个层级效果差别巨大。第一层是格式层换行、空格、标点符号、全角半角。这一层基本不影响水印因为分词器通常会把这些规整掉或者它们本身就不进词表统计。你把一段话复制到记事本再复制回来z 值基本不掉。第二层是局部编辑删几句话、改几个词、调整语序。这一层会掉一部分但掉不干净。原因在于没有被编辑的部分它们的上下文关系还在绿名单判定照样成立。我实测删掉 15% 的句子、替换 10% 的实词200 token 的文本 z 值从 4.6 掉到 3.1 左右还在疑似区间。这跟很多人的直觉相反——他们以为改几个字就洗掉了实际上统计信号是靠几百个位置累积起来的改十几个词只是从总数里减去十几。第三层是同义替换逐词换成近义词。这一层杀伤力大很多因为替换后的词大概率落在红名单里等于把命中数直接拉低。但同义替换有个天然限制——真正可替换的词只占一部分虚词、专有名词、数字基本动不了。所以它通常能把 z 值从 4 以上压到 2 以下但很难压到 0。第四层是模型重写把整段话丢给另一个模型让它用自己的话重写一遍。这一层基本能摧毁水印。因为重写会引入全新的采样随机性原来的 token 序列被打散绿名单的上下文依赖链断了剩下的只是零星巧合。实测重写后 z 值普遍落在 1 以内和人类文本没区别。第五层是翻译往返中译英再译回中。这一层比模型重写还彻底因为中间经过了另一套词表回来的时候 token 序列几乎完全重构水印归零。所以准确的回答是改几个词、删几句话查得出来整段重写、翻译往返查不出来。这句话我建议做内容审核的人都记一下它能帮你判断手上的文本值不值得送去检测。2.4 几个容易被忽略的实现细节第一个坑是分词器对齐。生成时用什么分词器检测时就必须用什么分词器版本号要一致。我见过有人生成时用的是带空格前缀的版本检测时用了不带前缀的版本同一段文本切出来的 token 序列长度都不一样z 值直接乱套。落地时建议在服务端固定分词器版本并且在检测接口的返回里带上版本号避免跨版本比对。第二个坑是首个 token 的处理。序列的第一个位置没有前一个 token通常用一个固定的起始符或者提示词的最后一位来顶替。这里生成和检测必须用同一套约定差一个位置就全错。第三个坑是特殊 token。换行、结束符、填充符这些 id 在词表里占位但语义上是结构性的如果把它们也算进统计会引入偏差。我的处理是统计时跳过特殊 token但它们作为前一个 token参与哈希计算是可以的因为它们提供了真实的上下文。第四个坑是批量生成时的密钥隔离。如果你给不同客户用同一个密钥那检测出来只能说明来自你的系统没法区分是哪个客户。正确的做法是按租户派生密钥比如用主密钥加租户 ID 再做一次哈希这样既能溯源到具体调用方又不会因为某个租户的密钥泄露影响全局。3. 从零复现一套完整流程3.1 环境准备与依赖我用的是一个开源的小参数模型做验证参数量在亿级单卡就能跑重点是验证流程而不是追求效果。依赖很常规python -m venv wm-env source wm-env/bin/activate pip install torch transformers numpy目录结构建议这样组织后面调试会方便很多wm-demo/ ├── config.py # 参数集中管理 ├── watermark.py # 哈希分区与 logits 处理 ├── generate.py # 生成脚本 ├── detect.py # 检测脚本 ├── rewrite.py # 改写压力测试 └── results/ # 输出与日志把参数放config.py里尤其是 γ、δ、密钥、分词器路径这四项。我一开始图省事把这些硬编码在脚本里后来调参调到怀疑人生改了生成忘了改检测z 值怎么算都不对查了两个小时才发现是密钥字符串少了个字符。3.2 采样阶段的水印注入核心是写一个 logits 处理器在每一步采样前把绿名单位置的分数抬上去。这里要注意偏置要在 softmax 之前加加的是 logit 而不是概率。import torch from watermark import green_mask DELTA 2.0 def apply_watermark(logits, prev_token_id, deltaDELTA): mask green_mask(prev_token_id) bias torch.zeros_like(logits) idx torch.from_numpy(mask).to(logits.device) bias[idx] delta return logits bias生成循环里调用它def generate_with_watermark(model, tokenizer, prompt, max_new200): ids tokenizer(prompt, return_tensorspt).input_ids out ids.clone() for _ in range(max_new): with torch.no_grad(): logits model(out).logits[:, -1, :] prev out[0, -1].item() logits apply_watermark(logits[0], prev) probs torch.softmax(logits / 1.0, dim-1) nxt torch.multinomial(probs, num_samples1).item() out torch.cat([out, torch.tensor([[nxt]])], dim1) if nxt tokenizer.eos_token_id: break return out这里有几个实操点。温度保持 1.0不要为了更自然去调低温度因为低温会让采样更集中绿名单偏置的效果被放大文本反而更容易出现重复温度调高又会让偏置被稀释检出率下降。1.0 是个平衡点。批处理要单独处理。上面这段是单条生成如果要做批量prev_token_id得按 batch 分别取哈希也要逐条算不能整批共用一个 mask。我一开始图快用了一个共享 mask结果批量输出的水印互相污染检测时 z 值虚高差点误判。还有一点max_new别设太小。100 个 token 以下的输出打不打水印在检测端几乎没区别纯粹浪费算力。实际部署时建议对短输出直接跳过水印或者只做记录不做判定。3.3 检测脚本与阈值验证检测脚本比生成简单得多import numpy as np from watermark import green_mask def detect(token_ids, gamma0.25, skip_specialTrue, special_idsNone): if special_ids is None: special_ids set() hits, total 0, 0 for i in range(1, len(token_ids)): cur, prev token_ids[i], token_ids[i - 1] if skip_special and cur in special_ids: continue mask green_mask(prev, gammagamma) hits int(mask[cur]) total 1 if total 0: return {hits: 0, total: 0, z: 0.0, verdict: insufficient} expected total * gamma std np.sqrt(total * gamma * (1 - gamma)) z (hits - expected) / std if z 4: verdict watermarked elif z 3: verdict suspicious else: verdict clean return {hits: hits, total: total, z: round(z, 3), verdict: verdict}验证环节要准备三组样本带水印的模型输出、不带水印的模型输出、真实人类写的文本。每组至少 50 条长度控制在 150 到 300 token。跑完后看分布正常情况下应该看到带水印组 z 值集中在 3 到 6 之间后两组集中在 −2 到 2 之间。我第一轮验证的时候人类文本那一组的 z 值有个别冲到 2.8当时心里一紧以为是误报。后来把样本拉出来逐条看发现那几条都是引用了大量模板化表达比如产品说明书式的短句用词高度集中在高频词上恰好命中了绿名单偏高的区域。这说明在样本量小的时候z 值的波动范围比理论值大不能拿单条结果下结论至少要看批次分布。3.4 改写压力测试与实测结果这是整个流程里最有意思的部分。我设计了五组改写手段每组取 30 条带水印文本统计改写前后的 z 值变化。改写手段具体操作平均 z 值改写前平均 z 值改写后仍可检出的比例格式规整去多余空格、统一标点4.624.58100%局部编辑删 15% 句子、改 10% 实词4.553.1273%同义替换逐词替换可替换词4.482.0527%模型重写另一模型整段重写4.510.863%翻译往返中译英再译回中4.440.410%数据能说明很多问题。格式规整几乎无损这符合预期因为分词器本来就会规整这些。局部编辑掉到 3.12还在疑似区间说明改几个字就想洗掉的想法不成立。同义替换掉到 2.05大部分检不出来了但仍有约四分之一能被抓到原因是替换不彻底。模型重写和翻译往返基本归零。有一点要提醒这个表的数字是在 200 token 左右的文本上测的。文本越长抗改写能力越强。我拿 600 token 的长文做过同样的测试局部编辑后 z 值还有 3.9同义替换后还有 2.6。反过来100 token 以下的短文本局部编辑一下就掉到 2 以下了。所以能不能查出来这个问题脱离文本长度谈是没有意义的。3.5 参数调优的实操建议调参这件事我的顺序是先定 γ 再定 δ最后调阈值。γ 的确定看你的文本长度分布。如果你的场景以长文本为主比如长文助手、报告生成γ 0.5 更合适文本质量优先靠长度堆显著性。如果以短文本为主比如客服回复、评论生成γ 0.25 更合适用敏感度换检出率但要接受质量上的一点点损失。δ 的确定看你的质量容忍度。我的做法是拿 100 条样本δ 从 0 开始每次加 0.5让人工盲评打分1 到 5 分找到平均分掉超过 0.3 分的那个临界点取它前一个值。实测下来这个临界点大多在 2.0 到 2.5 之间。阈值不要拍脑袋定。正确做法是拿至少 500 条干净的人类文本跑一遍检测看 z 值的实际分布取 99.9 分位数作为阈值下限。我这么做过一次算出来的阈值是 3.4比理论上的 3.09 高一些说明实际数据里存在轻微的正偏可能是因为人类写作本身就有用词集中的倾向。提示参数一旦上线就不要频繁改。水印参数变了历史文本的检测结果就对不上会出现昨天判有、今天判无的尴尬局面。建议把参数版本号写进检测结果的元数据里。4. 常见问题与排查实录4.1 检测不出来问题出在哪这是被问得最多的一类。排查顺序我总结成四步从便宜到贵。先查密钥。九成以上的检测不到最后都定位到密钥不一致——生成端读的是环境变量 A检测端读的是配置文件里的旧值 B。这种问题最难发现因为两边的代码看起来都对跑起来就是不匹配。建议在启动日志里打印密钥的哈希前缀不是密钥本身两边对一下就知道。再查分词器。生成和检测必须用同一个分词器实例、同一个版本。特别要注意的是大小写和前后空格的处理有的分词器默认会在句首加特殊前缀有的不加切出来的 token 序列长度差一位后面全部错位。然后查文本长度。低于 80 token 的文本z 值本来就没有判别力不是检测失败而是无法检测。这种情况应该在接口层面直接返回insufficient而不是返回一个接近 0 的 z 值让人误解。最后才怀疑参数。δ 是不是被谁调低了γ 是不是改了但没同步到检测端。这两个参数必须成对管理最好打包成一个配置对象整体下发给两端。4.2 误报怎么控误报的代价比漏检高得多。漏检只是少抓一条误报是把人写的文本判成机器写的一旦公开出去就是信任危机。控制误报的核心是提高阈值加多重校正。单条判定用 z 4批量判定用 Bonferroni 校正把阈值按批量大小往上提。批量 1000 条时有效阈值要提到 z 4.5 左右。第二个手段是加辅助特征。纯 z 值只看绿名单命中率信息量有限。我会额外看两个指标一是文本的重复度连续重复 n-gram 的比例机器文本通常略高二是词频分布的长尾程度人类写作的长尾更肥。这两个指标不做主判据只在 z 值处于 3 到 4 的灰区时做加权参考能明显降低灰区的误报。第三个手段是人工复核闭环。灰区的样本不要自动判转人工人工的判定结果回流成校准数据每季度重算一次阈值。这套机制跑半年下来我们的误报率从千分之三降到了万分之八。4.3 问题速查表症状可能原因排查动作处理方式z 值接近 0明显有偏置密钥不一致比对两端密钥哈希前缀统一配置源z 值随机跳动同一文本两次结果不同分词器版本不一致打印 token 序列对比长度锁定版本并写入元数据生成文本重复感强、用词单调δ 过大逐档降 δ 做盲评回退到质量临界点前一档长文本检出正常短文本全失败长度不足统计各长度段的 z 分布短文本改判insufficient批量检测误报集中出现未做多重校正检查批量大小与阈值加 Bonferroni 校正改写后完全检测不到改写强度过大看改写前后 token 重合率属正常现象不做挽回检测结果与人工判断相反文本含大量模板化表达抽样人工复核引入辅助特征加权4.4 几条踩过坑的经验第一条不要把水印当成唯一证据。它只是一个统计信号能在你已经有其他怀疑的时候提供佐证不能单独作为判定依据。我在项目里定的规矩是z 值只作为风控评分的一项输入权重不超过 30%。第二条公开密钥等于公开水印。密钥一旦泄露攻击者不但能检测还能逆向构造红名单优先的文本主动规避。密钥管理要走和其他敏感凭证一样的流程定期轮换。轮换的代价是历史文本要用旧密钥检测所以密钥要带版本号长期保存不能删。第三条别在同一个模型上既打水印又做蒸馏。学生模型学的是教师模型的输出分布水印偏置会被一并学走结果学生模型自己也带上了教师的水印检测时会指向错误的来源。如果业务上有蒸馏需求要么教师模型不打水印要么在蒸馏数据上做去偏处理。第四条性能开销比想象中小。哈希加排列这部分是纯 CPU 计算我实测下来在单卡上让生成速度慢了大约 6%主要成本在 Python 层的循环。改成批量化加缓存之后降到 2% 以内。检测端更快200 token 的文本单条检测在 3 毫秒左右批量跑十万条也就几分钟。第五条文本质量评测要用人评不能只看困惑度。困惑度对水印偏置不敏感δ 从 1.5 加到 3.0困惑度可能只涨了 0.1但人读起来已经明显别扭了。我做参数选择时都是拉三个人盲评每人 100 条取平均分虽然慢但靠谱。5. 影响范围与能力边界5.1 哪些场景真的用得上从我这段时间接触到的需求看真正有落地价值的场景集中在三类。第一类是内容平台的溯源。当一条内容被举报或者被质疑来源时平台能拿密钥跑一遍检测给出一个置信度。注意这里的关键词是置信度而不是结论平台对外披露时应该说明这是统计判断存在误判可能。我见过一些产品把这个能力包装成AI 内容识别器宣称准确率 99%这是过度承诺实际在改写场景下远达不到。第二类是企业内部的输出审计。比如客服系统、文档生成工具企业想知道某段对外内容是不是自家模型产出的、是哪条业务线产出的。这种情况下按租户派生密钥就有用了既能确认来源又不会泄露给其他租户。第三类是模型提供方防止输出被冒用。有些场景下模型输出被截图、被搬运、被包装成别人的成果带水印的输出至少能在争议时提供一个技术侧的佐证。但同样要注意这只是佐证不是确权。反过来说有几类场景我建议别碰。一是把水印检测作为处罚依据风险太高二是对短文本做自动判定技术上不可靠三是把检测能力开放给不受控的第三方密钥管理会失控。5.2 两个必须说清的边界第一个边界水印检测不等于AI内容检测。这是两套完全不同的技术。水印是这段文本是不是由持有这把密钥的模型生成的答案是确定的要么匹配要么不匹配。AI内容检测是这段文本像不像机器写的本质是概率判断靠的是困惑度、词汇分布、句法复杂度这些特征对改写和润色的鲁棒性很差。很多工具把两者混在一起宣传用户以为是同一件事实际能力差距很大。一段人类写的文本经过某把密钥的检测z 值就是随机水平但经过 AI 内容检测器可能会被判成高度疑似 AI因为它用词太规整。这两种结论的含义完全不同不能混用。第二个边界改写强度和检出概率是连续关系不是二元的。很多人问改写一遍还能查出来吗期待一个能或不能的答案。实际情况是一条曲线改 5% 的词检出率 90% 以上改 15%检出率掉到六成整段重写检出率接近零。而且这条曲线还受文本长度影响长文本的曲线更平缓。所以更有用的问法是我改到什么程度检出率会降到多少而不是改了还能不能查出来。我自己在实际操作中的体会是把水印当成一把粗糙但有方向感的尺子最合适。它能告诉你这段文本大概率来自某个源但不能告诉你这段文本一定是机器写的。做产品设计的时候把这个能力放在辅助位置配合人工复核和其他信号价值最大把它当成一锤定音的判据迟早出事。最后分享一个调试时的小技巧如果你怀疑检测结果不对先把 token 序列打印出来手工数前 20 个位置的绿名单命中情况和脚本算出来的比对一下。这二十个位置能覆盖掉九成的实现错误包括哈希不稳定、分词器错位、特殊 token 处理不当这几类。我每次换环境部署都会先跑这个手工核对比看日志快得多。