《互联网产品的服务端架构·规模化与架构演进》7.2 内容安全与未成年人保护

「回声」上线青少年模式后,审核仍只分“违规”和“正常”两档,年龄判断又放在客户端,结果一条擦边内容被未成年人看到并引发投诉。本节把内容分级、年龄与处置动作拆成三个独立维度,给出词表分级与年龄准入的实现和测试,并复盘一次分级与动作脱节导致的应用商店审核驳回。

一条“不算违规”的帖子

「回声」的社区里有大量二手交易和生活方式内容。审核后台最初只有两个按钮:正常、违规。运营同学每天处理几千条举报,判断标准也很简单:涉黄、涉赌、涉诈、明显辱骂,点违规;其他都点正常。

青少年模式上线时,团队在客户端加了一个开关,把年龄和模式存在本地,服务端只接收一个 minor=true 的参数。Feed 接口的逻辑是:如果 minor 为真,就过滤掉 prohibited 的内容。测试环境里一切正常,因为测试账号只有干净数据。

上线两周后,一位家长把截图发到了应用商店评论区:十二岁的孩子在青少年模式下看到了一条详细描述医美项目体验的帖子,配图打了码,但文字包含术前术后对比、价格和引流联系方式。这条帖子在审核后台被标成了“正常”,因为词表里没有命中任何“硬违规”词。

更严重的是,家长把客户端开关关掉之后,同一批内容仍然能通过直接调用接口拿到,因为服务端从来没有校验年龄,只信了客户端传来的布尔值。应用商店随后以“未成年人保护机制不完整”为由驳回了下一个版本。

复盘时团队发现,问题不是“词表不够大”,而是三个维度被揉成了一团:内容属于什么级别、用户是不是未成年人、这个级别对这类用户应该做什么处置。三者中任何一个缺失,另外两个都无法单独保证安全。

分级、年龄、动作必须拆开设计

内容安全最常见的简化是把所有判断压成一个布尔值:违规或不违规。这个模型在“删帖”场景里够用,但在未成年人保护场景里不够,因为同一内容对不同年龄的用户可能有不同处置:成年人可以看到并带提示,未成年人不应看到;而真正违法的内容对所有人都不能展示。

把三个维度分开之后,规则才有讨论空间:

内容分级判定依据(案例假设值)未成年人处置成年人处置
safe未命中任何敏感词,且无高风险结构正常展示正常展示
sensitive命中软性词、擦边描述或需要人工判断的引流不展示,转人工复核展示并加提示,限制推荐
prohibited命中硬违规词、违法交易或明确伤害内容不展示,删除并上报不展示,删除并上报

这张表是案例假设值,不是监管条文。真实产品应该由法务、运营和审核负责人共同确认每一档的边界,并把判断依据写进审核手册。技术实现的职责不是替业务定义边界,而是保证边界一旦确定,就不会在代码路径里被悄悄绕过。

分级不是置信度

很多团队会把 Check 的返回值当成“模型置信度”,例如 0.83 就展示、0.81 就隐藏。分级和置信度是两件事:分级回答“这是什么”,置信度回答“有多确定”。把两者混在一起,阈值会变成拍脑袋的数字,审核同学也无法解释为什么同一条内容昨天能发、今天不能发。

更稳的做法是分级之外单独记录命中词和命中位置。审核后台展示“命中词:医美引流;位置:正文第 3 段”,比展示一个 0.83 更有用。模型可以给出置信度,但处置动作应该由“分级 + 年龄 + 业务规则”共同决定。

年龄要由服务端确认

客户端传来的 minor=true 只能当作提示,不能当作授权。年龄应该来自账号资料、实名信息或支付渠道等可信来源,并由服务端在每次请求时读取。客户端可以做界面适配,但不能成为准入判断的唯一依据。

对于没有实名信息的场景,至少要做到:未成年人模式由账号维度开启,服务端保存状态;切换模式需要重新鉴权;接口不信任客户端传来的年龄字段。否则用户改一个参数就能绕过全部保护。

处置动作要有中间态

