技术博客_AI小说检测器实验复盘

我做了一个中文 AI 小说检测器,然后花了大部分时间研究它为什么会判错

一个轻量中文 AI 文本检测器的实验复盘:从误判排查到分布偏移。

这不是一篇「我做出了 86.71% 准确率模型」的技术汇报。恰恰相反,它记录的是一个能运行的分类器,如何在一连串误判和漏检中,被一次次实验逼着承认自己能力的边界。

我也用 AI 工具(agent)帮我写脚本、跑实验、整理分析。但本文最想讲清楚的,是哪些决定是我做的、哪些结论是我亲手用数据核对的,以及我为什么几次拒绝了 AI 给的漂亮解释。


目录


01 · 项目动机

我在做一个本地的「小说设定编写器」——用户可以配一个 OpenAI 兼容接口,让大模型按关键词生成小说设定。做着做着,我冒出一个很自然的想法:能不能加一个标签页,让用户粘贴一段文字,判断它是人写的还是 AI 生成的?

这个需求听起来简单,但我在动手前就清楚几件事:

  • 中文文学文本没有英文检测器里那么明显的「困惑度断层」信号;
  • 现代大模型生成的中文已经很自然,检测难度远高于早期的 GPT-2;
  • 我是一个个人开发者,硬件和算力有限,只能做轻量方案——微调一个小模型 + 一个简单分类头,而不是训练大模型。

所以我从一开始就没指望「造一个完美的检测器」。我的目标是:做一个能用的、并且我知道它会在哪里失效的东西。

整个项目的架构很简单:

文本输入
   │
   ▼
微调 RoBERTa(chinese-roberta-wwm-ext)
   │  提取 [CLS] 向量(768 维)
   ▼
StandardScaler → LogisticRegression
   │
   ▼
判定分 0-100(越高越倾向 AI 生成)

(辅助大模型:只做「文本风格分析」,不参与判定)

回头看,这个项目真正花时间的不是训练模型,而是搞清楚它为什么判错。下面几章就是这段排查史。


02 · 第一轮踩坑:我以为加特征会更好

我最开始的直觉是「特征工程越丰富越好」。于是花了相当多时间做了好几组对比,结果被打脸得很彻底:

方案 独立测试准确率 我的结论
纯统计特征(句长标准差、4-gram 重复率、标点变异、套话命中等 8 个) 33.3% 严重过拟合,弃
概率特征(3 个)拼接 83.3% 没有增量
残差门控融合(统计+PPL+判别加权) 66.7% 反而退化
微调 RoBERTa + 分类头 83.3% 最优

这一轮最重要的收获,不是「找到了最优方案」,而是逼自己用独立测试集的数据做决定,而不是凭直觉。我原本很看好「融合」方案——多个引擎投票听起来就靠谱,但它在独立测试集上只有 66.7%,比单模型还差。

我当时让 AI agent 帮我写这些 ablation 脚本、跑实验,但「要不要放弃融合、回退到朴素方案」这个决定是我自己做的。也是从这一轮开始,我养成了一个习惯:每一次加东西,都要先问「它到底有没有在独立数据上带来增量」,没有就砍掉。

这个习惯贯穿了后面所有实验——因为后面反复出现「越复杂的补强手段,收益越小甚至为负」。


03 · 数据问题:150 篇「没有句号」的网文

训练集里,人类样本来自两部分:约 220 篇经典文学(白鹿原、骆驼祥子、基督山伯爵、秦吏等),和 150 篇来自 CNNovel125K 的网文。

一开始我根本没注意到一个细节:那 150 篇网文几乎「没有句号」。准确地说,是缺少句末标点、句子边界缺失——逗号、省略号、引号都还在,但整段读下来找不到几个句号,一句话能拖几百字(这 150 篇的平均句长高达 325 字,句数只有 1.03)。

这带来一个具体的问题:这些文本要不要用、怎么用? 我做了一组对照实验(A/B/C/D 四组),分别用「150 篇无句号网文」「75 篇」「0 篇」「150 篇补过句号的网文」训练:

