跳到主要内容
从零造一个“这段话是不是 AI 写的”浏览器插件

从零造一个“这段话是不是 AI 写的”浏览器插件

造物
造物

· 阅读约 16 分钟

这一篇我们造一个浏览器插件,只干一件事:打开一篇文章,它给每个段落描边——像 AI 写的标红,像人写的标绿,拿不准的不吭声。玩具级的:没有模型结构,没有后端,准确率也不承诺。目标只有一个,把“标注 → 训练 → 导出 → 浏览器里推理”这条流水线亲手走一遍。

由头得交代一下。六月底 DEV 上有份月度开发报告,作者 FrancisTRdev,里面大半是日常——往 Forem 合 PR、拿 LeetCode 练 Ruby——跟这一篇没关系,不提。有关系的是他和 @codingwithjiro 两个人为 GitHub 挑战赛做的一个原型:ClassifierAI,Chrome 插件,用 TensorFlow.js 做图文分类,训练在 Google 的 Teachable Machine 里完成,数据集 866 张图,30 个 epoch,batch size 16,学习率 0.0001。

我看到它的第一反应不是“这东西准不准”,是“这条流水线真短”。你数一下:人工标注,浏览器里点几下训练,导出一个文件夹,塞进插件里推理——四步,全程不出浏览器,连 Python 环境都不用装,更别说部署和依赖地狱了。这种长度的流水线是最好的教学材料,每一层都摸得到,周末两天能从空目录走到能跑。

顺带说一句,“AI 内容检测”这事网上吵了不知道多少轮,吵的人大多两边都没碰过——没标过一条数据,没看过一条损失曲线,全在概念层面互扔结论。你造完这一遍再回去看那些争论,它们会忽然变得很具体:哪些其实是数据问题,哪些是阈值问题,哪些根本是“什么算 AI 写的”这个定义问题。准不准是科学问题,能不能亲手造一遍是教学问题,这一篇只管后者。

他的项目我不复刻。数据集不抄——那是人家基于一个社区的文风攒出来的资产,抄来你也不知道每张图为什么贴这个标签。我们从空目录开始,数据自己标,模型自己练,插件也一行行自己敲。这一篇结束时你手里会有:一个两百行左右的 Chrome 插件,一个自己标出来的小数据集,一个能在浏览器里实时跑的分类模型。它不准,但它从头到尾是你的。

先把三个选型定下来

动工之前把设计取舍摆出来。这一篇有三个,每个都有代价,我不把代价藏到踩坑的时候才说,现在就摆。

Teachable Machine 训练,不写 Python。理由是心智距离:整条链路不出浏览器,你拧一个参数,损失曲线立刻动给你看,这种即时反馈对理解“训练到底在干什么”非常值钱。代价是你碰不到模型结构,它给你什么就是什么,黑箱。对玩具来说这个代价可以接受——这一篇的主角是流水线,不是网络结构;哪天要自己搭模型了,再换 Keras 不迟,这个取舍不丢人。

文本当图片练。这是从 ClassifierAI 那儿学来的做法,乍看像歪门邪道——正经的文本分类要分词、要 embedding、要处理变长输入,每一样都够单独写三篇;把段落渲染成一张定宽图片丢给图像模型,一步全绕开。代价很实在:模型学到的可能不是“文风”,是“排版纹理”——字号、字体、折行位置,这些和 AI 不 AI 毫无关系的东西它照学不误。这个坑我们后面会亲自踩,踩的时候你就明白我为什么从头到尾管这东西叫玩具。

只认一个社区。插件只跑在 dev.to 上,标注只标 dev.to 的文章。这是那份月报评论区里的一个细节:作者明确说 ClassifierAI 有意只针对 dev.to 的内容、用这个社区自己的文风来训练,为的是准确率和以后的扩展。这个决定比看上去重要。“通用 AI 文本检测”是个深不见底的坑,大厂都做不利索;把域缩到一个社区、一种文风,信号才浮得出水面。缩域不是偷懒,是这类问题唯一现实的打法。

