上周接了公司运维侧的需求,要给内部研发文档仓库做AIGC内容筛查,所有数据不能出内网,完全禁止联网调用外部接口。
一开始图省事,测了七八款市面上现成的检测工具,什么GPTZero、Originality.ai、百度文心检测、团象AICG检测、Copyleaks、Crossplag,全试了一圈,要么要联网校验,要么本地版准确率不到60%,根本没法用。
这些工具要么强制上传文本到公网接口,要么本地部署包体积超过20G跑起来占满8卡资源,全满足不了需求,只能硬着头皮自研。
一开始我想直接套开源的roberta-base预训练模型微调,跑了一周下来,对中文长文本的准确率只有52%。
误判率高到把我存了3年的手写老研发笔记,直接标成100%AIGC生成的,完全没法落地使用。
排查了3天终于找到根因:大模型生成的文本不是靠“敏感关键词”判断,很多低阶统计特征是人类写作不会出现的,直接用语义预训练模型反而会学到大量无关特征。
我们最终放弃了重型预训练分类的思路,全部从底层统计特征入手,搭出了整套轻量方案。
第一类核心特征是Token级别的概率分布偏差。 大模型解码的时候是逐token选top k概率最高的结果,所以生成文本所有token的对数概率分布,和人类手写内容的偏差极大。
我们用开源的中文base模型chinese-llama-7b做前向推理,统计每个token的生成概率,代码如下:
import torch from transformers import LlamaTokenizer, LlamaForCausalLM def get_token_log_probs(text: str, model, tokenizer) -> list[float]: inputs = tokenizer(text, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model(**inputs, labels=inputs["input_ids"]) logits = outputs.logits[:, :-1, :] log_probs = torch.nn.functional.log_softmax(logits, dim=-1) target_log_probs = log_probs.gather(2, inputs["input_ids"][:, 1:].unsqueeze(-1)).squeeze(-1) return target_log_probs.squeeze().cpu().numpy().tolist()这里要注意把label设为input_ids,避免触发模型微调逻辑,只拿前向的输出概率,不需要反向传播,单条1000字文本推理耗时不到100ms。
人类写的文本会大量出现概率低于1e-6的“低概率token”,也就是我们常说的灵光一闪的个性化表达,大模型生成的文本几乎不会出现这类token。
我们统计了10万条人工标注的样本:5万纯人工写的研发文档,5万不同大模型生成的文档。 人工文本的低概率token占比均值在17.2%,AI生成文本的该指标均值仅为2.3%,两类样本的重叠占比不到4%,区分度极高。
第二类核心特征是标点与停用词的分布异常。 很多人忽略了,大模型生成文本的时候,对停用词、标点的选择偏好极其统一,和人类写作的习惯偏差很大。
比如中文里的逗号,人类写1000字平均会用32-47个,AI生成的文本逗号占比基本稳定在千分之55以上,连续三个逗号的出现概率是人类的7倍。
还有“的”“了”“在”这类高频停用词,不同的人类写作者的使用频率方差极大,但大模型生成的文本里这类词的占比几乎不会跳出千分之25-30的窄区间。
这里我踩过一个坑:不要做全局的停用词占比统计,要按200字的滑动窗口统计方差。 人类文本的窗口方差基本在0.02以上,AI生成的大多低于0.005,区分度比全局统计高3倍以上。
实现这个特征的代码非常轻量,不需要加载大模型,纯CPU就可以1s处理10万字:
from collections import Counter import torch STOP_WORDS = {"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一"} def stop_word_var(text: str, window_size:int=200) -> float: chars = list(text) ratios = [] for i in range(0, len(chars), window_size//2): window = chars[i:i+window_size] cnt = Counter(window) total_stop = sum(cnt.get(w, 0) for w in STOP_WORDS) if len(window) == 0: continue ratios.append(total_stop / len(window)) return torch.tensor(ratios).var().item()这段代码跑出来的方差特征,单独用就可以拿到72%的二分类准确率,配合前面的token概率特征,准确率直接拉到89%。
第三类核心特征是语义跳转的连贯性偏差。 人类写技术文档的时候,经常会出现逻辑跳转,比如写着写着突然插一句临时的注意事项,甚至有笔误的痕迹。
大模型生成的文本的语义向量的余弦相似度,在相邻段落之间的跳转极其平滑,几乎不会出现超过0.3的语义跳转变异。
这里我们不用大模型做embedding,直接用tf-idf特征统计相邻短块的向量余弦差,避免额外加载embedding模型拖慢性能。
这里踩的坑是不要用全量段落做对比,要把文本切成50字的短块,统计相邻短块的tf-idf向量的余弦值的方差,AI生成文本的该方差基本低于0.01。
把前面三个维度的特征全部抽出来之后,不需要用重型的分类模型,直接用轻量的XGBoost做二分类训练,就可以拿到远超之前预训练大模型的效果。
训练集我只用了2万条标注样本,其中正负样本1:1,训练10轮就收敛了,整个模型文件大小不到20M,不需要GPU,纯CPU部署就可以跑满每秒100篇的检测速度。
这里要做一个关键的阈值校准,不能直接用默认的0.5的分类阈值,要根据自己的业务场景调整。 我们的场景要求误判率不能超过1%,毕竟不能把研发辛辛苦苦写的文档随便标成AI生成的,所以把判定阈值调到了0.9。
调完阈值之后,我们在1万条测试集上的结果:误判率0.87%,召回率82.3%,完全满足内网文档筛查的需求。
一开始整个流程跑通的时候,单篇1万字的文本要耗时2.7s,在文档全量扫描的时候速度太慢,根本跑不完几十万存量文档。 排查了性能瓶颈,发现90%的耗时都花在了7b模型的token概率推理上,我们做了几个小优化,直接把速度提升了8倍。
第一个优化:把7b的LLaMA模型量化成4bit,用GPTQ加载,推理精度损失不到0.1%,但显存占用从14G降到了3.8G,推理速度直接翻了3倍。
from auto_gptq import AutoGPTQForCausalLM model = AutoGPTQForCausalLM.from_quantized( "chinese-llama-7b-4bit", device_map="auto", use_safetensors=True )第二个优化,做批量推理,每次喂32条短文本进去推理,利用GPU的并行能力,单卡A10就可以做到每秒处理超过30万字的文本。 第三个优化,提前过滤短文本,低于200字的文档直接跳过检测,这类短文本没有足够的特征支撑判断,误判率极高,本来就没有检测的意义。
优化完之后,我们扫完仓库里全部37万份存量研发文档,只用了不到2个小时,完全符合预期。 之前测试的时候发现有一批文档被批量误判,排查了很久才找到原因,这批文档是研发用Markdown写的,里面全是代码片段。
我们在特征提取之前,必须先用正则把所有的```包裹的代码块全部过滤掉,不能放进检测流程里,不然代码的字符分布完全不符合自然语言特征,会直接误判。 还有就是要过滤掉表格内容、自动生成的接口文档注释,这类机器生成的文本本身就不是自然语言,混入之后会直接拉高误判率。
最后还要做白名单机制,把老员工的常用写作习惯做小样本微调,单独校准阈值。 比如有的老工程师就爱用短句,逗号用的少,单独调完之后就不会被误判,整体体验顺滑了很多。
这次折腾下来最大的感受,就是很多场景根本不需要追新用什么千亿大模型做检测,把基础的统计特征摸透,轻量方案反而效果更好。 完全不用花大价钱采购商用服务,小团队照着这个流程搭一套,一周就能跑通,数据完全留在内网里,适配性比外部方案强不少。