只有“展示”和“删除”两档,会导致两个问题:轻微内容被直接删除,伤害创作;真正有害的内容因为审核来不及而暂时展示。中间态至少应该有“仅自己可见”“不进入推荐”“转人工复核”“限制搜索”几种。

中间态的价值在于给审核争取时间,同时把风险控制在可见范围内。对未成年人,中间态应该更保守:sensitive 默认不展示,而不是“先展示再复核”。因为未成年人看到的伤害是不可逆的,而成年人至少还有举报和关闭的能力。

词表是基线,不是全部

词表能挡住明显的硬违规,但挡不住擦边、谐音、变体和图文组合。内容安全系统通常需要多层:词表、规则、模型、人工审核、用户举报。词表是第一层,负责快速拦截高确定性的内容;规则处理结构特征,例如联系方式、价格、引流话术;模型处理语义;人工处理边界样本。

每一层都要有独立的评估集和误报率。只扩大词表,会把“医美”“术后”这类正常讨论也误伤;只靠模型,又会出现不可解释的漏判。分层的目的不是追求百分之百,而是让每一层都能被单独评估和回滚。

审计与申诉同样适用于内容

内容被处置后,作者需要知道原因和申诉路径。审核记录应该保存:内容 ID、分级、命中依据、处置动作、操作人和时间。没有这些记录,作者申诉时只能得到“系统判定”,审核同学也无法复盘误判。

对未成年人相关的处置,还要额外保存“为什么这个用户被认定为未成年人”以及“为什么这条内容对该年龄不可见”。这类记录既是合规要求,也是产品迭代的依据。

分级与动作脱节的那次驳回

事故的直接原因是一行看起来无害的判断。Feed 接口的过滤逻辑写成了:

if category == prohibited { hide }

开发同学的理解是“sensitive 只是提示级别,不应该影响展示”。这个理解在成年人场景里成立,但在青少年模式里不成立。于是所有 sensitive 内容都穿过了过滤,包括那条医美引流帖。

第二个问题是年龄来源。客户端在开启青少年模式时传 minor=true,服务端只把它当作过滤条件,没有校验账号年龄,也没有保存模式状态。用户关闭开关或直接调接口,minor 就变成 false,所有内容恢复可见。

第三个问题是词表覆盖。那条帖子没有命中任何硬违规词,因为它的用词都在正常讨论范围内,真正的问题是引流结构和术前术后描述。审核后台只有“正常/违规”两个按钮,运营同学即使觉得不合适,也没有“敏感待复核”这个选项。

三个问题叠加,才造成了“内容被判为正常、年龄校验被绕过、动作只过滤 prohibited”的结果。单独修任何一个都不够:只扩词表,下一次换个说法仍会漏;只修年龄,sensitive 内容仍会对未成年人展示;只改动作,客户端仍然可以绕过。

修正方案分三步。第一,服务端保存未成年人模式状态,年龄来自账号资料,接口忽略客户端传来的年龄参数。第二,分级返回值从布尔改成三档,动作表按“分级 × 年龄”生成,未成年人只允许 safe。第三,审核后台增加“敏感待复核”,并记录命中依据和处置动作。

上线前还做了一次回归:用一批历史内容分别以未成年人和成年人身份请求 Feed,逐条比对可见性。这个回归暴露了另一个边界:年龄正好 18 岁的用户应该按成年人处理,而旧代码写的是 age > 18,导致 18 岁用户仍被当成未成年人。边界值必须写进测试,而不是靠口头约定。

把分级和年龄准入写成纯函数

配套代码 internal/guard/content.go 只做两件事:根据词表给文本分级,根据分级和年龄判断是否允许展示。它不调用模型、不查账号库、不写审核队列,因为这些副作用会让“为什么这条内容对这个用户不可见”难以复现。

// file: internal/guard/content.go
// tier: L1
// verify: go test ./internal/guard/

package guard

import "strings"

// Category 是内容分级。safe 可以直接发布,sensitive 需要人工复核
// 或限制可见范围,prohibited 不对外展示。
type Category string

const (
	Safe       Category = "safe"
	Sensitive  Category = "sensitive"
	Prohibited Category = "prohibited"
)

