skip to content
tlj 的工程笔记
← 项目

内容合规检测系统:精确匹配 + 语义召回 + LLM 改写的三级流水线

个人

本地化实时内容合规服务。三级流水线把「确定性零漏报」和「语义擦边召回」分层解决,命中后还产出可直接用的合规改写。最有料的是一个单测逼出来的 Aho-Corasick 空词表静默崩溃 bug,以及一整段诚实的「没在真硬件上跑过」的边界。

时间
2026-03 ~ 2026-06
角色
独立开发(架构 / 实现 / 测试 / 部署脚本)
成果
三级流水线(AC 精确匹配 → bge-m3/Qdrant 语义召回 → Qwen2.5-7B 改写);Redis 缓存以违规词集合 SHA256 做 key、TTL 7 天、全程吞异常优雅降级;35 个纯 CPU 单测逼出 1 个 AC 空词表静默崩溃 bug

给电商 / 社交 / 直播这类 UGC 场景做实时内容合规:命中违规内容时不只报警,还产出可直接用的合规改写。系统分两段:离线把敏感词表 / 平台规则表用 bge-m3 嵌入写进 Qdrant;在线对每条输入做归一化 → 并行双层检测 → 命中才走 LLM 改写。

为什么是三级流水线(每级管一种失效模式)

三级职责不重叠,这是架构的核心判断:把「确定性该兜的」和「概率模型该补的」分层,而不是一把 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 空词表静默崩溃(单测逼出来的)

真实数字

35 个纯 CPU 单测(不吃 GPU、不连真实外部服务):detector 10(含逼出 bug 的空词表、繁体归一化命中、零宽绕过被挡)、normalizer 10(繁转简 / 全角→半角 / 零宽剔除 / 幂等性)、cache 15(用 fakeredis 测读写往返 / TTL / 中文不转义 / 降级路径)。这些是真跑过、真绿的部分。阈值(0.85 / 0.75、topK、改写 temperature 0.1)都是经验值、未标定。

诚实边界(这一段最重要)

小结

这个项目能说明问题的不是「用 LLM 做合规」,是那条分层判断:确定性该兜的(已知词零漏报)交给 AC、概率该补的(语义擦边)交给向量检索、生成该做的(改写)才上 LLM——别让一个模型把三种失效模式全背。以及那段如实写下来的边界:纯 CPU 部分真跑过、真硬件那半没跑过,目标值就标成目标值,不假装全绿。