三个定了:Teachable Machine、文本转图片、只认 dev.to。开始造。

先让插件能看见段落

模型多聪明都往后放,插件的第一件事永远是朴素的那件:打开一个页面,把文章里的段落全部找出来。我们先把这根骨架立起来,模型的事往里填肉。

目录结构砍到最小:

ai-or-not/
  manifest.json
  scan.js

manifest 用 Chrome 的 Manifest V3,一个内容脚本,匹配 dev.to:

{
  "manifest_version": 3,
  "name": "ai-or-not",
  "version": "0.1",
  "content_scripts": [
    {
      "matches": ["https://dev.to/*"],
      "js": ["scan.js"]
    }
  ]
}

scan.js 更短,抓正文里的所有段落,描个虚线边:

const paras = document.querySelectorAll(".crayons-article__body p");
for (const p of paras) {
  p.style.outline = "1px dashed cornflowerblue";
}
console.log("[ai-or-not] 扫到段落:", paras.length);

.crayons-article__body 是 dev.to 文章正文容器的 class,正文段落都挂在它下面。装法老一套:chrome://extensions 打开开发者模式,“加载已解压的扩展程序”,选这个目录,随便开一篇 dev.to 文章。

跑一下看看:

[ai-or-not] 扫到段落: 23

23 个段落全描上虚线。骨架立住了。它现在还什么都不会,但它能看见页面了——这一步换来的东西很朴素:一个后面所有功能都能往上长的落点。

数据,一根一根点出来

接下来是整条流水线里最不性感、也最值钱的一层:标注。你可以去爬别人的数据集,但爬来的标签是脏的,而这个玩具的全部家当就是干净的标签。ClassifierAI 用了 866 张图,两个类;我第一版每类标了一百多张,两个晚上点出来的——够看到信号,不够支撑信任,这笔账留到最后算。

动笔之前先回答一个比工具更要紧的问题:什么叫“一段 AI 写的段落”?整篇 AI 生成的文章里的每一段都算,还是这段话本身读着像 AI 才算?人写初稿、AI 润色的算哪边?这几个问题我能跟你吵一晚上,但吵赢了对训练没半点帮助——你必须给自己立一条规矩,并且从头到尾守着它。我立的规矩是“段落本身读着像不像”:哪怕出自一篇全 AI 的文章,只要这段是人味,就标人。标签的定义就是项目的定义,这个定义含糊,后面所有数字都是空的。

标注器不用另做工具,就长在插件里:Alt+点击一个段落,存成“AI”;Alt+Shift+点击,存成“人”。存的过程分两步:先把段落渲染成一张固定尺寸的图片,再触发下载。渲染这一步是整个项目的命门,代码完整摆出来:

// render.js —— 段落 → 512×288 的画布
function wrapText(ctx, text, maxWidth) {
  const lines = [];
  let line = "";
  for (const ch of text) {
    if (ctx.measureText(line + ch).width > maxWidth) {
      lines.push(line);
      line = ch;
    } else {
      line += ch;
    }
  }
  if (line) lines.push(line);
  return lines;
}

function paraToCanvas(p) {
  const canvas = document.createElement("canvas");
  canvas.width = 512;
  canvas.height = 288;
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, 512, 288);
  ctx.fillStyle = "#111111";
  ctx.font = "16px sans-serif";       // 记住这一行,它会回来咬人
  const lines = wrapText(ctx, p.innerText, 488).slice(0, 14);
  lines.forEach((l, i) => ctx.fillText(l, 12, 24 + i * 18));
  return canvas;
}

白底,黑字,16 像素无衬线体,512 宽,超过 14 行截断——所有段落过同一个漏斗,出来的是同一种长相的图片。然后是标注器本体:

// label.js —— Alt+点 = AI,Alt+Shift+点 = 人
const counter = JSON.parse(localStorage.getItem("aon") || '{"ai":0,"human":0}');

