给电商 / 社交 / 直播这类 UGC 场景做实时内容合规:命中违规内容时不只报警,还产出可直接用的合规改写。系统分两段:离线把敏感词表 / 平台规则表用 bge-m3 嵌入写进 Qdrant;在线对每条输入做归一化 → 并行双层检测 → 命中才走 LLM 改写。
为什么是三级流水线(每级管一种失效模式)
- 第一级 · Aho-Corasick 精确匹配:管已知敏感词的零漏报。一次 O(文本长度)扫描同时命中所有模式词,确定性、毫秒级、不吃 GPU。
- 第二级 · bge-m3 + Qdrant 语义召回:管没登记的变体 / 谐音 / 语义擦边。整段文本嵌成 1024 维向量,在 Qdrant 上 cosine 检索(敏感词阈值 0.85、规则 0.75)。
- 第三级 · Qwen2.5-7B-GPTQ(vLLM)改写:只在命中时触发,做受约束的局部替换。
三级职责不重叠,这是架构的核心判断:把「确定性该兜的」和「概率模型该补的」分层,而不是一把 LLM 全包。
几个工程决策
AC 比正则强在哪。 正则对 N 个敏感词要么扫 N 遍、要么拼一个巨型交替正则(回溯灾难风险),复杂度随词表线性恶化。AC 自动机把所有模式词预编译成一个 trie + fail 指针,单次扫描命中所有词、和词表规模解耦——这正是实时链路要的确定性低延迟。
并行而非串联。 AC(CPU 活,走 asyncio.to_thread)和语义层(IO 活,协程)没有数据依赖,用 asyncio.gather 并发——串联会让总延迟变成两者之和,并行后 ≈ 较慢的语义层。
归一化必须两侧同空间(一个很隐蔽的正确性约束)。 输入做 NFKC → 繁转简(OpenCC)→ 删零宽字符 → 转小写。关键是词表词入 AC 前也要走同一套归一化,否则「治癒 / 治 愈 / 治愈」这类繁体 / 全角 / 零宽绕过会让 AC 全部漏匹配——检测侧和词表侧必须在同一个归一化空间里,差一点就漏。
缓存 key 为什么用违规词集合的 hash。 缓存语义是「同一组违规词 → 复用历史改写策略」,所以 key 只取每条 violation 的 word、刻意排除带分数的 reason(语义命中的分数随输入微抖,纳入 key 会让该命中的缓存频繁 miss),再去重 + 排序后 SHA256。TTL 7 天,读写全程吞异常:Redis 没装 / 连不上一律降级为「不缓存」,绝不让缓存故障波及主链路。
真实坑:AC 空词表静默崩溃(单测逼出来的)
- 现象:敏感词表为空(或归一化后有效词被过滤光)时,
make_automaton()不报错,但之后第一次automaton.iter(text)抛AttributeError。构建阶段一切正常,崩在第一次实际匹配——典型的静默延迟爆炸。 - 根因:pyahocorasick 在没
add_word就make_automaton时,对象仍停在 trie 状态(没转成自动机),trie 状态没有iter方法。构建不校验、运行才暴露。 - 解法:统计有效词数,为 0 时不调
make_automaton、直接把自动机置 None;匹配入口判空走安全分支返回空列表。 - 为什么值得记:它是写
test_empty_wordlist这种边界单测时暴露的——测试不是验证已知正确,是把边界逼出来。生产里它只会在「敏感词表导入失败 / 为空」这种运维事故叠加时才崩,极难现场定位。
真实数字
35 个纯 CPU 单测(不吃 GPU、不连真实外部服务):detector 10(含逼出 bug 的空词表、繁体归一化命中、零宽绕过被挡)、normalizer 10(繁转简 / 全角→半角 / 零宽剔除 / 幂等性)、cache 15(用 fakeredis 测读写往返 / TTL / 中文不转义 / 降级路径)。这些是真跑过、真绿的部分。阈值(0.85 / 0.75、topK、改写 temperature 0.1)都是经验值、未标定。
诚实边界(这一段最重要)
- 本机无 GPU,bge-m3 嵌入 + vLLM/Qwen 改写整条链路没在真硬件上端到端跑过。 README 里那些 P50 < 200ms / < 800ms、显存约 18GB 是设计目标值,不是实测——必须这么标。能验证的只有上面那 35 个纯 CPU 单测覆盖的部分;语义召回质量、改写质量、并发吞吐都未在真实硬件上验证。
- 缓存当前不省 LLM 调用:命中时只是把历史改写当「参考」喂进 prompt,不是直接返回缓存文本,所以命中仍调一次 LLM。这是怕「同词不同上下文直接套用出错」的保守设计,代价是只省了想策略、没省调用——「真正靠缓存降调用量」是明确待优化点。
- 语义检索粒度错配(最该先验证的假设):把整段输入嵌成一个向量去和单个敏感词的向量算 cosine,句向量 vs 词向量本就错配,长文本会稀释信号。更优解(滑窗 / 分句逐段检索)没做。
- 缓存层上线前自查出的两个隐患(默认
REDIS_ENABLED=false,当前休眠未触发,但属于设计自洽性问题,如实记):① 缓存串味——key 只 hash「违规词集合」、与原文无关,两段不同原文只要违规词集合相同就撞同一 key;而缓存值是某条原文的整段改写全文,会作为【历史改写参考】注入下一条的 prompt,等于 A 的改写正文串味给 B。叠加「只缓存被改过的文本」那道门会放大。② 连接故障下降级不优雅——Redis 建连没设socket_connect_timeout,连接失败后_redis_client一直是 None、每个请求都重连重 ping;对一个静默丢包(防火墙 / 死机,非 connection-refused)的 host,ping()会阻塞到 OS TCP 默认超时。所以上面说的「优雅降级」只在「没启用」时成立,「连不上」场景下并不优雅。解法:建连传 0.2s 超时 + 熔断时间戳,N 秒内不重试。 - 其它缺口:超时默认值(30s)和文档(800ms)自相矛盾未修;多 worker 下 AC/Redis 进程内状态不共享;规则热更新需重启;CORS 全开 + 无鉴权(仅内网 / 演示)。
小结
这个项目能说明问题的不是「用 LLM 做合规」,是那条分层判断:确定性该兜的(已知词零漏报)交给 AC、概率该补的(语义擦边)交给向量检索、生成该做的(改写)才上 LLM——别让一个模型把三种失效模式全背。以及那段如实写下来的边界:纯 CPU 部分真跑过、真硬件那半没跑过,目标值就标成目标值,不假装全绿。