用正则做一个最小的「AI 腔」检测器

参考 deslop 项目的评估思路,用纯标准库的正则规则统计每千词的破折号、「not X, but Y」句式和三连列举等写作习惯,对比几段风格不同的样例文本,并检验规则在明显的人类写法上不误报。

已验证 Python 3.12.15 耗时 不到 0.1s 运行于 2026-10-20 12:44 UTC
结论

sloppy=306.7 no_dash=240.0 plain=0.0 per 1k words; all checks passed

检查 4/4 通过
  • sloppy 得分 > 3 × plain
  • plain 得分 < 15(参考原文所说人类文本通常 <15/千词)
  • 去掉破折号后 em_dash 计数为 0
  • 去掉破折号后得分下降,但仍高于 plain

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
sample           words  score/1k  hits
sloppy              75     306.7  {'em_dash': 3, 'not_x_but_y': 2, 'rule_of_three': 4, 'buzzwords': 8, 'hype_opener': 1}
sloppy_no_dash      75     240.0  {'em_dash': 0, 'not_x_but_y': 1, 'rule_of_three': 4, 'buzzwords': 8, 'hype_opener': 1}
plain               93       0.0  {'em_dash': 0, 'not_x_but_y': 0, 'rule_of_three': 0, 'buzzwords': 0, 'hype_opener': 0}
[PASS] sloppy 得分 > 3 × plain
[PASS] plain 得分 < 15(参考原文所说人类文本通常 <15/千词)
[PASS] 去掉破折号后 em_dash 计数为 0
[PASS] 去掉破折号后得分下降,但仍高于 plain

说明:只删破折号这类表面后处理,句式和用词层面的特征仍会被规则捕获。
RESULT: sloppy=306.7 no_dash=240.0 plain=0.0 per 1k words; all checks passed

目的

今天的条目 Qwen3-4B deslop 用蒸馏 + LoRA 把破折号、「not X, but Y」、三连列举等写作习惯从小模型里训练掉。作者的评估核心之一是一个基于正则的「slop score」,按每 1000 词统计这些特征(原文称人类文本通常低于 15)。

本实验用纯 Python 标准库写一个简化版检测器,帮助理解这类评估指标是怎么算的、能捕获什么、又有什么局限:

  1. 规则能否区分「AI 腔」样例和朴素的人类写法?
  2. 只做表面后处理(例如把破折号替换成句号)能把分数降到多少?

注意:规则和权重是本实验自拟的,不是原项目的检测器;样例文本是本实验自行编写的合成文本。

步骤

  1. 定义 5 条规则:破折号、「not X, but Y」句式、三连列举、高频套话词、「Great question!」式开场,每条带权重
  2. 准备三段英文样例:sloppy(刻意堆砌套话)、sloppy_no_dash(只把破折号换成句号)、plain(朴素叙述)
  3. 计算每千词加权得分,并检查: - sloppy 得分明显高于 plain - plain 得分低于 15 - 去掉破折号后得分下降,但仍高于 plain

运行:

python main.py

程序最后输出 RESULT: ...,任一检查失败时以非零状态退出。

预期结果

  • sloppy 远高于 plain,plain 接近 0
  • sloppy_no_dash 的破折号计数为 0,总分下降但仍明显偏高——表面替换消除不了句式和用词层面的特征

局限与可以继续尝试

  • 正则只能抓表面模式,会误报(例如正常的三项列举)也会漏报;原文作者也提到其评分是自己的检测器、没有做读者研究
  • 可以把自己常用模型的输出贴进来打分,或增加规则(如 "Moreover," 开头、过度的加粗小标题)
  • 可以把规则改成中文版本(如「不是……而是……」「总而言之」),看看在中文输出上的效果

内网复现

  • 无需任何第三方依赖,只用 Python 3.8+ 标准库(re),无需联网,可直接在内网运行
  • 把 main.py 拷贝到内网机器执行 python main.py 即可
  • 如果想对比原项目发布的模型,需要从 Hugging Face 下载权重(需要外网或内网的 Hugging Face 镜像);没有镜像时,可以用内网已有的任意模型生成文本,粘贴进样例里打分