组 训练人类构成 143 样本总体准确率
A 150 无句号网文 87.4%
B 75 无句号网文 83.9%
C 0 网文 83.2%
D 150 补句号网文 88.8%

说明:这是单次训练的结果,后面(第 06 章)我会用多 seed 再谈稳定性,这里先看相对趋势。

两个反直觉的结论:

  1. 无句号网文不是灾难,反而有价值。 C 组(把网文全删了)反而最差。网文文本本身是有效的训练信号,标点问题的影响远小于「删掉网文」的损失。
  2. 补句号有用,但收益不大。 D 组总体最好(88.8%),比 A 组高 1.4 个点。

真正让我意外的是「人类中心」分析。我把无句号网文原文和正常人类样本分别算到各自模型的「人类类中心」距离,得到这样一个梯度:

组 无句号网文到人类中心 正常人类基准 游离度差距
A 0.0247 0.0381 -0.0134(比正常人类还近)
C 0.0632 0.0372 +0.0260(明显游离)
D 0.0617 0.0631 -0.0013(几乎无差)

这个梯度揭示了「补句号」真正的价值,不是那 1.4 个点,而是机制层面的:

A 组把「没有句号」这个表层形式当成了「人类信号」——无句号网文到人类中心的距离比正常人类还近,说明模型学到的是「没有句号 = 更像人」这种廉价的形式捷径。

D 组补句号后,模型学到的是「网文的内容」,而不是「有没有句号」——所以无句号网文几乎不游离。

换句话说,补句号把模型从「形式捷径」掰回了「内容理解」。这个发现对我后来的很多判断都有影响:模型最容易被表层形式带偏,而这类捷径在真实场景里毫无区分力。

顺带一提:给 150 篇网文补句号时,我一开始用串行调用 API,每篇要 100 秒、150 篇要 4 小时。改成 10 线程并发后十几分钟跑完。这是一个很小的工程教训,但很典型:凡是能并行的 API 调用,不要串行。


04 · 假设被推翻

这是整个项目里我最想写清楚的一章,因为它最能体现我到底在做什么。

模型漏检了一些 AI 文本。于是我和 AI agent 一起提出了一连串假设,然后逐个去验证。大部分假设最后都被推翻了,但每推翻一个,我对问题的理解就更近一步。

假设一:「静态罗列描写」是罪魁祸首

有三个真实的人类文学片段被模型误判成 AI(白鹿原「求雨」70.9%、骆驼祥子「逃兵营」77.8%、永远讲不完的故事「书房」99.5%)。

我对「书房」做了遮挡测试——逐句删除,看删掉哪句后 AI 概率掉得最狠:

删除的句子 剩余文本 AI 概率 变化
(baseline) 99.5% —
删第 6 句(「地上放着一叠叠大开面的书…切口金光闪烁」) 0.2% -99.3
删第 8 句(「灯光里不时升起一个烟圈…」) 2.6% -96.9

删掉一句「静态罗列描写」,判定从 99.5% 掉到 0.2%。我又做了反事实实验:保持「书房/老先生」内容不变,只改表面形式——去掉外貌静态罗列后,判定从 99.5% 掉到 0.0%。

到这里,我必须诚实地说清楚结论的边界:

反事实实验显示,模型对这些文本形式变化高度敏感;但现有实验不足以把预测变化唯一归因于「静态罗列」这个语言学特征——因为改形式的同时,句法结构、动作结构、叙事组织也可能一起变了。

我一度很想写「模型学的是形式而不是内容」,但那样说过强了。我能确认的只是「模型对这些形式变化敏感」,至于具体是哪个特征在起作用,单靠这几个实验证明不了。

假设二:顿号、前置定语是漏检原因

早期我怀疑 AI 漏检和「顿号并列」「前置修饰」有关,做了一系列特征诊断。实验 2 的结果其实挺有意思:同一个内容,用「顿号型」「连接词型」写会被漏检(231%),但改成「从句型」「动作嵌入型」就被识别(96100%)。

