内容审核接入
WebRTC 把媒体加密做到了链路层,但链路加密不解决违规内容。只要业务对公众开放,监管和平台责任就要求你能在直播/连麦/视频会议里看到、阻断、留存违规内容。这篇讲清楚音频、视频、文本三条审核入口怎么接,以及它们各自的延迟、成本和处置粒度差异。
1. 为什么实时通信必须做内容审核
不是技术品味问题,是合规底线。
- 监管要求:中国《网络音视频信息服务管理规定》(2020)、《互联网信息服务深度合成管理规定》(2023)明确要求音视频信息服务提供者具备"违法违规音视频信息识别处置技术能力"。海外有 DSA(欧盟数字服务法)、英国 Online Safety Act、美国 KOSA 等。
- 平台责任:UGC 平台对违规内容的"过错责任"在司法实践里普遍成立。没有审核记录 = 没有免责凭证。
- 社区健康:实时通信场景下用户互相骚扰、刷屏、引战的速度比图文社区快一个量级,纯靠用户举报兜不住。
- 商业风险:广告主、应用商店、支付通道都会拿"是否有完整审核体系"作为合作条件。
什么时候适用:所有面向 C 端、半公开(如企业内多租户)以及含未成年人的实时通信业务。
什么时候不适用:私有化部署、闭环企业内部会议、医疗/律师等强保密场景,且法务确认豁免——但仍建议留存录制以便事后取证。
失败时的样子:监管通报、应用下架、被支付通道封停、出现极端事件时被追究刑责。
2. 三条审核入口的架构差异
实时通信里能产生违规内容的载体只有三类,每类的审核入口和工程模型都不一样。
| 维度 | 音频流审核 | 视频流审核 | 文本聊天审核 |
|---|---|---|---|
| 数据形态 | PCM / Opus 帧 | I 帧 / 短片段 | UTF-8 字符串 |
| 采样窗口 | 5~10 秒滑窗 | 每 5~30 秒 1 帧 | 每条消息 |
| 端到端延迟 | 3~8 秒 | 5~30 秒 | <500ms(同步)/ 1~3 秒(异步) |
| 同步/异步 | 旁路异步 | 旁路异步 | 可同步可异步 |
| 单次成本量级 | 0.001~0.01 元 / 分钟 | 0.001 元 / 张 | 0.0001 元 / 条 |
| 命中粒度 | 静音、踢出、上报 | 关闭视频、踢出、上报 | 拦截、撤回、禁言 |
| 漏检风险 | ASR 错字、方言、背景音 | 帧采样间隙、模糊画面 | 变体、谐音、跨语种 |
| E2EE 冲突 | 高,需端侧或放弃 E2EE | 高,需端侧或放弃 E2EE | 中,可端侧拦截 |
三条入口共用一个处置中心(policy engine + 房间控制 API),各自的差异主要在采集层和推理层。
3. 音频流审核
音频是实时通信里违规密度最高的载体,因为说话比打字门槛低。但它也是 ASR 链路最长、成本最高的入口。
3.1 链路
SFU 解包 SRTP → 单声道 PCM 16k → 流式 ASR → 滑窗文本 → 文本审核引擎 → 命中处置
- 解包来源:SFU 拿到的就是明文 RTP(SRTP 已经在 SFU 上解过)。直接对接 Opus 解码出 PCM,不要再回头走 RTMP 旁路,那会引入 2 秒以上额外延迟。
- 采样窗口:5
10 秒滑窗 + 12 秒重叠。窗口太小会让上下文不足,比如脏话被切成两半;太大则命中延迟拉长。 - ASR 选型:流式 ASR(streaming ASR)必须支持中间结果(partial),不要用批式 ASR。
什么时候适用:所有有公开语音的房间(直播间、语聊房、多人会议)。
什么时候不适用:1v1 私聊且双方实名、企业内部小会、明确 E2EE 场景。
失败时的样子:方言/外语漏识别,背景音乐被识别成歌词命中误伤,长串脏话被切窗分割漏检。
3.2 SFU 旁路推流代码示例
下面是 mediasoup 风格的 SFU 旁路音频实现,把每个 producer 的 RTP 用 PlainTransport 发到本地审核 worker:
// audio-moderation-relay.js
import { types as MS } from 'mediasoup';
/**
* 为指定 audio producer 建立审核旁路。
* @param {MS.Router} router 当前房间的 mediasoup Router
* @param {MS.Producer} producer 音频 producer
* @param {{ host: string, rtpPort: number, rtcpPort: number }} sink 审核 worker 监听地址
*/
export async function attachAudioModeration(router, producer, sink) {
if (producer.kind !== 'audio') return null;
const transport = await router.createPlainTransport({
listenIp: '127.0.0.1',
rtcpMux: false,
comedia: false,
});
await transport.connect({ ip: sink.host, port: sink.rtpPort, rtcpPort: sink.rtcpPort });
const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: router.rtpCapabilities,
paused: false,
});
producer.observer.once('close', () => transport.close());
return { transport, consumer };
}
审核 worker 端用 GStreamer / ffmpeg 把 RTP 还原为 PCM 后送给流式 ASR:
// asr-stream-worker.js
import { spawn } from 'node:child_process';
import { createAsrClient } from './asr-client.js'; // 厂商无关封装
const ffmpeg = spawn('ffmpeg', [
'-protocol_whitelist', 'file,udp,rtp',
'-i', 'sdp/in.sdp', // SDP 由 SFU 旁路时下发
'-vn', '-acodec', 'pcm_s16le',
'-ar', '16000', '-ac', '1',
'-f', 's16le', 'pipe:1',
]);
const asr = createAsrClient({ sampleRate: 16000, language: 'zh-CN' });
asr.on('partial', (text, ts) => {
// 中间结果只看趋势,不做处置
metrics.observe('asr_partial_len', text.length);
});
asr.on('final', async ({ text, startMs, endMs, speakerId }) => {
const verdict = await moderateText(text, { scene: 'live-audio' });
if (verdict.action !== 'pass') {
await policy.enforce({
roomId, userId: speakerId, channel: 'audio',
verdict, evidence: { text, startMs, endMs },
});
}
});
ffmpeg.stdout.on('data', (chunk) => asr.write(chunk));
ffmpeg.on('exit', () => asr.end());
要点:ASR 的 partial 只用来观测,final 才进审核。否则同一句话会被审核引擎判多次,命中策略会乱。
4. 视频流审核
视频审核处理的不是连续视频流,而是抽帧后的图片序列或短片段。原因有两个:一是连续视频审核模型很贵,二是违规画面通常持续秒级以上,抽帧足够覆盖。
4.1 链路
SFU 解包视频 → 解码取 I 帧 → 抽帧(每 5~30s 一张)→ 图像审核 / 短视频审核 → 命中处置
- 为什么抽 I 帧:I 帧本身就是完整画面,不需要参考前后帧,解码成本最低。P/B 帧抽出来还得跟前置 I 帧一起解码。
- 采样间隔:
- 低风险房间(实名认证、付费用户)30 秒 / 帧足够。
- 普通公开房间 10~15 秒 / 帧。
- 高风险房间(陌生人交友、新注册用户、被举报过的用户)3~5 秒 / 帧,必要时切到短视频审核(每 5 秒一段 3 秒视频)。
- 关键帧请求:如果当前视频流的 GOP 太长(10 秒以上),SFU 可以主动发 PLI 让发布端补 I 帧。但这会消耗带宽,谨慎做。
什么时候适用:所有开摄像头的房间,尤其是陌生人社交、秀场直播、未实名场景。
什么时候不适用:纯音频房间、屏幕共享纯文档展示(可以降到 60 秒 / 帧),但完全不审依然不建议。
失败时的样子:违规画面在两帧采样间隙快闪、画面被刻意压低分辨率/加马赛克绕过模型、夜间低光场景模型置信度坍塌。
4.2 关键帧抽取代码示例
// keyframe-sampler.js
import { spawn } from 'node:child_process';
/**
* 从 SFU 旁路出的 RTP 视频流里按时间间隔抽取关键帧 JPEG。
* @param {string} sdpPath SFU 下发的 SDP 文件
* @param {(jpeg: Buffer, ts: number) => void} onFrame
* @param {{ intervalSec: number }} opts
*/
export function startKeyframeSampler(sdpPath, onFrame, opts) {
const args = [
'-protocol_whitelist', 'file,udp,rtp',
'-i', sdpPath,
'-vf', `fps=1/${opts.intervalSec},select='eq(pict_type,I)'`,
'-vsync', 'vfr',
'-f', 'image2pipe',
'-vcodec', 'mjpeg',
'pipe:1',
];
const ff = spawn('ffmpeg', args);
let buf = Buffer.alloc(0);
ff.stdout.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
// JPEG SOI 0xFFD8 / EOI 0xFFD9 拆帧
let start;
while ((start = buf.indexOf(Buffer.from([0xff, 0xd8]))) >= 0) {
const end = buf.indexOf(Buffer.from([0xff, 0xd9]), start);
if (end < 0) break;
const jpeg = buf.slice(start, end + 2);
onFrame(jpeg, Date.now());
buf = buf.slice(end + 2);
}
});
return () => ff.kill('SIGTERM');
}
帧出来之后送到图像审核服务(自建或云服务):
const stopSampler = startKeyframeSampler(sdpPath, async (jpeg, ts) => {
const verdict = await imageModeration.detect(jpeg, {
scene: 'live-video',
biasTowards: ['porn', 'violence', 'politics', 'minor'],
});
if (verdict.action !== 'pass') {
await policy.enforce({
roomId, userId, channel: 'video',
verdict,
evidence: { jpeg, ts },
});
}
}, { intervalSec: 10 });
注意:evidence 里要保留原始 JPEG,不要只存模型置信度。监管核查、用户申诉都需要原图。
5. 文本聊天审核
文本审核是三条入口里最便宜、延迟容忍度最低的一条。问题不在算力,在变体识别。
5.1 链路
文本可以走 DataChannel(WebRTC 自带),也可以走独立 IM 通道(WebSocket / MQTT)。审核位置选择:
- 同步前置审核:客户端发 → 服务端 → 审核 → 命中阻断 / 通过 → 广播。延迟 200~500ms 是用户能接受的上限。
- 异步审核 + 撤回:客户端发 → 服务端 → 广播 + 异步审核 → 命中后撤回。体验更顺滑,但违规内容会有秒级曝光。
- 端侧前置 + 服务端复核:客户端做敏感词字典初筛,服务端用 AI 模型复核。适合高频弹幕场景。
什么时候适用:所有文本聊天,没有例外。
什么时候不适用:无。
失败时的样子:拼音/谐音/特殊字符变体绕过、跨语言混写绕过、字典更新跟不上热点、AI 模型对私聊上下文误判(如医疗咨询里的术语)。
5.2 DataChannel 同步审核拦截
// signaling-side text moderation
import { moderateText } from './moderation/text.js';
/**
* 房间内文本广播前置审核。
* 服务端代理 DataChannel 消息(不让客户端直连 DataChannel 互发),
* 这样才能保证审核不可绕过。
*/
async function handleChatMessage(socket, { roomId, text, msgId }) {
if (text.length > 500) {
return socket.emit('chat:reject', { msgId, reason: 'too_long' });
}
// 1) 字典初筛(毫秒级,本地内存)
const dictHit = sensitiveDict.scan(text);
if (dictHit.level === 'block') {
await evidence.log({ roomId, userId: socket.userId, text, dictHit });
return socket.emit('chat:reject', { msgId, reason: 'dict_block' });
}
// 2) AI 模型复核(百毫秒级,可并发)
const verdict = await moderateText(text, {
scene: 'live-chat',
userTags: socket.userTags,
roomTags: rooms.get(roomId).tags,
});
if (verdict.action === 'block') {
await evidence.log({ roomId, userId: socket.userId, text, verdict });
return socket.emit('chat:reject', { msgId, reason: verdict.reason });
}
if (verdict.action === 'review') {
// 异步人工复审,先放行带"待审"标记,命中后撤回
queueHumanReview({ roomId, userId: socket.userId, text, verdict });
}
io.to(roomId).emit('chat:new', { msgId, userId: socket.userId, text });
}
要点:客户端直连的 DataChannel 不能用于面向陌生人的聊天。直连 = 没有审核点。要么走服务端代理(这种 DataChannel 本质退化成 WebSocket),要么严格限制为已实名好友 1v1。
6. SFU 应该暴露的审核接口
一个生产可用的 SFU 必须对内提供这些能力,否则审核团队没法接:
| 接口 | 用途 | 形式 |
|---|---|---|
| 旁路推流 | 把指定 producer 推到外部审核服务 | RTMP / SRT / 内部 RTP |
| 内部 gRPC 拉流 | 审核 worker 跨节点拉媒体 | gRPC streaming |
| 关键帧回调 | 让 SFU 每 N 秒主动推送 I 帧 JPEG | Webhook |
| 房间控制 API | 处置中心调用,做静音/踢人/关视频 | HTTP / gRPC |
| 事件总线 | 加入、离开、发流、停流事件 | Kafka / NATS |
| 录制开关 | 强制开启原始流录制 | HTTP |
为什么不直接让审核服务连客户端:审核 worker 走 WebRTC 接客户端是反模式——客户端可以拒绝连接、可以发假流,审核完全失效。审核必须在 SFU 下游做。
7. 审核延迟与采样率的工程权衡
延迟和成本是反向的:采样越密、模型越大,命中越早,但成本爆炸。下面是一个有 10 万并发用户的语聊房参考预算:
| 项 | 配置 | 成本量级 |
|---|---|---|
| 音频 ASR | 10 万路 × 10 元/千分钟 | 6 万元 / 小时 |
| 文本审核 | 弹幕 50 万条/分钟 × 0.1 厘 | 300 元 / 小时 |
| 视频抽帧 | 5 万路 × 1 帧/10 秒 × 0.5 厘 | 9 千元 / 小时 |
| 总计 | — | 约 7 万元 / 小时 |
省钱思路:
- 风险分级采样:实名 + 历史无违规的用户降到 30 秒 / 帧,新注册或被举报用户拉到 3 秒 / 帧。
- 静音检测:音频 VAD 判定为静音段直接跳过 ASR。
- 关键房间优先:流量大、监管关注度高的房间走全量审核,长尾小房间走抽样。
- 本地模型前置:在 SFU 节点跑轻量模型做初筛,命中可疑再上云端大模型。
反例:对所有用户一律 3 秒 / 帧 + 大模型,账单顶不住,最后被迫关掉,等于裸奔。
8. 命中策略与处置粒度
处置不是单一动作,要分级。建议至少四级:
| 等级 | 触发条件 | 动作 |
|---|---|---|
| L1 提示 | 低置信度命中、首次轻微违规 | 弹窗提醒、客户端 toast |
| L2 静音 / 关视频 | 中置信度命中 | 单方向静音 60s、关闭视频、消息撤回 |
| L3 踢出房间 | 高置信度命中或累犯 | 强制下麦、踢出房间 |
| L4 封号 + 上报 | 涉政、涉黄、涉暴恐 | 全局封号、向监管平台上报、留存证据 |
处置要走处置中心统一执行,不要在审核 worker 里直接调 SFU API:
- 处置中心负责去重(同一用户 1 秒内只触发一次)、冷却(同一房间踢人间隔)。
- 处置中心负责写入证据库和审计日志。
- 处置中心负责跨系统联动(IM 全局封禁、风控系统打分、人工审核队列)。
9. 违规处置时序
实时阻断 vs 事后回溯不是二选一,要双轨:
- 实时阻断:命中即处置,避免违规内容继续传播。
- 事后回溯:保留完整录制 + 审核标记,便于监管核查、用户申诉、模型反哺训练。
10. 证据链与存证
处置可以被申诉,监管可以来要数据。证据缺失等于处置无效。必存项:
- 媒体证据:违规帧 JPEG(原图)、违规音频片段(命中前后各 5 秒 PCM 或 Opus)、违规文本原文。
- 元信息:roomId、userId、设备信息、IP、UA、SFU 节点 ID、SRTP SSRC。
- 时间戳:UTC 毫秒级 + NTP 同步标记。
- 审核结果:模型版本、置信度、命中标签、处置动作、操作人(系统/人工)。
- 链路完整性:建议对证据包做哈希链或写入只读对象存储(如 OSS WORM)。
存证保留期参考:监管要求至少 60 日,建议 180 日;涉重大违规永久。不要把证据存在业务数据库,要单独的存证库 + 审计日志,并开启 WORM。
11. E2EE 场景下的审核冲突
端到端加密和服务端审核逻辑上不相容。SFU 看不到媒体内容,就没法审。三种处理路径:
- 放弃 E2EE:面向公开房间的业务直接放弃,绝大多数 C 端场景属于这类。监管和审核优先级高于 E2EE。
- 限定场景 E2EE:1v1 私聊、企业内会议默认 E2EE,公开直播房间禁用。审核策略按房间类型分流。
- 端侧审核:客户端在加密前跑本地模型做一次审核,命中后阻止发送并上报指纹(不上报原文)。代价是模型受设备性能限制、可被逆向绕过。Apple CSAM 早期方案是这一路,争议很大,慎用。
什么时候适用 E2EE:医疗、法律咨询、企业并购会议、明确告知用户"对方说什么我们也看不到"的场景。
什么时候不适用:公开直播、陌生人社交、面向未成年人的产品——这些场景上 E2EE 等于把平台责任甩给用户,监管不认。
12. 与录制系统的集成
录制和审核共用同一份原始媒体流,工程上要协同:
- 共享旁路出口:SFU 旁路一次,分发给录制写盘和审核管线,避免重复解码。
- 审核标记落入录制元数据:每段命中违规的时间区间打 tag,回溯时直接跳到违规段,不用全程回放。
- 录制存证一体化:录制文件本身就是证据来源。命中违规时,处置中心从录制文件中切片,而不是再单独存一份。
- 隐私分级:被审核命中的录制片段进存证库(长保留),未命中的录制按业务策略短保留(如 7 天)。
13. 审核服务对接:自建 vs 云服务
主流路径只有两条,按业务规模选。
13.1 自建审核
- 算力:GPU 集群跑 ASR、图像检测、文本检测模型。
- 模型:开源底座(Whisper、CLIP、BERT 类)+ 自有违规样本微调。
- 运维:模型迭代、A/B、误杀监控、人工审核队列。
- 适用:日活千万级以上、违规样本量充足、监管对接频繁的平台。
13.2 云服务
阿里云、腾讯云、字节、网易易盾、Hive、Sightengine 等都提供音视频、图像、文本审核 API,接入模式高度同质化:
// vendor-neutral.js
export async function moderateImage(jpegBuffer, ctx) {
const resp = await fetch(`${VENDOR_BASE}/v1/image/moderation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${signRequest(ctx)}`,
'Content-Type': 'application/octet-stream',
},
body: jpegBuffer,
});
const { suggestion, labels, traceId } = await resp.json();
return {
action: mapAction(suggestion), // pass / review / block
labels, // [{ name: 'porn', score: 0.92 }]
traceId, // 用于事后申诉时找回原始判定
};
}
function mapAction(suggestion) {
if (suggestion === 'block' || suggestion === 'reject') return 'block';
if (suggestion === 'review' || suggestion === 'suspect') return 'review';
return 'pass';
}
接入注意:
- 厂商无关封装:审核业务方调统一接口,底层适配厂商,方便切换或多厂商互备。
- 多厂商兜底:高风险场景双厂商并联,任一命中即拦截,降低单一模型偏差。
- traceId 必存:所有审核请求的 traceId 落库,监管或用户申诉时能反查到底层判定。
- 本地降级:云服务超时 / 限流时,本地敏感词字典 + 规则兜底,绝不"审核挂了就放过"。
14. 反模式
| 反模式 | 后果 |
|---|---|
| 只在客户端做审核 | 用户改包、改 JS 就绕过;客户端模型还能被逆向 |
| DataChannel 客户端直连聊天 | 审核没有挂载点,文本完全不可控 |
| 命中即封号不留申诉通道 | 误杀引发投诉潮,监管反而要求整改 |
| 证据只存模型置信度不存原文 | 申诉/复核拿不出原始物证,等同没审 |
| 全量用户拉满采样率 | 成本爆炸,最后被迫关掉,导致裸奔 |
| 不区分房间风险等级 | 高风险房间漏审、低风险房间浪费 |
| 处置直接由审核 worker 调 SFU | 没去重没冷却,瞬间踢爆房间 |
| ASR 中间结果直接进审核 | 同一句话被判多次,误杀飙升 |
| 录制和审核走两条独立旁路 | SFU CPU 翻倍、时间戳对不齐、证据切片困难 |
| E2EE 房间不区分场景一刀切 | 要么违规放行,要么审核误杀 E2EE 的私聊 |
| 云服务超时直接放行 | 攻击者只需打满审核服务即可投放违规内容 |
| 审核结果不可申诉 | 商业上劝退正常用户,监管层面也要求双向通道 |
15. 自查清单
- 音频、视频、文本三条入口都有审核管线,且命中事件能在统一处置中心看到
- SFU 提供旁路推流、关键帧回调、房间控制 API
- 风险分级采样落地,不是所有用户都拉满
- 文本聊天不允许客户端直连 DataChannel 互发
- 处置分四级(提示 / 静音 / 踢出 / 封号上报),处置中心做去重和冷却
- 证据库与业务数据库隔离,开启 WORM,保留期符合监管
- 录制系统与审核共用旁路,命中区间打 tag
- 云审核服务有本地兜底,不会因为审核挂掉而放行
- 用户申诉通道存在,能反查 traceId 和原始证据
- E2EE 与公开房间策略分流,不一刀切
16. 权威资料
- 国家互联网信息办公室 / 文化和旅游部 / 国家广播电视总局《网络音视频信息服务管理规定》(2020 年施行):https://www.cac.gov.cn/2019-11/29/c_1576561820967678.htm
- 国家互联网信息办公室等三部门《互联网信息服务深度合成管理规定》(2023 年施行):https://www.cac.gov.cn/2022-12/11/c_1672221949354811.htm
- 全国信息安全标准化技术委员会 GB/T 41391-2022《信息安全技术 移动互联网应用程序(App)收集个人信息基本要求》:https://www.tc260.org.cn/
- Trust & Safety Professional Association:https://www.tspa.org/
- Trust & Safety Foundation 资源中心:https://www.tsf.foundation/resources
- OWASP Web Security Testing Guide:https://owasp.org/www-project-web-security-testing-guide/
- OWASP AI Security and Privacy Guide:https://owasp.org/www-project-ai-security-and-privacy-guide/
- W3C WebRTC Security Architecture (RFC 8826):https://www.rfc-editor.org/rfc/rfc8826
- 核对日期:2026-06-22