// Check 按词表判断文本分级。词表中的词默认是 sensitive;
// 以感叹号开头的词(例如 "!诈骗")表示 prohibited。
// 匹配不区分大小写,空白词会被忽略;只要命中一个 prohibited,
// 结果就是 prohibited,不受词表顺序影响。
func Check(text string, words []string) Category {
	lower := strings.ToLower(text)
	sensitive := false
	for _, raw := range words {
		word := strings.TrimSpace(raw)
		hard := strings.HasPrefix(word, "!")
		word = strings.TrimSpace(strings.TrimPrefix(word, "!"))
		if word == "" {
			continue
		}
		if strings.Contains(lower, strings.ToLower(word)) {
			if hard {
				return Prohibited
			}
			sensitive = true
		}
	}
	if sensitive {
		return Sensitive
	}
	return Safe
}

// MinorAllowed 判断某分级的内容能否展示给指定年龄的用户。
// 未成年人(age < 18)只允许 safe;成年人可以看 sensitive,
// prohibited 对任何年龄都不放行;未知分级和非法年龄一律拒绝。
func MinorAllowed(c Category, age int) bool {
	if age < 0 || c == Prohibited {
		return false
	}
	if c != Safe && c != Sensitive {
		return false
	}
	if age >= 18 {
		return true
	}
	return c == Safe
}

Check 用 ! 前缀区分硬违规和软性敏感词,而不是维护两个切片。好处是调用方只需要传一份词表,词表本身携带分级;坏处是词表格式变成了一种隐式协议,必须在文档和审核后台里写清楚。任何隐式协议都有被误用的风险,所以测试里专门覆盖了空词、只有感叹号的词和大小写差异。

Check 在命中多个词时优先返回 prohibited,不受词表顺序影响。这一点很重要:如果实现成“命中第一个词就返回”,那么词表排序会改变内容分级,运营调整词表顺序就可能让一条内容从 prohibited 变成 sensitive。分级结果不应该依赖配置文件的顺序。

MinorAllowed 把年龄边界固定为 18 岁,并且对未知分级返回 false。未知分级可能来自上游新增了分类但下游还没更新,此时放行等于把未评估的内容展示给用户;拒绝虽然会误伤,但至少不会造成不可逆的暴露。

MinorAllowed 还规定 prohibited 对成年人不放行。这个选择可能有争议:有的产品会把违规内容保留给审核员查看。审核员应该走独立的后台权限,而不是复用面向用户的准入函数。面向用户的函数应该默认“不展示”,把例外收在更小的权限边界里。

代码没有实现“中间态”,例如“仅自己可见”或“不进入推荐”。这些属于展示策略,不是准入判断。把策略塞进 MinorAllowed 会让函数签名迅速膨胀,也会让测试难以覆盖。更合理的拆分是:MinorAllowed 回答“能不能看”,另一个策略函数回答“在哪里能被看到”。

测试:三档分级与年龄边界

内容安全的测试要同时锁住两件事:词表命中后的分级是否稳定,以及年龄边界是否会把该拦的人放过去。下面的测试覆盖了三档分级、大小写、空词和 18 岁边界。

// file: internal/guard/content_test.go
// tier: L1
// verify: go test ./internal/guard/

package guard

import "testing"

func TestCheck(t *testing.T) {
	words := []string{"ad", "!scam", "广告", "  ", "!"}
	tests := []struct {
		name string
		text string
		want Category
	}{
		{"clean", "今天天气不错", Safe},
		{"empty", "", Safe},
		{"chinese sensitive", "这里有广告", Sensitive},
		{"english case insensitive", "This is an AD", Sensitive},
		{"prohibited", "SCAM link inside", Prohibited},
		{"prohibited wins", "广告里夹着 scam 链接", Prohibited},
	}
	for _, tt := range tests {
		if got := Check(tt.text, words); got != tt.want {
			t.Errorf("%s: Check(%q) = %q, want %q", tt.name, tt.text, got, tt.want)
		}
	}
}

