Go敏感词过滤:DFA前缀树建树、匹配与热更新实践
简介:这份 Go 语言敏感词过滤工具面向需要在服务端快速接入内容审核的开发者,核心移植自 Java 版 DFA 算法,并附带从百度搜索整理的基础词库,可直接用于昵称、评论、弹幕等文本的命中检测与替换。压缩包共 6 个文件,体积约 3KB,其中 4 个 .go 文件承载词库加载、匹配与替换等核心逻辑,1 个 .dict 词库文件便于按业务自行补充词条,1 个 .md 说明文件交代基本用法与测试入口。项目通过在 init 方法中调用 ReadSwfDict 加载词库,在检查点调用 Match 验证或 Repl 替换,swf_test.go 提供了具体调用示例,结构轻量,适合嵌入现有 Go 项目或作为 DFA 入门参考。已有 1911 人学习下载,读者可据此快速理解敏感词过滤的字典树思路,并替换成自有词库完成二次扩展。
1. 词库先落地,再谈算法:这套 Go 敏感词过滤能省掉多少自研时间
做内容审核的 Go 服务里,敏感词过滤是最不起眼却最容易背锅的一环。我见过一个评论区服务,词表一万出头,每次请求都拿strings.Contains把整张词表从头扫一遍,QPS 上到两百,P99 直接从 30ms 拉到 400ms;换成本地 DFA 前缀树之后,同样的机器和并发压到 12ms 以内。这份 Go 敏感词过滤项目给的就是这样一套能直接跑起来的东西:一份公开来源的行业词库、一套 DFA 树构建与匹配的实现、一个可以自己往文件里追加词条的扩展入口。代码量不大,读一遍心里就有数。它适合两类人:一类是给评论、私信、昵称做前置拦截的后端,另一类是想把过滤能力塞进已有网关、又不想引一整套内容安全服务的团队。下面按词库清洗、建树、匹配、变形对抗、避坑、压测的顺序拆。
2. 从词库文件到内存结构:DFA 前缀树的构建与参数取舍
拿到一份外部词库,绝大多数人的第一反应是for _, w := range words { if strings.Contains(text, w) {...} }。词表一千条、文本两百字,这个写法能撑住;词表十万条、文本一封私信两千字,就是两亿次子串扫描,一个核跑满也扛不住几十 QPS。DFA 前缀树把「和每一条词比」变成「在文本上走一遍树」,复杂度从 O(词数 × 文本长) 降到 O(文本长 × 最长词长),这是这套方案第一个值得抄的地方。这一步要做的是把磁盘上的词库变成一个常驻内存的树结构,并且保证这个结构建得快、占得少、改得动。
2.1 词库先做体检,再谈建树
外部词库几乎没有一个是干净的。我拆过的几份词表里,问题集中在五类:文件头带 UTF-8 BOM、行尾是 Windows 的\r\n、同一个词出现四五次、带「词,类别」或「词\t权重」的附加列、以及混进来几条几十个字符的拼接串。前两类最阴,BOM 会让第一个词的第一个 rune 变成\ufeff,\r会让最后一个词永远差一个字符,两者都不会报错,只会让线上漏杀,属于典型的黑匣子故障。
package filter import ( "bufio" "fmt" "os" "strings" ) // Stat 记录一次词库体检的结果,上线前拿它对账,比肉眼看文件靠谱。 type Stat struct { Total int // 原始行数 Kept int // 真正进入词表的行数 Dropped int // 空行、注释、重复行 MaxRunes int // 最长词条的字符数,决定匹配内层循环上限 ByLen map[string]int // 按长度分桶,看单字词占比 } // precheck 读入词库文件,做编码、去重、长度三项体检。 func precheck(path string) ([]string, Stat, error) { f, err := os.Open(path) if err != nil { return nil, Stat{}, err } defer f.Close() stat := Stat{ByLen: map[string]int{}} seen := make(map[string]struct{}, 1<<15) words := make([]string, 0, 1<<15) sc := bufio.NewScanner(f) // Scanner 默认单行上限 64KB,词库里一旦有拼接串就会报 token too long sc.Buffer(make([]byte, 0, 64*1024), 1<<20) first := true for sc.Scan() { line := sc.Text() if first { line = strings.TrimPrefix(line, "\ufeff") // 去 BOM first = false } line = strings.TrimSpace(line) // 顺手把 \r 去掉 stat.Total++ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { stat.Dropped++ continue } if i := strings.IndexAny(line, ",\t"); i > 0 { line = strings.TrimSpace(line[:i]) // 只取第一列 } if line == "" { stat.Dropped++ continue } if _, ok := seen[line]; ok { stat.Dropped++ continue } seen[line] = struct{}{} words = append(words, line) if n := len([]rune(line)); n > stat.MaxRunes { stat.MaxRunes = n } stat.ByLen[lenBucket(line)]++ } if err := sc.Err(); err != nil { return nil, stat, fmt.Errorf("scan wordlist: %w", err) } stat.Kept = len(words) return words, stat, nil } func lenBucket(s string) string { switch n := len([]rune(s)); { case n == 1: return "1" // 单字词单独统计,误杀率的主要来源 case n <= 4: return "2-4" case n <= 8: return "5-8" case n <= 16: return "9-16" default: return "17+" } }这段代码有三个参数值得盯住。sc.Buffer的第二个参数决定单行上限,我给到 1MB,因为有些词库会把一整个变体家族拼在一行里;如果你确定词条都在 200 字符以内,降到 64KB 也够。IndexAny(line, ",\t")决定附加列的切分符,词库格式不同就改这里,注意i > 0而不是i >= 0,否则第一列就是分隔符的行会被切成空串。lenBucket的分桶边界是给人看的,跑完打印stat.ByLen["1"],如果单字词超过总量的 5%,这批词上线前必须单独过一遍,单字词是误杀率最大的来源,后面第 5 章会专门讲。
体检跑完,正常的公开词库大概是「十万行读进来、九万五保留、最长词 30 字符」这个量级。数字不对就说明格式有问题,别急着建树。
2.2 节点定义与建树循环
DFA 前缀树的节点只需要回答三个问题:从这个节点还能往下走哪些字符、走到这里是不是一个完整词、这个词属于哪个级别。用map[rune]*node而不是固定长度的数组,是因为中文词库的字符集压根没法枚举,map 的哈希开销在建树阶段可以忽略,在匹配阶段才是热路径。
type node struct { children map[rune]*node word string // 非空表示这里是一个词的结尾,存原词便于回填结果 level int8 // 1=拦截 2=审核 3=提示,词库带级别列时填进来 } // build 把词条列表压成一棵前缀树,返回根节点。 func build(words []string, levels map[string]int8) *node { root := &node{children: make(map[rune]*node, 1024)} for _, w := range words { rs := []rune(w) if len(rs) == 0 { continue } cur := root for _, r := range rs { next, ok := cur.children[r] if !ok { // 叶节点分支极少,初始容量给 2 够用,能省下可观的内存 next = &node{children: make(map[rune]*node, 2)} cur.children[r] = next } cur = next } cur.word = w cur.level = levels[w] } return root }两个细节值得说。第一,根节点的 map 初始容量给到 1024,因为中文高频首字集中在几百个常用字上,预分配能少触发几十次扩容;叶节点给 2,是因为绝大多数词条在最后一层没有兄弟分支,给多了就是纯浪费,十万词条的树里叶节点占九成以上,这个数字直接决定内存。第二,cur.word存的是完整原词,而不是靠回溯字符拼出来,匹配时命中即取,省掉一次反向遍历,代码也短。
为什么不用 AC 自动机?AC 在纯英文或多模式批量匹配场景确实更快,但中文词库的共享前缀比例很高,DFA 的树高通常不超过 8 层,实测下来两者差距在 20% 以内,而 AC 要维护失败指针、建树和内存都更重。如果文本是几十 KB 的长文、词库几十万条,AC 才值得上;短文本高频调用的接口服务,DFA 是更划算的选择。
2.3 加载耗时、内存占用与并发读的取舍
树建好之后要放在哪、谁能读、怎么换,这三件事决定了这个组件能不能长期活在生产环境。十万词条的树,在我这边的机器上建一次大约 120ms,常驻内存 40MB 出头,这个成本只能付一次,绝不能放在请求路径上。服务启动时建一次,之后所有读请求共享同一棵树,这就是最朴素也最正确的方案。
type Filter struct { tree atomic.Value // 存 *node,读写分离的关键 writeMu sync.Mutex // 只保护 Reload,读路径完全无锁 } func NewFromFile(path string) (*Filter, error) { words, _, err := precheck(path) if err != nil { return nil, err } f := &Filter{} f.tree.Store(build(words, nil)) return f, nil } func (f *Filter) current() *node { return f.tree.Load().(*node) // 类型断言必须与 Store 的类型完全一致 } // Reload 支持运行期换词库:新请求立刻读新树,在途请求继续用完手上的旧树。 func (f *Filter) Reload(path string) error { f.writeMu.Lock() defer f.writeMu.Unlock() words, _, err := precheck(path) if err != nil { return err } f.tree.Store(build(words, nil)) return nil }用atomic.Value而不是sync.RWMutex的理由很直接:读路径上每毫秒都可能发生几万次,RWMutex 的读锁虽然是共享的,但仍有原子操作和写锁饥饿问题;atomic.Value是一次指针替换,读端没有临界区。代价是换树期间新旧两棵树同时存在,内存会短暂翻倍到 80MB,所以 Reload 不能太频繁,我一般限制在分钟级,绝不放进定时任务里的秒级循环。
Store和Load的类型必须严格一致,这是atomic.Value最坑的地方:第一次Store的是*node,后面就不能再存别的东西,包括不能存nil,否则直接 panic。writeMu只保护写,读路径上完全没有锁竞争,这是这套结构能扛住高并发的根本原因。
3. 匹配引擎怎么写:一次扫描拿到全部命中位置
树建好了,接下来是在文本上走。这里最容易写错的地方不是性能,是「命中语义」:一条文本里出现「敏感词」和「敏感」两个词条时该报几个、从第几个字开始、替换的时候覆盖多长。这些问题的答案决定了接口返回值长什么样,也决定了上层业务能不能正确脱敏。
3.1 主匹配循环与复杂度边界
匹配的核心是两层循环:外层遍历文本的每个 rune 作为起点,内层沿着树往下走。外层不能跳,因为任何一个位置都可能是某个词的开头;内层一旦走不下去就立刻 break,这是前缀树能做快的原因。
type Hit struct { Start int // 在入参文本 rune 数组中的起始下标,左闭 End int // 结束下标,右开 Word string // 命中的词条原文 Level int8 // 词条级别 } // Match 在原文上做一次扫描,返回合并后的命中列表。 // 复杂度 O(n*L),n 是文本 rune 数,L 是词库最长词长度。 func (f *Filter) Match(text string) []Hit { runes := []rune(text) root := f.current() hits := make([]Hit, 0, 4) for i := 0; i < len(runes); i++ { cur := root for j := i; j < len(runes); j++ { next, ok := cur.children[runes[j]] if !ok { break // 这棵树从这里走不下去了,直接下一处起点 } cur = next if cur.word != "" { hits = append(hits, Hit{Start: i, End: j + 1, Word: cur.word, Level: cur.level}) // 不 break:同起点可能还有更长的词,长词优先在上层合并时处理 } } } return mergeOverlap(hits) }三个点必须说清楚。第一,转成[]rune再遍历,不能直接按字节遍历。中文一个字三字节,按字节走树会把一个汉字劈成三段,永远匹配不上。这个转换会分配一次数组,2000 字的文本大概是 8KB,对 GC 有点压力,文本特别长时可以用for i, r := range text加utf8.RuneLen手工推进,省掉分配。第二,命中后不 break,因为「敏感」和「敏感词」可能在同一条路径上,提前退出就拿不到长词。第三,外层i++而不是跳到j+1,因为「AB」和「BC」这种交叉词必须都能被发现,跳过起点就会漏杀,这是血泪经验换来的保守写法。
内层循环的上限其实是树的深度,词库最长词 30 字符,那内层最多 30 次,和词条总数无关。这就是 DFA 相对strings.Contains循环最本质的差异。
3.2 命中结果与重叠合并
不合并会出什么问题?文本「这个人真敏感词」里,「敏感」和「敏感词」都会被命中,返回两条重叠的结果,上游按条数计分会把一次违规算成两次,按条打码会把后面几个字也涂黑。合并策略选「起点最早的优先,同起点取最长的」,是业务上最直觉的一种。
func mergeOverlap(hits []Hit) []Hit { if len(hits) < 2 { return hits } sort.Slice(hits, func(i, j int) bool { if hits[i].Start != hits[j].Start { return hits[i].Start < hits[j].Start } return hits[i].End > hits[j].End // 同起点,长的排前面 }) out := hits[:1] for _, h := range hits[1:] { last := out[len(out)-1] if h.Start < last.End { continue // 与上一条重叠,丢弃 } out = append(out, h) } return out }这里用hits[:1]复用底层数组,不额外分配。如果你想保留「所有命中」给上游做分级决策,就不要调mergeOverlap,或者给它加个开关。我一般两种都留:Match返回合并后的,MatchAll返回原始的,让业务自己选。
3.3 三种输出:布尔判断、命中列表、脱敏文本
同一个引擎,上层要的东西有三种,接口设计上别混在一起。只要个「能不能过」的,给布尔;要记录审核日志的,给命中列表;要直接把内容返回给用户的,给脱敏后的字符串。第三种最容易写错,因为替换的下标必须是原文的 rune 下标。
// IsSensitive 只要判断结果,命中第一条就返回,省掉后续遍历。 func (f *Filter) IsSensitive(text string) bool { runes := []rune(text) root := f.current() for i := 0; i < len(runes); i++ { cur := root for j := i; j < len(runes); j++ { next, ok := cur.children[runes[j]] if !ok { break } cur = next if cur.word != "" { return true // 提前返回,这是它比 Match 快的地方 } } } return false } // Replace 把命中区间替换为 mask,区间基于原文 rune 下标。 func (f *Filter) Replace(text string, mask rune) (string, []Hit) { hits := f.Match(text) if len(hits) == 0 { return text, nil } runes := []rune(text) for _, h := range hits { for k := h.Start; k < h.End; k++ { runes[k] = mask } } return string(runes), hits }IsSensitive和Match是两个函数而不是一个函数加参数,因为提前返回的性能差距在文本越长时越明显:一封 2000 字的私信,如果第三十个字就命中,IsSensitive只走了三十分之一的路程。Replace里遍历的是合并后的hits,区间天然不重叠,直接涂就行,不需要额外的边界判断。mask用 rune 而不是 string,是因为一个汉字三字节而*一字节,替换后字节长度会变,按 rune 数组改完再转字符串最稳。
4. 干扰字符对抗:归一化管线与位置映射怎么不丢坐标
词库里写的是「违禁词」,用户在评论区打的是「违·禁·词」,中间还夹了两个零宽空格。前面那套匹配一字不差地跑,结果就是不命中。真实业务里这类变形能占到漏杀的六成以上,来源包括输入法全角、复制粘贴带不可见字符、用户手动加分隔符。解决思路只有一个:先把文本归一化,在归一化文本上跑匹配,再把结果映射回原文坐标。这一步是这套方案里工程量最大的部分。
4.1 归一化顺序决定漏杀率
归一化的动作有四个:去不可见字符、全角转半角、大写转小写、去标点与空白。顺序错了会出问题:如果先转小写再转半角,全角的「A」转成半角后是「A」,还是大写,等于白转。正确顺序是先去不可见字符,再全角转半角,再转小写,最后去分隔符。
type NormText struct { Runes []rune // 归一化后的字符序列 Pos []int // Pos[i] 表示 Runes[i] 在原文 rune 数组中的下标 } func isIgnorable(r rune) bool { switch r { case '\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad': return true // 零宽空格、零宽连接符、BOM、软连字符 } return false } func toHalf(r rune) rune { if r >= 0xFF01 && r <= 0xFF5E { return r - 0xFEE0 // 全角 ASCII 区整体偏移 } if r == 0x3000 { return ' ' } return r } // Normalize 保留原文坐标的前提下做归一化,Pos 是后面回映射的关键。 func Normalize(s string) NormText { src := []rune(s) out := make([]rune, 0, len(src)) pos := make([]int, 0, len(src)) for i, r := range src { if isIgnorable(r) { continue // 不可见字符直接丢,不占坐标 } r = unicode.ToLower(toHalf(r)) if unicode.IsSpace(r) { continue // 空白与标点是否丢弃,见下面的说明 } out = append(out, r) pos = append(pos, i) } return NormText{Runes: out, Pos: pos} }要不要连标点一起丢,是个需要权衡的决定。丢的好处是「敏.感.词」「敏-感-词」这类插入分隔符的变形能被抓到;坏处是「他很敏感,词语贫乏」这种跨标点的正常句子会被拼成「敏感词语」,直接误杀。我的做法是只丢空白和零宽字符,标点保留,同时在词库里补一批「带标点的变体」——宁可多维护几百条词,也不要把误杀率放出去。如果业务上非要去标点,至少保证只在标点两侧都是待匹配词的中间字符时才拼接,别全局丢。
4.2 用下标映射还原原文位置
归一化之后长度变了,这是所有麻烦的根源。原文「敏·感」是三个 rune,归一化后是两个,如果拿归一化得到的[0,2)直接去切原文,切出来的是「敏·」。
// MatchNormalized 在归一化文本上跑同一棵树,返回的坐标是归一化下标。 func (f *Filter) MatchNormalized(n NormText) []Hit { root := f.current() hits := make([]Hit, 0, 4) for i := 0; i < len(n.Runes); i++ { cur := root for j := i; j < len(n.Runes); j++ { next, ok := cur.children[n.Runes[j]] if !ok { break } cur = next if cur.word != "" { hits = append(hits, Hit{Start: i, End: j + 1, Word: cur.word, Level: cur.level}) } } } return mergeOverlap(hits) } // ReplaceNormalized 把归一化命中的区间映射回原文再涂掉。 func (f *Filter) ReplaceNormalized(text string, mask rune) string { n := Normalize(text) hits := f.MatchNormalized(n) if len(hits) == 0 || len(n.Pos) == 0 { return text } runes := []rune(text) for _, h := range hits { start := n.Pos[h.Start] // 归一化起点 → 原文起点 end := n.Pos[h.End-1] + 1 // 归一化终点 → 原文终点(右开) if start < 0 || end > len(runes) { continue // 防御性判断,正常走不到 } for k := start; k < end; k++ { runes[k] = mask } } return string(runes) }关键在于n.Pos[h.End-1] + 1,边界取的是「最后一个归一化字符在原文里的位置再加一」,这样中间被丢掉的分隔符也会被一起涂掉,视觉上是连续的***,不会出现「敏*感」这种半遮半掩的结果。Pos数组必须和Runes一一对应,append两处必须同时发生,漏一处后面全错位。
代价是每次请求多两次数组分配。2000 字的文本,Normalize大约是 30 微秒,跟匹配本身在同一个量级,可以接受。如果接口对延迟极其敏感,可以只在归一化文本和原文不相等时才走这条慢路径,绝大多数正常文本能直接走 3.1 节的快速版本。
4.3 词库扩展与热更新怎么不炸
词库一定要能改,而且最好不用重启。第 2.3 节的Reload已经给了原子换树的方案,但实际用起来还有两个问题:一是多条业务线共用一份词表时,A 线加词会让 B 线也受影响;二是每次改一两个词都要全量重建,十万词条的树重建一次 120ms,改得频繁时 CPU 会毛刺。
常见的做法是按业务线拆树,每条线一份词表、一个Filter,互不干扰,公共词抽一份基础表在建树时合并进去。
// Sharded 按业务线分片,每条线独立建树独立重载。 type Sharded struct { mu sync.RWMutex shards map[string]*Filter } func (s *Sharded) Reload(line, path string) error { f, err := NewFromFile(path) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if s.shards == nil { s.shards = make(map[string]*Filter, 8) } s.shards[line] = f // 整体替换指针,不修改正在被读的树 return nil } func (s *Sharded) Get(line string) *Filter { s.mu.RLock() defer s.mu.RUnlock() return s.shards[line] }分片带来的是内存翻倍,每条线一份树,如果公共词占大头就不划算。另一种做法是公共树 + 业务树两棵,文本先过业务树再过公共树,合并结果,内存省一半,代价是两次遍历。我一般按业务规模选:日活不高、词表各不相同的,直接分片;公共词占 80% 以上的,用两棵树。
白名单也必须在这一层解决,不能靠上游过滤。做法是单独建一棵白名单树,匹配时先跑白名单,命中区间直接从候选里剔除。别用字符串黑名单去Contains白名单,那又回到了 O(n×m)。
5. 避坑与排查:上线后最容易翻车的五种现象
这套东西代码量不大,但每一个坑我基本都亲手踩过。下面五条按「现象 → 原因 → 解决」写,都是上线之后才暴露、本地测试跑不出来的那种。
5.1 加载与启动阶段
现象一:词库里明明有这个词,线上死活不命中,本地拿同一份文件测又是好的。原因大概率是文件编码差异:测试环境手工存的文件没有 BOM,生产环境从别处同步过来的带 BOM,第一个词的首字符变成\ufeff,建树时挂在了根节点下一个不可见的 rune 上,永远走不到。同理,Windows 行尾的\r会让最后一个词多一个字符。解决办法是 2.1 节的TrimPrefix(line, "\ufeff")加TrimSpace,并且在启动日志里打印词库的stat.Total和stat.Kept,两个数字对不上就先查格式。
现象二:服务启动直接 panic 或者报bufio.Scanner: token too long。原因是Scanner默认单行上限 64KB,词库里有把整个变体家族拼在一行的长词条,或者文件里混进了一段二进制内容。解决就是sc.Buffer(make([]byte, 0, 64*1024), 1<<20),把上限提到 1MB,同时在sc.Err()上做检查,别只判err == nil就以为读完了。这个问题在测试环境不会出现,因为测试用的精简词表没有长行,属于典型的「上线才炸」。
现象三:加了单字词之后,满屏都是星号,用户昵称「小明」变成了「」。** 原因是单字词在前缀树里是最短路径,任何包含该字的文本都会命中,而中文常用字就那么几百个,等于把整个语料库都标记了一遍。解决有两步:一是建树前统计stat.ByLen["1"],超过总量 5% 就人工过一遍;二是单字词强制要求上下文,比如规定「单字词只在前一个字符也在词库中时才生效」,或者干脆把单字词放到独立的低级别树里,只做提示不做拦截。
5.2 运行与更新阶段
现象四:热更新之后内存翻倍、GC 抖动明显,甚至 OOM。原因是atomic.Value换树后,旧树还被在途请求的局部变量持有,两棵十万节点的树同时存活,内存从 40MB 涨到 80MB,如果更新频率高,旧树还没被回收新树又建好了。解决办法是控制更新频率在分钟级,更新前先做个简单的容量判断,同时在监控上盯go_memstats_heap_inuse_bytes,看到台阶式上涨就说明旧树没释放干净。
现象五:归一化之后替换位置错位,本该涂掉「敏感」两个字,结果把后面三个字涂了。原因是拿归一化文本的下标直接去切原文,中间丢掉的零宽字符和空格没有补偿。解决是 4.2 节的Pos映射,所有替换必须走n.Pos[h.Start]和n.Pos[h.End-1]+1这条路径,不能有捷径。这个 bug 最坑的地方是只有带特殊字符的文本才复现,普通文本一切正常,灰度期间很难发现,只能靠对拍脚本覆盖。
还有一条不算坑但必须提:atomic.Value第一次Store的类型决定了之后所有Store的类型,如果哪里不小心写了f.tree.Store(nil),直接 panic 且没有堆栈提示。我习惯在NewFromFile里先Store一个空树,保证类型确定,后续Load时不用做 nil 判断。
6. 压测与对拍:用朴素实现当标尺把误杀率压下去
优雅的树写完了,怎么证明它是对的?我的习惯是写一个最笨的strings.Contains版本当标尺,拿同一批语料跑两边,结果对不上就是树或合并逻辑有问题。这个办法看着土,但能抓住 90% 的逻辑 bug,尤其是归一化和位置映射那一层。
// naiveMatch 是最笨的实现,只用来对拍,不进生产。 func naiveMatch(text string, words []string) map[string]struct{} { out := make(map[string]struct{}, 8) for _, w := range words { if w != "" && strings.Contains(text, w) { out[w] = struct{}{} } } return out }对拍语料不能只用正常句子,要专门造三类:带零宽字符的、全角混半角的、带标点分隔的。跑go test -run TestDiff -count=1时,只要有一句话两边结果不一致,就把那句话和两边的命中列表打出来,逐条看是漏杀还是误杀。我一般要求这批语料的对拍通过率 100% 才敢上线,因为这类 bug 一旦漏到线上,改起来要重新过一遍灰度。
指标口径也得统一,不然压测数字没有可比性:
| 指标 | 口径 | 参考值(10 万词条 / 2000 字文本 / 4 核) |
|---|---|---|
| 单次 Match 耗时 | 不含归一化 | 60~90 微秒 |
| 单次 Replace 耗时 | 含归一化 | 120~180 微秒 |
| 建树耗时 | 冷启动一次 | 100~150 毫秒 |
| 常驻内存 | 仅树结构 | 35~50 MB |
| 误杀率 | 人工标注 500 条正常语料 | 低于 0.5% |
| 漏杀率 | 人工构造 200 条变形语料 | 低于 2% |
误杀率那 0.5% 主要来自单字词和跨标点拼接,漏杀率那 2% 主要来自拼音谐音和拆字,这两块靠纯词库解决不了,得配合上游的语义模型。这套 DFA 的定位是「便宜、快、确定性强」的第一道闸,不是终点。
从那以后我每次接入新词库,都强制先跑一遍precheck看统计、再跑一遍对拍看差异,两个都过了才敢往线上推。这套流程救过我至少两次,一次是词库同步脚本把 BOM 带进来了,一次是单字词混进了主表。希望帮到你。
本文还有配套的精品资源,点击获取