☰
基于DFA算法的Python敏感词过滤实现与避坑指南
2026/10/20 10:48:47 网站建设 项目流程

简介:这是一份面向Python开发者与内容安全方向学习者的敏感词过滤实现资源,聚焦DFA(确定有穷自动机)算法在文本过滤场景中的落地应用,适合需要为社区、评论或聊天系统搭建高效敏感词拦截模块的初中级开发者参考。压缩包共7个文件,以4个Python源码文件为核心,另含1个yml配置、1个txt词库与1个md说明文档,整体约17KB,体量轻巧便于快速阅读与二次改造。源码部分围绕DFA构建与匹配逻辑展开,词库文件提供可替换的敏感词样本,配置与说明文档则帮助理解项目结构与运行方式。目前已有56人学习下载。读者可从中获取一套可直接运行的DFA过滤脚本、清晰的算法实现思路以及词库与代码分离的工程组织方式,便于在此基础上扩展词库、接入实际业务或对比其他过滤方案。

1. 敏感词过滤为什么总在“看起来没问题”时翻车

做过内容系统的人大多有过这种经历:本地测试几十条文本全绿,一上线就被用户用谐音、拆字、夹杂符号绕过去,日志里翻半天才发现是匹配逻辑漏了边界。基于 DFA 算法的 Python 敏感词过滤实现,解决的正是这类“规则明确、但匹配要快要准”的场景——它把敏感词库构建成一棵状态转移树,一次扫描就能命中所有模式串,时间复杂度只和待检测文本长度相关,和词库规模基本脱钩。适合谁?做社区、弹幕、评论、IM 消息、商品标题审核的后端和算法同学,尤其是词库几千到几十万条、QPS 又不低的线上服务。下面按“先立住原理、再动手复现、最后讲坑”的顺序拆开讲。

2. DFA 敏感词过滤的核心原理与选型理由

2.1 为什么是 DFA,而不是逐个 in 判断或正则

最朴素的做法是维护一个列表,对每条文本遍历词库做in判断。词库 1 万条、文本 200 字,单次检测就是百万级字符比较,QPS 上百就顶不住。正则稍好,但把上万个词拼成一条a|b|c的大正则,编译慢、回溯风险高,词库一更新就得重新编译,线上热更新很别扭。

DFA(Deterministic Finite Automaton,确定有限状态自动机)的思路完全不同:把词库里所有词的前缀合并成一棵树,每个节点代表“当前已经匹配到的前缀”,每条边代表下一个字符。检测时从根节点出发,逐字符走,走到终止节点就说明命中了一个敏感词。它的好处是:

  • 匹配时间只和文本长度成正比,与词库大小无关;
  • 词库更新只需重建树,不影响匹配接口;
  • 天然支持前缀共享,比如“敏感”“敏感词”“敏感信息”共用前两个字的状态。

常见做法是用嵌套字典表示这棵树:{字符: {字符: {..., 'is_end': 1}}}。is_end标记某个节点是不是某个敏感词的结尾。这个结构在 Python 里读写都直观,缺点是内存占用比双数组 Trie 高,但几万词量级完全够用。

2.2 状态树的构建:从词库到嵌套字典

先定义节点结构。每个节点是一个 dict,普通字符作为 key 指向子节点,特殊 keyis_end标记词尾。构建过程就是遍历每个词,从根节点逐字符下沉,不存在就新建,最后一个字符的节点打上is_end。

# 构建 DFA 状态树 def build_dfa(words): root = {} for word in words: word = word.strip() if not word: continue node = root for ch in word: # setdefault 保证节点存在,避免重复判断 node = node.setdefault(ch, {}) # 词尾标记,值为 1 表示一个完整敏感词在此结束 node['is_end'] = 1 return root

逻辑说明:setdefault(ch, {})在 key 不存在时创建空 dict 并返回,存在时直接返回已有子节点,这样一行完成“取或建”。参数上唯一需要注意的是is_end这个 key 不能和真实字符冲突——正常文本里不会出现这个字符串作为单字符,所以安全。如果词库里有单字敏感词,它会在根节点的直接子节点上打is_end,匹配时同样能命中。

2.3 匹配过程:一次扫描命中所有模式

匹配时对文本每个位置作为起点尝试走树,走到is_end就记录命中。这里有个关键选择:命中后是继续走还是回退。如果词库存在“AB”和“ABC”两个词,文本是“ABC”,从 A 出发走到 B 命中“AB”,继续走到 C 又命中“ABC”,两个都该报。所以命中后不能停,要继续沿树走,直到走不下去为止。