document.addEventListener("click", (e) => {
  if (!e.altKey) return;
  const p = e.target.closest("p");
  if (!p || !p.closest(".crayons-article__body")) return;
  e.preventDefault();
  const label = e.shiftKey ? "human" : "ai";
  const a = document.createElement("a");
  a.href = paraToCanvas(p).toDataURL("image/png");
  a.download = `${label}_${String(counter[label]).padStart(3, "0")}.png`;
  a.click();
  counter[label] += 1;
  localStorage.setItem("aon", JSON.stringify(counter));
  p.style.outline = "2px solid " + (label === "ai" ? "tomato" : "seagreen");
});

把这两个文件加进 manifest 的 js 数组,排在 scan.js 前面。刷新文章页,Alt+点一个段落,下载栏里掉出来一张 ai_000.png。数据这一层立起来了。

这里必须停下来讲一个我踩过的坑,因为它就是“文本转图片”这个选型的代价本体。我第一版偷懒,没写渲染函数,直接对段落截图——页面上长什么样就存什么样。dev.to 的正文、引用块、代码块字体各不相同,我标了两百张扔进 Teachable Machine,测试准确率 97%。我激动了大概三分钟,然后拿三篇没标过的新文章一验,基本在瞎猜。

问题出在哪?模型根本没在学文风,它在认字体和排版——训练集里恰好 AI 段落多出现在某几种版式里,它就把版式当成了标签。97% 这个数字不是好消息,是数据漏气的味道。改走统一渲染之后,测试准确率掉到 80% 上下,拿新文章验反而稳了。准确率掉下来那一刻我才放心。这一步换来的是泛化,代价是好看的数字——这笔交易永远值得做。

所以渲染函数里那行 ctx.font 的注释不是玩笑:训练和推理必须走同一个函数、同一套参数。这不是代码洁癖,是这类流水线的命门——你给模型看什么分布,它就学什么分布,字体和字号也是分布的一部分。凡是“把真实世界的东西喂给模型”的流水线,坑基本都埋在喂的那一步,不在模型那一步。这一段我们不跳。

标注还有一条纪律:两类要标得差不多多。标到后来手顺了,AI 类标了三百张、人类只标了八十张,模型会学出一个“无脑猜 AI 就赢”的先验,训练数字还挺好看。宁可每类少点,也要平。

训练:三个旋钮

数据齐了,打开 Teachable Machine,新建一个图像项目,建两个类,类名就叫 aihuman——这两个字符串后面会一路传进插件,别起中文名,别起带空格的名,省得到时候对不上。两个文件夹的图传上去,右边窗口就开始实时训练了。

点开 Advanced Settings,三个旋钮。ClassifierAI 那组数字是 30 个 epoch、batch size 16、学习率 0.0001,我用的也在这一组附近,正好拿它当参照把三个旋钮讲清楚。

epoch 是完整过一遍数据的次数,30 个 epoch 就是把你那几百张图来回看三十遍。太少学不动,太多会把训练集整个背下来——你那点数据量,三十遍是个不寒碜的中间值。

batch size 是每次更新参数前一口气看多少张图。16 张一批,几百张图一个 epoch 也就更新几十次,梯度抖一点,但方向大体是对的。这个旋钮在玩具阶段几乎不用碰。

学习率是每次更新迈多大的步子。0.0001 很保守,收敛慢但不容易飞。我的调参顺序是:损失不降,先动学习率;动了还不降,加 epoch;都不管用,最后才怀疑数据——虽然十次有八次最后真是数据的问题,但人总是最后才肯怀疑数据。

训练窗口里那个测试准确率,看一眼就好。它测的是“从你标的数据里留出来的一小撮”,和你明天要推理的真实文章不是一个分布。把它当烟雾报警器,别当成绩单。导出之前还有一道便宜的保险:拿预览窗上传两张你特意留出来没进训练集的图,亲眼看着它猜对一次,再点导出。Export Model → TensorFlow.js → Download,解压出来 model.json、weights.bin、metadata.json 几个文件,整个文件夹拖进插件目录,改名 model/。模型这一层立起来了——它现在还躺在磁盘上,下一步让它进浏览器。

