《游戏服务端架构·平台化与架构治理》11.2 智能客服与内容审核

一条高置信度误判暴露的不是模型问题,而是自动处罚规则缺少例外。本文区分智能客服与内容审核的目标和失败代价,给出置信度分流、保护类别、未成年人与重复违规的路由规则,用 Go 实现版本化审核策略与申诉状态机,并讨论异步审核、缓存、灰度与回滚的架构取舍。

一条被误判的私聊

《群岛纪元》的举报队列里,一条私聊被模型判为威胁,置信度 0.93(示例值)。流水线按“高于 0.9 自动禁言 24 小时”的规则执行。复核时发现,玩家引用的是歌词,上下文里没有指向任何人。

问题不在 0.93 这个分数。问题在规则:把“模型很有把握”直接等同于“处罚后果可以自动承担”。内容审核的失败代价不对称——漏放一条违规内容会被截图传播,误封一个正常玩家会带来申诉、口碑和合规成本。两种错误都要算,不能用单一阈值拍平。下文出现的阈值、比例与时长都是示例值,落地时需按业务与合规要求校准。

客服与审核不是同一条流水线

两者都调用模型,但目标、输入和失败代价不同,架构上必须分开。

维度智能客服内容审核
目标解决玩家问题,降低人工工单判断内容是否违规,决定放行或拦截
输入玩家问题、订单、角色、知识库文本、图片、语音、上下文
主要指标自助解决率、转人工率、满意度准确率、误封率、申诉翻转率
失败代价答错、绕圈、激怒玩家违规扩散或误封正常玩家
自动执行边界只读查询可自动,写操作禁止放行可自动,处罚默认人工
延迟要求秒级可异步,分钟级也可接受

把两者混在一个“AI 中台”里,最常见的结果是客服机器人被赋予退款、封禁、删角色权限,或者审核模型为了降低延迟被塞进同步链路。两条都不值得。

智能客服的边界:意图、知识库与转人工

智能客服的可用范围可以用一句话划:能查的自动查,能答的带出处答,能改的一律转人工。

  • 意图识别负责分流,不负责决策。识别成“充值未到账”只代表应该走查单流程,不代表可以直接补发。
  • 知识库必须有版本和生效时间。活动规则每天在变,模型答对昨天的问题,今天就是错误答案。
  • 转人工阈值要同时看置信度和风险。低置信度转人工;高置信度但涉及资产、封禁、隐私的,也转人工。
  • 敏感操作禁止自动执行。退款、补发、封号、解封、删除角色、修改实名信息,全部走既有的审批与双人复核流程,AI 只负责准备材料。
玩家意图可自动必须转人工原因
查询活动时间是否只读、可引用公告
查询订单状态是否只读、有明确数据源
充值未到账否是涉及资产,需要核对渠道
申请退款否是资金操作,合规要求
举报玩家否是需要证据与审核
账号找回否是身份核验
游戏玩法咨询是视情况知识库有答案时可自动

转人工不是失败。把“转人工率”当成唯一 KPI,会逼着团队把不该自动的流程也自动化草率放行。更合理的指标是“转人工时是否带齐上下文”:玩家不用重复描述,人工坐席能直接处理。

内容审核的置信度分流

审核模型输出一个 0 到 1 的违规概率。流水线把它映射成三种动作:放行、人工复核、自动拦截。关键不是阈值本身,而是例外规则。

分数区间(示例值)默认动作例外
0 到 0.20放行重复违规者转复核
0.20 到 0.90人工复核无
0.90 到 1.00自动拦截未成年人内容、保护类别一律转复核

三类内容永远不自动处罚:

  1. 涉及未成年人的内容。模型分数再高,也只能进入人工复核。未成年人保护的下限不能交给概率。
  2. 政策指定的保护类别,例如自伤、威胁、隐私泄露。这些类别误判代价高,必须有人看上下文。
  3. 重复违规者的边缘案例。重复违规者不能因为分数低就自动放行,但也不能因为历史记录就直接加重处罚;应该转人工,把历史和行为放在一起看。

这套规则的顺序很重要:先判断是否低于放行阈值,再判断重复违规,然后判断未成年人与保护类别,最后才进入自动拦截。顺序写反,就会出现“先拦截、后发现是未成年人”的事故。

代码:版本化的路由表

下面的实现把阈值、保护类别和重复违规规则放在一个 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. 影子模式:新模型只记录结果,不影响动作,与旧模型对比。
  2. 小流量:1% 到 5%(示例值)的内容走新模型,重点看误封率和申诉翻转率。
  3. 扩大流量:10%、50%,每一步观察至少一个完整业务周期。
  4. 全量:保留旧模型一段时间,支持按版本指针回滚。

灰度指标不能只看“拦截了多少”。更有意义的是:人工复核的推翻率、申诉翻转率、同一内容在新旧模型下的分歧率。分歧率高但人工推翻率低,说明模型在边界上不稳定;分歧率低但申诉翻转率高,说明阈值或策略有问题。

回滚要能做到“只切版本,不改代码”。模型文件、策略配置、提示词模板都应该是带版本的制品,而不是散落在部署脚本里的字符串。

未成年人保护与合规

未成年人保护的合规要求已在卷三第 10 章展开,这里只讲架构接口,不重复条款。系统需要三个明确输入:账号是否被标记为未成年人、标记来源与有效期、以及该标记是否允许用于审核决策。

三个原则:

  • 标记缺失不等于成年人。缺失时走更保守的复核路径,而不是默认放行。
  • 标记不能由模型推断。年龄判断不能靠聊天内容猜测,必须有可信来源。
  • 处罚与保护分开记录。保护性限制(例如限制私聊)和违规处罚是两件事,审计时要能区分。

检查表

  • 客服机器人是否只有只读权限,写操作是否全部转人工?
  • 知识库是否有版本和生效时间,过期答案是否自动下线?
  • 审核策略是否有版本号,阈值变更是否可回滚?
  • 未成年人、保护类别、重复违规是否在自动拦截之前判断?
  • 模型超时是否转入人工复核,而不是默认放行?
  • 缓存键是否包含内容哈希、模型版本和策略版本?
  • 申诉是否有截止时间、具名复核人和终态保护?
  • 灰度是否看过误封率与申诉翻转率,而不只是拦截量?
  • 未成年人标记的来源、有效期和使用范围是否可审计?

小结

智能客服与内容审核的难点不在模型,而在权限和例外。客服要克制“能自动就自动”的冲动,审核要接受“高置信度也不等于可以自动处罚”。把路由规则版本化、把申诉做成状态机、把降级路径写死,模型升级才不会变成一次赌博。下一节转向研发侧:AI 如何辅助测试与压测,以及为什么生成能力越强,准入和溯源越重要。

阅读导航:上一节:11.1 AIOps 与异常检测 · 下一节:11.3 AI 辅助测试与压测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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