作者用脚本检测 AI 批量内容中的固定短语污染,以及去除数字、名称和标点后的句式重复。实践覆盖 1664 条模型资料及其他目录数据,补足了人工抽查和常规质量门禁的盲区。
先说结论:当 AI 生成的目录条目超过 200 条后,靠逐条阅读来检查内容是否雷同,已经毫无意义。我是在撞上这堵墙之后,才写了 scripts/lint-humanization.mjs。它能发现两类人工审阅完全察觉不到的问题。
第一类是特定短语污染:即使没有使用 fallback template,Claude 仍会反复使用某些固定套话。第二类是句子结构重复:去掉数字、仓库名称和标点符号后,一些句子在语义上完全相同。这两类问题都真实存在于内容中,但扫一眼根本看不出来。
本文会介绍这两种检测方法的工作原理、各自的局限,以及它们在这个项目的 1,664 条 AI 模型条目、120 条独立游戏条目和 80 条 SaaS 条目中检测出的实际情况。
AI 生成的目录内容存在一种标准质量检查无法发现的失效模式。我在 6 月构建的第一版质量门禁,包括陈词滥调检测、字数门槛和标签池校验,它们都能发现结构性问题。但如果 Claude 在不同条目中把同一句话写了 40 遍,每次只是替换其中的名词,这些检查一个也发现不了。
雷同问题是大批量生成特有的现象。生成 20 个条目时,内容通常会自然产生变化——模型每次调用时获得的上下文不同,生成的结构也不同。而当条目达到 1,664 个时,也就是 AI Tools 目录目前的规模,模型就会开始向固定套话收敛。
这并不是 fallback template 导致的——这些条目的 model_used 中会标记 fallback-template,很容易统计。更棘手的是那些 model_used = "claude-haiku-4-5" 的条目:虽然确实是模型生成的,但几十行输出仍然近乎一模一样。
出现这种情况有两个原因:
系统 prompt 收敛:当 system prompt 规定了一套固定结构,例如“先生成 2~3 句摘要,再列出 3~5 个使用场景、3~5 个优点和 3~5 个缺点”,模型就会学着用相似的内容填充每个位置。各个位置的内容会有所变化——模型并不是直接复制——但句子开头、过渡短语和表示保留态度的措辞会不断重复。
接近 fallback 的措辞:即使没有真正触发 parseOrFallback 函数,Claude 仍会采用一些听起来像是 fallback template 生成的表达。例如,“typically requires self-hosting”或“OSS alternatives provide”既会出现在 fallback 输出中,也会出现在 Claude 的真实输出中。fallback template 本来就是为了模仿 Claude 的表达方式而设计的,因此两者之间的界限很模糊。
lint-humanization.mjs 的第一层检测非常直接:定义一个特定短语数组,为每个短语配置允许出现的最大次数和严重级别:
const RESIDUE_RULES = [
{ phrase: "The main gap", max: 0, severity: "error" },
{ phrase: "plays like", max: 12, severity: "warn" },
{ phrase: "because it combines", max: 6, severity: "warn" },
{ phrase: "handles instruction prompts, multi-turn dialogue, and open-ended text generation", max: 0, severity: "warn" },
{ phrase: "It follows chat template conventions and supports system-level role instructions", max: 0, severity: "warn" },
{ phrase: "OSS alternatives provide", max: 12, severity: "warn" },
{ phrase: "typically absent", max: 12, severity: "warn" },
{ phrase: "vendor lock-in", max: 24, severity: "warn" },
];
max: 0 表示这些短语绝对不应该出现。那两个长字符串——“handles instruction prompts, multi-turn dialogue, and open-ended text generation”和“It follows chat template conventions”——是直接从旧版 system prompt 中原封不动提取出来的。不知为何,它们混进了早期的模型条目,而我当时没能及时发现。
将其设置为 max: 0 和 severity: "error",意味着只要它们出现在数据集的任何位置,脚本就会以退出码 1 失败。这不是普通警告,而是会直接导致 CI 失败的错误。
max: 12 和 max: 24 的条目则允许一定的容差。在 80 个 SaaS 条目中,有 24 个提到“vendor lock-in”是可以接受的,因为它确实是这个领域中的真实概念。但如果 80 个条目中有 60 个都出现了这个短语,就说明模型正在把它当作万能话术,而不是准确的描述。这里的阈值,是根据我在实际数据中观察到的情况手动校准的。
如果重新来过,我会采用不同的做法:阈值应该按数据集分别设置,而不是全局共用。独立游戏条目和 AI 模型条目会产生不同的固定套话——对游戏来说,少量使用“plays like”合情合理,但对 AI 模型来说毫无意义。目前的实现会对三个数据集应用完整的 RESIDUE_RULES 数组,因此有些规则没有在该触发的地方触发,还有些规则则会产生错误警告。
指定短语检测只在你知道应该寻找哪些短语时才有效。句子归一化则不需要预先知道要找什么短语。
normalizeSentence 函数会删除一切让句子看起来不同的元素:
function normalizeSentence(s) {
return s
.toLowerCase()
.replace(/\b\d+(?:[,.]\d+)*\b/g, "<num>")
.replace(/\b[a-z0-9_.-]+\/[a-z0-9_.-]+\b/g, "<repo>")
.replace(/[^a-z0-9<> ]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
归一化后,“Llama-3.2-8B supports long-context reasoning up to 128,000 tokens”和“Qwen2.5-72B supports extended context windows up to 32,000 tokens”会分别变成“llama num supports long context reasoning up to num tokens”和“qwen num supports extended context windows up to num tokens”。它们并不相同。
但“The model supports long-context reasoning tasks and multi-turn conversation”和“The model supports multi-turn dialogue and long-context reasoning workflows”归一化后的结果就非常接近——虽然词序不同,但去掉 <num> 等差异后的核心内容相同。
接下来,repeatedSentences 会遍历数据集中的所有行,收集每个超过 48 个字符的句子的归一化版本,并统计每个版本出现在多少个不同的条目中。只要同一句子出现在 6 个或更多条目中,就会被标记:
.filter((item) => item.ids.length >= 6)
.sort((a, b) => b.ids.length - a.ids.length);
48 个字符的下限非常重要。像“This is a fast model”或“No GPU required”这样的短句,合理地出现在许多地方——它们只是简短、客观且显而易见。如果连这些句子也标记出来,归一化方法就会产生大量噪声。当归一化后的句子达到 48 个字符时,它通常已经足够具体,此时重复才真正具有意义。
我把阈值设为 6 个条目,而不是 10 个或 20 个,是因为一句话如果出现在 80 个 SaaS 条目中的 6 个里,就已经占到数据集的 7.5%。读者浏览目录时,从这个比例开始就会注意到重复模式。
--strict 标志应该用在哪里脚本有两种模式。不带 --strict 时,除非出现错误,也就是违反 max: 0 规则,否则脚本会以退出码 0 结束。警告会打印出来,但不会阻塞流程。带上 --strict 后,任何警告都会让脚本返回退出码 1。
在 CI 中,我不会使用 --strict。内容刷新 cron 每晚运行,并随着 HuggingFace 出现新模型而添加新条目。如果因为 1,664 个条目中有 8 个共享某种句式,就阻塞整个 ETL,未免过于激进——流水线会停滞,新条目无法添加,而我还得在凌晨 3 点手动调查一个误报。
严格模式是为我之前介绍的三级质量阶梯准备的:当我手动执行质量审计,并且有意让警告导致失败,从而迫使自己处理这些问题时,才会启用它。这是一项 human-in-the-loop 操作,而不是自动化操作。--strict 标志让质量门禁可以根据具体场景调整,而不是只能做非黑即白的选择。
我编写的四个内容 QC 脚本都采用了相同模式——每个脚本都有一套严重级别模型,用于区分会阻塞 CI 的错误和仅供参考的警告。选择使用哪个级别时,必须考虑是谁在运行脚本,以及为什么运行它。
根据现在掌握的经验,我会修改三件事:
从一开始就按数据集设置阈值。残留规则最初是根据我在 OSS alternatives 数据集中发现的问题逐步演化出来的,之后才通过复制粘贴覆盖到三个数据集。独立游戏数据集和 AI 模型数据集有着不同的固定套话。我本应从一开始就将规则结构设计为数据集专属。现在的权宜之计,是接受一些警告,即使相关短语对某些数据集其实并不重要。
检测语义相似性,而不只是字符串相等。句子归一化可以在去除数字之后发现结构相似性,却无法发现那些用不同词语表达相同概念的句子。“Requires a GPU with at least 16GB VRAM”和“Needs dedicated GPU with substantial memory”会被归一化为不同的字符串,但两者都是固定套话。
这就是归一化方法的局限——它只能发现简单的情况,无法发现微妙的重复。如果要实现语义去重,就需要为每个句子生成 embedding,再按余弦距离进行聚类,其复杂度完全是另一个量级。
跟踪残留问题随时间变化的趋势。目前的脚本是无状态的——它只会使用当前规则检查当前数据集。我无法知道警告数量是在随时间增加还是减少。只要增加一个简单的 JSON 日志,记录每次运行的警告数量,我就可以在不阅读原始输出的情况下,判断每晚的内容刷新究竟是在让情况变好还是变坏。
可以,但使用的是单独的检测方式。脚本会检查 row.model_used === "fallback-template",然后将匹配数量作为警告报告。这与句子残留检查不同——它只是统计 ETL 明确回退到模板、而没有调用 Claude 的行数。
目前三个数据集中的 fallback-template 行数都是 0,这意味着最近运行 ETL 时 Anthropic API 一直可用。三级质量阶梯会把这个数量作为健康状况信号。
audit-articles.mjs 质量门禁有什么区别?audit-articles.mjs 检查 content/articles/ 中的单篇文章文件——它会强制执行 quality_contract v2 的要求,包括陈词滥调检测、字数检查和标签池有效性校验。
lint-humanization.mjs 检查三个目录站点的 JSON 数据文件——它在数据集层面运行,寻找跨条目的重复模式,而不是单篇文章内部的结构问题。两者解决的是不同的问题。
当 AI tools 数据集达到大约 150 个条目时,我开始发现有意义的模式。在此之前,80 个条目中出现 6 次的模式,在统计上仍有较大噪声。
当条目达到 1,664 个时,重复句子列表中有 12 个句子分别在 20 多行中触发标记。这项技术会随着数据集规模扩大而变得更有效——条目越多,统计信号越强,而不是噪声越大。
残留数量最高的页面可以作为 noindex 的候选对象——如果某个模型描述中有 40% 的内容与其他条目相同,这个页面很可能没有提供独特价值。
我还没有把这两者自动关联起来,但每个条目的残留分数会是判断是否达到 noindex 决策阈值时非常有用的特征。
three-lint-rules 负责生成阶段的文章级 lint,包括陈词滥调、标签池和字数检查。本文介绍的方法负责跨 JSON 文件进行大规模的数据集级质量检查。两者的作用范围和执行时机都不同。
这是一个持续 6 个月的实验的一部分:运营三个由 AI 策展的目录站点。文中的技术结论均来自真实实践;本文在 AI 辅助下完成。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。