组装,把模型塞回插件

Failed to fetch。干巴巴就这一句,是我组装那晚拿到的全部报错。模型文件明明就在目录里,路径明明没拼错,它就是 fetch 不动——来回排查了半天,最后才发现是 manifest 里少了一行声明。组装这步在别的教程里经常一句“加载模型”带过,我上一篇造东西的时候就说过,几个模块单跑都通、拼起来就报错,问题多半出在接口上;这次又应验了,只是接口换成了 manifest。这里我承认讲细了啰嗦,但它恰恰是新人卡得最狠的地方,因为 MV3 有两条规矩在拦路。

第一条:MV3 禁止远程代码。老教程里那种从 CDN 引 TensorFlow 的写法,在 MV3 里直接判死,所以 tf.min.js 和 teachable-machine-image.min.js 要下载下来放进插件目录,老老实实本地引用。第二条就是我卡了一晚上的那条:内容脚本要 fetch 插件包里的 model.json,得先在 manifest 里声明这些资源可被网页访问。

改完的 manifest 长这样:

{
  "manifest_version": 3,
  "name": "ai-or-not",
  "version": "0.2",
  "content_scripts": [
    {
      "matches": ["https://dev.to/*"],
      "js": ["tf.min.js", "teachable-machine-image.min.js",
             "render.js", "label.js", "aon.js"]
    }
  ],
  "web_accessible_resources": [
    { "resources": ["model/*"], "matches": ["https://dev.to/*"] }
  ]
}

注意 js 数组的顺序:tf 在最前,我们的代码在最后,内容脚本按数组顺序执行,反过来就是 tmImage is not defined。有教程会把推理放进 background、内容脚本发消息过去,我没这么干——模型加载和 canvas 渲染都留在内容脚本里,少一层消息传递就少三个能出错的地方;真要抠内存,MV3 另有 offscreen document 的路子,这一篇不往那儿走。整条流水线是这么一条线:

dev.to 文章页
   │
   ▼
 抓正文段落 <p>
   │
   ▼
 render.js: 段落 → 512×288 画布     ← 训练和推理共用同一个函数
   │
   ▼
 tmImage.predict(画布) ──► [{ai: 0.87}, {human: 0.13}]
   │
   ▼
 阈值分流:>0.65 标红 / <0.35 标绿 / 中间不说话

一条线,单向,没有回头。主脚本 aon.js 把它落地:

// aon.js
async function main() {
  let m;
  try {
    m = await tmImage.load(
      chrome.runtime.getURL("model/model.json"),
      chrome.runtime.getURL("model/metadata.json")
    );
  } catch (e) {
    console.warn("[ai-or-not] 模型没加载上,插件退化为只描边:", e);
    return;
  }
  const paras = document.querySelectorAll(".crayons-article__body p");
  for (const p of paras) {
    const probs = await m.predict(paraToCanvas(p));
    const aiProb = probs.find(c => c.className === "ai").probability;
    badge(p, aiProb);
  }
}

function badge(p, aiProb) {
  if (aiProb > 0.65)      mark(p, "tomato",   `AI ${Math.round(aiProb * 100)}%`);
  else if (aiProb < 0.35) mark(p, "seagreen", `人 ${Math.round((1 - aiProb) * 100)}%`);
  // 中间那段:什么都不标
}

function mark(p, color, text) {
  p.style.outline = `2px solid ${color}`;
  const tag = document.createElement("span");
  tag.textContent = text;
  tag.style.cssText = `color:${color};font-size:12px;margin-left:6px;`;
  p.prepend(tag);
}

main();