# 在文本中查找所有敏感词,返回 (起始下标, 结束下标, 词) def search(text, root): hits = [] n = len(text) for i in range(n): node = root j = i while j < n and text[j] in node: node = node[text[j]] j += 1 if node.get('is_end'): # 命中,记录区间;继续走以捕获更长词 hits.append((i, j, text[i:j])) return hits

逻辑说明:外层i枚举起点,内层j沿树推进。node.get('is_end')用 get 避免 KeyError。返回三元组方便后续做替换或高亮。参数上,text建议先做统一大小写、全半角归一化,否则“ABC”和“abc”会被当成不同词,这是后面避坑章要展开的点。

2.4 复杂度与内存:什么量级该换方案

单次匹配最坏情况是每个起点都走满最长词长度,复杂度 O(n·L),L 是最长敏感词长度。实际中大量起点走一两步就断,均摊远低于这个上界。内存方面,嵌套 dict 每个节点有 Python 对象开销,经验值:1 万词、平均长度 4,树节点约 3 万到 4 万,占用几 MB 到十几 MB。词库到百万级、内存敏感时,才需要考虑双数组 Trie 或 AC 自动机(DFA 的加强版,带失败指针,能一次扫描命中所有模式而不需要每个起点重走)。对绝大多数业务,嵌套字典 DFA 是性价比最高的选择。

3. 用 Python 把 DFA 敏感词过滤跑起来

3.1 最小可运行版本:从词库文件到检测函数

把前面两段拼起来,加上词库加载和主流程,就是一个能直接跑的版本。词库用纯文本,一行一个词,方便运营维护。

# sensitive_filter.py import os class DFAFilter: def __init__(self): self.root = {} def load_words(self, path): """从文本文件加载词库,一行一个词""" with open(path, 'r', encoding='utf-8') as f: for line in f: word = line.strip() if not word: continue node = self.root for ch in word: node = node.setdefault(ch, {}) node['is_end'] = 1 def search(self, text): hits = [] n = len(text) for i in range(n): node = self.root j = i while j < n and text[j] in node: node = node[text[j]] j += 1 if node.get('is_end'): hits.append((i, j, text[i:j])) return hits def replace(self, text, mask='*'): """把命中的敏感词替换成等长掩码""" hits = self.search(text) if not hits: return text chars = list(text) for start, end, _ in hits: for k in range(start, end): chars[k] = mask return ''.join(chars) if __name__ == '__main__': f = DFAFilter() f.load_words('words.txt') print(f.search('这是一段包含敏感词的测试文本')) print(f.replace('这是一段包含敏感词的测试文本'))

逻辑说明:load_words逐行读词建树,search返回所有命中区间,replace把命中区间逐字符替换成掩码。参数上mask默认*,也可以传#或空串做删除。注意replace里用 list 改字符再 join,比字符串拼接高效,长文本下差别明显。

3.2 词库加载的性能与热更新

词库几千条时启动加载几十毫秒,几万条也就几百毫秒,可以接受。但线上服务不能每次请求都读文件,正确做法是进程启动时加载一次,常驻内存。词库更新时,常见做法是提供一个 reload 接口,重新构建一棵新树,用原子替换的方式换掉旧引用,避免匹配过程中树被改坏。

import threading class DFAFilter: def __init__(self): self.root = {} self._lock = threading.Lock() def reload(self, path): """重建状态树后原子替换,避免匹配中途读到半棵树""" new_root = {} with open(path, 'r', encoding='utf-8') as f: for line in f: word = line.strip() if not word: continue node = new_root for ch in word: node = node.setdefault(ch, {}) node['is_end'] = 1 with self._lock: self.root = new_root

逻辑说明:先在局部变量new_root上建完整棵树,再在锁内替换self.root引用。Python 的引用赋值是原子的,匹配线程要么看到旧树要么看到新树,不会看到半棵。参数上锁只保护替换动作,不保护整个匹配过程,所以对吞吐影响极小。

3.3 命中结果的处理:替换、高亮与打标

检测出命中区间后,业务上通常有三种处理:替换成掩码、前端高亮、或者只打标不修改原文交给人工复核。替换前面已给。高亮可以返回区间列表让前端处理。打标则把命中词和位置写进审核日志。

def highlight(text, hits): """返回带标记的文本,标记用自定义标签,前端自行渲染""" if not hits: return text # 按起点排序,从后往前插入避免下标偏移 hits_sorted = sorted(hits, key=lambda x: x[0], reverse=True) for start, end, word in hits_sorted: text = text[:start] + '<em>' + text[start:end] + '</em>' + text[end:] return text

