从布隆过滤器到 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 都包成 unmodifiableMapoutputs 也是 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 的节点查找成为瓶颈,可以换成 charNode 的数组,但那样会浪费一些空间。权衡下来,HashMap 的通用性更好。

结语

从布隆过滤器到 AC 自动机,再到流式状态隔离,这一路下来最大的感受是:好的方案往往不是单个技术的炫技,而是把几个简单的组件组合起来,解决真实场景里的具体问题。

布隆过滤器确实快,但它解决不了跨片段匹配;AC 自动机能解决,但需要小心处理并发状态。把两者拆开,让布隆过滤器做第一道粗筛,AC 自动机做精确匹配,同时把状态隔离出去,这样既保证了性能,又保证了正确性。

当然,如果词库不大、并发不高,直接用最开始的 StreamingACAutomaton(每个会话一个实例)也完全没问题。技术选型这件事,最终还是看自己的实际场景。

下面是完整的 SharedACAutomaton 代码,供参考。

// 这里贴上文中给出的完整代码,为避免重复粘贴,可参考前文的最终实现。
// 实际使用时复制前文中的 SharedACAutomaton 类即可。

写于 2025 年 4 月。如果你也在做类似的事情,希望这些记录能帮你少踩几个坑。