从单条内容到账号维度——AIGC 风险评分的工程实现

单条内容的检测结果,本质上是一个带噪声的观测值。拿它直接做决策,会踩两个坑:

  • 一条 200 字的短评,confidence = 0.91,但模型对短文本本来就不敏感,这个 0.91 的方差极大;
  • 一个正常作者偶尔写了一篇模板化的产品说明,被判成 AI,直接限流,投诉就来了。

风控的正确姿势是聚合:不看单条,看这个账号在一段时间内的整体表现。这篇讲怎么把逐条分数变成一个可决策的账号分数。

一、先明确要解决什么

设账号 u 在窗口内有 n 条内容,每条有一个 AI 概率 p_i ∈ [0,1]。要回答的问题是:

这个账号是「批量生产 AI 内容」的概率有多大?

注意问的不是「这 n 条里有几条是 AI」,而是「这个账号的行为模式是不是 AI 批量生产」。两者的差别在于:前者是计数,后者是推断。计数对样本量极度敏感------3 条里 3 条是 AI,和 300 条里 300 条是 AI,结论完全不同。

所以需要一个带样本量感知的模型。Beta-Binomial 正好合适。

二、模型:Beta 先验 + 加权观测

把每条内容的判定看作一次伯努利试验(AI / 非 AI),账号的「AI 倾向」记为 θ,先验取 Beta(a₀, b₀)。观测到数据后,后验是:

css 复制代码
θ | data ~ Beta(a₀ + Σ wᵢ·yᵢ,  b₀ + Σ wᵢ·(1 - yᵢ))

其中 yᵢ ∈ {0,1} 是第 i 条的硬标签,wᵢ 是权重。

a₀ = b₀ = 2 是一个温和的先验,等价于「先假设看过 4 条内容,2 条 AI、2 条人工」。它保证新账号不会因为一条内容就冲到 0.9。

硬标签怎么来? 检测结果给的是连续概率,需要先离散化:

python 复制代码
def to_label(p: float, lo: float = 0.30, hi: float = 0.85) -> int | None:
    if p >= hi:
        return 1        # 判为 AI
    if p <= lo:
        return 0        # 判为人工
    return None         # 灰区,不参与统计

灰区直接丢弃,而不是硬塞成 0 或 1。这是整套逻辑里最重要的一条:不确定的样本比错误的样本更毒。把 0.55 强行当成「人工」,会把一个 AI 账号的分数拉低;当成「AI」,会冤枉正常用户。

权重 wᵢ 怎么定? 三个因子相乘:

复制代码
wᵢ = 时效衰减 × 文本长度可信度 × 检测置信度
  • 时效衰减:exp(-Δt / τ),τ = 14 天。一个月前的内容对「当前是否在批量生产」的参考价值很低。
  • 长度可信度:min(1, len/300)。200 字的短文本证据力弱,5000 字的长文证据力强。
  • 检测置信度:|p - 0.5| × 2。0.95 的判定比 0.55 的判定更可信。

三个因子都在 [0,1],乘起来天然有界。

三、决策:用后验均值,还是下界?

后验均值 E[θ] = a / (a + b) 是最直观的分数,但它有个问题:样本少的时候不可靠。一个账号只有 1 条内容、被判为 AI,后验均值是 (2+1)/(4+1) = 0.6,看起来已经很高了。

更好的做法是用后验的下分位数(比如 5% 分位),也就是「保守估计下,这个账号至少有多 AI」。样本少时下界会被拉得很低,样本多时才逼近均值------正好符合「证据不足不下结论」的原则。

Beta 分布的分位数没有闭式解,用 scipy.stats.beta.ppf 或者自己写二分。不想引入 scipy 的话,用 Wilson 下界近似也够用:

python 复制代码
import math

def wilson_lower(pos: float, n: float, z: float = 1.96) -> float:
    if n <= 0:
        return 0.0
    phat = pos / n
    denom = 1 + z * z / n
    center = phat + z * z / (2 * n)
    margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n))
    return max(0.0, (center - margin) / denom)

pos = Σ wᵢ·yᵢ,n = Σ wᵢ。Wilson 下界的好处是:n 很小时结果被压到接近 0,n 变大后收敛到 phat。直觉上就是「样本不够,别下结论」。

四、冷启动:样本不够就不评分

再好的模型也救不了零样本。规则很朴素:

有效样本量 n 处理
< 3 不评分,只记录
3 ~ 10 出分,但只用于观察,不触发任何动作
> 10 出分,可按阈值触发动作

「有效样本量」是加权后的 n,不是条数。10 条 100 字的短评,加权后可能只有 2.5,仍然算冷启动。

这条规则能挡掉大部分误伤投诉。真实的投诉场景里,绝大多数是「我这个号一共就发了 5 篇」。

五、完整实现

python 复制代码
import math
from dataclasses import dataclass
from datetime import datetime, timezone

A0, B0 = 2.0, 2.0          # 先验
TAU_DAYS = 14.0            # 时间衰减常数
LO, HI = 0.30, 0.85        # 硬标签阈值


@dataclass
class Item:
    confidence: float      # 检测给出的 AI 概率
    chars: int             # 文本长度
    created_at: datetime


def decay(created_at: datetime, now: datetime, tau_days: float = TAU_DAYS) -> float:
    delta_days = max(0.0, (now - created_at).total_seconds() / 86400)
    return math.exp(-delta_days / tau_days)


def item_weight(item: Item, now: datetime) -> float:
    length_w = min(1.0, item.chars / 300)
    conf_w = abs(item.confidence - 0.5) * 2
    return decay(item.created_at, now) * length_w * conf_w


