Skip to content
前端

提示词去重把 C++ 和 C 判成同一个标题:我给校验脚本挖出的 4 个坑

prompt-hub-cn 标题/正文去重复现:\p{P}\p{S} 误伤 C++/Node.js/AI/ML、纯标点归一成空串撞车、零宽字符漏检、{{ 变量 }} 空格与 vars 对不齐。

涛哥2026-11-055 分钟阅读

昨天给提示词百宝箱加了一条校验:标题不能重复、正文不能重复。规则写得很干脆——比标题时去掉大小写、空白和所有标点符号,比正文时只折叠空白。单元测试也绿了:Vue 3 组件! 和 vue3组件 会被抓出来,现有 66 条数据全部通过。

写完总觉得「去掉所有标点」这句话有点猛。技术名词里到处是 +、.、/,这些算不算「装饰」?今晚拿几组看起来不像重复的标题去撞,再顺手测了正文和变量声明,结果翻出 4 个问题。数字都是本机跑的:Node 24.22.1,仓库 HEAD d3b0006,校验入口是 scripts/check.js,数据在 src/data/*.js。

原来的归一化 ​

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 被判成同一个标题 ​

js
normTitle('C++ 入门') // 'c入门'
normTitle('C 入门')   // 'c入门'

+ 属于 \p{S},直接没了。同理还有这些:

标题 A标题 B归一后
Node.js 调试Nodejs 调试nodejs调试
AI/ML 入门AIML 入门aiml入门
A-BABab

校验脚本会报「标题与 xxx 重复」,其实这两组根本不是一回事。提示词库里技术名词特别多,C++、Node.js、AI/ML 都是常客,这条误伤迟早会踩到——不是现在 66 条里已经有,而是以后加人的时候会被误拦,或者反过来:真想防的重复,因为符号不同而漏过去(这个方向今晚没复现到反例,但规则本身是双向的)。

我本地塞了三条:C++ 入门 / C 入门 / C-- 入门,后两条都被判成和第一条重复。-- 的减号同样属于被剥掉的符号。

2. 纯标点标题归一成空串,然后互相撞车 ​

js
normTitle('!!!') // ''
normTitle('???') // ''

两条完全不同的「标题」变成同一个空键,第二条报重复。更麻烦的是:单独一条 !!! 也能过校验——空串只是 Map 里的一个合法 key,没有人检查「归一化之后是不是空的」。

真实库里大概不会有人交纯标点标题,但这是规则漏洞的信号:归一化函数的值域里出现了无意义的空串,调用方却没处理。类似的坑在做 slug、做搜索归一化时也很常见。

3. 正文里塞零宽字符就能躲开重复检测 ​

比正文时只折叠了空白,没动零宽字符。复制粘贴、某些编辑器、从网页扒下来的文本,经常夹着 \u200b(零宽空格)、\u200c、\u200d、BOM。

关键复现要用「只差一个零宽」的用例:

js
// 'hello world' vs 'hello\u200b world'
// 不剥 ZWSP 时两者不同 → 不算重复(漏检)

本意是防「复制粘贴出两条一样的提示词」,结果加个看不见的字符就能绕过。注意:hello\u200bworld(中间没有普通空格)和 hello world 本来就不是同一段字,剥不剥零宽都不该判重——我第一版复现脚本就写错过,修脚本比修产品还容易让人害羞。

4. {{ 代码 }} 带空格,和 vars 对不上 ​

占位符用的是 {{(.+?)}},捕获组带两边空格;vars 里写的是不带空格的 代码:

js
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 通过):

  1. 标题只剥空白和装饰性标点(中英文句读、引号、感叹号问号),保留 + . / - 这类有语义的符号。
  2. 归一化后如果是空串,直接报「标题去掉空白和装饰标点后为空」,而不是拿空串去撞车。
  3. 正文先去掉 \u200b\u200c\u200d\ufeff,再折叠空白。
  4. 占位符改成 {{\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} 一锅端——先列出哪些符号在你的领域里有意义,再决定剥哪些。

仓库:https://github.com/MsNan/prompt-hub-cn

最后更新: