一条被误判的私聊
《群岛纪元》的举报队列里,一条私聊被模型判为威胁,置信度 0.93(示例值)。流水线按“高于 0.9 自动禁言 24 小时”的规则执行。复核时发现,玩家引用的是歌词,上下文里没有指向任何人。
问题不在 0.93 这个分数。问题在规则:把“模型很有把握”直接等同于“处罚后果可以自动承担”。内容审核的失败代价不对称——漏放一条违规内容会被截图传播,误封一个正常玩家会带来申诉、口碑和合规成本。两种错误都要算,不能用单一阈值拍平。下文出现的阈值、比例与时长都是示例值,落地时需按业务与合规要求校准。
客服与审核不是同一条流水线
两者都调用模型,但目标、输入和失败代价不同,架构上必须分开。
| 维度 | 智能客服 | 内容审核 |
|---|---|---|
| 目标 | 解决玩家问题,降低人工工单 | 判断内容是否违规,决定放行或拦截 |
| 输入 | 玩家问题、订单、角色、知识库 | 文本、图片、语音、上下文 |
| 主要指标 | 自助解决率、转人工率、满意度 | 准确率、误封率、申诉翻转率 |
| 失败代价 | 答错、绕圈、激怒玩家 | 违规扩散或误封正常玩家 |
| 自动执行边界 | 只读查询可自动,写操作禁止 | 放行可自动,处罚默认人工 |
| 延迟要求 | 秒级 | 可异步,分钟级也可接受 |
把两者混在一个“AI 中台”里,最常见的结果是客服机器人被赋予退款、封禁、删角色权限,或者审核模型为了降低延迟被塞进同步链路。两条都不值得。
智能客服的边界:意图、知识库与转人工
智能客服的可用范围可以用一句话划:能查的自动查,能答的带出处答,能改的一律转人工。
- 意图识别负责分流,不负责决策。识别成“充值未到账”只代表应该走查单流程,不代表可以直接补发。
- 知识库必须有版本和生效时间。活动规则每天在变,模型答对昨天的问题,今天就是错误答案。
- 转人工阈值要同时看置信度和风险。低置信度转人工;高置信度但涉及资产、封禁、隐私的,也转人工。
- 敏感操作禁止自动执行。退款、补发、封号、解封、删除角色、修改实名信息,全部走既有的审批与双人复核流程,AI 只负责准备材料。
| 玩家意图 | 可自动 | 必须转人工 | 原因 |
|---|---|---|---|
| 查询活动时间 | 是 | 否 | 只读、可引用公告 |
| 查询订单状态 | 是 | 否 | 只读、有明确数据源 |
| 充值未到账 | 否 | 是 | 涉及资产,需要核对渠道 |
| 申请退款 | 否 | 是 | 资金操作,合规要求 |
| 举报玩家 | 否 | 是 | 需要证据与审核 |
| 账号找回 | 否 | 是 | 身份核验 |
| 游戏玩法咨询 | 是 | 视情况 | 知识库有答案时可自动 |
转人工不是失败。把“转人工率”当成唯一 KPI,会逼着团队把不该自动的流程也自动化草率放行。更合理的指标是“转人工时是否带齐上下文”:玩家不用重复描述,人工坐席能直接处理。
内容审核的置信度分流
审核模型输出一个 0 到 1 的违规概率。流水线把它映射成三种动作:放行、人工复核、自动拦截。关键不是阈值本身,而是例外规则。
| 分数区间(示例值) | 默认动作 | 例外 |
|---|---|---|
| 0 到 0.20 | 放行 | 重复违规者转复核 |
| 0.20 到 0.90 | 人工复核 | 无 |
| 0.90 到 1.00 | 自动拦截 | 未成年人内容、保护类别一律转复核 |
三类内容永远不自动处罚:
- 涉及未成年人的内容。模型分数再高,也只能进入人工复核。未成年人保护的下限不能交给概率。
- 政策指定的保护类别,例如自伤、威胁、隐私泄露。这些类别误判代价高,必须有人看上下文。
- 重复违规者的边缘案例。重复违规者不能因为分数低就自动放行,但也不能因为历史记录就直接加重处罚;应该转人工,把历史和行为放在一起看。
这套规则的顺序很重要:先判断是否低于放行阈值,再判断重复违规,然后判断未成年人与保护类别,最后才进入自动拦截。顺序写反,就会出现“先拦截、后发现是未成年人”的事故。
代码:版本化的路由表
下面的实现把阈值、保护类别和重复违规规则放在一个 Policy 里。每个结果都带策略版本,方便回滚和追责。
// file: internal/aimoderation/pipeline.go
// tier: L1
// verify: go test ./internal/aimoderation/
package aimoderation
import (
"errors"
"strings"
)
// Decision is the outcome of routing one moderation score.
type Decision string
const (
// Pass means publish without a human.
Pass Decision = "pass"
// Review means a human must look before the content is published or
// removed. Review is the safe default for anything uncertain.
Review Decision = "review"
// Reject means block automatically. It is reserved for high-confidence,
// non-protected cases.
Reject Decision = "reject"
)
var (
ErrScoreRange = errors.New("aimoderation: score out of range")
ErrPolicyInvalid = errors.New("aimoderation: invalid policy")
ErrMissingVersion = errors.New("aimoderation: missing policy version")
)
// Policy is a versioned routing table. Every result carries the version so a
// bad release can be traced and rolled back without guessing.
type Policy struct {
Version string
PassBelow float64
RejectAbove float64
Protected []string
RepeatLimit int
}
// Validate rejects ambiguous bands. An overlapping or inverted band is a
// configuration incident, not something to paper over at runtime.
func (p Policy) Validate() error {
if strings.TrimSpace(p.Version) == "" {
return ErrMissingVersion
}
if p.PassBelow < 0 || p.RejectAbove > 1 || p.PassBelow >= p.RejectAbove {
return ErrPolicyInvalid
}
if p.RepeatLimit < 0 {
return ErrPolicyInvalid
}
return nil
}
// Item is one piece of user-generated content. Score is the model's
// violation probability in [0,1]; higher means more likely to violate.
type Item struct {
ID string
Category string
Score float64
Minor bool
Repeat int
}
// Verdict explains why a decision was made. The reason is part of the audit
// record and must not be a free-form model output.
type Verdict struct {
Decision Decision
Reason string
Policy string
}
// Route maps a score to pass, review, or reject. The ordering is deliberate:
// repeat offenders, minors, and protected categories are checked before the
// automatic reject band, because those cases carry irreversible risk.
func (p Policy) Route(it Item) (Verdict, error) {
if err := p.Validate(); err != nil {
return Verdict{}, err
}
if it.Score < 0 || it.Score > 1 {
return Verdict{}, ErrScoreRange
}
v := Verdict{Policy: p.Version}
if it.Score <= p.PassBelow {
if p.RepeatLimit > 0 && it.Repeat >= p.RepeatLimit {
v.Decision = Review
v.Reason = "repeat_offender"
return v, nil
}
v.Decision = Pass
v.Reason = "below_pass_threshold"
return v, nil
}
if it.Minor {
v.Decision = Review
v.Reason = "minor_protection"
return v, nil
}
if p.protected(it.Category) {
v.Decision = Review
v.Reason = "protected_category"
return v, nil
}
if it.Score >= p.RejectAbove {
v.Decision = Reject
v.Reason = "above_reject_threshold"
return v, nil
}
v.Decision = Review
v.Reason = "low_confidence"
return v, nil
}
// protected matches category names case-insensitively. A miss here means an
// automatic reject on content that policy says must be seen by a human.
func (p Policy) protected(category string) bool {
for _, c := range p.Protected {
if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(category)) {
return true
}
}
return false
}
几个设计点:
Validate拒绝重叠区间。PassBelow >= RejectAbove是配置事故,应该在启动时失败,而不是在线上产生随机行为。- 保护类别匹配忽略大小写和空白。配置里写
Self-Harm、模型输出self-harm,不应该因为格式差异绕过保护。 - 重复违规只阻止自动放行,不自动加重。历史记录可以作为复核输入,但不能变成第二套未经审核的处罚规则。
- 结果带策略版本。同一个分数在不同版本下可能得到不同结论,没有版本号就无法解释“为什么昨天放行、今天拦截”。
这套代码不调用模型。模型是上游的一个可替换组件,路由是稳定的控制面。把两者分开,才能在换模型时不重写业务规则。
申诉是状态机,不是客服备注
申诉必须有明确的生命周期:开启、维持原判、翻转、过期。只有“开启”可以迁移,其他状态是终态,防止重放请求把已结案的申诉重新打开。
// file: internal/aimoderation/appeal.go
// tier: L1
// verify: go test ./internal/aimoderation/
package aimoderation
import (
"errors"
"fmt"
"strings"
"time"
)
// AppealState is the lifecycle state of one user appeal. Only Open can move;
// every other state is terminal so a replay cannot silently reopen a case.
type AppealState string
const (
AppealOpen AppealState = "open"
AppealUpheld AppealState = "upheld"
AppealReversed AppealState = "reversed"
AppealExpired AppealState = "expired"
)
var (
ErrAppealClosed = errors.New("aimoderation: appeal already closed")
ErrAppealExpired = errors.New("aimoderation: appeal window expired")
ErrNoReviewer = errors.New("aimoderation: reviewer required")
ErrBadWindow = errors.New("aimoderation: window must be positive")
)
// Appeal records who decided and when. ResolvedAt is evidence for the
// compliance report; it is never inferred from the request time.
type Appeal struct {
ItemID string
State AppealState
Deadline time.Time
Reviewer string
ResolvedAt time.Time
}
// NewAppeal opens a window. A positive, bounded window is mandatory: an
// appeal that never expires creates an unbounded review queue.
func NewAppeal(itemID string, createdAt time.Time, window time.Duration) (*Appeal, error) {
if strings.TrimSpace(itemID) == "" {
return nil, fmt.Errorf("aimoderation: appeal needs an item id")
}
if window <= 0 {
return nil, ErrBadWindow
}
return &Appeal{
ItemID: itemID,
State: AppealOpen,
Deadline: createdAt.Add(window),
}, nil
}
// Resolve closes an open appeal. The reviewer must be named; an empty value
// would make the audit record useless. reverse=true means the original
// decision is overturned, not that the content is deleted.
func (a *Appeal) Resolve(now time.Time, reviewer string, reverse bool) error {
if a.State != AppealOpen {
return ErrAppealClosed
}
if now.After(a.Deadline) {
return ErrAppealExpired
}
if strings.TrimSpace(reviewer) == "" {
return ErrNoReviewer
}
a.Reviewer = reviewer
a.ResolvedAt = now
if reverse {
a.State = AppealReversed
} else {
a.State = AppealUpheld
}
return nil
}
// Expire closes an appeal that nobody handled in time. It is a separate
// operation from Resolve so a scheduled job cannot claim a human reviewed it.
func (a *Appeal) Expire(now time.Time) error {
if a.State != AppealOpen {
return ErrAppealClosed
}
if !now.After(a.Deadline) {
return fmt.Errorf("aimoderation: appeal window still open")
}
a.State = AppealExpired
a.ResolvedAt = now
return nil
}
实现上有三个硬约束:
- 截止时间必须有限。永久开放的申诉队列会无限增长,也无法给玩家明确预期。
- 复核人必须有名有姓。空字符串或“系统”不能作为复核人,否则审计记录没有意义。
- 过期与复核分离。定时任务只能调用
Expire,不能伪装成人工复核;人工复核必须显式选择维持或翻转。
边界条件要写清楚:在截止时间点提交仍然有效,超过截止时间才拒绝。这个细节看起来小,但玩家和客服对“最后一天”的理解经常不一致,代码必须给出确定答案。
异步审核的架构
内容审核不适合放在发消息的同步链路里。正确的位置是:消息先落库并可见性置为“待审”,审核异步完成后更新可见性。这样模型超时不会阻塞聊天,也不会把未审内容直接放出去。
| 组件 | 故障 | 降级动作 | 禁止的降级 |
|---|---|---|---|
| 接入层 | 队列满 | 拒绝新内容并提示稍后重试 | 绕过审核直接放行 |
| 模型服务 | 超时 | 转入人工复核队列 | 默认放行或默认拦截 |
| 结果缓存 | 未命中 | 回源模型 | 用旧版本结果冒充新版本 |
| 策略服务 | 不可用 | 使用上一份已签名策略 | 使用内置默认阈值 |
| 申诉服务 | 不可用 | 先记录,恢复后补处理 | 丢弃申诉 |
缓存键至少包含“内容哈希 + 模型版本 + 策略版本”。只按内容哈希缓存,会在模型升级后继续返回旧结论;只按模型版本缓存,会在策略调整后继续执行旧规则。
灰度与回滚
审核模型的发布应该像服务发布一样有阶段:
- 影子模式:新模型只记录结果,不影响动作,与旧模型对比。
- 小流量:1% 到 5%(示例值)的内容走新模型,重点看误封率和申诉翻转率。
- 扩大流量:10%、50%,每一步观察至少一个完整业务周期。
- 全量:保留旧模型一段时间,支持按版本指针回滚。
灰度指标不能只看“拦截了多少”。更有意义的是:人工复核的推翻率、申诉翻转率、同一内容在新旧模型下的分歧率。分歧率高但人工推翻率低,说明模型在边界上不稳定;分歧率低但申诉翻转率高,说明阈值或策略有问题。
回滚要能做到“只切版本,不改代码”。模型文件、策略配置、提示词模板都应该是带版本的制品,而不是散落在部署脚本里的字符串。
未成年人保护与合规
未成年人保护的合规要求已在卷三第 10 章展开,这里只讲架构接口,不重复条款。系统需要三个明确输入:账号是否被标记为未成年人、标记来源与有效期、以及该标记是否允许用于审核决策。
三个原则:
- 标记缺失不等于成年人。缺失时走更保守的复核路径,而不是默认放行。
- 标记不能由模型推断。年龄判断不能靠聊天内容猜测,必须有可信来源。
- 处罚与保护分开记录。保护性限制(例如限制私聊)和违规处罚是两件事,审计时要能区分。
检查表
- 客服机器人是否只有只读权限,写操作是否全部转人工?
- 知识库是否有版本和生效时间,过期答案是否自动下线?
- 审核策略是否有版本号,阈值变更是否可回滚?
- 未成年人、保护类别、重复违规是否在自动拦截之前判断?
- 模型超时是否转入人工复核,而不是默认放行?
- 缓存键是否包含内容哈希、模型版本和策略版本?
- 申诉是否有截止时间、具名复核人和终态保护?
- 灰度是否看过误封率与申诉翻转率,而不只是拦截量?
- 未成年人标记的来源、有效期和使用范围是否可审计?
小结
智能客服与内容审核的难点不在模型,而在权限和例外。客服要克制“能自动就自动”的冲动,审核要接受“高置信度也不等于可以自动处罚”。把路由规则版本化、把申诉做成状态机、把降级路径写死,模型升级才不会变成一次赌博。下一节转向研发侧:AI 如何辅助测试与压测,以及为什么生成能力越强,准入和溯源越重要。
阅读导航:上一节:11.1 AIOps 与异常检测 · 下一节:11.3 AI 辅助测试与压测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。