简介:面向Go语言开发者的敏感词过滤工具包,源自Java版DFA算法实现,核心逻辑直接迁移且未经改动,集成百度敏感词库,同时支持自定义扩展词条。DFA算法即确定性有限自动机,匹配效率高且内存占用小,适合对性能有要求的文本过滤场景。压缩包共6个文件,包含4个Go源码、1个词典文件与1个Markdown说明文档,整体仅3KB,非常轻量,可快速集成到内容审核、评论过滤、聊天消息检测等在线服务中。项目中可在init方法中调用ReadSwfDict加载词库,在检查点调用Match方法验证文本是否包含敏感词,或调用Repl方法执行替换,swf_test.go提供了完整调用示例,清晰展示初始化、匹配与替换流程,开发者可按需更换词典或扩充敏感词条。资源已有1902人学习下载,适合需要快速实现敏感词过滤功能的中级Go开发者参考,代码结构简洁,便于二次开发与学习DFA算法在敏感词过滤中的实际应用。 敏感词过滤这种需求,说实话每个做内容产品的团队早晚都会碰到。不管是社区评论、用户昵称、聊天消息还是文章发布,只要涉及UGC,就必须有一套能扛得住事的敏感词拦截方案。我这次做的这个Sensitive-word-filtering项目,是基于百度开源敏感词库做的一套可扩展的过滤服务,底层用前缀树完成高效匹配,同时保留了自定义词库、黑白名单、动态更新这些能力。整理这篇文章,主要是想把整个项目的设计思路、关键实现和踩坑经历都摊开来讲,给那些正准备做敏感词过滤、或者已经做了但总被漏报误杀折腾的朋友一个可参考的模板。
这个项目适合谁?后端开发、内容安全相关的工程师,还有独立开发者在给自己的应用做内容风控时,都能直接参考。下面从架构思路到具体实现一步步拆解。
1. 整体思路与架构设计
1.1 为什么选择百度敏感词库作为起点
做敏感词过滤,第一个绕不开的问题就是:词库从哪来。很多团队一开始喜欢自己整理,运营提几个、测试补几个,结果上线之后漏报不断,用户发个擦边内容直接绕过拦截,这其实是底子没打好。
百度敏感词库的好处在于,它是公开积累的通用敏感词集合,覆盖了低俗、暴力、赌博、诈骗等常见高危类别。虽然在实际使用中会发现它并不能覆盖所有场景,但作为基础库,它的覆盖面比小团队自己手动收集要完整得多。更重要的是,它不是静态的,社区里有人在更新维护,你可以把它当一个"种子词库",在这个基础上不断补充自己的行业词、地区词、黑话变体。
我实际跑下来,直接用百度词库做基础过滤,准确率大概能到七成以上,剩下的三成就靠后面的扩展机制去补。别指望一个词库解决所有问题,能把基础拦截做扎实、留好扩展通道,这个方案就算成功了一大半。
1.2 可扩展架构的核心考量
项目设计的时候,我把"可扩展"当成了一等公民,不是后面想到才加的。扩展点主要卡在三个层面:
第一个是词库来源的扩展。除了启动时加载本地文件,还要支持从数据库、远程配置中心拉取词库,这样运营同学改个词不用求着开发发版。
第二个是过滤策略的扩展。敏感词不是一棍子打死,有的词直接拦截,有的词需要人工审核,有的词在特定场景下不算敏感。所以过滤结果不能只有一个"通过/不通过",最好能返回命中的词、位置、类别,方便上层做差异化处理。
第三个是算法的可替换性。用前缀树(Trie)做基础实现,但如果词库到了百万级别,就得考虑换成AC自动机(Aho-Corasick),架构上要把算法接口抽象出来,别写死。
打个比方,这就像装修房子,先别急着选沙发颜色,得先把水电改造和插座位置留好。扩展性就是那些"插座",现在用不上,等真需要的时候就知道有多值钱了。
2. 词库加载与预处理
2.1 词库格式与初始化流程
词库文件的格式我是用一行一个词的方式,这种最基础也最好处理。不过直接用txt文件有个问题:没有分类信息。所以在实际项目中,我扩展成了两列,用分隔符把词和类别分开。
暴力|暴力 违禁品|违禁品 代购|广告这样做的好处是过滤的时候能知道命中的词属于什么类别,方便做不同处理。初始化流程分为三步:
- 读取词库文件,逐行解析,跳过空行和注释行
- 把词条插入前缀树,同时记录词的长度和类别
- 对比新旧词库,输出新增和删除的词条数量
这里有个细节很多人会忽略:加载之前要先对词条做去重和排序。去重很好理解,排序是为了后面调试方便,两个词在文件里的顺序稳定了,出问题的时候才能快速定位。
2.2 用前缀树组织敏感词
前缀树(Trie)是敏感词过滤最直观的数据结构。核心思想是把敏感词按字符拆开,每个节点存一个字符,从根节点到叶子节点的一条完整路径,就是一个敏感词。
举个例子,假设词库里有"赌博"和"赌场",前缀树会长这样:
- 根节点 → "赌" → "博"(终止)
- 根节点 → "赌" → "场"(终止)
"赌博"和"赌场"共享了"赌"这个前缀,所以在匹配的时候,只需要遍历一遍文本就能找出所有敏感词,不需要对每个敏感词都跑一遍字符串查找。
在Java里,每个节点用一个Map存子节点,再加一个标志位表示当前节点是否是一个词的结尾,顺便存一下词的类别。
class TrieNode { Map<Character, TrieNode> children = new HashMap<>(); boolean isEnd = false; String category; int length; }插入词条的时候,从根节点开始,逐字符往下走,没有子节点就创建。走到最后一个字符,把isEnd置为true,记录词的长度和类别。这段逻辑很基础,但它是整个过滤器的基石。
2.3 加载结果的验证方法
词库加载完,别急着上线,先做一轮验证。我的做法是写一个自检方法,从词库里随机抽一批词,逐个拿来过滤,确认每个词都能被命中。再手动造几个正常句子,确保没有误杀。
这一步看着简单,但能拦住大量低级问题。我遇到过的情况是,数据库里导出的词条自带BOM头或者Windows换行符\r,结果第一个词永远匹配不上,或者最后一个字符被吞了。用自检方法一跑,问题马上就暴露了。
验证通过的指标我一般卡两个:召回率大于99.5%,也就是词库里的词基本都能命中;误报率低于0.1%,拿正常语料跑一遍,不能有莫名其妙被拦截的。
3. 核心过滤算法实现
3.1 单次扫描匹配算法
基础匹配算法的思路是双指针加前缀树。外层指针遍历文本的每一个字符,内层指针顺着前缀树往下走,只要能一直匹配上就一直走,走到某个节点时isEnd为true,就说明命中了一个敏感词。
有个关键细节:命中了词之后,要从"敏感词最后一个字符的下一个位置"继续匹配,而不是从起始位置的下一个字符继续。这样能避免重叠匹配导致同一个敏感词被重复处理。不过这里需要做取舍,像"赌博赌场"这种两个敏感词紧挨着的场景,直接从尾部继续会导致第二个词漏掉。我的处理方式是:命中后记录结果,然后把外层指针移动到当前词结束位置,同时再从下一个位置继续匹配一次,也就是"跳跃 + 重试"。
public List<HitWord> filter(String text) { List<HitWord> hits = new ArrayList<>(); TrieNode current = root; int start = 0; int i = 0; while (i < text.length()) { char c = text.charAt(i); TrieNode next = current.children.get(c); if (next == null) { // 当前路径匹配失败,从下一个字符重新开始 current = root; i = start + 1; start = i; } else { if (next.isEnd) { hits.add(new HitWord(text.substring(start, i + 1), next.category, start, i)); // 跳到敏感词后面继续匹配,同时从start+1重试,防止漏掉重叠词 current = root; i = i + 1; start = i; } else { current = next; i++; } } } return hits; }这段代码看着简单,其实我前前后后调了好几版,主要就是处理重叠匹配和连续命中的边界问题。实际测试下来,这个算法对常规敏感词过滤场景完全够用,1万词库、1000字的文本,匹配耗时在毫秒级。
3.2 命中"星号替换"策略
匹配到敏感词之后,最常见的处理方式是替换成*。但这个看起来简单的需求,其实也有门道。
我原来的实现是直接替换成固定数量的星号,跟原词长度一致。后来测试发现,有些场景希望固定替换成三个星号,不管原词多长。两种策略各有适用场景:
- 按长度替换:适合需要保留原文可读性的场景,用户能看出哪里被屏蔽了
- 固定数量替换:适合直接抹除信息的场景,让敏感内容完全不可见
所以我在这块做了一个可配置的策略接口,默认按长度替换,但提供固定数量的选项。另外还加了一个"保留首字符"的策略,比如"赌*"这种,既能起到提示作用,又不至于完全看不出来原词。
实际业务中还有一个场景:有些词不是敏感词,但组合在一起就是敏感内容。比如涉政词汇周围跟着某些形容词,这些上下文关联的判定,单纯靠词典匹配是搞不定的,得靠规则引擎或者模型。我在这个项目里暂时没有做这层,以后可以作为一个扩展方向。
3.3 多模式匹配的进阶优化:AC自动机
说句实在话,如果词库只有几万个词,上文的前缀树匹配完全足够了。但如果你要处理的文本特别长比如几MB的文档,或者词库膨胀到百万级别,那就得上AC自动机了。
AC自动机可以理解成"前缀树上跑KMP",它给每个节点加了一个失败指针(fail指针),当某个字符匹配失败的时候,通过fail指针跳到另一个节点继续匹配,避免了回溯重新匹配的成本。
这样说可能还有点抽象,举个生活化的例子:你在图书馆找一本书,A书架没有,不需要回到门口重新找,而是直接去邻接的B书架继续翻。fail指针就是这个"邻接书架"的索引。
在一开始的项目里我并没有立刻用AC自动机,而是专门写了一个可替换的接口。后来词库从5万扩到30万的时候,我实现了AC版本,匹配性能提升了将近三倍。不过代价是构建fail指针的复杂度增加,代码量也大了不少。所以这块的建议是:词库小用Trie够用,真到了性能瓶颈再切AC,别过度设计。
4. 扩展机制设计
4.1 三种扩展方式
百度词库只是一个起点,真正的竞争力在于扩展能力。我在项目里实现了三种扩展方式:
第一种是本地词库文件更新。运营直接把新词加到文件里,通过管理接口触发热加载,不需要重启服务。
第二种是数据库扩展。建一张敏感词表,服务启动时和定时任务里从数据库加载词库,支持在线增删改查。适合词库变更非常频繁的场景。
第三种是配置中心扩展。如果是微服务架构,可以接入Nacos或者Apollo,词库变更通过配置中心推送,所有服务实例同步更新。
这三种方式不是互斥的,实际使用中我建议"本地文件 + 数据库"组合:本地文件放通用基础词库,数据库放业务自定义词库,加载的时候做一个合并。
4.2 黑白名单与权重设置
扩展机制里最容易被忽视但实际价值极高的是白名单。白名单的作用是:某些词虽然命中了敏感词库,但在特定业务场景下是合法的,需要放行。
举个例子,"小姐"这个词在通用词库里大概率是敏感的,但在酒店服务行业,它就是一个正常称呼。又比如一些游戏里的特殊术语,可能和敏感词字面重合,但游戏场景里完全没问题。
我的实现是维护一个白名单前缀树。过滤的时候,先跑敏感词命中,再对命中的词做一次白名单校验,如果在白名单里就直接跳过。黑名单则是反过来,有些词不在基础词库里,但在这个业务里必须拦截,比如某个产品特有的竞品词。黑名单可以直接当普通敏感词处理,只是来源不同、优先级更高。
权重设置这一块我做得比较简单,每个词可以配一个等级:高、中、低。高等级的词一命中就拒绝发布,中等级的词走人工审核,低等级的词只记录不拦截。这种分级策略在实际业务中特别有用,能大大降低误杀带来的用户投诉。
4.3 动态更新词库而不重启服务
敏感词过滤服务最忌讳的就是每次改词库都要重启。重启意味着短暂的不可用,高并发场景下还会造成请求堆积。所以热更新是刚需。
热更新实现的核心思路是"读写分离的引用切换"。我用了一个volatile关键字修饰当前活跃的前缀树引用,更新词库的时候,先在内存里构建一棵新的前缀树,构建完成后再把引用切换过去。
private volatile TrieNode activeRoot; public synchronized void reload(List<String> words) { TrieNode newRoot = buildTrie(words); this.activeRoot = newRoot; }因为volatile保证了内存可见性,其他线程在activeRoot切换后,下一次读取就能拿到新词库。构建期间,旧词库还在服务,整个过程对请求方完全无感。这种方案我实测下来,词库从10万更新到12万,切换耗时在几十毫秒级别,线上服务完全不用停。
不过有个坑需要注意:构建新前缀树期间,如果有写入操作,线程安全性需要保障。我的做法是加synchronized锁住reload方法,每次只允许一个线程触发重载,避免并发构建出多棵新树互相覆盖。
5. 常见问题与排查技巧实录
5.1 词库加载慢、内存占用高怎么办
词库从5万发展到30万时,我第一次遇到了内存告警。排查了一下,内存主要吃在TrieNode上。每个节点都有一个HashMap<Character, TrieNode>,而HashMap本身的存储开销就不小,几万个节点叠加起来,内存占用轻松超过几百MB。
这里的第一个优化方案是,当节点只有一个子节点时,用char字段替代HashMap,极端情况下保存单个字;当节点有多个子节点时才升级为HashMap。这个方案实现起来稍微复杂一点,但内存能省下30%左右。
另一个处理路径是直接启用AC自动机的fail指针构建,虽然构建时也会消耗一些内存,但后续匹配时的计算开销显著下降,整体用了后内存占用其实也降了,因为Trie树里很多字符串共享节点,AC自动机在优化重叠前缀方面比纯Trie更高。
5.2 误杀与漏报的平衡策略
误杀和漏报是敏感词过滤里的"跷跷板",压了一头必然翘起另一头。很常见的一个场景:词库里加了"代购",结果所有提到"海外代购攻略"的帖子都发不出去了,用户疯狂投诉。
我自己的经验是,一定要做分级处理和上下文感知。分级处理前面说过了,差异化拦截。上下文可以是词的前后若干个字,命中了敏感词再往前看看是不是有特定的修饰语,比如"反对XX"和"坚决反对XX",语义截然相反,但字面上匹配的是同一个敏感词。这种场景规则引擎能解决一部分,但根本解法还是要引入语义模型,属于另一个层面的问题了。
对于纯粹的词典匹配,建议是:敏感词宁可漏报也不要误杀。漏报还能通过后续的人工审核补救,误杀直接流失的是用户信任。所以词库里能不碰的"灰色词"尽量不碰,命中高危词再走审核流程。
5.3 性能压测与最优参数选择
压测是我上线前的固定动作。我用JMeter对过滤接口做了压测,词库10万词,文本长度平均500字,机器配置普通的4核8G。
实测结果,Trie树版本QPS约3000,延迟P99在3ms左右。切换AC自动机后,QPS提升到8000以上,P99延迟降到1ms以内。如果词库不经常变化,强烈建议用AC自动机,这个词库规模下的收益非常明显。
还有一个参数容易被忽视:最大匹配长度。有些异常长的"词"其实是用户误输入的,不是真敏感词,过滤时却拖慢了匹配速度。我的方案是给敏感词长度设一个上限,超过这个长度的候选词不再继续匹配,默认50个字符足够覆盖所有正常敏感词。单条超长文本的处理也要有兜底,超时直接放行,绝不能因为过滤拖垮主流程。
写在最后
敏感词过滤这个项目做下来,我最大的体会是它看着简单,实则有非常多细节。词库怎么维护、算法怎么选、误杀漏报怎么平衡、热更新怎么做到无感,每一个点都能展开讲很久。这个版本的方案不敢说多完美,但至少在我经手的几个业务线里扛住了真实流量,通过这套"基础词库 + 可扩展架构 + 分级策略"的组合拳,从上线到现在没有出过比较大的内容安全事故。
最后再分享一个小技巧:词库的更新日志一定要做,每次变更了什么词、谁改的、什么时候改的,全部记录下来。遇到线上问题回溯的时候,这些日志比任何技术方案都管用。如果后续有时间,我打算在这个项目的基础上继续做上下文语义维度的过滤,把"意思对但字面不匹配"的那些漏网之鱼也捞出来。
本文还有配套的精品资源,点击获取