func TestMinorAllowed(t *testing.T) {
	tests := []struct {
		name string
		c    Category
		age  int
		want bool
	}{
		{"minor safe", Safe, 10, true},
		{"minor sensitive", Sensitive, 10, false},
		{"minor prohibited", Prohibited, 10, false},
		{"teen safe", Safe, 17, true},
		{"teen sensitive", Sensitive, 17, false},
		{"adult sensitive", Sensitive, 18, true},
		{"adult prohibited", Prohibited, 18, false},
		{"invalid age", Safe, -1, false},
		{"unknown category", Category("unknown"), 20, false},
	}
	for _, tt := range tests {
		if got := MinorAllowed(tt.c, tt.age); got != tt.want {
			t.Errorf("%s: MinorAllowed(%q, %d) = %v, want %v",
				tt.name, tt.c, tt.age, got, tt.want)
		}
	}
}

TestCheck 覆盖了中文敏感词、英文大小写、硬违规优先和空文本。其中“prohibited wins”这条用例最关键:文本同时命中 soft 和 hard 两类词,结果必须是 prohibited。如果实现依赖词表顺序,这条用例会随词表变化而失效;现在的实现显式保证硬违规优先。

TestCheck 还覆盖了空词和只有感叹号的词。它们必须被忽略,否则空字符串会命中所有文本,让整个分级系统瞬间失效。这类边界在词表由运营手工维护时尤其容易发生。

TestMinorAllowed 把 17 岁和 18 岁单独列出。17 岁是未成年人,只能看 safe;18 岁是成年人,可以看 sensitive。边界差一岁,对应的产品行为和合规责任完全不同,不能靠 age > 18 这种写法碰运气。

测试还覆盖了负年龄和未知分级。它们都返回 false,属于保守失败。真实系统里,负年龄通常意味着数据解析错误,未知分级意味着上下游版本不一致;这两种情况都应该告警,而不是静默放行。

测试没有覆盖真实模型和人工审核。模型效果需要独立的评估集,人工审核需要抽检和一致性分析。单元测试只能保证“给定分级和年龄,准入函数的行为是确定的”,不能证明分级本身准确。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/guard/ -run 'TestCheck|TestMinorAllowed' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestCheck
--- PASS: TestCheck (0.00s)
=== RUN   TestMinorAllowed
--- PASS: TestMinorAllowed (0.00s)
PASS
ok  	webapp/internal/guard	0.418s

两个用例全部通过,退出码为 0。-run 只筛选了内容分级和年龄准入相关的用例,同包的反爬评分与脱敏测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

未成年人保护不是一个开关,而是三个维度共同作用的结果:内容属于什么级别、用户是不是未成年人、这个级别对这类用户应该做什么。只做词表,会漏掉擦边内容;只做客户端年龄,会被参数绕过;只做“违规/正常”两档,会让敏感内容无处分流。

分级要稳定、可解释,不能依赖词表顺序;年龄要由服务端确认,不能信任客户端传参;处置动作要有中间态,给审核争取时间。三者拆开之后,每一层都可以单独测试、单独回滚,也才能在事故复盘时说清楚“哪一层失效了”。下一节讨论个人信息保护:当字段进入日志、导出和审计时,如何做到最小化、脱敏和可追溯。

阅读导航:上一节:7.1 反爬与接口防刷 · 下一节:7.3 个人信息保护与审计 。

常见错误与边界

  • 把内容安全压成布尔值。只有“违规/正常”两档,敏感内容没有分流空间,未成年人保护也无从谈起。
  • 只信客户端传来的年龄或模式参数。改包、关开关、直接调接口都能绕过,年龄必须来自服务端可信来源。
  • 用 age > 18 判断成年人。18 岁整的用户会被错误地当成未成年人,边界值必须写进测试。
  • 让分级结果依赖词表顺序。命中多个词时应显式定义优先级,否则运营调整顺序就会改变处置结果。
  • 词表里保留空字符串。空词会命中所有文本,导致全站内容被误判,词表加载时必须清洗。
  • 只有删除没有中间态。轻微内容被直接删除会伤害创作,有害内容又可能因为审核排队而暂时展示。
  • 处置没有审计记录。作者申诉时只能得到“系统判定”,审核也无法复盘误判和责任边界。
  • 用单元测试证明模型准确率。模型效果需要独立评估集和人工抽检,单元测试只覆盖确定性的准入规则。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查