昨天给提示词百宝箱加了一条校验:标题不能重复、正文不能重复。规则写得很干脆——比标题时去掉大小写、空白和所有标点符号,比正文时只折叠空白。单元测试也绿了:Vue 3 组件! 和 vue3组件 会被抓出来,现有 66 条数据全部通过。
写完总觉得「去掉所有标点」这句话有点猛。技术名词里到处是 +、.、/,这些算不算「装饰」?今晚拿几组看起来不像重复的标题去撞,再顺手测了正文和变量声明,结果翻出 4 个问题。数字都是本机跑的:Node 24.22.1,仓库 HEAD d3b0006,校验入口是 scripts/check.js,数据在 src/data/*.js。
原来的归一化
export const normTitle = (s) => s.trim().toLowerCase().replace(/[\s\p{P}\p{S}]/gu, '')
const normBody = (s) => s.trim().replace(/\s+/g, ' ')\p{P} 是 Unicode 标点类别,\p{S} 是符号类别。加在一起等于:空格、感叹号、加号、点号、斜杠、减号……只要不是字母数字汉字,能剥的全剥。当初这么写,是为了让「带不带感叹号」「多不多一个空格」别算两条;副作用是把有语义的符号也一并删了。
校验流程大致是:扫一遍提示词,用 Map 记下「归一化标题 → 第一条的 id」,正文同理;后面再撞上同一个 key,就报「标题与 xxx 重复」或「prompt 正文与 xxx 相同」。单元测试覆盖了「装饰性标点算重复」和「正文只差空白算重复」,但没有覆盖「有语义的符号被误删」。
1. C++ 和 C 被判成同一个标题
normTitle('C++ 入门') // 'c入门'
normTitle('C 入门') // 'c入门'+ 属于 \p{S},直接没了。同理还有这些:
| 标题 A | 标题 B | 归一后 |
|---|---|---|
| Node.js 调试 | Nodejs 调试 | nodejs调试 |
| AI/ML 入门 | AIML 入门 | aiml入门 |
| A-B | AB | ab |
校验脚本会报「标题与 xxx 重复」,其实这两组根本不是一回事。提示词库里技术名词特别多,C++、Node.js、AI/ML 都是常客,这条误伤迟早会踩到——不是现在 66 条里已经有,而是以后加人的时候会被误拦,或者反过来:真想防的重复,因为符号不同而漏过去(这个方向今晚没复现到反例,但规则本身是双向的)。
我本地塞了三条:C++ 入门 / C 入门 / C-- 入门,后两条都被判成和第一条重复。-- 的减号同样属于被剥掉的符号。
2. 纯标点标题归一成空串,然后互相撞车
normTitle('!!!') // ''
normTitle('???') // ''两条完全不同的「标题」变成同一个空键,第二条报重复。更麻烦的是:单独一条 !!! 也能过校验——空串只是 Map 里的一个合法 key,没有人检查「归一化之后是不是空的」。
真实库里大概不会有人交纯标点标题,但这是规则漏洞的信号:归一化函数的值域里出现了无意义的空串,调用方却没处理。类似的坑在做 slug、做搜索归一化时也很常见。
3. 正文里塞零宽字符就能躲开重复检测
比正文时只折叠了空白,没动零宽字符。复制粘贴、某些编辑器、从网页扒下来的文本,经常夹着 \u200b(零宽空格)、\u200c、\u200d、BOM。
关键复现要用「只差一个零宽」的用例:
// 'hello world' vs 'hello\u200b world'
// 不剥 ZWSP 时两者不同 → 不算重复(漏检)本意是防「复制粘贴出两条一样的提示词」,结果加个看不见的字符就能绕过。注意:hello\u200bworld(中间没有普通空格)和 hello world 本来就不是同一段字,剥不剥零宽都不该判重——我第一版复现脚本就写错过,修脚本比修产品还容易让人害羞。
4. {{ 代码 }} 带空格,和 vars 对不上
占位符用的是 {{(.+?)}},捕获组带两边空格;vars 里写的是不带空格的 代码:
validate([{
id: 'x', cat: '编程', title: '解释', scene: 's', example: 'e',
prompt: '请解释:{{ 代码 }}', vars: ['代码'],
}])
// → vars 未在 prompt 中使用:代码
// → prompt 中的变量未声明: 代码一条提示词同时报「声明了没用」和「用了没声明」,其实只是多打了两个空格。详情页填变量、导出脚本里的 fillVars / leftVars 也是同一套正则,空格对不齐时,页面上填了「代码」也替换不进去——得填带空格的键才行,正常人不会这么干。
这算校验和运行时的双重坑:校验误报,运行时静默留着 {{ 代码 }} 原文。
现有 66 条数据本身没误伤
对仓库里真实数据跑 validate(),结果是 []。问题出在规则本身:误伤要等以后有人加 C++ 这类标题才爆发;漏检则是零宽和变量空格,现在就能复现。这也是为什么「测试全绿」和「规则正确」不是一回事——测试只锁住了你想到的例子。
打算怎么改
补丁还在本地,没推远程(2026-11-05-title-norm.patch,对 d3b0006 git apply --check 通过):
- 标题只剥空白和装饰性标点(中英文句读、引号、感叹号问号),保留
+./-这类有语义的符号。 - 归一化后如果是空串,直接报「标题去掉空白和装饰标点后为空」,而不是拿空串去撞车。
- 正文先去掉
\u200b\u200c\u200d\ufeff,再折叠空白。 - 占位符改成
{{\s*(.+?)\s*}},取变量名时.trim();fillVars/leftVars一起改。
改完之后:C++ 和 C 不再撞车,Node.js 和 Nodejs 不再撞车,装饰性的 Vue 3 组件! / vue3组件 仍然算重复;纯标点标题会明确报错;零宽躲不过去;{{ 代码 }} 能对上 vars: ['代码']。测试从 6 个加到 10 个,全绿。
还有一条故意没「修死」:你好吗 和 你好?吗 在剥掉中文问号之后仍然一样。中文问号算装饰还是语义,见仁见智;我倾向先当装饰,真有两条这种标题再单独说。文章里把这类「已知保留行为」写清楚,比假装完美有用。
一点复盘
去重看起来是字符串小题,真正难的是「相等」的定义。库里的标题既有中文口语,又有英文技术词,一套 \p{P}\p{S} 很难两头讨好。比较稳的做法是:
- 先写一张「要剥 / 要留」的表,用真实会进库的标题做样例;
- 归一化之后做空值检查;
- 正文侧把不可见字符单独处理,不要指望
\s能包圆; - 变量声明和占位符共用同一套 tokenize,避免校验通过、运行时对不齐。
如果你也在给提示词 / 模板库写去重,别直接上 \p{P}\p{S} 一锅端——先列出哪些符号在你的领域里有意义,再决定剥哪些。