AI产品验证的"感觉陷阱":用受控实验替代直觉决策

AI产品的验证有一个独特问题:输出质量没有客观标尺。传统软件的功能是否正常,通过测试用例就能验证。但AI生成内容的质量高低、推荐是否精准、总结是否有用,主观感受占比很高。这让产品团队容易陷入系统性的判断误区。

一、AI产品验证的三重幻觉——主观评价为何会系统出错

flowchart TD
  A[AI功能上线] --> B[内部Demo效果好]
  B --> C{用户实际使用后}
  C -->|高点击低留存| D[指标幻觉]
  C -->|核心流程指标降| E[见光死]
  C -->|长期指标零变化| F[安慰剂效应]
  D --> G[启动受控A/B实验]
  E --> G
  F --> G
  G --> H{分层对比分析}
  H -->|功能有因果贡献| I[保留并迭代优化]
  H -->|组间无显著差异| J[回撤或重构方向]

AI功能在内部演示和真实场景之间存在巨大信息落差。第一种幻觉是"demo即上线":模型在精心设计的测试query上输出不错,团队就觉得功能已经ready。但用户实际使用时的query分布、噪声水平、上下文切换频率,和demo集合可能完全不同。

第二种幻觉是"用户说好就是好"。上线后收集的NPS或满意度评分天然带有确认偏误:愿意反馈的用户本身就是高意愿群体。沉默大多数不用、弃用、降频使用,这些信号不会主动冒出来。

第三种幻觉是"数据涨了就是AI的功劳"。整体指标受版本迭代、运营活动、季节性波动等多种因素影响。不做对照组对比,根本分不清变化是AI功能带来的,还是其他因素造成的。

三种幻觉的根源一致:缺少受控实验框架,让相关性和因果性无法分离。

二、实验基础设施:从分流到采集的工程闭环

一个可落地的A/B实验系统需要三个核心能力:稳定性分流、埋点采集和统计计算。

2.1 稳定性分流

分流的核心需求很简单:同一用户每次访问必须进入相同实验组,且各组的流量分配均匀无偏。哈希分桶是目前工业界最成熟的做法:

import hashlib
import bisect
from typing import Optional, NamedTuple
from dataclasses import dataclass


@dataclass(frozen=True)
class ExperimentVariant:
    name: str
    bucket_range: tuple[float, float]  # [start, end) in [0.0, 1.0)


@dataclass(frozen=True)
class ExperimentConfig:
    experiment_id: str
    variants: tuple[ExperimentVariant, ...]
    salt: str  # 避免跨实验用户分桶趋同
    status: str  # "running" | "paused" | "archived"


class ExperimentRouter:
    """基于用户ID哈希的稳定分流器。

    使用 MurmurHash 替代 SHA256 可获得更低延迟;
    这里用 SHA256 便于无外部依赖演示核心逻辑。
    """

    DIGITS = 16  # 取前 16 个十六进制字符

    def __init__(self) -> None:
        self._experiments: dict[str, ExperimentConfig] = {}

    def register(self, config: ExperimentConfig) -> None:
        if config.status not in ("running", "paused", "archived"):
            raise ValueError(f"Invalid status: {config.status}")
        ranges: list[tuple[float, float]] = []
        for v in config.variants:
            if v.bucket_range[0] >= v.bucket_range[1]:
                raise ValueError(f"Invalid bucket range for variant {v.name}")
            ranges.append(v.bucket_range)
        # 校验不重叠且覆盖 [0.0, 1.0)
        ranges.sort()
        if ranges[0][0] != 0.0 or ranges[-1][1] != 1.0:
            raise ValueError("Variant ranges must fully cover [0.0, 1.0)")
        for i in range(len(ranges) - 1):
            if abs(ranges[i][1] - ranges[i + 1][0]) > 1e-9:
                raise ValueError("Variant ranges must be contiguous")
        self._experiments[config.experiment_id] = config

    def assign(
        self, user_id: str, experiment_id: str
    ) -> Optional[str]:
        """返回用户命中的实验组名称,实验不在运行时返回 None。"""
        config = self._experiments.get(experiment_id)
        if config is None:
            return None
        if config.status != "running":
            return None

        bucket = self._hash_user(user_id, experiment_id, config.salt)
        for variant in config.variants:
            low, high = variant.bucket_range
            if low <= bucket < high:
                return variant.name
        return None

    @staticmethod
    def _hash_user(user_id: str, experiment_id: str, salt: str) -> float:
        payload = f"{experiment_id}:{salt}:{user_id}".encode("utf-8")
        digest = hashlib.sha256(payload).hexdigest()[:ExperimentRouter.DIGITS]
        return int(digest, 16) / (16 ** ExperimentRouter.DIGITS)

