规则引擎解决的是「谁先说话」
8.1 给了账户级退款评分,8.2 给了广告流量评分。把两套分数直接相加,会得到一个看似统一、实际无法解释的数字:一个广告点击异常可能让玩家无法充值,一个退款争议也可能让渠道结算被挂起。业务域不同、证据强度不同、处置动作不同,不能靠一个总分解决。
规则引擎的职责,是把这些判断拆成独立的规则,再回答三个问题:
- 哪些规则命中了?
- 命中的规则里,谁的动作优先级最高?
- 这个决定应该由系统直接执行,还是进入人工队列?
它不负责计算「可疑概率」,也不负责替客服做最终判断。它负责让决定可复现、可解释、可审计。
一条规则的四个要素
一条可维护的规则,至少要有条件、动作、权重和优先级:
| 要素 | 作用 | 反例 |
|---|---|---|
| 条件 | 判断这条规则是否命中 | 把多个业务域的条件写在一个表达式里 |
| 动作 | 命中后建议做什么 | 只有「是否作弊」布尔值,没有处置 |
| 权重 | 参与分数排序 | 权重和动作混在一起,分高就必须封 |
| 优先级 | 决定同分或冲突时的顺序 | 依赖数组顺序,改一行代码就改变行为 |
除此之外,还要有一个 reason。风控决定最终要给人看:客服要知道为什么冻结,渠道要知道为什么扣量,审计要知道当时哪条规则生效。只存一个分数,事后无法复盘。
本节示例把动作限定为三种:
allow:放行。review:进入人工队列,业务动作暂不阻断。block:阻断当前动作,例如拒绝支付、拒绝发货、拒绝结算。
这三种动作覆盖了大多数场景。更细的动作,例如「仅限制大额支付」「仅挂起广告结算」,应该建模成不同规则的目标,而不是继续往动作枚举里加值。
动作优先级不是分数优先级
最常见的实现错误,是把命中的权重相加,然后按总分决定动作。这样会产生一个荒谬结果:一条权重 35 的 block 规则,可能输给两条权重 20 的 review 规则,因为总分 40 大于 35。
正确的做法是把「分数」和「动作」分开:
- 动作按固定优先级合并:
block>review>allow。只要有一条block命中,最终动作就是block,不受其他规则权重影响。这叫 deny-overrides。 - 分数只用于队列排序和解释,不改变动作。一个
block决定的分数可能只有 35,但它依然是阻断。 - 规则顺序按优先级字段排序,不依赖代码里的数组顺序。优先级相同再按规则 id 排序,保证结果稳定。
为什么不让分数决定动作?因为分数是人为加权的,权重调整会悄悄改变处置强度。把动作和分数解耦,策略调整才能只影响排序,不影响「能不能做」。
代价是规则设计更啰嗦:每条规则都要明确动作,不能靠总分兜底。但这份啰嗦换来的是可预测性,风控系统最怕的就是「改了一个权重,一次发布就多封了一批人」。
规则要能解释,不只是一个分数
第一版风控系统很容易走向两个极端:要么全是人工经验,规则散落在代码里;要么直接上一个模型,输出一个 0 到 1 的分数,没人说得清为什么。
更稳的路径是先规则、后模型:
- 规则阶段,每个决定都能追溯到规则 id、信号值和阈值。
- 模型阶段,模型只用于排序或发现新规则,不直接执行封禁。
- 人工复核阶段,复核员看到的应该是「命中 chargeback-seen,权重 40,因为该账号 30 天内有 1 笔拒付」,而不是「风险分 0.87」。
这不是反对模型,而是反对不可解释的自动处置。支付和广告都涉及真金白银,误判需要申诉,申诉需要证据。一个无法解释的分数,在客服和渠道面前没有说服力。
规则的解释信息至少包括:规则 id、命中时间、输入信号、阈值、动作、权重、策略版本。策略版本尤其重要:同一笔订单在今天和三个月后复核,可能适用不同规则,必须能还原当时的判断依据。
人工复核是容量问题
规则引擎可以无限产出 review,但人工复核的容量有限。以事实表的欺诈订单量估算:
| 指标 | 消消 | 方舟 |
|---|---|---|
| 月支付欺诈订单 | 约 336 笔 | 约 45 笔 |
| 月退款订单 | 约 5,040 笔 | 约 1,200 笔 |
| 平均客单价 | ¥14.5 | ¥182 |
如果每单复核按 12 分钟、每月 21 个工作日计算,336 笔约合每天 16 笔、3.2 小时、0.4 个全职人力;45 笔约合每天 2.1 笔、0.43 小时、0.05 个全职人力。这个量级说明:消消的支付风控需要专人或轮班,方舟可以兼职覆盖,但方舟的单笔金额更高,复核时限应该更短。
注意,这里算的是「欺诈订单」,不是「所有退款」。如果把 5,040 笔退款全部送进人工,消消每天要处理约 240 笔,按 12 分钟计算就是 48 小时,不可能靠人完成。规则引擎的第一价值不是抓作弊,而是把人工量压到可管理范围。
容量规划要反过来做:先确定每天可用于复核的工时,再确定队列容量,最后调阈值。先调阈值再喊人手不够,通常会导致两种结果:要么队列积压、SLA 失守,要么复核员草草点「通过」,规则形同虚设。
队列排序:SLA 优先于分数
复核队列最常见的排序是按分数从高到低。这在系统刚上线、队列很短时没问题;一旦积压,低分但已经超时的案件会永远排在后面,最后变成「先来的没人看,后来的先处理」。
更合理的排序是三层:
- 是否超过 SLA:超过时限的案件优先,不管分数高低。
- 分数:同一起跑线内,高分优先。
- 创建时间:分数相同,先到先处理。
这样排序的副作用是:一个低分案件可能因为超时插到高分案件前面。这是故意的。SLA 的意义是「承诺在多久内给出答复」,不是「只处理重要的」。如果低分案件长期无人处理,玩家会重复提交、升级投诉,最终成本更高。
队列还需要一个明确的容量上限。容量为 0 时,所有案件进入 deferred,而不是让系统假装处理了;容量为负或非整数时直接报错,避免配置错误静默通过。这些边界看起来琐碎,但风控系统的配置错误往往比规则错误更常见。
把规则引擎写成可运行产物
下面的实现包含三部分:默认规则集、规则求值、复核队列。规则用纯函数表达条件,求值不修改输入,队列排序不依赖调用方传入的顺序。
// file: src/biz/risk-engine.ts
// tier: L1
// verify: node --test test/biz/risk-engine.test.ts
export type RiskAction = "allow" | "review" | "block";
export type RiskEventKind = "purchase" | "refund" | "ad-reward" | "login";
export interface RiskEvent {
readonly eventId: string;
readonly accountId: string;
readonly kind: RiskEventKind;
readonly amountCents: number;
readonly atMs: number;
readonly signals: Readonly<Record<string, number>>;
}
export interface RiskRule {
readonly id: string;
readonly priority: number;
readonly action: RiskAction;
readonly weight: number;
readonly reason: string;
readonly when: (event: RiskEvent) => boolean;
}
export interface RuleHit {
readonly id: string;
readonly action: RiskAction;
readonly weight: number;
readonly reason: string;
}
export interface RiskDecision {
readonly eventId: string;
readonly accountId: string;
readonly score: number;
readonly action: RiskAction;
readonly hits: readonly RuleHit[];
}
export interface ReviewCase {
readonly caseId: string;
readonly accountId: string;
readonly score: number;
readonly amountCents: number;
readonly createdAtMs: number;
}
export interface ReviewPlan {
readonly accepted: readonly ReviewCase[];
readonly deferred: readonly ReviewCase[];
readonly breached: readonly string[];
readonly capacityUsed: number;
}
export interface ReviewLoad {
readonly dailyCases: number;
readonly minutesPerDay: number;
readonly fte: number;
}
const ACTION_RANK: Readonly<Record<RiskAction, number>> = { allow: 0, review: 1, block: 2 };
function signal(event: RiskEvent, key: string): number {
return event.signals[key] ?? 0;
}
export function buildDefaultRules(): readonly RiskRule[] {
return [
{
id: "chargeback-seen",
priority: 10,
action: "block",
weight: 40,
reason: "a chargeback is already on record",
when: (event) => signal(event, "chargebacks") >= 1,
},
{
id: "device-farm",
priority: 20,
action: "block",
weight: 35,
reason: "too many accounts share one device",
when: (event) => signal(event, "deviceAccounts") >= 5,
},
{
id: "refund-burst",
priority: 30,
action: "review",
weight: 30,
reason: "three or more refunds in thirty days",
when: (event) => signal(event, "refunds30d") >= 3,
},
{
id: "ad-verification-gap",
priority: 40,
action: "review",
weight: 25,
reason: "reward claims outpace server-side ad verification",
when: (event) => signal(event, "adGapRatio") >= 0.1,
},
{
id: "purchase-velocity",
priority: 50,
action: "review",
weight: 20,
reason: "six or more orders within one hour",
when: (event) => signal(event, "ordersLastHour") >= 6,
},
{
id: "large-new-device",
priority: 60,
action: "review",
weight: 20,
reason: "top-tier purchase from a one-day-old device",
when: (event) =>
event.amountCents >= 64_800 &&
event.signals.accountAgeDays !== undefined &&
event.signals.accountAgeDays <= 1,
},
];
}
export function evaluateRisk(event: RiskEvent, rules: readonly RiskRule[]): RiskDecision {
const hits = rules
.filter((rule) => rule.when(event))
.slice()
.sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id))
.map<RuleHit>((rule) => ({
id: rule.id,
action: rule.action,
weight: rule.weight,
reason: rule.reason,
}));
const score = Math.min(100, hits.reduce((sum, hit) => sum + hit.weight, 0));
const action = hits.reduce<RiskAction>(
(current, hit) => (ACTION_RANK[hit.action] > ACTION_RANK[current] ? hit.action : current),
"allow",
);
return { eventId: event.eventId, accountId: event.accountId, score, action, hits };
}
export function triageReviews(
cases: readonly ReviewCase[],
capacity: number,
nowMs: number,
slaMs: number,
): ReviewPlan {
if (!Number.isInteger(capacity) || capacity < 0) {
throw new RangeError(`capacity must be a non-negative integer, got ${capacity}`);
}
const overdue = (item: ReviewCase): boolean => nowMs - item.createdAtMs > slaMs;
const sorted = cases.slice().sort((left, right) => {
const leftOverdue = overdue(left) ? 1 : 0;
const rightOverdue = overdue(right) ? 1 : 0;
if (leftOverdue !== rightOverdue) {
return rightOverdue - leftOverdue;
}
if (left.score !== right.score) {
return right.score - left.score;
}
if (left.createdAtMs !== right.createdAtMs) {
return left.createdAtMs - right.createdAtMs;
}
return left.caseId.localeCompare(right.caseId);
});
return {
accepted: sorted.slice(0, capacity),
deferred: sorted.slice(capacity),
breached: sorted.filter(overdue).map((item) => item.caseId),
capacityUsed: Math.min(capacity, sorted.length),
};
}
export function estimateReviewLoad(
monthlyCases: number,
minutesPerCase: number,
workingDays: number,
minutesPerReviewerDay = 480,
): ReviewLoad {
if (monthlyCases < 0 || minutesPerCase < 0 || workingDays <= 0 || minutesPerReviewerDay <= 0) {
throw new RangeError("load inputs must be non-negative and use a positive working day");
}
const dailyCases = monthlyCases / workingDays;
const minutesPerDay = dailyCases * minutesPerCase;
return { dailyCases, minutesPerDay, fte: minutesPerDay / minutesPerReviewerDay };
}
export function formatDecision(decision: RiskDecision): string {
const ids = decision.hits.map((hit) => hit.id).join(",") || "none";
return `${decision.eventId} ${decision.action} score=${decision.score} hits=${ids}`;
}
代码里有几个刻意的设计。
第一,默认规则集只覆盖本章讨论过的信号:拒付、设备聚集、退款爆发、广告验证缺口、购买频率、大额新设备。它不包含登录风控、内容风控和营销活动风控,那些属于其他章节或其他系统。规则集的边界清晰,才能避免「一个引擎管所有事」。
第二,evaluateRisk 先按优先级排序,再按 id 排序。优先级字段是显式的,不依赖数组顺序;id 排序保证同一优先级下结果稳定。规则命中列表按同样顺序返回,方便日志对比。
第三,分数封顶 100。权重相加可能超过 100,但分数只用于排序,封顶可以避免一个账号因为命中太多规则而出现「分数 180」这种无法解释的值。真正决定动作的是动作优先级,不是分数。
第四,triageReviews 把超时案件排在最前面,然后才看分数。breached 返回所有超时案件,不管它们是否被本轮容量接收;这样监控可以独立于队列容量统计 SLA。
第五,estimateReviewLoad 只做容量估算,不参与实时判定。它接收月案件量、单案分钟数、工作日和每名复核员的可用分钟数,返回日均案件、日均分钟和 FTE。参数都显式传入,避免把「12 分钟」「21 天」写死在业务代码里。
双案例收口:同一中台,两套阈值
消消和方舟共用账号、支付、活动、数据四条中台能力,但风控策略不能共用一套阈值。差异来自三个方面:
| 维度 | 消消 | 方舟 | 策略含义 |
|---|---|---|---|
| 月欺诈订单 | 约 336 笔 | 约 45 笔 | 消消重吞吐,方舟重单笔 |
| 平均客单价 | ¥14.5 | ¥182 | 方舟的复核优先级更高 |
| 月退款订单 | 约 5,040 笔 | 约 1,200 笔 | 消消必须自动化分流 |
| 广告作弊点击占比 | 1.5% | 0.6% | 消消的广告风控样本更多 |
| 平台结构 | 国内安卓 55%、iOS 30% | iOS 45%、国内安卓 40% | 渠道规则和拒付流程不同 |
同一套规则 id、同一套动作语义,但阈值和队列容量分开配置:
- 消消:退款爆发阈值可以设得更高,因为订单基数大;复核线偏低,靠自动化分流控制工单量;广告验证缺口规则可以更敏感,因为样本密度足够。
- 方舟:退款爆发阈值可以设得更低,因为单笔金额高;大额新设备规则必须开启;复核 SLA 更短,因为一笔 ¥648 的欺诈抵得上消消四十多笔小额订单。
这不是「两套系统」,而是「一个引擎、两套策略」。规则实现共用,策略配置分离,监控按产品拆分。共用的好处是修复一次、两边生效;分离的好处是不会为了消消的吞吐牺牲方舟的金额安全。
上线顺序:影子、只审不封、分级
规则引擎最危险的上线方式,是第一天就开启自动封禁。正确的顺序通常是三步:
- 影子模式:规则只记录命中,不改变任何业务动作。用一到两周对比命中量和人工判断,找出误判最多的规则。
- 只审不封:所有命中都进入复核队列,由人工决定是否阻断。这个阶段主要验证队列容量、SLA 和解释信息是否够用。
- 分级执行:只对高置信规则开启自动阻断,例如拒付、设备聚集、重复退款;其余规则保持人工复核。自动阻断必须带到期时间和申诉入口。
每一步都要有回滚开关。规则引擎的配置应该版本化,能按产品、渠道、规则 id 单独关闭;不要用一个总开关控制所有规则,否则出问题时只能全关或全开。
上线后的监控至少包括:每日命中量、各规则命中占比、复核队列长度、超时率、误判申诉率、自动阻断后的申诉成功率。某一个规则命中量突然翻倍,通常不是玩家突然变坏,而是上游信号、SDK 版本或渠道政策发生了变化。
取舍:自动封禁省人力,但误杀不可逆
规则引擎的最终取舍,是自动化程度。
自动化越高,人工成本越低,但误杀越难挽回。支付封禁影响玩家充值,广告扣量影响渠道结算,两者都有商务后果。案例里的选择是「高置信自动、低置信人工」:
- 拒付、重复退款、设备聚集,证据强、可复核,允许自动阻断。
- 退款率、购买频率、广告验证缺口,受样本和链路影响大,默认只进复核。
- 所有自动阻断都带到期时间,默认 24 小时;到期自动解除,除非人工续期。
- 每次阻断都保留规则版本、输入信号和分数,申诉时可以还原现场。
代价是系统更复杂:需要开关、版本、队列、SLA、申诉和审计。收益是误杀可逆、责任清晰。风控系统真正的成熟标志,不是「封得多」,而是「封错了能解释、能恢复」。
小结
- 规则引擎把判断拆成条件、动作、权重、优先级四要素,并强制记录可解释的 reason。
- 动作按
block>review>allow合并,分数只用于排序,不改变动作。 - 人工复核是容量问题:消消月欺诈约 336 笔、方舟约 45 笔,先算工时再定阈值。
- 队列按「超时优先、分数次之、先到先处理」排序,容量为 0 时全部 deferred,非法容量直接报错。
- 同一引擎、两套策略:消消重吞吐,方舟重单笔;上线按影子、只审不封、分级执行三步走。
阅读导航:上一节:8.2 广告作弊与点击农场 · 下一节:9.1 活动配置、时间窗口与灰度 。
常见错误与边界
- 用总分决定动作。两条低权重
review可能压过一条高置信block,导致该拦的没拦;动作必须独立按优先级合并。 - 依赖数组顺序表达规则优先级。改一行代码就可能改变运行行为;优先级要显式写在规则里,并用 id 兜底排序。
- 只存分数、不存规则 id 和版本。申诉时无法还原当时的判断依据,也无法定位是哪次策略变更导致误判。
- 把模型分数直接用于自动封禁。不可解释的处置在客服和渠道面前没有说服力;模型应先用于排序或发现规则。
- 所有退款都送人工复核。消消月退款约 5,040 笔,按 12 分钟一单需要约 48 小时/天,队列必然崩;必须先自动化分流。
- 队列只按分数排序。积压后低分超时案件永远排不到,SLA 形同虚设;超时案件应优先于分数。
- 容量配置为负或小数仍然放行。配置错误会静默改变队列行为,非法容量必须直接报错。
- 把「超时案件」只统计已接收的部分。deferred 里的超时案件同样违反 SLA,监控必须覆盖全量。
- 同一套阈值套到两个产品。消消和方舟的订单量、客单价、平台结构不同,同阈值会产生完全不同的误杀率。
- 第一天就全量自动封禁。没有影子模式和回滚开关,规则错误会直接变成运行事故;至少先跑只审不封。
- 自动阻断没有到期时间。永久阻断把临时风控变成永久封禁,误杀成本不可逆;默认应带到期和申诉入口。
- 规则命中量突增就加阈值。突增可能来自 SDK 版本或渠道政策变化,先查上游信号再调规则。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。