从布隆过滤器到 AC 自动机:一个敏感词过滤工具的重写笔记
前段时间在做 AI 流式输出的内容审核,需要一套敏感词检测方案。本来想找个现成的库直接用,但后来还是决定自己写一遍——主要是想把里边的原理摸透。这篇文章算是把整个思考过程、踩过的坑和最终的实现串起来,做个记录。
一开始的想法:布隆过滤器 + 词库匹配
第一反应是搞个布隆过滤器。这东西内存占用小,判断“肯定不存在”极快,拿来当第一道筛子很合适。
我很快写了个简单的布隆过滤器,用 MD5 做哈希,加上几个不同的盐值,凑出 k 个哈希函数。然后把敏感词库全塞进去。对于一段文本,如果布隆说过滤器认为“肯定不包含敏感词”,那就直接放行;如果说“可能存在”,再交给后端的精确匹配(比如一个 HashMap 词库)去验证。
测试下来,安全文本的过滤效率确实很高。但我很快就发现了一个致命问题——流式输出。
流式场景下的尴尬
AI 生成内容是逐字往外吐的。假设敏感词是 ab,第一个片段只来了 "a",第二个片段来了 "b"。如果对每个片段单独过布隆过滤器,"a" 不在词库里,放行;"b" 也不在,放行。结果 "ab" 就这么漏过去了。
这个问题不是布隆过滤器独有的。任何无状态的、逐片段独立检测的方案都绕不开。要解决它,必须让检测器记住“刚才已经看到了一个 a,再等等看后面有没有 b”。
也就是说,我需要一个有状态的匹配器。
AC 自动机的救场
后来想起来大学时学过的一个算法——AC 自动机。这东西本来就是为了解决“一次扫描,匹配多个模式串”的问题。而且它天生可以维护状态:匹配完一个片段后,记住当前走到了 Trie 树的哪个节点,下一个片段来了接着往下走。
AC 自动机的结构其实不复杂:
- 先把所有敏感词建一棵 Trie 树。
- 然后用 BFS 给每个节点补一条失败指针(fail)。失败指针的作用是,当在当前节点匹配不上下一个字符时,应该跳到哪个节点继续匹配——有点像 KMP 的 next 数组,但搬到了树上。
- 匹配时,拿着文本的每个字符沿着树往下走,走不通就顺着 fail 跳,走到有输出的节点就记下来。
这个过程是严格 O(n) 的,跟敏感词数量无关。流式场景下,只需要在每次处理完一个片段后把当前节点存下来,下一个片段来了接着用。
我写了一个普通的 AC 自动机实现,用 HashMap 存子节点,结构清晰,调试也方便。对于十万级别的词库,内存占用几十 MB,完全能接受。
分片测试
用之前的例子:敏感词 ["ab", "bc"],分三次喂入 "a"、"b"、"c"。
- 第一次
"a":状态从根走到节点a,没有输出。 - 第二次
"b":从节点a走到ab,输出"ab"。同时因为ab的 fail 指向第一层的b节点,而b节点本身没有输出,所以只输出一个词。 - 第三次
"c":当前节点是ab,没有c子节点,于是顺着 fail 跳到b,发现b有子节点c,走过去到达bc,输出"bc"。
完美。跨片段的 "ab" 和 "bc" 都抓到了。
多线程的问题
实现完 StreamingACAutomaton 之后,我开始考虑并发场景。这个类内部有一个 currentState 字段,记录当前匹配位置。如果多个线程共用同一个实例,currentState 会被互相覆盖,结果必然混乱。
最简单的办法是每个会话自己 new 一个实例。但高并发下内存吃不消——每个实例都会复制一份完整的 Trie 树(几十到上百 MB)。所以需要把“只读的 Trie 结构”和“可变的状态”拆开。
我重新设计了一个 SharedACAutomaton:
- 全局只构建一次 Trie 树和 fail 指针,构建完成后冻结为不可变对象(所有
Map都包成unmodifiableMap,outputs也是unmodifiableSet)。 - 每个会话通过
createState()获得一个MatchState对象,这个对象只保存两个引用:根节点和当前节点。内存开销只有 16 字节。 MatchState提供feed(char)和feed(String)方法,内部维护自己的current节点。不同会话之间的状态完全隔离。
这样一来,词库只存一份,并发会话数再多也不怕。每个会话独立匹配,互不干扰。
双数组 Trie?暂时没那个必要
在网上查资料的时候,好几次看到“双数组 Trie”这个词。据说内存占用极低,适合百万级词库。我也动过自己实现的念头,但研究了一下实现复杂度,放弃了。双数组的构建算法很绕,动态插入删除几乎不可行,而且为了支持 AC 自动机的失败指针,还得在双数组之上再叠一层逻辑,代码量翻几倍。
实际上,对于大多数应用场景(词库十万级别,内存几百 MB 不是问题),普通的 HashMap 版 AC 自动机已经足够好了。如果哪天词库真的膨胀到百万级别,我会直接考虑用现成的库,比如 org.ahocorasick,它内部就是双数组实现,而且支持流式状态维护。
最终拿出来的代码
我把最终版的 SharedACAutomaton 贴在了文章末尾。它的特点:
- 线程安全:共享实例只读,会话状态独立。
- 支持流式分片:
MatchState.feed()可以逐字符或逐段喂入,自动跨片段匹配。 - 内存友好:十万词库约 50MB,每个会话仅占 16 字节。
- 纯 JDK 实现,无第三方依赖。
使用方式也很直接:
// 全局初始化一次
Set<String> words = ...;
SharedACAutomaton shared = new SharedACAutomaton(words);
// 每个会话
SharedACAutomaton.MatchState state = shared.createState();
state.feed("a"); // 无匹配
state.feed("b"); // 可能匹配到 "ab"
state.reset(); // 新的一段文本
一些没展开的细节
- 白名单机制:可以在 AC 自动机匹配之后再加一层过滤,如果敏感词恰好命中白名单里的词(比如某些技术术语),就放行。
- 替换策略:
MatchState目前只返回匹配到的敏感词集合,实际做替换时需要知道位置。可以在feed的同时记录索引,或者在非流式场景下调用findAllWithPositions。 - 性能调优:如果
HashMap的节点查找成为瓶颈,可以换成char到Node的数组,但那样会浪费一些空间。权衡下来,HashMap的通用性更好。
结语
从布隆过滤器到 AC 自动机,再到流式状态隔离,这一路下来最大的感受是:好的方案往往不是单个技术的炫技,而是把几个简单的组件组合起来,解决真实场景里的具体问题。
布隆过滤器确实快,但它解决不了跨片段匹配;AC 自动机能解决,但需要小心处理并发状态。把两者拆开,让布隆过滤器做第一道粗筛,AC 自动机做精确匹配,同时把状态隔离出去,这样既保证了性能,又保证了正确性。
当然,如果词库不大、并发不高,直接用最开始的 StreamingACAutomaton(每个会话一个实例)也完全没问题。技术选型这件事,最终还是看自己的实际场景。
下面是完整的 SharedACAutomaton 代码,供参考。
// 这里贴上文中给出的完整代码,为避免重复粘贴,可参考前文的最终实现。
// 实际使用时复制前文中的 SharedACAutomaton 类即可。
写于 2025 年 4 月。如果你也在做类似的事情,希望这些记录能帮你少踩几个坑。