逻辑说明:从后往前插入标记,前面区间的下标不受影响,这是处理区间替换的通用技巧。参数上标签名按前端约定改,不要用会与正文冲突的字符。

3.4 和业务系统对接的接口设计

过滤模块对外一般暴露两个方法:check(text)返回是否命中及命中词,filter(text)返回处理后的文本。接口要幂等、无副作用,方便在网关、业务层、异步审核队列多处复用。如果 QPS 很高,可以在前面加一层本地缓存,把最近检测过的文本和结果缓存起来,命中率高的场景能省不少 CPU。

from functools import lru_cache class DFAFilter: @lru_cache(maxsize=10000) def check_cached(self, text): """对重复文本做缓存,注意 text 必须可哈希""" return tuple(self.search(text))

逻辑说明:lru_cache按参数缓存结果,适合短文本、重复率高的场景。参数maxsize按内存预算调,1 万条短文本结果占用很小。注意缓存的是search结果,词库 reload 后要清缓存,否则会返回旧结果,这是容易忽略的点。

4. 敏感词过滤的避坑与排查清单

4.1 现象:明明词库里有,就是匹配不到

原因通常是文本预处理没做。用户输入“敏 感 词”中间夹空格、“SENSITIVE”全角、“Sensitive”大小写混用,而词库里是标准写法。解决:在search前对文本做归一化——去空白、全角转半角、统一小写。归一化会改变下标,如果业务需要原文位置,要维护一张映射表把归一化后的下标还原回原文。

4.2 现象:短词把长词“吃掉”,只报了一个

原因在于命中后没有继续走树,或者外层循环提前 break。比如词库有“敏感”和“敏感词”,文本“敏感词”,如果命中“敏感”就停,就漏了“敏感词”。解决:命中后继续沿树走,把所有以该起点开头的词都收集,最后按业务需要决定报最长还是全报。常见做法是报最长匹配,但审核场景建议全报,避免漏判。

4.3 现象:替换后文本长度变了,前端高亮错位

原因是用字符串 replace 或删除式替换改变了长度。解决:替换时保持等长,用掩码字符逐位替换,不要用text.replace(word, '')。如果必须删除,先收集所有区间,再从后往前删,并同步调整前端的高亮区间。

4.4 现象:词库更新后老进程还在用旧词

原因是没有 reload 机制,或者 reload 时直接改旧树导致匹配线程读到中间状态。解决:按 3.2 的方式建新树再原子替换引用,替换后清掉lru_cache。另外多进程部署时,每个 worker 都要触发 reload,不能只更新一个进程。

4.5 现象:内存涨得比预期快

原因可能是词库里有大量长词或重复前缀没共享,也可能是缓存无上限。解决:先统计词库词数和平均长度,估算节点数;缓存设 maxsize;如果确实到百万词量级,考虑双数组 Trie 或把词库分片,按业务维度拆成多棵小树,减少单棵树的节点数。

5. 让 DFA 过滤更抗绕过的几个进阶技巧

基础版 DFA 能解决标准词的匹配,但真实对抗里用户会变形。一个实用技巧是在归一化阶段做“变形还原”:把常见谐音、拆字、夹杂符号的写法映射回标准形式,再送进 DFA。比如维护一张变体映射表,把“氵”加“咸”这类拆字组合、把中间插入的*、.、空格去掉后再匹配。注意这一步要在原文上做映射记录,否则替换时会破坏原文结构。

第二个技巧是分级词库。把词库拆成“拦截级”“审核级”“提醒级”,每级一棵树或一棵树里用不同is_end值标记级别。匹配时返回级别,业务按级别决定是直接拒绝、转人工还是仅记录。这样同一套匹配逻辑能服务多种策略,不用为每个级别写一遍代码。

第三个技巧是结合 AC 自动机做超长文本。DFA 每个起点都要重走,文本几万字时开销明显。AC 自动机在 DFA 基础上加了失败指针,一次扫描就能命中所有模式,适合日志审计、批量历史消息清洗这类离线场景。实现上可以用现成库,也可以自己加失败指针,核心是把每个节点的失败指向“最长后缀对应的节点”。

验证过滤效果不能只靠人工看几条。常见做法是准备一批正样本(应命中的变形文本)和负样本(不应命中的正常文本),跑一遍统计召回和误报。召回低就补变体映射,误报高就检查词库有没有过短或过泛的词。我自己的习惯是每次改词库或改归一化逻辑,都先跑一遍这个回归集,确认没有把之前能拦的漏掉、也没把正常内容误伤。这套流程跑顺之后,敏感词过滤就从“上线靠运气”变成“改动有依据”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询