代码里有个 try/catch,先说它。模型文件放错位置、路径拼错、web_accessible_resources 忘了声明,任何一样都会让 load 挂掉;没有这个边界,插件在每篇文章的控制台里静默崩掉,你还以为是模型不行。挂了就退化成最早那个只会描边的骨架——错误边界是组装阶段的一等公民,不是收尾才补的装饰。

然后是阈值中间那段。0.35 到 0.65 之间一个字都不标,这是我有意的设计。你想想如果逼它对每段话都表态会怎样——它就会用胡说来凑数。一个只会喊“AI”和“人”的分类器是话痨,一个敢说“这段我看不准”的分类器才有资格装进别人的浏览器。

还有一件小事:predict 吃的是画布,不是字符串。paraToCanvas 你在标注阶段见过——训练数据和推理数据走同一个漏斗,前面踩过的那个坑,在这里兑现成一条纪律。

一段一段 await,一篇文章二十几个段落几百毫秒跑完,玩具够用;要快有 predictTopClass 和批量推理的路子,这一篇不往性能工程里走。

跑起来

重新加载插件,打开一篇 dev.to 文章,控制台先给一行确认,然后页面开始一块块上色:

[ai-or-not] 模型加载 ok
[ai-or-not] 扫到段落: 23,标红 9,标绿 7,不表态 7

我拿三篇文章验过,说点实话。一篇教程里那种“五个技巧”式的段落,节奏均匀、每段等长、开头全是 First 和 Second,标红 0.9 上下,活该。一个明显人手写的碎碎念,有错别字、句子长短乱跳,稳定标绿 0.2 附近——错别字和参差的句长是人味,这个结果我看着挺乐。你猜我自己手写的一段得了多少分?0.58,插件没表态。中间区救了它的体面,也救了我的。

至于模型到底在学什么特征,说实话我不确定。句长方差?标点密度?还是某些词的出没?我做过一个不严谨的小实验:把自己写的一段话喂进去,0.4 出头;往里手工撒了几个破折号和一两个长从句,再测,涨到 0.55。方向对不对另说,至少说明它在看的东西和文风有关,不全是排版——比第一版那个认字体的强多了。

到这里,这个插件已经能跑了:从空目录到两百来行代码,加一个自己标的数据集,加一个浏览器里实时推理的模型。这就是这一篇交付的东西。

边界,和下一步

照例把边界交代清楚,不能让你以为手里捧的是个能投产的检测器。

这个玩具没有的东西:正经的文本模型——文本转图片是绕路的 hack,真做该上 embedding,tfjs 生态里有现成的句子编码器可以加载;跨社区的能力——它只认 dev.to 的文风,换一个站点准确率立刻塌,这不是缺陷,是缩域换来的另一面,那份月报的作者在评论里也是这么给自己项目定位的;自动化的数据回流——现在标新数据要手动重训重导出。ClassifierAI 的作者给未来立的计划是再做一个插件专门抓取和存数据,然后手动更新主项目的数据集。注意,人家几百张图的规模也选了手动,这个判断是对的:数据飞轮在玩具阶段就该是手摇的,急着自动化,只会把脏数据自动地转起来。

还有一条边界比功能清单重要:别拿它当裁判。一个 80% 上下、连作者自己都说不清学了什么特征的玩具,可以帮你把“疑似 AI”的段落捞出来自己再看一眼,仅此而已。让它去给人扣帽子,是拿玩具干凶器的活。

下一步往哪走?两个方向,都比再造一个新玩具值。一是把文本转图片换成真的文本模型,你会第一次亲手处理“变长输入”这个文本问题真正麻烦的部分。二是把插件原样搬到另一个内容社区,代码一行不改,只重新标注、重训一遍,然后看着准确率和错法一起变化——迁移的痛感,这一下比任何教程都教得快。

到这一步,我们不跳。

造物
造物

万字手把手从零造一个项目(compiler/DB/解释器),每步代码能跑、讲到能复现。

查看主页 →

评论

还没有评论,写下第一条讨论。