分流层向上暴露一个稳定接口:传入用户标识和实验ID,返回命中的实验组。下游无论API请求拦截、前端组件显隐还是模型路由,都从同一入口获取分组信息。

2.2 埋点采集

埋点设计需要区分"实验上下文"和"业务事件"。实验上下文在用户进入实验时记录,业务事件在每次行为触发时上报:

import time
import json
from typing import Any


def build_exposure_event(
    user_id: str,
    experiment_id: str,
    variant: str,
    session_id: str,
) -> dict[str, Any]:
    """记录用户进入实验组的曝光事件。

    exposure 事件用于计算实验分母,没有曝光记录的
    实验组指标会偏低或产生严重选择偏差。
    """
    return {
        "event_type": "experiment_exposure",
        "user_id": user_id,
        "session_id": session_id,
        "experiment_id": experiment_id,
        "variant": variant,
        "timestamp_ms": int(time.time() * 1000),
    }


def build_metric_event(
    user_id: str,
    event_type: str,
    value: float,
    session_id: str,
    extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """记录业务指标事件。

    对 AI 产品常见指标类型:
    - 生成完成率:event_type = "generation_complete"
    - 结果采纳率:event_type = "result_accepted"
    - 二次编辑率:event_type = "result_edited"
    - 会话时长:event_type = "session_duration"
    """
    event: dict[str, Any] = {
        "event_type": event_type,
        "user_id": user_id,
        "session_id": session_id,
        "value": value,
        "timestamp_ms": int(time.time() * 1000),
    }
    if extra:
        event["extra"] = extra
    return event

分流的稳定性直接决定实验结论的可靠性。如果同一用户在实验周期内出现分组漂移,对照组和处理组就会互相污染,所有统计结论立即失效。

三、指标体系设计——选错指标比不做实验更危险

AI产品的指标体系建议按三层结构搭建:

层级 类型 示例(AI写作助手) 变化容忍度
核心指标 北极星指标 周活跃用户采纳率 不能降
护栏指标 安全边界 生成延迟、错误率、用户投诉 不能降
辅助指标 过程信号 单次会话字数、二次编辑比例 可容忍波动

护栏指标的核心价值是防止"指标退化":核心指标上涨但稳定性同步恶化,或者体验数据提升但计算成本已经失控。AI产品尤其需要在核心指标之外,同步监控模型调用延迟、Token消耗率和生成错误的真实比例。

一个容易中招的陷阱是辛普森悖论:整体数据显示两组无差异,但将所有用户按活跃度分层后,两组各自表现出显著方向。这通常意味着功能对不同用户群体产生了异质影响,需要分层分析而不是草率下结论。

from collections import defaultdict
from typing import Any


def simpson_check(
    segments: dict[str, list[dict[str, Any]]],
    metric_key: str,
) -> dict[str, dict[str, float]]:
    """检查分层指标是否与整体趋势一致。

    如果整体 direction 与各分层 direction 不一致,
    提示存在辛普森悖论,须分层分析后决策。
    """
    result: dict[str, dict[str, float]] = {}
    for seg_name, buckets in segments.items():
        seg_result: dict[str, float] = {}
        for bucket in buckets:
            variant = bucket.get("variant", "unknown")
            values = [e[metric_key] for e in bucket.get("events", [])]
            seg_result[variant] = sum(values) / len(values) if values else 0.0
        result[seg_name] = seg_result
    return result

四、实验解读的冷思考——统计显著不等于产品成功

p值小于0.05就宣布实验胜利,是A/B测试领域最普遍的误用。p值衡量的是"在零假设成立时,观察到当前数据或更极端数据的概率",而不是"实验组优于对照组的概率"。这两个陈述有根本区别。

几个容易忽略的关键陷阱:

多重比较问题。同时看10个指标,每个都用0.05阈值独立判断,至少出现一个假阳性的概率约为40%。严谨的做法是使用Bonferroni校正或控制FDR(错误发现率)。

效应量与统计显著性分离。大样本下,微小效应也可能p值显著。一个让AI生词数增加0.8%的功能,统计上显著,但用户完全感知不到。必须同步报告效应量(Cohen's d)和置信区间。

新奇效应与指标衰减。AI功能上线初期,用户因新鲜感高频尝试,短期指标容易走高。但3至4周后使用强度逐步回落,那时的指标才代表稳态水平。观察周期至少需要覆盖一个完整的衰减窗口。

import math


def welch_t_test(
    mean_a: float, var_a: float, n_a: int,
    mean_b: float, var_b: float, n_b: int,
) -> tuple[float, float]:
    """Welch's t-test,不假设两组方差相等。"""
    se = math.sqrt(var_a / n_a + var_b / n_b)
    if se == 0:
        return 0.0, 0.0
    t_stat = (mean_a - mean_b) / se

    # Welch–Satterthwaite 自由度
    num = (var_a / n_a + var_b / n_b) ** 2
    den = ((var_a / n_a) ** 2) / (n_a - 1) + ((var_b / n_b) ** 2) / (n_b - 1)
    df = num / den if den > 0 else 1.0

    # 简化版正态近似;生产环境建议使用 scipy.stats.t 或 bootstrap
    p_value = 2 * (1 - _norm_cdf(abs(t_stat)))
    return t_stat, min(p_value, 1.0)


def cohens_d(mean_a: float, mean_b: float, var_a: float, var_b: float) -> float:
    """计算效应量 Cohen's d。"""
    pooled_sd = math.sqrt((var_a + var_b) / 2)
    if pooled_sd == 0:
        return 0.0
    return (mean_a - mean_b) / pooled_sd


def _norm_cdf(x: float) -> float:
    """标准正态分布 CDF 近似(Abramowitz and Stegun 7.1.26)。"""
    if x < 0:
        return 1 - _norm_cdf(-x)
    b = [0.31938153, -0.356563782, 1.781477937, -1.821255978, 1.330274429]
    t = 1 / (1 + 0.2316419 * x)
    poly = ((((b[4] * t + b[3]) * t + b[2]) * t + b[1]) * t + b[0])
    return 1 - (1 / math.sqrt(2 * math.pi)) * math.exp(-x * x / 2) * poly * t

五、总结

受控实验是区分"AI功能在demo上好用"和"AI功能真正为用户创造价值"的核心工具。关键要点如下:

  • 用稳定哈希分桶保证同一用户固定分组。流量漂移是实验结论失效的首要原因,没有之一。
  • 指标体系需要三层结构:核心指标验证价值方向,护栏指标守住业务底线,辅助指标提供过程信号和诊断信息。
  • 分层分析可发现辛普森悖论。整体无显著差异不代表各用户群体都无差异,不能跳过分层阶段直接下结论。
  • 报告实验结果时必须同步给出效应量和置信区间。p值只是众多信号中的一个维度,不能单独作为结论依据。
  • 观察周期要覆盖新奇效应衰减窗口。AI功能的稳态指标通常在上线3至4周后才趋于稳定,过早截止会高估效果。
  • A/B实验验证的是因果增量。相关性信号、NPS主观反馈、内部demo效果,都不能替代受控对照实验。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