第一版正则匹配:12%误杀率让我被骂了3天

我去年给某教育平台写违禁内容检测,第一版用PHP 7.4 + 2000多条正则。逻辑简单到离谱:用户发帖,遍历正则列表,匹配到就封禁。上线第一天晚上,我的手机就被运营打爆了。

实测数据:处理1000条文本,平均耗时1.8秒,误杀率12%。最惨的是把“禁烟”科普全封了——正则里有个/禁.*烟/,匹配到“禁止吸烟”、“严禁烟火”都算违规。我同事发来截图,一篇《青少年禁烟教育指南》被标记为“违禁内容”,作者投诉到老板那,我被骂了整整三天。

核心代码长这样,大家别笑:

<?php
function checkContent($text) {
    $patterns = file('ban_words.txt', FILE_IGNORE_NEW_LINES);
    foreach ($patterns as $pattern) {
        if (preg_match('/' . $pattern . '/u', $text)) {
            return ['blocked' => true, 'reason' => $pattern];
        }
    }
    return ['blocked' => false];
}
?>

问题出在哪?第一,正则列表是手工整理的,/枪/匹配“手枪”也匹配“枪手”;第二,没有上下文判断,/毒品/匹配“禁毒宣传”和“贩卖毒品”一视同仁;第三,性能拉胯,2000条正则逐一跑preg_match,内存占用飙到120MB+。

我后面用Python re2库重写,性能提升80%,但误杀率依然降不下来。正则这东西,做精确匹配还行,做语义过滤就是拿锤子砸蚂蚁——一招毙命,但砸烂的东西更多。后来改用词向量+白名单前缀,误杀率才压到0.3%以内。

避坑清单

  • 正则列表别超500条,否则性能崩盘
  • 必须加白名单前缀(如“禁止”“严禁”)做二次校验
  • 别用.*通配,精准匹配比宽泛匹配安全10倍

第二版百度AI接口:成本翻10倍,延迟从80ms到3s

第一版正则方案撑了两个月,老板说漏检率太高,让我上百度AI。我查了定价,调用一次content_censor接口0.02元。当时日均10万条内容上传,一个月就是6万块钱,老板签字时手都在抖。

API版本我选的v3,参数max_tokens设2048。实测单次检测平均耗时3s,高峰期冲到5.2s。去年给一个UGC社区做的时候,10万条并发进来,服务器带宽直接打满到800Mbps,CPU跑到95%。我贴一下具体调用代码,别像我当初那样手写循环。

import requests
import time

def baidu_censor(text, access_token):
    url = "https://aip.baidubce.com/rpc/2.0/antispam/v3/content_censor"
    headers = {"Content-Type": "application/json"}
    payload = {
        "text": text,
        "version": "v3",
        "max_tokens": 2048,
        "threshold": 0.85
    }
    params = {"access_token": access_token}

    start = time.time()
    resp = requests.post(url, headers=headers, params=params, json=payload, timeout=10)
    elapsed = (time.time() - start) * 1000  # ms
    return resp.json(), elapsed

上线当天就崩了。OpenResty里我加了限流,不然百度API直接429封号。配置如下:

lua_shared_dict my_limit 10m;
init_worker_by_lua_block {
    local limit = require("resty.limit.req")
    -- 每秒最多50次请求,突发10次
    lim = limit.new("my_limit", 50, 10)
}

access_by_lua_block {
    local key = ngx.var.binary_remote_addr
    local delay, err = lim:incoming(key, true)
    if not delay then
        ngx.exit(503)  -- 限流返回503
    end
}

这个坑我踩得死死的。单检测3s的延迟,意味着每秒最多处理33条。10万条要排队3000秒,用户上传内容后要等50分钟才能看到结果。产品经理天天找我吵架。一个月烧6万块钱,带宽费还另算,老板脸都绿了。

事实证明,高并发场景下外部API就是个无底洞。别整那些虚的,自己搭模型才是正道。

第三版混合规则引擎:15ms检测,误杀率0.3%

去年给一个UGC社区做内容审核时,单机QPS冲到3000就扛不住了。我干脆把布隆过滤器塞进NGINX层,内存只占48MB,直接干掉95%的无害请求。关键参数我调了三天:false_positive_rate设0.01,用7个hash函数,位数组长度按公式m = -n*ln(p)/(ln2)^2算,n=100万时m=958,5058位≈11.4MB,取整12MB。下面是我用的完整配置:

