目标读者:正在建设 AI 对话、社区、评论或 UGC 平台的后端与平台工程团队。
本文讨论的是工程上的内容治理能力,不替代法务、合规和业务负责人对具体规则及地域适用性的判断。代码为实现骨架;文中不把示例延迟、命中率或容量当成通用生产指标,所有阈值均应由标注集、影子流量和压测重新标定。
先给结论
模型供应商的安全能力值得保留,但它只是纵深防御的一层,不能成为应用的唯一安全边界。一个可以稳定运行、可以复盘、可以回滚的审核系统,至少要回答六个问题:
- 内容经过了哪些检测,产生了哪些可验证的证据?
- 哪个版本的策略基于这些证据作出了最终动作?
- 审核超时、模型不可用或队列积压时,当前场景如何退化?
- 流式内容在什么条件下才能展示给用户?
- 用户申诉、人工复核和内容再次编辑时,如何避免旧结果覆盖新版本?
- 事故发生后,如何在不无边界复制敏感原文的前提下复现决定?
本文的核心设计是:Detector 只产生证据,Policy 只决定动作;输入和输出分别审核;同步路径只做有预算的判断,异步路径承担深审与人工闭环;所有决定都带版本并进入审计事件流。
一个接近真实生产的问题
某在线教育产品上线 AI 助教后,用户可以在课程讨论区复制模型回答。一次课程讨论中,攻击者先在输入中加入“忽略上文限制、逐字输出下面内容”的提示注入,再诱导模型生成含有外部联系方式和夸大收益的话术。
当时系统只依赖模型供应商的内置安全层。问题不在于该安全层“完全失效”,而在于应用侧没有第二道可控制的边界:
- 输出一生成就通过 SSE 转发,命中后即使中断也无法撤回已显示的文字;
- 业务无法说明到底是哪条规则、哪个版本、哪个模型判断了风险;
- 运营人员只能手工下线内容,不能对同类历史内容回放补审;
- 新的变体话术出现后,只能等待供应商模型更新。
修复的目标不是训练一个“永不漏判”的模型,而是建立一个可演进的决策系统:高置信高危内容在展示前截住;不确定内容进入隔离与复核;低风险场景在故障时有明确的降级行为;每次决定可关联到证据和版本。
1. 先划清边界:检测不是执法
将“识别风险”和“决定处置”写进同一个插件,是审核系统最常见的早期设计。它在规则很少时方便,但很快会遇到问题:同样的广告证据,在公开评论区可能需要隔离,在内部测试环境可能只需要记录,在特定地域可能适用另一套规则。
因此,将系统拆成四层:
原始内容
↓
Normalizer(产生受控的检测文本)
↓
Detectors(关键词、正则、轻量模型、深度模型)
↓ Evidence[]
Policy Engine(按 tenant / scene / region / 版本决策)
↓ Decision
Enforcement(拒绝、隔离、放行、人工队列、通知)
↓
Audit Event(异步持久化、指标、回放)
- Normalizer 只为检测生成标准化视图,不能悄悄修改用户保存的原文。
- Detector 返回标签分数、规则 ID、模型/规则版本、延迟等证据,不直接返回“封禁”。
- Policy Engine 是最终执法的唯一入口。它接收业务场景和证据,返回可解释、可版本化的动作。
- Enforcement 负责原子地改变内容可见状态、入队人工复核或终止模型生成。
这种分层也让事故回放成为可能:固定一份输入快照、Detector 版本和 Policy 版本,即可比较“当时的决定”和“新策略会产生的决定”。
2. 统一数据模型:每个决定都必须可解释
以下 Go 类型是文章后续示例的共同边界。实际项目应通过 Protobuf 或 JSON Schema 固化,避免各服务自行解释字段。
package moderation
type Label string
const (
LabelSpam Label = "spam"
LabelFraud Label = "fraud"
LabelSexual Label = "sexual"
LabelViolence Label = "violence"
LabelSensitive Label = "sensitive"
)
type Content struct {
ID string
Version int64 // 内容每次编辑递增
TenantID string
UserID string
Scene string // chat_output / comment / profile / internal_assistant
Region string // 由可信业务数据提供,不由用户自由填写
Text string // 原文:只在最小权限链路中使用
Metadata map[string]string
}
type Evidence struct {
Detector string `json:"detector"`
Version string `json:"version"`
Scores map[Label]float64 `json:"scores"`
RuleIDs []string `json:"rule_ids,omitempty"`
LatencyMs int64 `json:"latency_ms"`
// 摘要应避免携带不必要的原文;需要人工复核时再通过受控引用取原文。
Summary string `json:"summary,omitempty"`
}
type Action string
const (
ActionAllow Action = "allow"
ActionReview Action = "review"
ActionQuarantine Action = "quarantine"
ActionBlock Action = "block"
ActionSafeReply Action = "safe_reply" // 对生成式输出返回安全替代回复
)
type Decision struct {
Action Action `json:"action"`
PolicyID string `json:"policy_id"`
PolicyVersion int64 `json:"policy_version"`
ReasonCode string `json:"reason_code"`
Evidence []Evidence `json:"evidence"`
}
这里最重要的不是字段命名,而是约束:ReasonCode 是稳定枚举,例如 FRAUD_HIGH_CONFIDENCE,不能只保存一段模型自由生成的解释;PolicyVersion 与 Detector.Version 必须进入审计记录;Content.Version 必须参与异步回写条件。
3. 输入规范化:提高召回,但不改变原文事实
攻击者常用零宽字符、全角字符、异常空白或大小写混排绕过字面规则。例如 加\u200b微\u200b信 和 加微信 对人几乎相同,对未经规范化的关键词匹配却不同。
规范化应当是可版本化、可测试的纯函数。以下示例将 NFKC、零宽字符和空白统一处理;它不处理视觉上相似但语义不同的跨文字字符,也不试图“纠正”用户拼写。后两类会带来更高误伤风险,应由单独 detector 处理。
package moderation
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
func NormalizeText(s string) string {
s = norm.NFKC.String(s)
var b strings.Builder
for _, r := range s {
switch r {
case '\u200b', '\ufeff', '\u2060': // 零宽空格、BOM、word joiner
continue
case unicode.IsSpace(r):
b.WriteByte(' ')
default:
b.WriteRune(unicode.ToLower(r))
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
必须同时保留以下事实:检测使用的 normalized_hash、规范化器版本、原文哈希。必要时,人工复核系统以短期加密引用访问原文;常规日志不要复制原文。
4. Detector 链:快速证据、语义证据与升级路由
审核不是“所有检测器串行跑一遍”。同步路径需要明确预算。一个可行的路由原则是:
- 高置信硬规则命中,直接产生足以阻断的证据;
- 轻量模型或组合规则判断明确安全时,记录证据并结束同步检测;
- 只有落在不确定区间、或命中高价值场景时,才升级到深度检测;
- 人工复核不是“模型不知道”的垃圾桶,而是有容量上限和 SLA 的有限资源。
type Detector interface {
Name() string
Version() string
Detect(ctx context.Context, normalized string, c Content) (Evidence, error)
}
type Route string
const (
RouteDecide Route = "decide"
RouteDeep Route = "deep"
)
// Router 是可配置函数;阈值属于 policy/config,而不是写死在模型插件中。
func RouteAfterFast(e []Evidence) Route {
if score(e, LabelFraud) >= 0.98 || score(e, LabelSexual) >= 0.99 {
return RouteDecide
}
if maxScore(e) >= 0.45 {
return RouteDeep
}
return RouteDecide
}
阈值仅为示意。落地时应对每个标签分别评估:漏过诈骗与误杀普通课程讨论的代价完全不同,不能共享一个“万能 0.9”。
4.1 L1:规则检测应返回命中的规则,而不是硬编码业务动作
AC 自动机适合大词表匹配,正则适合格式化变体,二者都只能提供证据。下面的简化规则 detector 返回规则 ID 和风险分数;真实实现要对正则进行预编译、超时/复杂度控制,并对词库构建进行离线验证。
type RuleDetector struct {
version string
// engine 包含只读 AC 自动机和经过验证的正则集合。
engine *RuleEngine
}
func (d *RuleDetector) Name() string { return "rules" }
func (d *RuleDetector) Version() string { return d.version }
func (d *RuleDetector) Detect(ctx context.Context, text string, _ Content) (Evidence, error) {
start := time.Now()
hits := d.engine.Match(text)
ev := Evidence{
Detector: d.Name(), Version: d.Version(),
Scores: map[Label]float64{}, LatencyMs: time.Since(start).Milliseconds(),
}
for _, hit := range hits {
ev.RuleIDs = append(ev.RuleIDs, hit.RuleID)
if hit.Score > ev.Scores[hit.Label] {
ev.Scores[hit.Label] = hit.Score
}
}
return ev, nil
}
规则引擎的热更新应采用“后台构建、校验、原子切换”,不要拿写锁包住全量构建。新引擎先通过词条格式、重复规则、正则复杂度和离线回放校验,再用 atomic.Value 切换只读指针。请求只读取当前已验证版本,因此不会因大词库重建而阻塞。
4.2 L2/L3:模型输出也是证据,必须受约束
轻量分类器和深度模型可以提高变体召回,但不能因“模型返回 JSON”就被视为可信。调用边界至少需要:请求 deadline、供应商/模型版本、结构化输出 schema、标签白名单、数值范围校验和失败分类。
func validateScores(in map[Label]float64, allowed map[Label]struct{}) (map[Label]float64, error) {
out := make(map[Label]float64, len(in))
for label, value := range in {
if _, ok := allowed[label]; !ok {
return nil, fmt.Errorf("unknown label %q", label)
}
if math.IsNaN(value) || value < 0 || value > 1 {
return nil, fmt.Errorf("invalid score for %q", label)
}
out[label] = value
}
return out, nil
}
深度模型输入必须作为数据字段传入,不能与系统指令拼接成一段无边界文本。若供应商支持 JSON schema、工具调用或约束解码,应优先启用;解析失败是一次 detector 失败,不应被伪装成“安全通过”。
5. Policy Engine:把业务执法配置化、版本化
Policy 的输入是内容上下文和证据,输出是动作与理由码。它必须做到相同的输入、相同版本,得到相同结果。
type PolicyInput struct {
TenantID string
Scene string
Region string
Evidence []Evidence
}
type Policy interface {
ID() string
Version() int64
Decide(PolicyInput) Decision
}
func (p *PublicCommentPolicy) Decide(in PolicyInput) Decision {
fraud := score(in.Evidence, LabelFraud)
switch {
case fraud >= 0.98:
return Decision{Action: ActionBlock, PolicyID: p.ID(), PolicyVersion: p.Version(),
ReasonCode: "FRAUD_HIGH_CONFIDENCE", Evidence: in.Evidence}
case fraud >= 0.55:
return Decision{Action: ActionQuarantine, PolicyID: p.ID(), PolicyVersion: p.Version(),
ReasonCode: "FRAUD_NEEDS_REVIEW", Evidence: in.Evidence}
default:
return Decision{Action: ActionAllow, PolicyID: p.ID(), PolicyVersion: p.Version(),
ReasonCode: "NO_ENFORCEABLE_EVIDENCE", Evidence: in.Evidence}
}
}
不要把某个宽泛标签直接等同于永久封禁或不可申诉。动作应由具体业务、地域适用规则、风险等级及人工升级条件共同决定。策略配置要经法务/合规与业务审批,并能回滚到上一已知版本。
6. 同步、异步与故障:按场景分配预算
同步与异步不是架构二选一。关键是“内容能否先对外可见”与“漏放的影响”。下面是一个可用于评审的起始矩阵,具体 SLA 由产品体验和压测决定。
| 场景 |
对外可见前的动作 |
同步链路 |
异步链路 |
审核异常时 |
| 公开评论/帖子 |
默认不可见 |
L1/L2 + Policy |
深审、人工、状态回写 |
Quarantine |
| AI 对话输出 |
有限缓冲后释放 |
流式 L1/L2 + Policy |
全量抽检、复杂语义复核 |
SafeReply 或停止输出 |
| 内部低风险助手 |
可见 |
快速规则与审计 |
抽检 |
Allow + Audit |
| 个人资料 |
不立即公开 |
快速规则 |
人工复核 |
Quarantine |
| 历史补审 |
不适用 |
无 |
批量任务 |
暂停并告警 |
故障策略必须显式挂在场景 Policy 上。统一 fail-open 会形成绕过入口;统一 fail-closed 则将审核服务变成业务单点。
type FailureMode string
const (
FailOpen FailureMode = "open"
FailQuarantine FailureMode = "quarantine"
FailSafeReply FailureMode = "safe_reply"
)
func FailureDecision(policyID string, version int64, mode FailureMode) Decision {
action := ActionAllow
switch mode {
case FailQuarantine:
action = ActionQuarantine
case FailSafeReply:
action = ActionSafeReply
}
return Decision{Action: action, PolicyID: policyID, PolicyVersion: version,
ReasonCode: "MODERATION_DEPENDENCY_UNAVAILABLE"}
}
同步调用还应继承上游 context deadline;不要在用户请求路径做无上限重试。超出预算的深审转异步处理,或按场景返回安全降级响应。
7. 流式输出:只有未释放的内容才可能被阻断
“发现违规就断流”并不能解决已经发送到浏览器的内容。流式输出必须在服务端保留一个 hold-back buffer:每个到达 chunk 先进入待释放窗口,快速 detector 检查窗口内文本;只有确认不会跨边界形成高危命中时,才释放安全前缀。
LLM SSE → pending buffer → L1/L2 fast detectors → Policy → Browser
│ │
│ └─ 命中高危:取消上游、丢弃 pending、记录事件
└─ 保留尾部窗口,避免敏感词被切在两个 chunk 中
以下实现按 rune 截断而非 byte 截断,避免破坏 UTF-8。它只处理缓冲逻辑;调用方仍需对 pending + chunk 执行检测,并在取消上游后确保后续 chunk 被丢弃。
type StreamBuffer struct {
pending []rune
keepRunes int // 至少覆盖最长规则匹配窗口,并由压测决定
}
func (b *StreamBuffer) Push(chunk string) string {
b.pending = append(b.pending, []rune(chunk)...)
if len(b.pending) <= b.keepRunes {
return ""
}
releaseN := len(b.pending) - b.keepRunes
safe := string(b.pending[:releaseN])
b.pending = append([]rune(nil), b.pending[releaseN:]...)
return safe
}
func (b *StreamBuffer) Flush() string {
out := string(b.pending)
b.pending = nil
return out
}
这里没有一个通用的“最佳窗口长度”。窗口越短,首字和持续输出体验越好,但跨 chunk 漏检风险越高;窗口越长,风险越低但用户等待越明显。应以最长规则长度、中文/英文 token 切分规律和真实生成速度压测确定,并对“内容已释放后才发现语义风险”的残余风险做异步补救预案。
审计投递不能阻塞输出主路径。使用有界、非阻塞的事件缓冲;缓冲满时计数、采样或降级,而不是让一个慢的审计消费者拖住 SSE。高危阻断事件则应走可靠事件通道或 outbox。
8. 异步审核:至少一次投递意味着必须幂等
Kafka 等消息系统通常提供至少一次处理语义。同一事件重复送达是正常现象,人工工单、用户通知和内容状态回写都不能以“只会消费一次”为前提。
事件应带有稳定 ID、内容版本和策略版本:
type ModerationEvent struct {
EventID string
ContentID string
ContentVersion int64
PolicyID string
PolicyVersion int64
TraceID string
}
func (h *Handler) Handle(ctx context.Context, e ModerationEvent) error {
inserted, err := h.inbox.TryInsert(ctx, e.EventID)
if err != nil {
return err
}
if !inserted {
return nil // 已成功处理过
}
if err := h.process(ctx, e); err != nil {
// 失败后允许重试;实现须保证删除与状态机语义匹配。
_ = h.inbox.Delete(ctx, e.EventID)
return err
}
return nil
}
当一次内容状态变更同时要写数据库和发布审核事件时,用 Transactional Outbox 将事件与业务状态写入同一事务,再由独立投递器发布。消费者侧使用 Inbox 或等价去重机制。对于无法重试的副作用(例如外部通知),另设幂等键。
内容版本是人工回写的安全带
假设评论 A 在人工审核期间被用户编辑为 A2。若人工结果仅按 content_id 回写,旧内容 A 的“通过”可能错误地公开 A2。回写必须同时检查版本与当前状态:
UPDATE content
SET moderation_status = 'APPROVED', moderation_decision_id = :decision_id
WHERE id = :content_id
AND version = :content_version
AND moderation_status = 'HUMAN_REVIEW';
受影响行数为零时,旧决定不再有效;最新版本重新进入审核。这一规则同样适用于撤销、隔离解除和申诉结果。
9. 人工复核与申诉:把系统误差变成改进数据
人工审核不是只用来“兜底”,还承担校准 detector、评估 Policy 和纠正用户侧错误的职责。每张工单至少关联:内容版本、最终决定、完整 evidence、策略版本、最小必要的内容访问引用、审核规范版本和审核人结果。
推荐的状态机:
PENDING_REVIEW → IN_REVIEW → APPROVED / REJECTED
│ │
└── 内容更新 → SUPERSEDED ──────┘(结果不得回写新版本)
用户申诉:BLOCKED / QUARANTINED → APPEAL_PENDING → 人工复审 → 维持或纠正
“是否允许申诉、复核时限、用户提示文案”是业务与合规策略的一部分,不要散落在 detector 代码中。申诉推翻率应按标签、策略版本、地区、审核员组别分析;高推翻率通常意味着规则过宽、模型漂移或审核规范不一致。
10. 审计与隐私:既能复盘,也不滥存内容
审计记录不等于日志里保存全部原文。默认保存哈希、版本、证据、策略和决定;原文如确需保留,应进入隔离加密存储,设置最小权限、访问审计、留存期限和删除流程。
type AuditRecord struct {
TraceID string `json:"trace_id"`
ContentID string `json:"content_id"`
ContentVersion int64 `json:"content_version"`
TenantID string `json:"tenant_id"`
Scene string `json:"scene"`
Region string `json:"region"`
OriginalHash string `json:"original_hash"`
NormalizedHash string `json:"normalized_hash"`
NormalizerVersion string `json:"normalizer_version"`
PolicyID string `json:"policy_id"`
PolicyVersion int64 `json:"policy_version"`
Decision Decision `json:"decision"`
CreatedAt time.Time `json:"created_at"`
}
审计流可由 Kafka 异步写入 ClickHouse 等分析存储;生产表还需要考虑分区、TTL、访问控制和删除任务。留存时间不是“统一保存 N 天”的技术常量,应按数据类型、地域要求、合同义务与最小化原则确定。
建议至少监控以下指标,并强制携带版本维度:
| 类别 |
指标 |
| 性能 |
QPS、p50/p95/p99、超时率、in-flight、队列等待时间 |
| 决策 |
allow/block/review/quarantine 比例,按 policy/version/scene/region 分组 |
| 质量 |
Precision、Recall、FPR、FNR、人工推翻率、抽检不一致率 |
| 系统 |
detector 错误率、规则版本分布、Kafka lag、最老消息年龄、DLQ、人工队列年龄 |
单纯看到拦截率上升不是成功;它可能意味着误杀变多。质量指标必须依赖独立、持续更新的人工标注集。
11. 规则和模型上线:先证明,再执行
每一次规则、模型、规范化器或 Policy 变更都应有唯一版本,并走受控发布:
Draft
↓ 静态校验:格式、重复、正则复杂度、授权范围
Offline Replay
↓ 与基线比较混淆矩阵、成本和延迟
Shadow
↓ 在线计算、不改变用户可见状态
Canary(小流量、可观测、可回退)
↓
Active
└── 异常 → 回滚至上一已知稳定版本
离线集不应只包含普通内容和已知违规内容,至少覆盖:
normal.jsonl
spam_and_fraud.jsonl
unicode_obfuscation.jsonl
prompt_injection.jsonl
boundary_cases.jsonl
appeal_overturned_cases.jsonl
上线门槛应由标签和场景定义。例如,候选策略在诈骗标签的召回率不能低于基线,同时公开评论的误杀率、人工队列增长、同步 p99 延迟与单位成本不能越过批准阈值。若任一门槛失败,保持 Shadow 或回滚,而不是因“拦截更多”直接上线。
12. 容量与扩缩容:用可测量的队列信号
不要直接复制“每天多少条内容对应多少 GPU”之类的结论。容量由请求峰值、文本/图片/视频比例、平均 token、模型延迟、批处理窗口、可接受队列等待时间和可用副本共同决定。
同步审核服务可同时看 CPU、in-flight 请求、超时率和 p95/p99;异步消费者重点看 Kafka lag、最老消息年龄、worker 利用率、DLQ 增长和下游模型错误率。Kubernetes 本身不会自动提供 kafka_consumer_lag,需要 KEDA、Prometheus Adapter 或等价的指标链路。
文本、图片、视频通常应分队列、分消费组、分资源池:慢任务不能挤占直播消息或评论审核的资源。所有扩缩容阈值都应在压测中验证“扩容完成前队列是否已超过业务 SLA”。
13. 最小可落地版本
不要从“多模态大模型审核平台”开始。一个可靠的第一版可以按以下顺序实现:
- 为公开内容增加
PENDING_REVIEW 状态,对外默认不可见。
- 建立版本化 Normalizer 与 L1 规则 detector,输出
Evidence,不在 detector 中封禁。
- 实现一个版本化 Policy,将高置信证据阻断、中等证据隔离、其他内容放行。
- 以 Outbox + Kafka 接入异步深审和人工队列,并用内容版本条件回写。
- 写入最小审计记录:trace、内容版本、哈希、规则/模型版本、策略版本、理由码和动作。
- 为新规则建立离线回放和 Shadow;没有标注集就不要自动扩大执法范围。
- 对 AI 流式输出增加 hold-back buffer、上游取消和安全降级响应。
这条路径先解决“内容是否会在未审情况下公开”“决策能否解释和回滚”“异步是否会错误回写”三个高风险问题,再逐步引入更昂贵的模型能力。
上线检查清单
- 输入与输出都经过审核;流式输出在服务端有未释放缓冲。
- Normalizer、每个 detector、模型、规则和 Policy 都有版本。
- Detector 只返回证据,最终动作只由 Policy 产生。
- 同步调用继承 deadline;超时按场景进入明确的失败策略。
- 公开内容在通过前不可见,或有等效的隔离机制。
- 消息事件、人工工单、通知和状态回写具备幂等设计。
- 回写条件包含
content_id + content_version + expected_status。
- 审计事件包含 trace、哈希、版本、证据、理由码和最终动作。
- 原文访问受控、加密、可审计,并有留存和删除机制。
- 新规则先经过 Replay、Shadow、Canary,并能一键回滚。
- 指标覆盖质量、延迟、错误、积压和人工队列,且可按版本比较。
- 已准备包含对抗样本和申诉推翻案例的标注集。
结语
内容审核的目标不是保证“模型永远不说错”,而是将风险管理变成可以控制、解释、恢复和持续校准的工程系统。保留模型供应商安全能力,同时在应用侧建立标准化、证据检测、版本化策略、可靠执行、人工闭环和审计回放,才能在规则变化、流量增长、模型漂移或事故发生时仍然掌握主动权。在云栈社区,围绕内容治理与后端架构的这类讨论,也始终是工程团队关注的重点方向。