代码

main.py79 行
#!/usr/bin/env python3
"""最小「AI 腔」(slop) 检测器。

参考 deslop 项目公开的评估思路:用正则规则统计常见的模型写作习惯,
按每 1000 词归一化。规则与权重为本实验自拟,不是原项目的检测器。
样例文本均为本实验自行编写的合成文本。
"""
import re
import sys

RULES = {
    # 名称: (正则, 权重)
    "em_dash": (re.compile(r"—|(?<=\w) -- (?=\w)"), 1.0),
    "not_x_but_y": (re.compile(r"\b(?:it's|it is|this is|that's)?\s*not (?:just |only |merely )?[\w' -]{1,40}?[,;—-]+\s*(?:but|it's|it is)\b", re.I), 2.0),
    "rule_of_three": (re.compile(r"\b\w+, \w+,? and \w+\b"), 0.5),
    "buzzwords": (re.compile(r"\b(?:delve|tapestry|testament|seamless(?:ly)?|leverag(?:e|ing)|landscape|unlock|game[- ]changer|navigate the|in today's fast-paced)\b", re.I), 1.5),
    "hype_opener": (re.compile(r"(?:^|\n)\s*(?:great question|absolutely|certainly)[!,.]", re.I), 2.0),
}


def words(text):
    return re.findall(r"[A-Za-z0-9']+", text)


def score(text):
    n = max(len(words(text)), 1)
    hits = {name: len(rx.findall(text)) for name, (rx, _) in RULES.items()}
    weighted = sum(hits[name] * w for name, (_, w) in RULES.items())
    return weighted * 1000 / n, hits, n


SLOPPY = """Great question! Remote work is not just a trend — it's a fundamental shift.
In today's fast-paced landscape, teams must navigate the challenges of focus, trust, and communication.
It's not about where you work, but how you work. Tools like chat, video, and docs seamlessly connect people —
and that's a game-changer. Ultimately, remote work is a testament to flexibility, resilience, and innovation.
Leveraging the right habits can unlock productivity, creativity, and balance — for everyone."""

PLAIN = """I have worked from home for three years. The first winter was hard because I stopped
leaving the flat, so now I walk to a cafe two mornings a week. My team meets on video at ten.
We write most decisions down in a shared doc, which saves us from repeating the same argument.
The main cost is that new people take longer to learn how things are done here. We pair them with
someone for their first month, and that seems to help. I would not go back to commuting every day."""

# 把 SLOPPY 里的破折号换成句号:只去掉一个表面特征
SLOPPY_NO_DASH = SLOPPY.replace(" — ", ". ").replace(" —\n", ".\n")


def main():
    samples = [("sloppy", SLOPPY), ("sloppy_no_dash", SLOPPY_NO_DASH), ("plain", PLAIN)]
    results = {}
    print(f"{'sample':<16}{'words':>6}{'score/1k':>10}  hits")
    for name, text in samples:
        s, hits, n = score(text)
        results[name] = (s, hits)
        print(f"{name:<16}{n:>6}{s:>10.1f}  {hits}")

    s_sloppy, h_sloppy = results["sloppy"]
    s_nodash, h_nodash = results["sloppy_no_dash"]
    s_plain, _ = results["plain"]

    checks = [
        ("sloppy 得分 > 3 × plain", s_sloppy > 3 * max(s_plain, 1)),
        ("plain 得分 < 15(参考原文所说人类文本通常 <15/千词)", s_plain < 15),
        ("去掉破折号后 em_dash 计数为 0", h_nodash["em_dash"] == 0),
        ("去掉破折号后得分下降,但仍高于 plain", s_plain < s_nodash < s_sloppy),
    ]
    ok = True
    for desc, passed in checks:
        print(f"[{'PASS' if passed else 'FAIL'}] {desc}")
        ok &= passed

    print("\n说明:只删破折号这类表面后处理,句式和用词层面的特征仍会被规则捕获。")
    print(f"RESULT: sloppy={s_sloppy:.1f} no_dash={s_nodash:.1f} plain={s_plain:.1f} per 1k words; "
          f"{'all checks passed' if ok else 'some checks failed'}")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助