做内容安全这一行,最怕的不是功能不够,而是功能上线之后才被用户和监管“教做人”。我去年接手的那个社区类应用就栽过跟头:评论区和昵称展示里时不时冒出来一些明显违规的表述,接云端接口又慢又贵,还赶上鸿蒙机型的用户反馈说“断网就完全没防护”。也就是在那段时间,我认真把端侧净化墙这件事从头做了一遍,最终沉淀出 censor_it 这个 Flutter 组件,并且完整跑通了鸿蒙 HarmonyOS 的适配。这篇文章就把整个实战过程摊开讲:为什么非要在本地做敏感词过滤、组件架构怎么设计、匹配引擎怎么选型、鸿蒙侧怎么桥接、性能怎么抠、坑都踩在哪里。适合正在做 Flutter 跨端应用、又必须兼顾内容合规的开发者参考,不管团队有没有专职的安全工程师,这套思路都能直接拿过去用。
1. 端侧净化墙:为什么偏要在本地做敏感词过滤
先别急着看代码,把“为什么”这个问题想清楚,后面所有技术选型就都顺了。端侧过滤不是新鲜概念,但很多团队直到被线上事故推着走才愿意做。这节我把需求拆开讲:业务上到底缺什么,技术上为什么必须用端侧方案。
1.1 一次合规治理事故引发的重构
事情起因很典型。某一天运营同事发现,用户通过特殊字符变形绕过了平台的关键词过滤,在某位大V的评论区里刷了满屏的违规内容。后台用的那一套云端审核接口只覆盖了正文,头像、昵称、签名这些“边缘字段”完全没管,结果被人钻了空子。
更麻烦的是,那批内容在服务器日志里根本看不到完整形态——用户端做了某种编码转换,传到云端已经是另一套字符串了。如果继续依赖云端接口,要么把所有传输协议重做一遍,要么在端侧先把这些变体内容拦截下来。
我当时的判断很直接:把过滤能力下沉到端侧,让每一台设备都变成一个独立的“净化节点”。云端继续做深度的内容理解,但高频、低成本、可预判的违规拦截,全部交给端侧完成。
1.2 端侧过滤的四个核心收益
从这次事故往后推,端侧过滤带来的好处非常具体,不是抽象的技术优越感。
第一是实时性。本地字符串匹配走的是 CPU 和内存,命中一次敏感词通常只需要微秒到毫秒级。云端方案即使接口再快,也要算上网络 RTT,弱网环境下一次审核可能耗时 2 到 5 秒,用户早就不耐烦了。评论、弹幕这类高吞吐场景,等云端回来再渲染,体验是灾难性的。
第二是隐私合规。用户的私聊消息、本地草稿、个人名片这类数据,如果全部上传云端做内容审核,会带来巨大的隐私风险。很多应用被通报的下架原因不是“没过滤违规内容”,而是“违规内容没过滤就上传了”。端侧过滤让这些敏感数据留在本地,合规压力一下就小了很多。
第三是成本。云端审核按调用量计费,日活百万的应用一天可能产生上千万次检测请求,这是一笔不小的开销。端侧方案把 90% 以上的简单命中在本地消化,云端只需要处理少量真正“存疑”的内容,费用能省一个量级。
第四是离线可用性。鸿蒙设备的用户群体里有很大一部分是学生、老年用户,他们经常处在地铁、校园、偏远地区等弱网环境。端侧过滤不依赖网络,断网状态下依然能保证基线防护。
1.3 哪些内容必须端侧拦截,哪些交给云端
做过内容安全的人都知道,敏感词过滤只能解决“已知的坏”,不能解决“未知的坏”。所以我从来不主张端侧一把抓,而是把内容安全分成三层。
下表是我在项目里实际采用的拦截分层策略:
| 层级 | 内容类型 | 拦截方式 | 典型场景 |
|---|---|---|---|
| 一层(端侧强拦截) | 明确违规的固定词条、变体变形 | 本地词库、精确/模糊匹配 | 昵称、签名、评论正文、私聊消息 |
| 二层(端侧提示) | 疑似违规、需要人工确认 | 本地先打标,弹窗询问 | 用户发布前的二次确认 |
| 三层(云端深审) | 图像、语义、上下文风险 | 云端策略引擎 + 模型 | 图片 OCR、长文语义、跨场景关联 |
实践下来,第一层能覆盖 60% 到 70% 的明显违规,这已经帮云端省了一大半压力。第二层和第三层属于“兜底”,在端侧方案无法判断时再走人工或云端路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. censor_it 组件架构设计与鸿蒙适配路线
架构设计这一步最忌讳上来就写代码。我习惯先把组件划分成清晰的层次,再考虑每一层在鸿蒙和 Flutter 之间怎么落。censor_it 最终采用的是“Dart 引擎 + 平台通道 + 原生适配层”的三层结构,下面展开讲。
2.1 组件整体架构分层设计
censor_it 的设计目标是:上层业务方只看到一个简单 API,不需要关心底层是啥平台。所以我把它分成了三层:
- API 层:面向 Flutter 业务侧,提供检测、替换、脱敏、回调注册等统一接口。
- 引擎层:纯 Dart 实现敏感词匹配引擎,负责加载词库、构建状态机、执行匹配。
- 平台层:通过 MethodChannel 与鸿蒙原生 ArkTS 代码通信,处理平台特有能力,比如获取系统词库、调用本地 OCR、读取相册图片做过滤。
三层之间严格单向依赖,引擎层不感知平台,平台层只做能力补充。这样设计的好处是:如果哪天要适配 iOS,只需要新增一个 iOS 平台实现,Dart 层一行都不用改。
目录结构大概是这样的:
text复制censor_it/
├── lib/
│ ├── censor_it.dart # 对外统一入口
│ ├── engine/
│ │ ├── censor_engine.dart # 引擎核心(Dart)
│ │ ├── double_array_trie.dart
│ │ └── word_loader.dart
│ └── platform/
│ ├── censor_platform.dart
│ ├── censor_method_channel.dart
│ └── censor_platform_interface.dart
├── ohos/
│ └── src/main/
│ ├── ets/
│ │ └── CensorPlugin.ets
│ └── module.json5
└── example/
2.2 鸿蒙适配的核心:平台能力探测与降级
鸿蒙适配的第一个难点在于运行环境的不确定性。Flutter 应用在鸿蒙设备上可能有三种存在形态:纯鸿蒙化 Flutter 引擎跑起来、传统 Android 兼容包跑起来、或是嵌入到鸿蒙原生工程里跑。三种形态下,Platform Channel 的行为细节都有差异。
我用的策略是“能力探测 + 自动降级”。组件启动时先通过 censor_platform_interface 探测当前环境是否支持原生通道,如果支持,就启用完整的鸿蒙原生能力;如果不支持,引擎层自动切到纯 Dart 模式,保证核心过滤功能不失效。
核心代码长这样:
dart复制abstract class CensorPlatformInterface {
Future<bool> get isHarmonyOSNativeSupported;
Future<List<String>> loadSystemWordList();
Future<CensorResult> censorText(String text, {CensorOptions options});
}
class CensorMethodChannel implements CensorPlatformInterface {
static const MethodChannel _channel = MethodChannel('censor_it');
@override
Future<bool> get isHarmonyOSNativeSupported async {
try {
final supported = await _channel.invokeMethod<bool>('isNativeSupported');
return supported ?? false;
} on PlatformException {
return false;
}
}
@override
Future<CensorResult> censorText(String text, {CensorOptions options}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'censorText',
{'text': text, 'mask': options.mask},
);
return CensorResult.fromMap(result);
}
}
注意 invokeMethod 外层一定要包 try-catch。鸿蒙侧如果插件没有正确注册,调用会抛异常,没有降级逻辑的话整个组件就崩了。
2.3 为什么选“组件化 + Channel”而不是纯 Dart 重写
有人会问:敏感词过滤不就是在 Dart 里做个字符串匹配吗?为什么非要跟鸿蒙原生通信?这个想法对了一半。
纯 Dart 实现匹配引擎完全可行,censor_it 的引擎层就是纯 Dart 写的。但有些能力 Dart 侧拿不到:比如获取系统级输入法联想词库、调用鸿蒙自带的 OCR 能力对图片做内容识别、或者从系统剪贴板里读取文本时读取附加的格式信息。这些只能通过原生通道拿。
还有性能因素。鸿蒙侧的 ArkTS 对字符串处理有一些 Native 层的优化,当词库量非常大(比如超过 50 万词条)时,把构建好的自动机放在原生层,用 ArkTS 做批量匹配,内存访问效率比 Dart 侧高一些。所以最终方案是“Dart 兜底 + 鸿蒙侧增强”,两边都有完整的匹配实现,谁环境好谁上。
3. 敏感词匹配引擎:算法选型与实现
引擎是 censor_it 的心脏,也是性能差异最大的地方。很多团队第一版用 List.contains 或者正则硬匹配,词条一多就卡成 PPT。这一节我把从暴力匹配到双数组 Trie 的演进过程完整记录下来,附上可运行的 Dart 实现。
3.1 第一版性能惨案:遍历与正则的复杂度黑洞
最初原型我是直接用正则做的,词库 1 万条,每条转成一个 RegExp,检测一句 200 字的评论需要把 1 万个正则跑一遍。实测下来,单次检测耗时约 80 到 120 毫秒,打出的日志让我直接沉默了。每秒只能处理不到 10 条消息,完全没法用。
问题出在复杂度上。假设词库有 N 个词条,待检文本长度为 M,暴力遍历的时间复杂度是 O(N * M)。N 和 M 稍微一涨,耗时呈线性爆炸。更糟的是正则引擎还有回溯机制,某些变形词还会触发灾难性回溯,单条检测可能卡几百毫秒。
3.2 Trie 树与 DFA 匹配原理
后来引入了基于前缀树的匹配,业界也叫 Trie 树或字典树。我用一个生活化的例子来解释:想象你在查一本按拼音排序的词典,想找“内容安全”这个词,你不是一页一页翻,而是先翻到“内”这个部首,再顺着“容”“安”“全”一步步往下走。Trie 树做的事情就是这个:把所有敏感词拆成字符路径,共享前缀,形成一棵树。
匹配时从待检文本的每个位置出发,尝试从根节点往下走,每走一步就检查当前节点是否是一个完整词条的终点。如果走到了终点,就说明命中了敏感词。
进一步优化,把 Trie 树转成确定有限状态自动机(DFA),匹配过程中字符跳转是 O(1) 的,整体复杂度降到 O(M)。M 是文本长度,跟词库大小完全无关。这就是为什么词库从 1 万涨到 50 万,检测速度几乎不变的秘密。
3.3 双数组 Trie 的 Dart 实现
Trie 树的朴素实现用 Map 嵌套,每个节点存一个 Map<String, int> 子节点索引,内存开销很大。50 万词条构建完能吃掉 800 MB 内存,这在端侧显然不可接受。于是我用双数组 Trie(Double Array Trie,DAT)来压缩存储。
DAT 的核心思想是用两个数组 base 和 check 表示整棵树的节点关系。base[i] 表示节点 i 的转移基地址,check[i] 表示节点 i 的父节点校验信息。查询字符 c 从节点 i 转移时,下一个节点位置是 next = base[i] + code(c),然后检查 check[next] == i,成立则转移成功。
下面是我在 Dart 里实现的构建与查询核心代码:
dart复制class DoubleArrayTrie {
late List<int> _base;
late List<int> _check;
late List<String> _words;
late int _size;
DoubleArrayTrie(this._words) {
_base = List<int>.filled(65536, 0);
_check = List<int>.filled(65536, -1);
_size = 65536;
_build();
}
void _build() {
// 简化实现:按字构建状态转移
for (final word in _words) {
int current = 0;
for (int i = 0; i < word.length; i++) {
final c = word.codeUnitAt(i);
int next = _base[current] + c;
if (_check[next] != current) {
_expand(next);
_base[current] += c;
_check[next] = current;
}
current = next;
}
}
}
void _expand(int index) {
// 动态扩容,实际实现需要做数组拷贝和重哈希
while (index >= _base.length) {
_base.addAll(List<int>.filled(_base.length, 0));
_check.addAll(List<int>.filled(_check.length, -1));
}
}
List<int> matchAll(String text) {
final hits = <int>[];
for (int i = 0; i < text.length; i++) {
int current = 0;
for (int j = i; j < text.length; j++) {
final c = text.codeUnitAt(j);
final next = _base[current] + c;
if (_check[next] != current) break;
current = next;
if (_base[current] < 0) {
hits.add(i);
hits.add(j + 1);
break;
}
}
}
return hits;
}
}
这里收到的是一个简化版,重点看 matchAll 的双循环结构:外层从文本每个位置起跳,内层沿 Trie 状态走,一旦遇到“无转移”就 break,不会发生回溯,整体是线性的。实际生产版本还需要处理 base 值冲突重定位、负值表示“词尾”等细节,但骨架就是这两层循环。
3.4 词库管理与差异化热更新
算法再快,词库不更新也白搭。censor_it 内置了一份基础词库随包发布,同时支持远端拉取增量词库。增量词库不是简单下载一个 JSON,而是用词条哈希做差分合并,避免全量覆盖导致误删运营手动添加的自定义词。
词库加载流程是这样的:
- 启动时先加载内置词库和本地持久化的自定义词库。
- 引擎异步请求远端词库版本号,只下载大于当前版本号的增量包。
- 增量合并完成后,重新构建双数组 Trie。
- 构建期间查询走旧 Trie,构建完成后原子切换,不阻塞业务。
dart复制Future<void> updateRemoteWordList(String version, List<String> words) async {
final merged = await _wordLoader.mergeRemoteWords(
localVersion: _currentVersion,
remoteVersion: version,
remoteWords: words,
);
final newTrie = DoubleArrayTrie(merged);
_engine.switchTrie(newTrie); // 原子切换
_currentVersion = version;
}
4. 鸿蒙侧渲染与交互适配实战
讲完引擎,来到重头戏:鸿蒙适配。鸿蒙的 Flutter 生态跟 Android、iOS 不太一样,Platform Channel 的基础概念保持兼容,但原生侧写法完全是 ArkTS 的一套。这一节我把桥接、动态打码和富媒体过滤的实操细节写清楚。
4.1 Platform Channel 桥接实现:Dart 调 ArkTS
Flutter 侧通过 MethodChannel 发消息,鸿蒙侧要做的第一件事是注册插件并监听这些调用。在鸿蒙工程里,插件类需要实现 Plugin 接口,并在 onRegister 时绑定 MethodChannel。
ArkTS 侧核心代码如下:
typescript复制import plugin from '@ohos/hamock';
import { MethodChannel, MethodCall, Plugin } from '@ohos/hamock';
export default class CensorPlugin implements Plugin {
onRegister(pluginContext: plugin.PluginContext) {
const channel = new MethodChannel(pluginContext, 'censor_it');
channel.setMethodCallHandler(async (call: MethodCall) => {
if (call.method === 'censorText') {
const text = call.arguments['text'] as string;
const mask = call.arguments['mask'] as string;
const result = this.censorText(text, mask);
return result;
} else if (call.method === 'isNativeSupported') {
return true;
}
});
}
censorText(text: string, mask: string): object {
// 调用鸿蒙侧的匹配引擎
const engine = CensorNativeEngine.getInstance();
const filtered = engine.filter(text, mask);
return {
text: filtered.text,
hits: filtered.hits,
replaced: filtered.replaced
};
}
}
注意 ArkTS 里 call.arguments 拿到的 map 类型是 object,需要显式断言成 Record<string, Object> 再做取值,直接下标访问容易报类型错误,这是 ArkTS 严格类型系统跟 Java/Kotlin 的行为差异。
4.2 动态打码与文本宽度测量
过滤结果怎么呈现?censor_it 支持三种策略:替换成固定 mask 字符、替换成随机占位符、以及保留原始长度只做样式置灰。第三种最考验细节。
UI 上如果只是把敏感词替换成 *,占位符宽度跟原文不一致,页面布局会跳动。比如用户昵称原来是“某某某”,替换后变成“***”,视觉上短了一截,整个列表项高度都可能变。鸿蒙侧的文本排版引擎跟 Flutter 不完全一致,同一个字符串在不同引擎里测量出的像素宽度可能有差异。
我的解决办法是:过滤结果返回时携带敏感词在原文中的起止索引。UI 层拿到索引后,不直接替换字符串,而是在 TextPainter 层面做“保留原始字符 + 覆盖遮罩”的渲染方案。
dart复制class MaskedTextPainter {
final String text;
final List<OffsetRange> hitRanges; // 命中区间
final String maskChar;
String get maskedText {
final buffer = StringBuffer();
int last = 0;
for (final range in hitRanges) {
buffer.write(text.substring(last, range.start));
buffer.write(maskChar * range.length);
last = range.end;
}
buffer.write(text.substring(last));
return buffer.toString();
}
}
这样既保证了字符数一致,又避免了宽度跳动,代价是略微增加渲染层的计算量,实测完全可以接受。
4.3 富媒体场景过滤:图片 OCR 与语音转写
现在的违规内容不只有文本,还有图片、语音。鸿蒙系统本身提供了 OCR 能力和离线的语音转写基础服务,censor_it 的平台层直接调这些能力做预处理,再把识别出的文本丢给同一个匹配引擎。
管线长这样:用户选择图片 -> 鸿蒙侧调用 OCR 识别 -> 得到文本 -> 走 censorText 检测 -> 返回命中区域 -> UI 层在图片上叠加遮罩并提示“该图片包含违规内容”。
这里要特别注意隐私边界,OCR 和语音转写都明确在本地完成,不上传云端,符合个人信息保护的基本要求。我在工程里给这两个能力加了独立的开关,默认打开,并提供关闭入口。
4.4 在鸿蒙原生页面嵌入过滤结果
最后是组件如何嵌入鸿蒙原生页面。如果应用是 Hybrid 架构——部分页面用鸿蒙原生 ArkUI,部分页面用 Flutter——过滤结果需要两种 UI 都能消费。censor_it 的做法是:过滤逻辑统一走引擎层,返回结构是纯 JSON 可序列化对象,原生 ArkUI 拿到后通过 Text 组件自定义渲染遮罩。
5. 性能优化、内存控制与包体裁剪
端侧组件的底线是不能让用户明显感知到“手机变慢了”。敏感词过滤这种高频操作,性能优化是必修课。这一节讲的每个点都是在真实压测中起过关键作用的。
5.1 预编译自动机与内存映射加载
词库构建双数组 Trie 的过程是纯计算密集型的,50 万词条构建一次需要大约 2 到 4 秒。这个耗时不可能让用户每次冷启动都等。
我把构建好的 DAT 序列化到本地文件,启动时直接 mmap 映射进内存,省去构建计算,加载时间从秒级降到百毫秒级。配合文件哈希校验,词库没变就直接用缓存,变了才重新构建覆盖缓存。
dart复制Future<void> loadEngine() async {
final cached = await _mmapLoader.loadFromFile(_cachePath);
if (cached != null && cached.hash == _expectedHash) {
_engine = CensorEngine.fromLoadedTrie(cached.trie);
} else {
_engine = CensorEngine.initializeFromWords(await _wordLoader.loadAll());
await _cacheEngine(_engine);
}
}
5.2 异步隔离与并发控制
Flutter 是单线程模型,如果过滤逻辑直接写在主 isolate 里,长文本、高频调用会卡 UI。censor_it 把匹配引擎放进独立 isolate,业务侧通过 Isolate.run 发起异步调用,主 isolate 只收结果。
dart复制Future<CensorResult> censorTextAsync(String text, CensorOptions options) {
return Isolate.run(() {
return _engine.censorText(text, options);
});
}
这里有个容易踩的坑:Dart 的 isolate 之间默认不能直接共享内存对象,每次调用 Isolate.run 都要复制参数和结果。如果单次文本非常大(比如几万字的文章),复制开销反而比匹配大。我的优化策略是按句子切分,超过 2000 字的文本先切成句子分别丢给 isolate,匹配完再合并结果。
5.3 包体裁剪与 AOT 编译注意事项
原生层依赖的 OCR、语音能力是通过延迟加载方式引用的,只有用户真正触发图片或语音过滤时才会加载对应模块,避免安装包体积虚胖。词典本身做了压缩编码,常用汉字映射到 16 位编码,相比 UTF-8 能省 30% 到 40%。
AOT 编译时还有个细节:Dart 引擎层使用了 dart:io 的 File 操作做缓存,但 Flutter 鸿蒙化环境对一些文件操作 API 的底层实现与标准 Flutter 不同,需要做兼容判断。我加了运行时检测,不支持文件内存映射时自动回退到普通内存加载,不会直接抛异常。
5.4 压测数据与调优结论
以下是一台中端鸿蒙机型上的实测数据,词库规模 20 万条,文本长度 200 字符:
| 方案 | 首轮耗时 | 冷启动耗时 | 内存占用 |
|---|---|---|---|
| 正则暴力匹配 | 80 到 120 ms | 无 | 低 |
| Trie(Map 嵌套) | 1.5 ms | 40 ms | 高(约 300 MB) |
| 双数组 Trie(纯内存) | 0.8 ms | 80 ms | 中(约 60 MB) |
| 双数组 Trie(mmap 缓存) | 0.8 ms | 120 ms | 中(约 60 MB) |
优化结论:匹配性能由双数组 Trie 保证,启动体验由 mmap 缓存保证,内存控制靠词条编码压缩和懒加载。三个环节缺一不可。
6. 踩坑实录:鸿蒙适配期间最扎心的 7 个问题
最后这块是整篇文章的“私货”部分。这些坑不是看文档能发现的,都是真金白银的线上事故换来的。我整理成了一个速查表,后面挑重点展开说。
6.1 问题速查表
| 问题现象 | 直接原因 | 解决方案 |
|---|---|---|
| 鸿蒙侧调用返回空结果 | Channel 未在插件 onRegister 中绑定 | 检查模块是否注册 CensorPlugin |
| 中文敏感词漏匹配 | UTF-16 代理对拆开了 emoji | 统一用 codeUnit 匹配,emoji 单独成词处理 |
| 打码后列表项高度跳动 | 替换字符宽度与原文字符不一致 | 保留原文长度,用遮罩渲染 |
| 增量词库更新失败 | 本地词库哈希与远端版本不匹配 | 增加 diff 合并和版本号强校验 |
| 断网环境下远端词库请求超时 | 网络请求没有设置短超时 | 超时缩短为 3 秒,静默降级 |
| AOT 编译后文件缓存失效 | 鸿蒙化环境文件路径变化 | 通过能力探测获取真实缓存路径 |
| 违规词在拼音、谐音下漏检 | 纯字符匹配无法识别变形 | 引入拼音归一化和同音替换映射 |
6.2 高频问题详解
第一个要重点说的是 Unicode 边界问题。鸿蒙和 Dart 都使用 UTF-16 编码,中文汉字绝大多数落在 BMP 平面,一个字符一个 code unit,没问题。但 emoji 落在增补平面,由两个 code unit 组成,如果词库里存了“表情符号 + 文字”的复合敏感词,拆词时会识别成两个字符,导致漏检。
我的处理办法是词条预处理阶段把 emoji 当成一个完整 token,不拆 code unit。Dart 里用 characters 包来遍历,它能正确处理字形簇,确保 emoji 和变体选择符被当整体看待。
第二个坑是增量词库的版本校验。远端下发的增量包如果基础版本对不上,合并后轻则词库混乱,重则触发构建异常。我在合并前做了双重校验:先比较基础版本哈希,再用词条总数做 sanity check,发现异常直接回滚到上一版可用词库,绝不让线上进入“无词库”状态。
第三个坑是 Channel 调用的超时。鸿蒙原生侧如果某个能力暂时不可用(比如 OCR 模型正在初始化),Dart 侧调用方会一直等待,表现为界面卡死。我给所有平台通道调用统一加了 3 秒超时,超时后根据场景选择跳过本次过滤或降级到纯 Dart 引擎。
第四个坑是文件路径。鸿蒙应用沙箱路径跟 Android 不一样,同一个 getApplicationContext().filesDir 的写法在鸿蒙上拿不到路径。最终通过鸿蒙侧能力探测接口返回正确的沙箱目录,Dart 侧不硬编码任何路径。
第五个坑是拼音变形。动态生成的变形词(比如把“违规”拆成“违 规 划”)靠字符精确匹配抓不到。我在引擎里增加了一个可选的“宽松模式”,匹配前先做字符归一化:移除空白符、统一大小写、全角转半角。宽松模式只在上送云端前启用,本地展示依然保留原始文本,避免误伤正常内容。
6.3 两个容易被忽略的体验细节
最后讲两个跟代码无关、但直接影响用户观感的细节。
第一个是打码字符的选择。很多人图省事直接用 *,但中文语境下 * 在视觉上很突兀,用户一看就知道自己被“盯上了”。我实际测试下来,用“口”字(对应违规内容的字数)做遮罩,视觉上更自然,用户反感度明显降低。针对不同业务模块还可以配置不同遮罩字符:社区用“*”,私信用“▪”,官方公告用“○”。
第二个是误杀后的申诉机制。再好的词库也有误判的时候,必须给用户一个“我发的内容没违规”的申诉入口。censor_it 的检测结果对象里带有 ruleId 字段,业务方可以据此生成申诉工单。这个设计在合规治理里非常加分——监管问起来的时候,你能拿出“先拦截再申诉”的完整证据链。
在我个人的实际使用中,最深刻的体会是:端侧敏感词过滤不是“写个函数替换字符串”那么简单,它是一个从算法、架构到用户体验的系统工程。词库再全,匹配再快,只要漏一个变体、误杀一个正常用户、或让界面卡上一帧,都会变成新的问题。censor_it 目前还只是完成了第一阶段的鸿蒙适配,后续我计划加入基于本地的语义向量检索,用端侧小模型覆盖那些“字面上干净、语境里违规”的内容,让这个净化墙从“查字典”进化到“能看懂上下文”。希望这次的实战拆解,能给正在做内容合规的同行省下几个加班的夜晚。
