1. 从"贴SDK代码"到"封装工具类":我为什么非得多做一层
运营同学在群里甩了张截图:评论区一晚上被灌进几千条垃圾广告,人工删到凌晨。常规应对方案大家都想得到——接入云厂商的文本审核服务,把所有 UGC 文本先过一遍机器审核,然后封装成一个统一的工具类供业务调用。不过真进到代码里,事情远没有"贴一个 SDK 调用示例"那么简单。
我最初图省事,在评论、昵称、私信几个业务模块里各写了一份一模一样的 SDK 调用代码。结果不到一个月就后悔了。这里先把裸调接口的教训说清楚,你再回头看工具类的设计,就会明白哪些地方是刚需,哪些地方是过度设计。
1.1 裸调接口的三个具体痛点
第一个痛点是重复代码失控。文本审核这个动作,本质上就四步:构造请求、设置参数、发起调用、解析响应。但每个调用点都会把这些步骤复制一遍。一个请求参数在七个模块里面被改了七次,改漏一个就是线上事故。我后来数过,光"是否把 review 当作放行"这个逻辑,在不同文件里就有三种写法,其中一种甚至直接忽略了 review 这个枚举值。
第二个痛点是策略各不相同。有人不传 dataId,有人超时时间写死成 0,有人把异常静默吞掉,有人干脆把服务端返回的错误当成了"审核通过"。所有调用方都觉得自己在"灵活处理",但出了问题根本没法排查,因为你不知道线上到底跑的是哪一份代码逻辑。
第三个痛点是上下游耦合。一旦云厂商升级 SDK 版本,或者你想换一家审核服务商,散落在各处的调用代码全部要改一遍。业务层被迫感知 SDK 的版本差异和 API 变化,这是典型的关注点没有分离。
下面这张表是我当时整理的对比,也是后面所有设计的出发点:
| 维度 | 裸调 SDK | 封装工具类 |
|---|---|---|
| 代码量 | 每个调用点重复 20~30 行 | 一处封装,业务一行调用 |
| 超时/重试 | 各写各的或完全没写 | 统一策略、默认合理值 |
| 异常处理 | 静默吞掉或直接中断 | 统一转业务异常,调用方可选降级 |
| 结果模型 | 暴露 SDK 原始对象 | 统一返回 AuditResult,业务不感知 SDK |
| 切换服务商 | 所有调用点全改 | 只改工具类内部实现 |
1.2 工具类的职责边界
封装之前先想清楚一件事:工具类到底管什么、不管什么。我的定义是,它只负责"把文本送到审核服务,把审核结论取回来",不负责"决定要不要放行"。
拦截规则、人工复审策略、降级策略这些都是业务层的决策,不应该写进工具类里。比如运营要求"广告评论直接删、疑似辱骂转人工",这个判断逻辑应该放在调用工具类的业务代码里,而不是让工具类替业务做决定。否则审核策略一变,你又得来改这个类。
工具类的收益,就是把底层 SDK 的细节全部挡住。业务方只需要拿到一个统一结果:pass、review 还是 block,附带命中的标签和耗时。至于 SDK 内部怎么鉴权、怎么重试、怎么解析响应,业务方一概不用关心。
2. 动手之前:把审核服务的"游戏规则"搞清楚
很多人封装工具类失败,不是因为代码写得不好,而是根本没搞清楚审核服务的请求响应结构就开始写。这里有一个非常容易混淆的地方:请求成功不等于内容安全。我见过不止一个同事拿着"响应码 200"当"审核通过"去放行内容,这是会出大事的。
2.1 开通服务、密钥与最小权限
第一步是在云厂商控制台开通内容安全服务里的文本审核能力。不同厂商的入口名称不太一样,有的是"内容安全",有的是"内容审核",但流程大差不差:开通服务、拿到 AccessKey 和 SecretKey、配置服务区域。
这里想特别强调密钥管理。请务必创建一个只授权文本审核相关权限的子账号,不要用主账号的钥匙。主账号密钥一旦泄露,整个账号下的所有云资源都暴露了,而子账号最多只能调用审核接口。密钥也不要写死在代码仓库里,放到环境变量或者配置中心,按环境隔离。开发环境、测试环境、生产环境各用各的密钥,泄露了好回收,审计也清楚。
区域的选择也要注意。审核服务不是一个"随便调任意区域都通"的接口,每个区域的 endpoint 可能不同,服务开通的区域和请求里写的区域必须匹配,否则会出现诡异的鉴权失败或者连接超时。选一个离你业务部署位置近的区域即可,你部署在上海就把审核服务开在上海,没必要绕一圈。
2.2 看明白请求体和响应体再做封装
文本审核请求的核心字段通常就这几个:
- content:要审核的正文内容
- scenes:要检测的场景数组,比如垃圾信息场景、广告场景,以及你自定义的词库场景
- dataId:业务幂等 ID,用于对账和去重
- 自定义词库标识:如果你在控制台配置了自定义名单,请求里需要带上对应 ID
响应体长这样,我用 JSON 示意一下,不同厂商的字段名略有差异,重点是结构:
{ "code": 200, "dataId": "comment:102381:8f14e45fceea167a", "results": [ { "scene": "antispam", "suggestion": "block", "labels": ["垃圾信息"], "details": [ { "label": "垃圾信息", "confidence": 97.3 } ] } ] }注意,最外层的 code 是调用层的状态码,200 只代表"请求到达并处理了",不代表"内容没问题"。真正的审核结论在 results 数组里,每个 scene 对应一个结果。suggestion 字段通常是三选一:pass、review、block。
这三个值的业务含义必须刻在脑子里:
| suggestion | 含义 | 建议业务处理 |
|---|---|---|
| pass | 未命中风险 | 正常放行 |
| review | 疑似风险,需要人工确认 | 进复审队列,或按规则延迟展示 |
| block | 明确命中高风险 | 拦截,禁止展示 |
容易出问题的恰恰是 review。很多人只判断 block 和 pass,review 被当成了 pass 直接放行。严格来说,review 是"机器不确定,但有一定风险倾向",你要是直接放行,那机器审核等于白做了。
2.3 上线前必做的三件事:完整日志、内容脱敏、结果缓存
第一件事是日志。工具类里一定要把每次请求的完整原始响应打出来,包括 dataId、suggestion、命中的 labels、耗时。排查问题的时候,没有这个日志你就是瞎子。我自己踩过一个大坑,后面第 5 节会细讲,当时就是因为日志只打了结论没打 details,漏审问题查了一整天才定位到根因。
第二件事是脱敏。审核接口的日志不建议把正文全文打出来,内容涉及用户隐私,也容易让日志文件变得巨大。正确做法是打印内容的一个短哈希,比如 contentMd5,需要回查的时候再用哈希比对。
第三件事是结果缓存。很多文本会被重复提交,比如用户反复提交同一段评论、同一个昵称被多次校验。以内容哈希为 key,在本地缓存最近的审核结果,可以省下不少审核费用,也能降低接口压力。这笔账算下来很划算,尤其是 UGC 量大的产品。
3. 核心实现:一个够用的文本审核工具类代码走读
下面这段代码是示意风格,类名、方法名以你实际用的 SDK 为准,重点是封装的骨架和边界。我会把一个最小可用版本拆成三部分来讲:初始化和配置、主方法 checkText、响应解析。
3.1 类字段与初始化
public class TextAuditTool { private final AuditClient auditClient; // SDK 客户端,线程安全 private final List<String> defaultScenes; private final int maxTextLength = 10000; private TextAuditTool(AuditConfig config) { this.auditClient = AuditClient.builder() .accessKey(config.getAccessKey()) .secretKey(config.getSecretKey()) .region(config.getRegion()) .connectTimeout(config.getConnectTimeoutMillis()) .readTimeout(config.getReadTimeoutMillis()) .build(); this.defaultScenes = config.getDefaultScenes(); } public static TextAuditTool create(AuditConfig config) { return new TextAuditTool(config); } }关键点是 SDK 客户端只初始化一次。客户端内部有连接池、线程池这些开销,如果每次请求都 new 一个,性能会很难看,还容易出现连接句柄泄漏。所以工具类适合做成单例或者由容器管理生命周期,整个应用共享一个客户端实例。
超时时间建议从配置里注入,connectTimeout 给 1~2 秒,readTimeout 给 2~5 秒。SDK 的默认超时往往偏长,一旦审核服务挂起,调用线程会一直占着不放,把业务线程池拖垮。这是线上很常见的事故模式。
3.2 主方法 checkText 的完整流程
public AuditResult checkText(String content) { return checkText(content, null, defaultScenes); } public AuditResult checkText(String content, String dataId, List<String> scenes) { if (content == null || content.trim().isEmpty()) { return AuditResult.pass("empty input"); } content = normalizeInput(content); if (content.length() > maxTextLength) { content = content.substring(0, maxTextLength); } TextScanRequest request = new TextScanRequest(); request.setContent(content); request.setDataId(resolveDataId(dataId, content)); request.setScenes(scenes); long start = System.currentTimeMillis(); try { TextScanResponse response = doWithRetry(() -> auditClient.scan(request)); return parseResponse(response, start); } catch (AuditException e) { logAuditError(dataId, e); throw e; } }几个细节说一下。空内容直接返回 pass 而不是去调接口,这是现实层面的妥协:用户确实可能提交一个空串,没必要为此付一次审核费用。超长文本不能直接拒绝,但也不能什么都不做就丢掉,截断到服务允许的最大长度是常规做法。当然截断会损失语义,更严谨的做分片,第 5 节细聊。
normalizeInput 是文本预处理,核心是解决"看起来一样但编码不一样"的文本绕过问题。全角转半角、去掉零宽字符、统一换行符,这些你都应该在进审核前做掉。很多违规文本就是靠零宽字符和全角字符混过检测的。
resolveDataId 的逻辑是这样的:业务如果传了 dataId 就用业务的,没传就用"业务类型 + 内容哈希"拼一个。重点是不能用随机 UUID,后面第 4 节对账部分会讲为什么。
3.3 响应解析与结果模型
public class AuditResult { private final boolean success; private final String suggestion; // pass / review / block private final List<String> labels; private final long costMs; public boolean isPass() { return "pass".equals(suggestion); } public boolean isReview() { return "review".equals(suggestion); } public boolean isBlock() { return "block".equals(suggestion); } }解析逻辑最核心的是多场景结论合并。一个内容可能同时检测垃圾信息、广告、自定义词库三个场景,每个场景都可能返回各自的 suggestion。合并原则我从严处理:只要有一个场景返回 block,整体就是 block;没有 block 但有 review,整体就是 review;全部 pass 才是 pass。
private AuditResult parseResponse(TextScanResponse resp, long start) { long cost = System.currentTimeMillis() - start; if (resp == null || !resp.isSuccess()) { throw new AuditUnavailableException("invalid audit response"); } String suggestion = null; List<String> labels = new ArrayList<>(); for (TextScanResponse.Result item : resp.getResults()) { String s = item.getSuggestion(); if ("block".equals(s)) { suggestion = "block"; } else if (suggestion == null || "pass".equals(suggestion)) { suggestion = s; } if (item.getLabels() != null) { labels.addAll(item.getLabels()); } } return new AuditResult(true, suggestion == null ? "pass" : suggestion, labels, cost); }为什么从严重合?因为审核服务的多场景结果是并行的,任何一个场景觉得有问题,你都应该认为这个内容有风险。反过来,如果你把结论取交集,只要有一个场景漏了,审核就形同虚设。
这里要注意,我故意没有在工具类里写"block 就拒绝、review 就进人工"这种业务策略。工具类只负责告诉你机器审出来的结论是什么,怎么处置是业务层的事。这样工具类才能在所有业务模块里复用。
4. 容易被忽略的四个细节:超时、重试、限流与幂等
真正上线之后,工具类能不能扛住生产流量,看的不是主流程代码,而是这些边角细节。我把它们单拎出来讲,因为这些地方决定了工具类的鲁棒性。
4.1 超时和异常分类
SDK 抛出的异常不能一视同仁。至少分成两类:
- 可重试的:连接超时、读超时、网络抖动、服务端 5xx 类错误
- 不可重试的:鉴权失败、参数错误、请求被拒绝的 4xx 类错误
为什么要区分?因为鉴权失败你重试一百次也是失败,纯粹浪费资源和时间。参数错误同理,重试只会放大问题。如果一个工具类对所有异常都无脑重试三次,在密钥过期的时候会把 QPS 顶到限流阈值,这就是自己把自己打挂了。
我给的建议是:connectTimeout 1 秒、readTimeout 3 秒起,然后根据你的业务容忍度调整。超过 readTimeout 并不代表审核服务没有处理这个请求,它可能已经处理完了只是响应回来得慢。所以超时之后的重试一定不能盲目重复发同一个请求,必须依赖幂等机制,这个下一节讲。
4.2 重试策略与退避算法
重试代码写成下面这样:
private <T> T doWithRetry(Supplier<T> supplier) { int attempts = 0; while (true) { try { return supplier.get(); } catch (AuditRetryableException e) { attempts++; if (attempts >= maxRetries) { throw e; } long backoff = Math.min( baseDelayMillis * (1L << (attempts - 1)), maxBackoffMillis); sleepQuietly(backoff + randomJitterMillis(backoff)); } } }重试次数控制在 2~3 次就够,指数退避的基准值 100~200 毫秒起,每次翻倍,封顶 1~2 秒。加一点随机抖动,避免大量请求在同一时刻重试形成雷击效应。不加抖动的重试在故障恢复瞬间是很经典的流量雪崩触发点。
重试只包住"可重试"的异常,这一点在前面说的异常分类是前提。另外,重试不能无限进行,耗尽之后要快速失败,把控制权交还给调用方,由业务层决定是走降级还是报错。
4.3 并发控制与本地限流
云厂商的审核服务都有 QPS 配额,超过配额后服务端会拒绝请求或者告警。问题在于,业务侧流量是起伏的,你无法保证每个时刻的请求量都低于配额。所以工具类内部要做本地并发控制,而不是寄希望于业务方"悠着点调"。
我用信号量实现了一个简单的并发闸门:
private final Semaphore semaphore = new Semaphore(maxConcurrent); public AuditResult tryCheck(String content, String dataId) { if (!semaphore.tryAcquire(50, TimeUnit.MILLISECONDS)) { // 并发太高,拿不到令牌,按疑似处理,走人工复审 return AuditResult.review("local concurrency limit, fallback"); } try { return checkText(content, dataId); } finally { semaphore.release(); } }maxConcurrent 设置为你的 QPS 配额乘以单请求期望耗时的估算值。比如配额是 100 QPS,单次请求平均 100 毫秒,并发窗口期大概能容纳 10 个在途请求,那 maxConcurrent 设为 10 左右比较稳妥。
tryAcquire 拿到才执行,拿不到就立即返回 review 而不是阻塞等待。这个设计取舍很重要:宁可让这条内容进人工复审队列,也不能把业务线程死死卡住。你要知道,一个落在社区评论链路上的审核阻塞,可能意味着用户发一条评论要等上好几秒,这对用户体验是毁灭性的。
4.4 dataId 的幂等与对账
dataId 是很多人不重视、但排查问题时最救命的东西。它本质上是这次审核请求的业务标识,用于把审核请求与业务记录对应起来。
推荐格式是"业务类型 + 业务ID + 内容哈希"拼接,比如:
comment:102381:8f14e45fceea167a这种格式的好处是天然的幂等。同一个用户对同一条评论触发的审核,dataId 完全相同;服务端做去重、日志做对账都方便。如果你用一个随机 UUID,那同一个业务动作产生两次审核请求时,你完全无法区分它们是不是同一个内容。
内容哈希还有一个附加价值:可以用来识别同一段文本被不同用户反复提交。后面做本地缓存和频率控制,都用得上这个哈希。
5. 线上实测最容易踩的坑:排查链与参数调优
这一节是从真实事故里提炼的经验。每个坑都对应一条排查链路,建议你直接收藏,上线之后大概率用得到。
5.1 一个"漏审"问题的完整排查链路
有次运营反馈,一条明显带广告的评论竟然通过了审核。我当时第一反应以为是阈值问题,结果排查完才发现根因完全不在阈值。把排查链路完整记录下来:
第一步,查工具类日志里的原始响应。打开日志发现,这条评论返回的是 suggestion=pass,结果本身"看起来没毛病",问题出在哪?
第二步,看命中了哪些场景。我当时的日志只打了 suggestion 没打 details,所以一眼看不到漏审原因。补上 details 日志后才发现,这次请求只检测了垃圾信息场景,压根没有检测广告场景。
第三步,追查 scenes 配置。工具类初始化时用了默认场景列表,而这份默认列表里漏配了广告场景。业务方调用的时候又没显式传 scenes,于是所有内容都只走了垃圾信息检测。
根因就这么简单:场景配置不完整。但为什么查了一整天才定位?就是因为日志字段残缺,没有把请求里实际用的 scenes 和响应里的 details 全部打出来。这也是我在第 2 节强调"完整原始响应日志"的原因——排查效率差的本质是信息缺失。
修复之后我把默认场景列表补全,同时规定:业务方传 scenes 时必须是显式全量覆盖,不允许传 null 走默认配置,避免有人无意中把场景改没了。
5.2 不同业务场景的阈值与场景配置
同一个审核工具类,在不同业务位置上的处置策略应该不同。昵称和正文如果采取完全一样的拦截策略,一定会出现"昵称误杀惨重"或者"正文漏审严重"这种失衡。
我按照业务位置把策略分成四类,供你参考:
| 业务位置 | 场景配置 | 建议策略 |
|---|---|---|
| 昵称 / 签名 | 垃圾信息 + 自定义词库 | block 直接拦,review 先禁止修改并提示 |
| 评论 / 弹幕 | 垃圾信息 + 广告场景 | block 拦截,review 进人工队列 |
| 私信 / 聊天 | 垃圾信息 + 广告 + 自定义词库 | block 强拦,review 延迟送达等待复审 |
| 长文正文 | 默认全场景 | 最严格,review 也先不进推荐池 |
这里没有给出具体的置信度阈值数字,因为不同厂商的分数体系不一样,而且同一家在不同版本里也可能调整。正确的做法是在控制台看近一段时间的风险标签命中分布,再反推阈值。工具类里不要写死阈值,做成配置项,运营可以根据误杀率随时调。
一个经验之谈:宁可多 review,不要跳过 block。误杀是可以申诉、可以人工恢复的,漏审是事后弥补不了的。尤其是法律法规风险越来越受重视的今天,"放行了一个不该放的"和"拦掉了一个冤枉的",后果完全不是一个量级。
5.3 编码、截断和特殊字符的坑
文本审核对输入长度是有限制的,一般单次文本上限在 1 万字左右。超过之后不能简单地一刀切,因为截断可能把关键违规片段切掉。比如一段长文本前半部分正常、后半部分违规,如果你截断了后半部分,审核结果就是 pass。更稳妥的方案是分片:按段落或按字符数切成多片,每一片单独审核,只要有任何一片 block,整体就是 block。
然后是编码问题。全角字符、半角字符、表情符号、零宽空格,这些在用户输入里非常常见。一些绕过策略就是利用编码差异:把违规关键词里某个字换成全角,或者中间插入零宽字符,让文本从视觉上看没变,但机器一比对就失效。
工具类入口做 normalizeInput 是必要防线:
- 全角字符转半角
- 去掉零宽字符和不可见控制字符
- 统一换行符
- 可选:大小写统一
这套预处理成本极低,但能有效挡掉一大批"低级绕过"。记住,审核服务不是万能的,把输入规范好,是在帮审核服务提高准确率。
6. 让工具类更好用的三个进阶扩展
工具类跑通只是开始。真正要让它成为一个稳定的基础设施,下面这三个扩展是我认为最有价值的,按优先级排序。
6.1 本地敏感词预检
文本审核是按调用次数计费的,量大的时候这是一笔不小的开销,而且云端调用有网络延迟,用户在关键路径上等不起这几十毫秒。
本地敏感词预检能解决一部分问题:把一个自定义违规词库在本地构建成 AC 自动机,checkText 之前先本地扫一遍。命中了就直接拦截,不再发云端请求;没命中才走云端。这样可以挡掉最高频的那部分违规内容,减少云端调用量,也降低用户可见延迟。
public class LocalKeywordFilter { private final AhoCorasickMatcher matcher; public boolean hit(String text) { return matcher.findFirst(text) != null; } }AC 自动机的原理是给词典构建一棵 Trie 树加失败指针,匹配一次文本的复杂度是 O(n),跟词库大小基本无关,非常适合大词库场景。实现上,你可以自己写,也可以用开源库,网上参考不少。
但要清醒:本地预检只能命中你已知的、明确的词,变形、组合、上下文相关的内容它无能为力。所以本地词库只是第一道粗筛,云端审核依然是主力,二者是互补关系,不是替代关系。
6.2 review 结果接入人工复审
review 这个中间状态,如果处理不好,要么变成漏放行的后门,要么变成阻塞业务的堵点。我的建议是给工具类配一个简单的人工复审通道。
业务层拿到 review 之后,把这条内容连同 dataId、命中的 label、置信度,一起写入一个复审表,后台运营有一个审核页面去快速处理。处理完的结果回填,可以定期统计"复审率"和"命中率",反过来调整审核阈值。
这里有个容易犯的错:把 review 简单当成"待定"挂起,但没有超时机制。用户发了一条评论,等了两天还在"待定",体验会非常差。复审队列要有 SLA,比如"人工在 10 分钟内未处理则默认放行但不置顶展示",或者"默认拦截并通知用户可申诉"。具体选哪个,取决于你的业务对错判的容忍度。
6.3 兜底降级,别让审核服务挂掉拖垮业务
任何一个外部依赖都可能有故障,文本审核服务也不例外。工具类在设计上一定要想清楚,云端不可用的时候业务应该怎么办。
降级策略分两类。fail-closed:一旦审核服务不可用,所有需要审核的内容一律按拦截处理。适合金融、电商等合规要求严格的场景,宁可损失一点用户体验,也不能放出违规内容。fail-open:审核不可用时先放行,同时把内容打入异步复审队列,等审核服务恢复后再补审。适合社区、社交等用户活跃优先的场景。
我最推荐的混合方案是"本地词库 + fail-open 加标记":云端挂了,先用本地词库挡掉明确违规的,剩下的放行但标记为"未过机审",进复审队列。既不会让用户体验雪崩,又保留了一道兜底防线。
我自己在实际项目里是这么落地的:工具类暴露一个 healthCheck 方法,定时去探测审核服务的连通性,不可用就自动切换降级开关,恢复后自动切回。这个开关放在配置中心里,还能支持运营手动干预,比在代码里写死判断要灵活得多。
最后再分享一个小技巧。工具类上线后,建议把每天的审核统计单独汇总一张报表:总调用量、block 率、review 率、平均耗时、超时率。这张报表的价值会随着时间越来越明显,因为你会发现,与其争论"阈值到底该设多少",不如看数据说话——哪一类标签的误杀率高,哪一类标签的漏审率高,一眼便知。文本审核这种事情,没有一劳永逸的参数,只有持续用数据去校正的策略。