这确实说明修饰的呈现方式有影响。但当我拿它去解释「跨生成器漏检」时,对不上了——同一个「堆砌形容词」的写法,glm/deepseek 生成的大部分被识别,longcat/qwen 生成的全部漏检。如果「顿号」是唯一原因,那应该所有生成器都漏,而不是只有两个漏。

假设三:是生成器的问题

于是我把怀疑转向「生成器差异」。测试集 6 用 4 个不同生成器(GLM / DeepSeek / LongCat / Qwen)各生成 AI 样本:

生成器 识别情况
glm 几乎全对(90~100%)
deepseek 部分对
longcat 几乎全漏(0~5%)
qwen 大部分漏(0~13%)

LongCat 和 Qwen 生成的 AI 几乎全部漏检。而且我在 CLS 表示空间里发现:LongCat 的样本到「人类中心」的平均余弦相似度高达 0.882,是四个生成器里最「像人」的(GLM 只有 0.655)。这似乎坐实了「生成器差异」假说。

但紧接着的 LOBO 实验(leave-one-generator-out) 又把它推翻了。我让 4 个生成器各写 110 篇完全相同风格的文本(11 类风格 × 10 篇),然后用「留出某个生成器」的方式训练 8 个模型,测「见过的生成器」vs「没见过的生成器」的准确率差。结果:

留出的生成器 见过生成器准确率 未见生成器准确率 下降
GLM / DeepSeek / LongCat / Qwen 100% 100% 0%

全 100%,下降 0%。 这不是「泛化好」,而是实验设计把「生成器差异」抹平了——当 4 个生成器被要求写完全相同的风格时,它们的输出在模型眼里是同分布的。

把 LOBO 和测试集 6 并排看,结论才真正清晰:

场景 longcat/qwen 表现 根因
测试集 6(堆砌形容词) 几乎全漏 训练集没有的风格
LOBO(11 类正常风格) 100% 识别 训练集都有的风格

所以,严谨的说法应该是:

在当前「控制任务风格、使用既定提示词」的实验里,没有观察到未见生成器导致的准确率下降;但在「极端描述风格」的测试里,模型仍然大量漏检。这支持「风格分布偏移是重要的失效因素」,但不能证明 generator shift 在所有场景下都不重要。

把上面的证据合起来

走到这里,我基本排除了「单一特征」(顿号、前置定语)和「单一生成器」这两个解释,把矛头指向了「风格覆盖」。而最后一锤,是下面这个分布分析:

我把测试集 6 的 16 个 AI 样本,拿到训练 AI 分布(280 篇)上,逐个特征算 percentile。结果:16/16 的样本都有 ≥5 个特征落在分布边缘之外(低于 5 分位或高于 95 分位),平均每个样本有 11.6/33 个特征越界。其中最刺眼的是「形容词密度」——16 个样本里有 15 个的形容词密度高于训练 AI 的 95 分位。

大量样本在多个维度同时落在训练分布边缘之外,这就不再是「个别难例」,而是系统性的风格覆盖缺口。

这一连串「假设 → 验证 → 推翻 → 收窄」的过程,是我觉得整个项目里最有价值的部分。我没有直接换更大的模型,而是靠实验一点点缩小问题范围。


05 · 失败也要记录

这一章很短,但我想单独写,因为它直接体现「我愿不愿意接受负面结果」。

既然发现「风格覆盖不足」,最自然的反应是:补数据。于是我给训练集定向增补了 24 篇 hard-negative(12 篇 AI + 12 篇人类,专门针对「堆砌形容词」这种漏检风格),得到新模型 D1,和原来的 D0 对比:

测试集 D0 D1 变化
测试集 6(极端 OOD) 71.88% 65.62% -6.25
总体 86.71% 84.62% -2.10

增补样本反而让 OOD 表现变差了。 更糟的是,D1 引入了一个副作用:原来 D0 能正确判「人类」的白鹿原「求雨」片段,D1 把它误判成了 AI(1.3% → 66.4%)。我在 CLS 空间里看到,那 24 篇增补样本把决策边界往错误的方向推了。

于是我做了一个决定:回退 D0,不接受这份「更努力但更差」的结果。

