折腾3天,我用100行Python代码搞定了内网论文AIGC痕迹离线校验
最近实验室要求所有毕设初稿必须先过一轮内网AIGC痕迹筛查。 不能接公网,不能上传学生隐私数据,找了一圈现成工具踩了一堆坑。 之前试过GPTZero、原创力、团象AI检测、易撰、知网研学自带的筛查模块。 统统不好用,要么要联网传数据,要么本地部署动辄几G依赖,完全满足不了需求。 得,就干脆自己撸代码写个轻量版,跑在实验室旧服务器上刚好能用。
很多人以为AIGC痕迹检测是靠抓固定关键词,其实根本不是那回事。 商用检测工具的核心逻辑,是基于LLM的困惑度和突发熵两个维度做综合判定。 AI生成的文本,每一个后续token的生成概率分布太平均,困惑度普遍低于人类写作。 人类写东西经常跳脱,会有长句突然接短句,某段用词熵值突然飙升的情况。 我们做离线版不需要接入大模型,直接用开源小参数量预训练模型就能算,无GPU也能跑。
我这次选的底座模型是开源124M参数版中文GPT2,整个模型文件加起来才500MB不到。 最低配置只要2G内存,CPU就能完成推理,实验室2018年的旧戴尔服务器都能扛。 依赖库总共就4个:transformers、torch、numpy、jieba,连CUDA都不需要强制安装。
环境安装命令如下:
pip install transformers==4.28.1 torch numpy jieba==0.42.1这里有个我踩过的大坑,transformers别装4.29以上的版本。 新版对GPT2的分词器逻辑做了改动,老CPU跑起来的速度直接慢3倍以上。 我一开始图新鲜装了4.35版本,算一篇8000字的论文要15分钟,人都等麻了。 退回到4.28稳定版之后,相同配置下耗时直接降到2分钟以内,效率提升非常明显。
很多网上的开源教程计算困惑度,直接用逐token求和的逻辑,会有严重的溢出问题。 我之前跑长文本的时候,算到3000字以上就报RuntimeError: exp overflow的错,卡了快一小时。 后来翻了HuggingFace官方的示例代码,才知道要把概率取对数再累加,最后再取指数。 这个方案能100%避免数值溢出的问题,长文本跑起来非常稳。
核心的困惑度计算代码如下:
import jieba import torch import numpy as np from transformers import GPT2LMHeadModel, GPT2Tokenizer # 自动下载中文预训练GPT2到本地离线缓存 model_name = "uer/gpt2-chinese-cluecorpussmall" tokenizer = GPT2Tokenizer.from_pretrained(model_name) model = GPT2LMHeadModel.from_pretrained(model_name) model.eval() def calc_ppl(text: str) -> float: # 限制最大token长度不超过1024,避免低配置机器内存溢出 tokens = tokenizer.encode(text, return_tensors="pt", truncation=True, max_length=1024) with torch.no_grad(): outputs = model(tokens, labels=tokens) # 直接取框架返回的损失值计算困惑度,跳过手动求和步骤 loss = outputs.loss ppl = np.exp(loss.item()) return round(ppl, 2)这里要特别提醒一句,千万别用英文预训练的GPT2来跑中文文本。 用英文底座算中文困惑度,结果偏差能超过40%,根本没有任何参考价值,新手特别容易踩这个坑。 我选的是UER团队用中文CLUE语料微调的GPT2版本,对中文的特征适配度非常高。
但光靠困惑度这一个指标,误报率会高到30%以上,完全没法用。 我专门找了20篇纯AI生成、20篇学生手写的毕设初稿做对照测试,结果发现两者的数值区间有大量重叠。 AI生成的内容ppl基本在25-65之间,人类手写的内容ppl分布在70-200之间,中间交叉的样本特别多。 这时候就必须引入第二个核心判定指标:突发熵(Burstiness)。 AI生成的内容,每句话的长度基本都稳定在15-25字之间,方差小得离谱。 人类写作的句子长度分布极不稳定,刚写完30字的长定语从句,下一句可能就是5字的短总结。 这个指标算起来非常简单,直接把文本按标点拆成单句,统计每句的字数,计算变异系数即可。
突发熵的实现代码如下:
def calc_burstiness(text: str) -> float: # 按中文常用句末标点拆分完整句子 sentences = [s for s in text.replace("\n", "").split("。") if s.strip()] sentences = [s for s in sum([s.split("!") for s in sentences], []) if s.strip()] sentences = [s for s in sum([s.split("?") for s in sentences], []) if s.strip()] # 过滤掉句子数量不足的异常短文本 len_list = [len(s) for s in sentences] if len(len_list) < 2: return 0.0 # 归一化结果用 方差/均值 避免不同篇幅文本的数值差异过大 mean_len = np.mean(len_list) var_len = np.var(len_list) burstiness = round(var_len / mean_len, 2) return burstiness两个指标都拿到之后,我用100篇标注好的样本做阈值校准,最终定的判定规则非常简单。 如果文本的ppl小于60,且burstiness小于12,直接判定为高概率AI生成。 如果ppl在60-100之间,burstiness在12-25之间,判定为混合生成,建议人工复核。 剩下的所有情况,全部判定为人类原创。 这套规则跑我手上的测试集,整体准确率能到93%,完全满足实验室的日常筛查需求。
处理动辄几万字的完整毕设时,千万不要直接把整篇论文塞进函数里计算。 整文本混算会抹平局部段落的异常特征,就算某几章全是AI写的,也容易被稀释成正常结果。 正确的做法是按1000字左右的窗口做滑动切片,逐段计算,最后把所有段的结果做加权平均。 这样还能精准定位到具体哪一个段落存在AI生成痕迹,不用通篇人工排查。
我写了个按段落边界切分的工具函数,不会把完整的长句强行拆碎:
def split_text(text: str, window_size: int = 1000) -> list: # 先按换行拆分原生段落 paragraphs = [p for p in text.split("\n") if p.strip()] chunks = [] current_chunk = "" for p in paragraphs: if len(current_chunk) + len(p) < window_size: current_chunk += p + "\n" else: chunks.append(current_chunk.strip()) current_chunk = p + "\n" if current_chunk.strip(): chunks.append(current_chunk.strip()) return chunks写完核心逻辑之后,我直接用FastAPI包了个极简的上传接口,前端用原生HTML写了个简单页面。 整个服务打包起来才800MB,用Docker一键部署,不需要任何额外的配置步骤。 所有上传的论文文件不会落盘到服务器硬盘,直接在内存里读完就做处理,处理完立刻清空内存。 完全符合实验室要求的「学生敏感数据绝对不能外传」的合规规定。 我自己跑了一周的测试,处理100篇平均8000字的毕设,总共耗时不到3小时。 之前问过商用的同类型离线筛查系统,一年授权费要大几万,我们这个自研版本零成本就能跑。
最后说几个我踩过的冷门坑,大家写代码的时候可以直接避开。 第一,别把参考文献、公式块、图片说明这些内容算进统计范围。 这些部分的文本格式非常固定,比如参考文献全是[1][2]开头,句子都很短,burstiness数值极低。 我专门加了个前置过滤逻辑,把开头是方括号序号的行、全是英文的行全部跳过不计算。 第二,学生手动修改的AI生成内容,根本躲不开这套检测逻辑。 很多人以为把GPT写的内容逐字改30%的词就能混过去,其实整体的句子结构、token分布特征还是AI生成的。 之前有个学生把AI写的段落改了快一半用词,测出来的ppl还是只有42,直接触发高风险警报。 第三,不要用这套默认配置去检测古汉语、小语种等特殊领域的文本。 我们用的底座是通用中文语料训练的,对垂直领域的特征适配度不够,结果偏差会比较大。 这类场景只要拿对应的领域语料微调几十步GPT2底座,就能把准确率拉到可用水平。
很多人一提到AIGC检测,就觉得必须得上百亿参数大模型,搞集群分布式部署。 其实针对小体量的内网学术筛查场景,这种轻量自研方案反而更实用。 完全不需要花冤枉钱买昂贵的商用授权,全程数据不出内网,性能也完全够小团队用。 最近有内网论文合规校验需求的朋友,完全可以把这套代码拉下来改改直接跑。