一条被机审拦下的宠物视频
「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。
一位用户上传了自家猫打翻水杯的视频。机审返回三个信号:画面模型认为“可能包含液体泼溅”置信度 0.86,文本模型认为标题正常置信度 0.12,账号历史模型认为“无违规记录”置信度 0.91。第一版规则很简单:任意信号超过 0.8 就拒绝。
视频被拒,提示只有一句“内容违规”。用户不知道哪条规则触发,也不知道该改什么。他重新上传同一段视频,结果一样;又剪辑了一版,仍然一样。三次之后,他放弃了发布,并在社交平台上说这个产品“莫名其妙”。
问题不在模型准确率,而在结论的收敛方式。三个信号来自三条独立规则,置信度的含义并不相同;把它们塞进“任意超过 0.8”的规则里,等于让一条中等强度的信号拥有最终否决权。
三个结论和两条阈值
机审的输出应该是三档,而不是二元:
| 结论 | 条件(本实现的策略) | 用户看到什么 | 谁来处理 |
|---|---|---|---|
| pass | 最强信号达到 passAt,且其余信号都低于 rejectAt | 直接发布 | 机器 |
| reject | 最强信号未达到 passAt,但达到 rejectAt;或另一条信号也达到 rejectAt | 失败原因、可修改项、处理时限 | 机器,可申诉 |
| review | 所有信号都低于 rejectAt;或没有任何信号 | “审核中,预计 X 分钟内完成” | 人审 |
这张表是案例假设值,阈值的具体数字应该由业务风险和人工成本共同决定,而不是照抄。
passAt 必须大于 rejectAt。放行需要的把握高于拦截,否则放行区间会被拦截区间完全覆盖。这个约束不是数学上的装饰,而是产品语义:机器只有在非常确信时才自动放行,中等确信时宁可拒绝或转人工。
还要注意“另一条信号也达到 rejectAt”这一条。它的意思是:一条信号再强,也不能在另一条独立规则同样高声的情况下单独放行。系统不赌哪条规则更可信,直接拒绝。这个策略偏保守,适合内容风险高、人工复核成本可控的场景;如果业务更怕误杀,可以把冲突结果改成 review,但必须显式改代码和测试,而不是靠调阈值碰运气。
复审不是“把内容再跑一遍”。机审模型没变,重跑只会得到同样的分数。复审有两种合理触发:
- 抽样:按比例抽取已自动处理的样本,由人审检查机审是否漂移。
- 申诉:用户对 reject 结果提出申诉,进入 2.3 的状态机。
抽样解决的是“模型悄悄变差”,申诉解决的是“单条内容被误判”。两者的数据流向不同,不能共用一个队列,否则人审会被大量重复内容淹没。
失败场景:阈值太严,人审队列爆掉
「回声」上线第二个月,运营把 passAt 从 0.9 调到 0.97,理由是“宁可错杀,不可放过”。rejectAt 仍然是 0.8。结果如下(案例假设值):
- 日均发布 40 万条,自动放行从 62% 降到 38%。
- 自动拒绝从 6% 升到 11%。
- 进入人审的比例从 32% 升到 51%,约 20.4 万条。
- 人审团队日均处理 6 万条,队列积压到 6 小时以上。
- 审核中的内容不能进入推荐和搜索,创作者的阅读量在发布首小时下降 70%。
更严重的是积压改变了审核质量。人审同学为了清队列,开始快速点“通过”,抽样复核的准确率从 94% 掉到 81%。系统看起来还在工作,实际上把机审的严格换成了人审的随意。
修复不是简单调回阈值,而是把三件事分开:
- 按内容品类设不同阈值。宠物、美食等低风险品类的
passAt可以低一些,时政、医疗保持严格。 - 给 review 队列设最大等待时间。超过时限自动升级或降级,并把积压量作为独立指标告警。
- 把抽样比例和申诉率纳入阈值评审,用数据判断“严”是否真的减少了风险,而不是只看拒绝量。
阈值调整必须可回滚。把阈值写进配置,保留变更记录和灰度比例;一次全量调整如果方向错了,代价是几个小时的内容积压和一批创作者流失。
用 Decide 收敛多条信号
下面的代码来自本书配套的 webapp-lab。Decide 不做加权平均,也不把多个分数相加;它只取最强信号,再检查是否存在另一条同样越过 rejectAt 的独立信号。输入不合法时直接报错,避免把 NaN 或越界置信度悄悄当成 0。
// file: internal/moderate/review.go
// tier: L1
// verify: go test ./internal/moderate/
package moderate
import (
"fmt"
"math"
)
// Verdict 是机审给出的结论。
type Verdict string
const (
// VerdictPass 表示机器足够确信,可以直接放行。
VerdictPass Verdict = "pass"
// VerdictReject 表示机器拒绝放行,进入失败提示或申诉流程。
VerdictReject Verdict = "reject"
// VerdictReview 表示灰区,交给人工审核。
VerdictReview Verdict = "review"
)
// Signal 是一个独立审核规则的判断。
// Confidence 是该规则对自身结论的把握,取值范围 [0,1];Name 用于区分规则。
type Signal struct {
Name string
Confidence float64
}
// Decide 把多条规则的判断收敛成一个结论。
//
// 规则不使用加权平均:平均会把一条高置信度的信号摊薄成灰区。
// 这里只取最强的信号,并额外执行一次冲突检查:
// - 最强信号达到 passAt,且其余信号都低于 rejectAt 时,pass;
// - 最强信号未达到 passAt 但达到 rejectAt 时,reject;
// - 最强信号达到 passAt,却有另一条独立信号也达到 rejectAt 时,
// 视为两条规则同时高声、无法判断谁更可信,同样 reject;
// - 其余情况交给人工,review;
// - 没有任何信号时也交给人工。
//
// passAt 必须大于 rejectAt:放行需要的把握高于拦截,否则放行区间会被拦截区间完全覆盖。
func Decide(signals []Signal, passAt, rejectAt float64) (Verdict, error) {
if math.IsNaN(passAt) || math.IsNaN(rejectAt) {
return "", fmt.Errorf("moderate: thresholds must be numbers")
}
if passAt < 0 || passAt > 1 || rejectAt < 0 || rejectAt > 1 {
return "", fmt.Errorf("moderate: thresholds must be in [0,1], got passAt=%v rejectAt=%v", passAt, rejectAt)
}
if passAt <= rejectAt {
return "", fmt.Errorf("moderate: passAt %v must be greater than rejectAt %v", passAt, rejectAt)
}
if len(signals) == 0 {
return VerdictReview, nil
}
top := -1
seen := make(map[string]bool, len(signals))
for i, s := range signals {
if s.Name == "" {
return "", fmt.Errorf("moderate: signal %d has empty name", i)
}
if seen[s.Name] {
return "", fmt.Errorf("moderate: duplicate signal %q", s.Name)
}
seen[s.Name] = true
if math.IsNaN(s.Confidence) || s.Confidence < 0 || s.Confidence > 1 {
return "", fmt.Errorf("moderate: signal %q confidence %v is outside [0,1]", s.Name, s.Confidence)
}
if top < 0 || s.Confidence > signals[top].Confidence {
top = i
}
}
if signals[top].Confidence >= passAt {
for i, s := range signals {
if i != top && s.Confidence >= rejectAt {
return VerdictReject, nil
}
}
return VerdictPass, nil
}
if signals[top].Confidence >= rejectAt {
return VerdictReject, nil
}
return VerdictReview, nil
}
实现里有两个细节值得注意。第一,阈值和置信度都限制在 [0,1],因为超出范围的分数通常意味着上游模型或配置写错了,静默截断会掩盖问题。第二,重复的信号名会被拒绝;同一个规则在一次决策里出现两次,说明调用方拼接了重复数据,继续计算只会让结果难以解释。
测试:空信号、灰区和冲突信号
测试把每种结论和每种输入错误都固定下来,尤其是“强信号 + 另一条强信号”的冲突分支,避免以后为了“提高通过率”悄悄删掉否决逻辑。
// file: internal/moderate/review_test.go
// tier: L1
// verify: go test ./internal/moderate/
package moderate
import (
"math"
"strings"
"testing"
)
func TestDecideReviewsEmptySignals(t *testing.T) {
got, err := Decide(nil, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if got != VerdictReview {
t.Fatalf("verdict = %q, want review", got)
}
}
func TestDecidePassesStrongSingleSignal(t *testing.T) {
signals := []Signal{{Name: "spam", Confidence: 0.95}}
got, err := Decide(signals, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if got != VerdictPass {
t.Fatalf("verdict = %q, want pass", got)
}
}
func TestDecideRejectsMiddleBand(t *testing.T) {
signals := []Signal{{Name: "copyright", Confidence: 0.85}}
got, err := Decide(signals, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if got != VerdictReject {
t.Fatalf("verdict = %q, want reject", got)
}
}
func TestDecideReviewsWeakSignals(t *testing.T) {
signals := []Signal{{Name: "nsfw", Confidence: 0.5}}
got, err := Decide(signals, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if got != VerdictReview {
t.Fatalf("verdict = %q, want review", got)
}
}
func TestDecideRejectsWhenAnotherSignalVetoes(t *testing.T) {
signals := []Signal{
{Name: "spam", Confidence: 0.95},
{Name: "copyright", Confidence: 0.85},
}
got, err := Decide(signals, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if got != VerdictReject {
t.Fatalf("verdict = %q, want reject", got)
}
}
func TestDecideTreatsThresholdsAsInclusive(t *testing.T) {
pass, err := Decide([]Signal{{Name: "spam", Confidence: 0.9}}, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if pass != VerdictPass {
t.Fatalf("exact passAt verdict = %q, want pass", pass)
}
reject, err := Decide([]Signal{{Name: "spam", Confidence: 0.8}}, 0.9, 0.8)
if err != nil {
t.Fatal(err)
}
if reject != VerdictReject {
t.Fatalf("exact rejectAt verdict = %q, want reject", reject)
}
}
func TestDecideValidatesInputs(t *testing.T) {
cases := []struct {
name string
signals []Signal
passAt float64
rejectAt float64
want string
}{
{
name: "passAt-not-above-rejectAt",
signals: []Signal{{Name: "spam", Confidence: 0.5}},
passAt: 0.8,
rejectAt: 0.8,
want: "must be greater",
},
{
name: "threshold-above-one",
signals: []Signal{{Name: "spam", Confidence: 0.5}},
passAt: 1.1,
rejectAt: 0.8,
want: "in [0,1]",
},
{
name: "threshold-nan",
signals: []Signal{{Name: "spam", Confidence: 0.5}},
passAt: math.NaN(),
rejectAt: 0.8,
want: "must be numbers",
},
{
name: "empty-name",
signals: []Signal{{Name: "", Confidence: 0.5}},
passAt: 0.9,
rejectAt: 0.8,
want: "empty name",
},
{
name: "duplicate-name",
signals: []Signal{
{Name: "spam", Confidence: 0.5},
{Name: "spam", Confidence: 0.6},
},
passAt: 0.9,
rejectAt: 0.8,
want: "duplicate signal",
},
{
name: "confidence-above-one",
signals: []Signal{{Name: "spam", Confidence: 1.2}},
passAt: 0.9,
rejectAt: 0.8,
want: "outside [0,1]",
},
{
name: "confidence-negative",
signals: []Signal{{Name: "spam", Confidence: -0.1}},
passAt: 0.9,
rejectAt: 0.8,
want: "outside [0,1]",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Decide(c.signals, c.passAt, c.rejectAt)
if err == nil {
t.Fatalf("want error, got verdict %q", got)
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("error %q does not contain %q", err, c.want)
}
})
}
}
TestDecideTreatsThresholdsAsInclusive 明确了一个边界:置信度恰好等于 passAt 时放行,恰好等于 rejectAt 时拒绝。阈值是闭区间,文档、代码和测试必须一致,否则运营按文档调参,线上行为却差一档。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/moderate/ -run 'TestDecide' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestDecideReviewsEmptySignals
--- PASS: TestDecideReviewsEmptySignals (0.00s)
=== RUN TestDecidePassesStrongSingleSignal
--- PASS: TestDecidePassesStrongSingleSignal (0.00s)
=== RUN TestDecideRejectsMiddleBand
--- PASS: TestDecideRejectsMiddleBand (0.00s)
=== RUN TestDecideReviewsWeakSignals
--- PASS: TestDecideReviewsWeakSignals (0.00s)
=== RUN TestDecideRejectsWhenAnotherSignalVetoes
--- PASS: TestDecideRejectsWhenAnotherSignalVetoes (0.00s)
=== RUN TestDecideTreatsThresholdsAsInclusive
--- PASS: TestDecideTreatsThresholdsAsInclusive (0.00s)
=== RUN TestDecideValidatesInputs
=== RUN TestDecideValidatesInputs/passAt-not-above-rejectAt
=== RUN TestDecideValidatesInputs/threshold-above-one
=== RUN TestDecideValidatesInputs/threshold-nan
=== RUN TestDecideValidatesInputs/empty-name
=== RUN TestDecideValidatesInputs/duplicate-name
=== RUN TestDecideValidatesInputs/confidence-above-one
=== RUN TestDecideValidatesInputs/confidence-negative
--- PASS: TestDecideValidatesInputs (0.00s)
--- PASS: TestDecideValidatesInputs/passAt-not-above-rejectAt (0.00s)
--- PASS: TestDecideValidatesInputs/threshold-above-one (0.00s)
--- PASS: TestDecideValidatesInputs/threshold-nan (0.00s)
--- PASS: TestDecideValidatesInputs/empty-name (0.00s)
--- PASS: TestDecideValidatesInputs/duplicate-name (0.00s)
--- PASS: TestDecideValidatesInputs/confidence-above-one (0.00s)
--- PASS: TestDecideValidatesInputs/confidence-negative (0.00s)
PASS
ok webapp/internal/moderate 0.348s
真实输出里所有子测试都通过,只说明当前实现与测试一致,不说明阈值对业务最优。阈值需要靠抽检准确率、申诉率和人审积压三条曲线共同评估。
小结
机审的价值不是替代人审,而是把大量明确的内容快速分流。机审给置信度,人审处理灰区,复审靠抽样和申诉触发;三者职责不同,不能用一个“再审一遍”的动作糊在一起。
Decide 只做一件小事:把多条独立信号收敛成 pass、reject、review 三档,并拒绝不合法的输入。真正难的是阈值治理:分品类、可回滚、看积压和抽检准确率,而不是在事故发生后把阈值调回原样。
阅读导航:上一节:2.1 发布链路的同步与异步 · 下一节:2.3 审核失败的用户体验与申诉 。
常见错误与边界
- 把多个模型的分数加权平均。平均会把一条强拦截信号摊薄,也会让一条无关信号改变最终结论。
- 只设一个阈值。没有灰区,机器被迫在“放行”和“拒绝”之间二选一,误杀和漏放都会增加。
passAt小于或等于rejectAt。两个区间重叠,结论取决于判断顺序,调参时无法解释线上行为。- 用复审代替申诉。复审是抽样检查模型漂移,申诉是处理具体误判,两者混用会让人审重复劳动。
- 阈值全品类共用。宠物视频和医疗建议的风险不同,用同一套阈值只会让低风险内容被过度拦截。
- 阈值改动没有灰度和回滚。一次全量调整方向错了,会在几小时内制造大量积压和客诉。
- 把 review 当成垃圾桶。所有不确定都丢给人审,队列迟早爆掉;review 的比例必须被监控和限额。
- 忽略上游分数的非法值。NaN、负数和大于 1 的置信度应该报错,而不是截断后继续决策。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。