这件事当时很反直觉——我花了力气做增补,最后却选择不用。但我现在很庆幸自己留下了这个记录。一个愿意展示「我试过、失败了、回退了」的复盘,比只展示漂亮数字更可信,因为真实工程里绝大多数优化尝试就是会失败的。


06 · 最终结果与局限

到这里,我把「最终结果」和「局限」放在一起讲,因为它们本来就不该分开。

数字要分清口径

我在前面说过单次训练会波动,这里把口径讲清楚:

  • 我用 5 个固定 seed 分别训练 D0,单次训练的总体准确率均值 86.71%,标准差 2.73%;
  • 把 5 个模型概率平均(5-seed ensemble),143 个测试样本上:总体 86.7%,目标域 98.02%,极端 OOD 59.52%。

这三个数字放在一起,才是我真正想给你看的结果:

指标 数值
总体准确率(5-seed ensemble,143 样本) 86.7%
目标域(训练时见过的风格) 98.02%
极端 OOD(训练时没见过的风格) 59.52%

如果只报 86.71%,会掩盖一个关键事实:这个模型在「见过的风格」上很可靠,在「没见过的风格」上接近抛硬币。 而单次训练的波动也很大——5 个 seed 里,最难的 dense_adjective 子集准确率从 20% 到 60% 乱跳。

所以我不会把「86.71%」包装成真实世界的准确率估计。它是当前这个测试集上的结果,而目标域和极端 OOD 之间的巨大落差,才是这个项目最值得讨论的地方。

我最后留下了什么

  • 一个能运行的轻量检测器:微调 RoBERTa + Logistic,输出 0-100 判定分;
  • 一张诚实的 UI:不显示「100% 绝对 AI」,而是「判定分 + 区间说明」,并明确告诉用户「文本风格分析」不参与判定、也不是主模型的逐项解释;
  • 一份已知局限清单:对「堆砌形容词」等训练分布之外的风格,模型会漏检。

产品化的一处关键收敛

实验之外,我还做了一次重要的产品决策:不让辅助大模型做「第二次判定」。

早期 UI 里,辅助大模型会输出「未见明显 AI 式空泛重复」这类文字。这天然会和主模型冲突(主模型说 100% AI,辅助分析说「不像 AI」),让用户误以为「两个 AI 在投票」。

最后我把它改成了彻底分离的「检测器 + 描述器」:

  • 主模型回答「它更像 AI 吗」→ 判定分;
  • 大模型只回答「这段文字是什么风格」→ 客观的风格描述(文体、叙事视角、语言特点、修辞……),明令禁止输出「不像 AI / 更像人类」这类结论;
  • 显式标注「解释边界」:主模型无法把判定拆解成独立特征,文本风格分析不是主模型的逐项解释。

这套设计的本质是诚实——黑盒模型说不清「为什么」,那就不要让它装成说得清。


07 · 个人复盘:AI 帮我加快了实验,但没有替我决定结论

最后回到我写这篇博客最根本的动机:我想记录我是怎么用 AI 工具的。

这件事我想说得具体一点,而不是一句「我熟练使用 AI」。

在整个项目里,AI agent 帮我做了大量事情:写特征提取脚本、跑 batch 实验、生成不同风格的 AI 样本、做最近邻和表示空间分析、整理结果表。如果没有它,我根本不可能在有限时间里跑完几十个实验。

但我想诚实地区分清楚分工:

AI 帮我做的:脚本编写、实验执行、结果汇总、部分候选解释和假设的提出,意见分析和方案的罗列。

我自己做的:实验设计的方向(测什么、控制什么变量)、假设的提出与取舍、结果的核对、以及每一次「接受还是拒绝 AI 的解释」的决定。

举一个最能说明问题的例子:当测试集 6 大量漏检时,AI 最初给出的解释是「前置定语过多导致漏检」。我没有直接接受,而是继续看不同生成器、看受控的文本变体、看 CLS 最近邻。随着反例出现,我先后修正了自己的假设。最后,统一提示词的 LOBO 实验显示「受控风格下未见生成器也能被正确识别」,真正明显的失败集中在特定的极端风格上。