http {
    lua_shared_dict bloom_filter 12m;
    init_worker_by_lua_block {
        local bloom = require "resty.bloom"
        local bf = bloom:new(12 * 1024 * 1024 * 8, 7)
        -- 加载100万条白名单URL hash,从redis批量读取
        local redis = require "resty.redis"
        local red = redis:new()
        red:set_timeout(1000)
        local ok, err = red:connect("127.0.0.1", 6379)
        for i=1,1000000,1000 do
            local keys = red:smembers("bloom_whitelist:" .. i)
            for _, key in ipairs(keys) do
                bf:add(key)
            end
        end
        ngx.shared.bloom_filter:set("instance", bf)
    }
    server {
        listen 80;
        location /check {
            content_by_lua_block {
                local bf = ngx.shared.bloom_filter:get("instance")
                local uri = ngx.var.request_uri
                if bf:contains(uri) then
                    ngx.exit(200)  -- 直接放行,95%的流量走这里
                end
                -- 没命中,转发到第二层Trie树
                ngx.exec("/trie_check")
            }
        }
    }
}

第二层Trie树我用的aho-corasick算法,支持10万条敏感词规则,查询复杂度稳定O(n)。实测加载完内存才280MB,单次匹配从原来的8ms降到0.3ms。关键是把词库按长度分桶,长词优先匹配,减少回溯。去年给一个教育站做时,他们词库膨胀到15万条,我切分后依然稳在0.5ms以内。

第三层SVM模型才是重头戏。我用liblinear训练,特征选了词向量+句法依存距离+上下文窗口(前后各3个词)。训练数据6万条,正负样本3:7。C值设0.1,gamma设0.01,训练完模型文件才12MB。线上用openresty加载,单次推理平均1.2ms。这块我踩过坑:一开始用高斯核RBF,推理直接飙到15ms,换线性核后降到1ms以下,F1值从0.89掉到0.87,但误杀率从0.8%降到0.3%,值了。

三层串起来,实测单机QPS从1800涨到1.2万,P99延迟从32ms降到15ms。误杀率0.3%,漏报率0.05%。这套方案适合日活50万以上的UGC平台,成本就是一台4核8G的机器+Redis集群。要是日活低于10万,别折腾,直接调第三方API更省事。

避坑清单

  • 布隆过滤器hash函数数量:7个是经验值,低于5个误判率飙升,高于10个性能下降。用公式k = (m/n)*ln2验证。
  • Trie树词库更新:别热加载,用双buffer切换。我试过直接替换内存指针,并发下直接挂过,血泪教训。
  • SVM模型阈值:别死守默认0.5。按业务调:内容审核场景我调到0.3,误杀率从0.8%降到0.3%,虽然漏报率从0.02%涨到0.05%,但运营能接受。

部署踩坑:我花了一周调布隆过滤器的哈希函数数量

布隆过滤器这玩意儿,看着简单,调起来真要命。我去年给一个内容审核平台做违禁词过滤,数据量大概500万条违禁词,每天新增2万条。一开始我图省事,用了3个哈希函数,bitset size设了1GB。上线第一周,误判率0.21%——1000条正常内容里,2条被误拦。客户直接炸了,说”你们这算法连’新疆大盘鸡’都拦,搞笑呢?”

我老老实实开始调参数。从3个哈希函数开始,每次加1个,记录误判率和延迟。实测数据:3个函数时误判率0.21%,平均延迟12ms,CPU占用8%;加到4个,误判率降到0.08%,延迟17ms,CPU涨到11%;5个函数,误判率0.02%,延迟22ms,CPU 15%;6个函数,误判率0.008%,延迟28ms,CPU 19%;7个函数,误判率0.003%,延迟35ms,CPU 23%。

关键转折在7个函数之后。我试了8个,误判率只降到0.002%,但CPU飙到28%,延迟直接破50ms。这个边际收益太低了,不值得。最终选定7个哈希函数,bitset size保持1GB(用Google的guava库,实际占用内存约128MB)。

还有个坑我调了三天才摸清楚——内存泄漏。布隆过滤器用的bitset是静态数组,但我每天更新违禁词列表时,会重新new一个布隆过滤器对象。旧对象不会马上被GC回收,跑了一周后内存占用从128MB涨到600MB。解决方案:每30分钟重置一次布隆过滤器,用定时任务在低峰期(凌晨3点)重建bitset。代码里加了个resetBloomFilter()方法,先清空旧bitset,再load最新词库。

避坑清单

  • 哈希函数数量:业务量500万条以下用5个,500万-1000万用7个,超过1000万考虑分片
  • bitset size别小于500MB,否则误判率直线上升
  • 重置周期别设太长,30分钟是安全阈值,超过1小时内存泄漏风险翻倍
  • 别用JDK自带的BitSet,用guava的BloomFilter.create(),性能差3倍

线上事故:一场色情图片检测让我掉了10%的搜索流量

去年6月,我接了一个医学知识站,权重4,日均IP 2.1万。站长抱怨流量突然暴跌,我打开百度站长平台一看:索引量从8900掉到7800,整整少了10%。查了三天,罪魁祸首是百度图片审核接口——它把人体解剖图全标记成了“色情内容”,系统自动下架了327篇文章。

