《互联网产品的服务端架构·内容、社交与实时互动》2.2 机审、人审与复审

用一条被误判的宠物视频串起机审、人审与复审:机审输出置信度,人审处理灰区,复审靠抽样与申诉触发而不是把内容再跑一遍。文章给出 pass、reject、review 三档的阈值对照表和 Go 实现,并复盘一次阈值过严导致人审队列积压六小时的故障。

一条被机审拦下的宠物视频

「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。

一位用户上传了自家猫打翻水杯的视频。机审返回三个信号:画面模型认为“可能包含液体泼溅”置信度 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%。系统看起来还在工作,实际上把机审的严格换成了人审的随意。

修复不是简单调回阈值,而是把三件事分开:

  1. 按内容品类设不同阈值。宠物、美食等低风险品类的 passAt 可以低一些,时政、医疗保持严格。
  2. 给 review 队列设最大等待时间。超过时限自动升级或降级,并把积压量作为独立指标告警。
  3. 把抽样比例和申诉率纳入阈值评审,用数据判断“严”是否真的减少了风险,而不是只看拒绝量。

阈值调整必须可回滚。把阈值写进配置,保留变更记录和灰度比例;一次全量调整如果方向错了,代价是几个小时的内容积压和一批创作者流失。

用 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 的置信度应该报错,而不是截断后继续决策。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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