这个过程的本质是:

AI 提供候选假设 → 我设计可证伪的实验 → 我根据结果修正认识

有几次,AI 给出了很漂亮、很「完整」的解释,但我核对原始结果文件后发现对不上,或者实验没有它说得那么强。判断「什么时候该反对 AI 的建议」,是我在这个项目里学到的最重要的能力。

对 HR 来说,这可能比我「用了 RoBERTa」更有信息量:它说明我如何处理不确定性——不迷信模型、不迷信 agent、用数据说话,并且敢于展示失败。


附录 A · 关键结果汇总

(正文只讲了主线,这里放详细表格,供技术读者核对。)

A.1 A/B/C/D 四组(单次训练,143 样本)

测试集 A(150无句号) B(75无句号) C(0网文) D(150补句号)
hard_examples(50) 100% 100% 100% 100%
测试集5(41) 100% 100% 100% 100%
测试集6(32) 68.8% 65.6% 59.4% 71.9%
sc(10) 90% 80% 80% 80%
dense_adjective(10) 30% 0% 10% 50%
总体 87.4% 83.9% 83.2% 88.8%

A.2 人类中心几何(四组)

指标 A B C D
人类类内紧凑度(cos) 0.0327 0.0697 0.0372 0.0595
类间距离 cH-cA(cos) 1.2038 0.6608 1.1910 0.7564
分离度比值 16.87 4.65 13.60 5.43
无句号网文游离度 - 非网文基准 -0.0134 +0.0027 +0.0260 -0.0013

注:分离度差异混入了微调的随机性(dropout、数据顺序),A 16.87 vs D 5.43 不能完全归因于数据。

A.3 5-seed 稳健性(D0)

指标 均值 标准差 判定
总体准确率 86.71% 2.73% 不稳定
AI 召回 75.26% 5.09% 不稳定
人类召回 99.70% 0.60% 稳定
测试集6 70.00% 7.02% 不稳定
dense_adjective 30.00% 15.49% 不稳定

A.4 生成器差异(CLS 空间)

生成器 到人类中心平均 cos
GLM 0.655
DeepSeek 0.785
Qwen 0.843
LongCat 0.882

A.5 style coverage gap(16 个 Test6 AI 样本 vs 训练 AI 280 篇)

  • 有 ≥5 个特征落在边缘外(<5 或 >95 分位)的样本:16/16
  • 平均每个样本越界特征数:11.6 / 33
  • 越界最多的特征:形容词密度(15/16 偏高)、修饰链数量(14)、字数(13)、句数(13)

附录 B · 完整实验记录索引

(这里是「全部记录」的完整清单,方便感兴趣的人按图索骥。)

