这一篇我们造一个浏览器插件,只干一件事:打开一篇文章,它给每个段落描边——像 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,新建一个图像项目,建两个类,类名就叫 ai 和 human——这两个字符串后面会一路传进插件,别起中文名,别起带空格的名,省得到时候对不上。两个文件夹的图传上去,右边窗口就开始实时训练了。
点开 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”的段落捞出来自己再看一眼,仅此而已。让它去给人扣帽子,是拿玩具干凶器的活。
下一步往哪走?两个方向,都比再造一个新玩具值。一是把文本转图片换成真的文本模型,你会第一次亲手处理“变长输入”这个文本问题真正麻烦的部分。二是把插件原样搬到另一个内容社区,代码一行不改,只重新标注、重训一遍,然后看着准确率和错法一起变化——迁移的痛感,这一下比任何教程都教得快。
到这一步,我们不跳。