def score_account(items: list[Item], now: datetime | None = None) -> dict:
    now = now or datetime.now(timezone.utc)

    pos = neg = 0.0
    used = 0
    for it in items:
        p = it.confidence
        if LO < p < HI:
            continue                     # 灰区不参与
        w = item_weight(it, now)
        if w <= 0:
            continue
        used += 1
        if p >= HI:
            pos += w
        else:
            neg += w

    n_eff = pos + neg
    a, b = A0 + pos, B0 + neg
    mean = a / (a + b)

    return {
        "score": round(mean, 4),                      # 后验均值
        "lower": round(wilson_lower(pos, n_eff), 4),  # 保守下界
        "n_eff": round(n_eff, 2),
        "used": used,
        "skipped": len(items) - used,
        "cold_start": n_eff < 3,
    }


def decide(r: dict) -> str:
    if r["cold_start"]:
        return "observe"                  # 只看不动
    s = r["lower"]                        # 决策用下界,不用均值
    if s >= 0.80:
        return "block"
    if s >= 0.60:
        return "manual_review"
    if s >= 0.40:
        return "throttle"
    return "normal"

跑一组对比,能直观看出样本量的作用:

python 复制代码
now = datetime.now(timezone.utc)

# A:1 条高置信 AI
a = [Item(0.95, 800, now)]
# B:20 条,其中 18 条高置信 AI
b = [Item(0.93, 800, now) for _ in range(18)] + [Item(0.10, 800, now) for _ in range(2)]

for name, items in (("A", a), ("B", b)):
    r = score_account(items, now)
    print(name, r, decide(r))

输出大致是:A 的均值 0.6、下界 0,判定 observe;B 的均值 0.88、下界 0.78,判定 manual_review。这正是想要的效果------一条内容不足以定性,二十条才能定性。

六、阈值与动作

下界 动作 说明
< 0.40 正常 不干预
0.40 ~ 0.60 限流 降低分发权重,不通知用户
0.60 ~ 0.80 人工复核 进审核队列,24 小时内处理
≥ 0.80 处置 限制发布 / 封禁,需保留申诉入口

三个原则:

1. 能用轻动作就不用重动作。 限流是可逆的,封禁不是。风控的第一目标是降低损失,不是抓住所有人。

2. 高置信区间要窄。 ≥ 0.80 才自动处置,意味着只有「大量高置信 AI 内容 + 长文本」的账号会被自动处理。宁可漏,不可错------漏掉的成本是几条低质内容,错杀的成本是一次舆情。

3. 动作必须可解释。 给用户的通知不能只说「检测到 AI 内容」,要给依据:命中了几条、时间段、置信区间。用户能看懂,申诉才有意义;审核员能看懂,复核才有效率。

七、工程细节:别每次全量重算

如果每次评分都把账号的所有历史内容拉出来算一遍,成本会随数据量线性增长。实际做法是维护增量累加器:

sql 复制代码
CREATE TABLE account_risk (
  user_id    BIGINT PRIMARY KEY,
  pos        DECIMAL(12,4) NOT NULL DEFAULT 0,   -- Σ w·y
  neg        DECIMAL(12,4) NOT NULL DEFAULT 0,   -- Σ w·(1-y)
  updated_at DATETIME(3) NOT NULL,
  version    BIGINT NOT NULL DEFAULT 0
);

每来一条新结果,pos 或 neg 加上当次权重;同时用定时任务按 exp(-Δt/τ) 对所有行做整体衰减,比如每天凌晨乘一次 exp(-1/14)。这样查询是 O(1),衰减是批量操作。

version 列用于乐观锁,防止并发的两条结果互相覆盖。

还需要一个回填 能力:模型升级、阈值调整后,历史账号的分数需要重算。这时不能只靠增量累加器,要有「按时间窗口全量重算某个账号」的函数,并且能离线跑。前面那个 score_account(items) 就是为回填准备的------增量算日常,全量算回填,两套逻辑必须用同一个函数,否则分数会对不上。

八、小结

账号级评分的价值不在于「更准」,而在于把噪声平均掉:

  1. 单条分数是观测,账号分数是推断,两者不能混用;
  2. 用 Beta-Binomial 做聚合,先验保证冷启动不被极端值带偏;
  3. 灰区样本直接丢弃,不确定的样本比错误的样本更毒;
  4. 权重 = 时效 × 长度 × 置信度,三者相乘天然有界;
  5. 决策用下界不用均值,样本不足时自动趋于保守;
  6. 阈值分级,轻动作优先,重动作留申诉;
  7. 增量累加器负责日常,全量函数负责回填,两者共用同一份计算逻辑。

最后一句话:风控模型的上限由数据决定,下限由产品设计决定。阈值调得再精细,也不如给用户留一条能说清楚的申诉通道。


示例基于文识云(sbnew.top)的 AIGC 检测结果字段。评分模型与具体检测服务商无关,换成任何给出 0~1 概率的接口都能套用。

相关推荐
一条泥憨鱼1 小时前
【消息队列】ACK确认了什么:消息队列可靠投递的完整链路
java·后端·kafka·消息队列·rabbitmq
木头左2 小时前
【Python】不点浏览器发博客:逆向 CSDN 创作中心接口做 md-publish 技能
python·自动化·api
孟陬2 小时前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript
BingoGo2 小时前
PHP 标准库的价值远不止少写几行代码
后端·php
Tt有了t2 小时前
设计模式速查
java·python·设计模式
JaguarJack3 小时前
PHP 标准库的价值远不止少写几行代码
后端·php·服务端
deepseek233 小时前
Python日志拖慢请求?QueueHandler排队、丢失与退出实测
python·性能优化·logging·后端开发·queuehandler
鶴哥只手遮天3 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
用户400315043353 小时前
《从 skynet.timeout 到回调执行:Skynet 分层时间轮源码剖析》
后端