实验 脚本 结果文件 一句话结论
补句号 add_periods.py 网文_补句号/ 并发 10 线程,串行 4h → 十几分钟
分组数据准备 prepare_groups.py — 生成 A/B/C/D 四组人类样本
分组训练 train_group.py 模型_A/B/C/D组/ 四组模型训练
四组对比 compare_groups.py — D 组 88.8% 最优
A/D 逐样本差异 diff_AD.py — D 修复 2 漏检、sc2 退化
误判诊断(最近邻/TSNE/UMAP) analyze_hard_examples.py 分析报告/hard_examples_analysis.txt 3 人类样本被判 AI
UMAP 局部密度 umap_analysis.py 分析报告/umap_analysis.txt 误判样本 30 邻里有 27~30 个 AI
结构诊断 diagnose_structure.py 分析报告/diagnose_structure.txt 14 项结构属性对比
密度统计 density_stats.py 分析报告/density_stats.txt 信息/事件密度对比
100% 误判剖析 analyze_100pct.py 分析报告/100pct_analysis.txt 童话001 结构剖析
遮挡测试 occlusion_fairy_001.py 遮挡测试_童话001.txt 删第6句 99.5%→0.2%
反事实实验 counterfactual_fairy_001.py 反事实实验_童话001.txt 去静态罗列 99.5%→0.0%
怪物反事实 counterfactual_monster.py 反事实实验_怪物.txt 数值化×动态化主效应≈0
配对差分 experiment_pairing.py 实验1_配对差分.txt 中度→重度 Δ=-68.7pp
反事实(修饰) experiment_counterfactual.py 实验2_反事实.txt 顿号型漏检、从句型识别
特征诊断 experiment_diagnosis.py 实验3_特征诊断.txt 漏检 AI 前置修饰更重
修饰堆叠 experiment_modifier.py 局部修饰分析.txt 漏检AI MaxMod 3.75 vs 识别2.75
生成器差异 generator_diff.py 生成器差异矩阵.txt G+D vs L+Q 组间差异
生成器 CLS cls_generator_analysis.py CLS_generator分析.txt LongCat 最像人 0.882
来源溯源 source_provenance.py 来源溯源分析.txt 人类训练集 0/370 误判
来源特征 source_features.py 来源特征统计.txt 网文句长 325、句数 1.03
来源 CLS source_cls_analysis.py 来源CLS空间分析.txt 漏检样本最像基督山伯爵/骆驼祥子
最近邻统计 neighbor_stats.py 最近邻统计.txt 漏检样本作品分布
最近邻漏检 nearest_neighbors.py 最近邻_漏检样本.txt 12 漏检样本逐最近邻
D0/D1 对比 compare_D0D1.py D0D1_benchmark对比.txt D1 退化,回退 D0
D0/D1 CLS 移动 analyze_D0D1.py D0D1_CLS移动分析.txt 增补推偏决策边界
D0 最近邻对照 analyze_D0_neighbors.py D0最近邻对照.txt Test6 差异方向对齐边界
seed 稳健性 eval_D0_seeds.py D0_seed稳健性.txt 总体 86.71%±2.73%
ensemble eval_D0_ensemble.py D0_ensemble对比.txt ensemble 无实质提升
人类中心 human_center_analysis.py human_center_分析.txt A 学形式、D 学内容
留一溯源 leave_one_out.py LeaveOneOut_分析.txt 网文对人类中心影响最大
LOBO 生成 gen_lobo.py lobo数据/ 4 生成器 × 110 篇
LOBO 训练 lobo_train.py 模型_LOBO_*/ 8 个 LOBO 模型
LOBO 评估 lobo_eval.py lobo_结果.txt 全 100%,下降 0%
style gap style_coverage_gap.py style_coverage_gap.txt 16/16 边缘外,平均 11.6/33
特征对比 extract_features.py / feature_extract_syntax.py AIvs人类_特征对比.txt / AIvs人类_句法对比.txt 修饰链/语义冗余 AI 远高于人类
训练集特征 train_features.py / train_features_summary.py 训练集特征概况.txt 650 篇特征分布

附录 C · 复现与数据说明

代码仓库:https://github.com/dredgeship/chinese-fiction-ai-detector —— 全部脚本、结果文件、LOBO 样本均已开源。

代码:核心代码(数据预处理、训练、评估、各分析脚本)都在仓库根目录。训练入口是 train_detector.py + final_detector.py,评估用 batch_evaluate.py。

模型:detector_model/(微调 RoBERTa,约 24GB,未入库)+ final_logistic.pkl + final_scaler.pkl(已在仓库中)。微调权重可按 README 重新训练,或联系我获取。

数据说明:训练集含真实小说文本(白鹿原、骆驼祥子等),涉及版权,我不公开原始语料。可公开的是:代码、统计结果表、以及不含版权文本的示例。LOBO 实验里由 4 个生成器产出的 440 篇 AI 样本(lobo数据/)是我用公开 API 生成的,不涉及他人版权,已开源。

随机种子:5-seed 稳健性用的是 [42, 123, 456, 789, 2024]。

如果你对某个实验的具体做法有疑问,或者想指出我哪里做得不对,欢迎在仓库提 issue 或直接给我反馈——这正是我写这篇博客、把这些材料整理出来的目的。


全文完。

posted @ 2026-10-13 17:25  车俊贤  阅读(13)  评论(0)    收藏  举报