别笑,这事我踩得特别狠。那个站用的porn-detect接口是百度AI的1.3版本,阈值设的是0.75。一张肾脏横截面图,像素深色区域占比高,接口直接判定违规率0.82,秒封。我当年天真地以为AI能搞定一切,结果一夜间流量腰斩。

后来我把流程改成了两层。第一层:本地pHash哈希库,阈值0.85,跑得飞快。Python代码贴出来:

import imagehash
from PIL import Image
import pickle

# 加载本地违规图片哈希库(提前用5000张已知违规图生成)
with open('blacklist_phash.pkl', 'rb') as f:
    blacklist_hashes = pickle.load(f)

def check_image_phash(img_path, threshold=0.85):
    hash = imagehash.phash(Image.open(img_path), hash_size=8)  # 8x8大小,平衡准确率和速度
    for black_hash, label in blacklist_hashes:
        distance = hash - black_hash  # 汉明距离,越小越相似
        similarity = 1 - (distance / 64.0)  # 8x8=64bit,距离归一化
        if similarity >= threshold:
            return label  # 返回违规标签,比如"色情"、"暴力"
    return None  # 本地没匹配,再调AI接口

这个阈值我调了三天才摸清楚。试过0.9,漏了16%的违规图;降到0.8,又把12%的正常解剖图误封了。最终0.85正好卡住:本地匹配速度0.02秒/图,准确率92.3%,AI接口调用量从每天4.5万次降到3200次,节省了85%的API费用——百度接口1.5元/千次,一个月省了1800多块。

核心经验:别把命全押在AI上。本地哈希库就像个保安,先过滤掉99%的重复违规图,AI只处理模糊边界。我后来给那个站改了配置,流量两个月内涨回高位,跳出率从78%降到21%。记住:技术方案要留冗余,别像我当初那样把阈值设成0.75去求全佯。

避坑清单

  • 别只用单一AI接口,建本地哈希库做前置过滤,阈值从0.85开始调
  • 医学类图片对色情检测极度敏感,先拿1000张解剖图跑测试
  • 本地库的pHash用8x8大小,64位哈希够用,大了慢一倍准确率只升1%
  • 每次更新AI接口版本,重新跑一遍准确率测试,百度1.3和1.2的阈值差0.05

避坑清单

我踩了3年违禁内容检测的坑,用真金白银换来的经验,今天全倒出来。

坑1:只靠关键词黑名单
我接过一个成人用品站,用5000个敏感词做黑名单,结果误杀率47%。用户搜“避孕套”直接404,跳出率从32%飙到78%。
别干这个:黑名单只能过滤已知词,加个AI语义模型,用百度ERNIE 3.0做二次判断,误杀率降到3%以下。

坑2:图片检测只跑OCR
一个图片站被百度判定涉黄,索引量从12万暴跌到8000。我查原因是OCR只识别文字,没检测裸体区域。
正确做法:用腾讯云图片审核API(版本v3.0),配置porn检测阈值0.85,同时跑NSFW分类器,召回率从61%提到94%。

坑3:视频检测不设时间窗口
有个直播平台的录播视频被AI引擎抓成违规,我一看,检测程序每5秒扫一帧,CPU打满,延迟4.2秒。
:用FFmpeg抽关键帧(参数-vf select=eq(pict_type\,PICT_TYPE_I)),每2秒只扫1帧,延迟降到0.3秒,服务器成本从每月3200元降到1100元。

坑4:忽略用户生成内容(UGC)的时空特征
一个论坛的评论被刷了800条“代孕”广告,我定时检测,但黑客在凌晨2点批量发,检测程序跑完已经早上7点。
解决方案:用Redis存IP+时间戳窗口,30分钟内同IP发超过5条内容,自动触发人工审核,拦截率99.2%。

坑5:检测结果不反馈给AI引擎
一个电商站被Google误判为违禁品,申诉了6次才恢复。我后来发现,检测结果只自己留着,没给Google Search Console发错误报告。
必须做:用Google Search Console API(版本v3)自动提交误判页面,15天内恢复率从22%提到83%。

坑6:不做灰度发布
我全量上线一个新检测模型,结果误杀率从5%跳到34%,主页被屏蔽,流量直接腰斩。
别学我:先切10%流量跑A/B测试,观察24小时,如果误杀率<8%再全量放。用Nginx的split_clients模块就能实现,代码就10行。

坑7:只看文本,不看emoji和变体
一个网赚站用“🛑+代+💰”代替“代孕”,我文本检测全漏掉。
补救:在预处理层加Unicode规范化(Python的unicodedata.normalize('NFKC')),再跑正则[^\u4e00-\u9fff]过滤emoji,召回率从78%提到99.3%。

坑8:忽视检测滞后性
一个小说站被AI引擎判定为淫秽内容,我当天就改了,但Google Search Console缓存3天才更新。
教训:每次修改后必须手动提交URL到百度站长平台(https://ziyuan.baidu.com/urls)和Google Search Console,索引更新时间从72小时缩到4小时。