Agent上线后不知道效果好不好?用Python搭建A/B测试+效果评估平台完整实战

🎁个人主页:我滴老baby

🎉欢迎大家点赞👍评论📝收藏⭐文章

🔍系列专栏:AI


Agent上线后不知道效果好不好?用Python搭建A/B测试+效果评估平台完整实战

Agent每次迭代都靠"感觉"调参?改了Prompt不确定有没有效果?换了大模型不知道值不值?本文从零带你用Python + OpenAI构建一套完整的A/B测试与效果评估平台,涵盖实验设计、一致性哈希流量分割、自动化指标采集、Welch t检验与卡方检验统计分析,最终整合为一个从创建到自动出报告的全自动平台,让你的Agent每次上线都有数据撑腰。


一、为什么Agent需要A/B测试

Agent的评估与传统软件测试有着本质区别。传统软件的行为是确定性的------相同的输入永远产生相同的输出,单元测试和集成测试能覆盖绝大多数场景。但Agent的行为具有内在的随机性:LLM的Temperature参数引入了输出波动,工具调用的顺序可能因上下文理解差异而变化,即使Prompt完全相同,两次运行的结果也可能不同。这意味着我们无法通过简单的功能测试来判定一个Agent的"好坏",必须依赖统计学方法进行系统性的效果评估。

更关键的是,Agent的迭代速度极快。改了一个System Prompt、换了模型版本、调整了工具参数------这些变更对用户体验的影响往往是复杂的、非线性的。你以为把Prompt改得更详细会让回答更准确,但实际上可能只是增加了Token消耗和延迟,而准确率并没有显著提升。A/B测试提供了一种科学的、可量化的方法来评估每次变更的真实效果,彻底告别"我觉得变好了"的主观判断。

1.1 Agent评估的核心挑战

在实际项目中,Agent评估至少面临三大挑战。第一,输出质量难以用单一指标衡量------准确率、延迟、用户满意度、成本等多个维度之间存在此消彼长的权衡关系,用一个指标优化往往会牺牲另一个指标。第二,性能受到输入分布的影响------Agent在常见问题上表现好不代表在长尾场景也能做好,简单的平均指标可能掩盖严重的局部退化。第三,行为具有随机性------单次测试结果的波动可能很大,只有积累足够的样本量才能得出可靠结论。

下面的表格列出了Agent A/B测试中最核心的四个评估维度,每个维度都有对应的采集方式和评估标准。在实际的A/B测试中,你需要同时跟踪这些维度,才能全面评估一次变更的真实影响:

评估维度 核心指标 采集方式 评估标准
准确性 回答准确率、任务完成率 LLM-as-Judge + 人工抽检 准确率提升 >= 2%
响应速度 首Token延迟、总响应时间 系统埋点自动采集 P95延迟不退化
用户满意度 点赞率、会话续聊率 用户行为反馈采集 满意度评分提升
成本效率 Token消耗量、单次调用成本 OpenAI API计费数据 单次交互成本不增加

这四个维度之间往往存在此消彼长的关系。比如换用更大的模型可能提高准确率但增加延迟和成本,加长推理链可能提高任务完成率但降低用户满意度。A/B测试的价值正在于帮助你在这些维度之间找到最优平衡点。


二、第一步:实验设计框架

好的实验从好的假设开始。在动手写代码之前,必须明确三个问题:你想验证什么、对照组和实验组分别是什么、成功标准是什么。一个清晰可执行的假设应该包含具体的变更内容和预期的可量化效果,比如"将System Prompt从通用型改为医疗专业型,预计能将任务完成率从72%提升到78%,同时延迟增加不超过10%"。

有了假设之后,需要定义对照组(Control Group)和实验组(Treatment Group)。对照组使用当前线上版本的Agent,实验组使用变更后的新版本。两组用户在所有其他条件上必须尽可能一致,唯一的区别就是Agent的版本。这样才能确保观测到的差异确实是由你的变更引起的,而不是其他混淆因素导致的。

下面这段代码实现了一个ExperimentDesigner类,用于规范化地定义和管理每一次A/B实验。每个实验都有唯一的ID、明确的假设、对照组和实验组的Agent配置,以及预设的评估指标和运行参数。这种结构化的实验管理方式可以有效避免"凭感觉上线"的随意性。design()方法会自动校验实验配置的合理性,确保对照组和实验组之间确实存在差异:

python 复制代码
# ab_testing/experiment_designer.py
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from datetime import datetime
import uuid


@dataclass
class ExperimentConfig:
    """实验配置数据结构"""
    name: str
    description: str
    hypothesis: str
    control_config: Dict            # 对照组Agent配置(model、prompt等)
    treatment_config: Dict          # 实验组Agent配置
    metrics: List[str]              # 需要追踪的指标列表
    target_sample_size: int         # 目标样本量
    confidence_level: float = 0.95  # 统计检验置信水平
    min_effect_size: float = 0.02   # 最小可检测效应
    max_duration_hours: int = 168   # 最大运行时长(小时)


@dataclass
class Experiment:
    """实验实例"""
    experiment_id: str
    config: ExperimentConfig
    status: str = "draft"           # draft / running / completed / stopped
    created_at: str = field(
        default_factory=lambda: datetime.now().isoformat()
    )
    started_at: Optional[str] = None
    ended_at: Optional[str] = None


class ExperimentDesigner:
    """实验设计器 - 规范化管理A/B实验的全生命周期"""

    def __init__(self):
        self.experiments: Dict[str, Experiment] = {}

    def design(
        self,
        name: str,
        hypothesis: str,
        control_config: Dict,
        treatment_config: Dict,
        metrics: List[str],
        target_sample_size: int = 10000,
        confidence_level: float = 0.95,
        min_effect_size: float = 0.02,
    ) -> Experiment:
        """设计并创建一个新实验,自动校验配置合理性"""
        config = ExperimentConfig(
            name=name,
            description=f"验证假设: {hypothesis}",
            hypothesis=hypothesis,
            control_config=control_config,
            treatment_config=treatment_config,
            metrics=metrics,
            target_sample_size=target_sample_size,
            confidence_level=confidence_level,
            min_effect_size=min_effect_size,
        )
        experiment = Experiment(
            experiment_id=f"exp_{uuid.uuid4().hex[:8]}",
            config=config,
        )
        self.experiments[experiment.experiment_id] = experiment
        print(f"[实验创建] ID: {experiment.experiment_id}")
        print(f"  名称: {name}")
        print(f"  假设: {hypothesis}")
        print(f"  追踪指标: {metrics}")
        print(f"  目标样本量: {target_sample_size}")
        return experiment

    def validate_experiment(self, experiment_id: str) -> bool:
        """验证实验配置是否合理"""
        exp = self.experiments.get(experiment_id)
        if not exp:
            print(f"[错误] 实验 {experiment_id} 不存在")
            return False

        issues = []
        config = exp.config

        if config.control_config == config.treatment_config:
            issues.append("对照组和实验组配置完全相同,无法检测差异")

        if not config.metrics:
            issues.append("未指定任何评估指标")

        if config.target_sample_size < 100:
            issues.append(f"样本量 {config.target_sample_size} 过小,建议 >= 100")

        if not (0.8 <= config.confidence_level <= 0.99):
            issues.append(
                f"置信水平 {config.confidence_level} 不在合理范围 [0.8, 0.99]"
            )

        if issues:
            print(f"[验证失败] 实验 {experiment_id}:")
            for issue in issues:
                print(f"  - {issue}")
            return False

        print(f"[验证通过] 实验 {experiment_id} 配置合理,可以启动")
        return True

    def start_experiment(self, experiment_id: str) -> bool:
        """启动实验"""
        if not self.validate_experiment(experiment_id):
            return False
        exp = self.experiments[experiment_id]
        exp.status = "running"
        exp.started_at = datetime.now().isoformat()
        print(f"[实验启动] {experiment_id} - {exp.config.name}")
        return True

    def stop_experiment(self, experiment_id: str, reason: str = "") -> None:
        """手动停止实验"""
        exp = self.experiments.get(experiment_id)
        if exp:
            exp.status = "stopped"
            exp.ended_at = datetime.now().isoformat()
            print(f"[实验停止] {experiment_id} - 原因: {reason}")

    def list_experiments(self) -> List[Dict]:
        """列出所有实验及其状态"""
        results = []
        for eid, exp in self.experiments.items():
            results.append({
                "id": eid,
                "name": exp.config.name,
                "status": exp.status,
                "hypothesis": exp.config.hypothesis,
                "created_at": exp.created_at,
            })
        return results


# ===== 使用示例 =====
designer = ExperimentDesigner()

experiment = designer.design(
    name="Prompt专业化效果验证",
    hypothesis="将System Prompt从通用型改为医疗专业型,"
               "预计可将任务完成率从72%提升至78%",
    control_config={
        "model": "gpt-4",
        "system_prompt": "你是一个有帮助的AI助手。",
        "temperature": 0.7,
    },
    treatment_config={
        "model": "gpt-4",
        "system_prompt": "你是一名专业的医疗咨询助手,"
                         "擅长解答常见健康问题,回答时需引用"
                         "相关医学依据并给出建议等级。",
        "temperature": 0.7,
    },
    metrics=["task_completion_rate", "response_latency_p95",
             "user_rating", "token_cost_per_interaction"],
    target_sample_size=5000,
)

designer.start_experiment(experiment.experiment_id)

这个设计器会自动生成唯一的实验ID,记录创建时间,并通过validate_experiment()方法检查实验配置的合理性------包括对照组与实验组是否有差异、指标列表是否为空、样本量是否充足、置信水平是否在合理范围内。在实际项目中,你可以将它与数据库结合,实现实验配置的持久化存储和团队共享。


三、第二步:流量分割与分配

实验设计好之后,接下来的核心问题是如何将用户流量分配到对照组和实验组。流量分割需要满足三个关键条件:第一是均匀性,两组的样本量应该大致相等;第二是一致性,同一个用户多次请求必须始终被分到同一组;第三是独立性,分组结果不应该与用户的任何属性相关联。只有同时满足这三个条件,实验结果才具有统计可信度。

最常用的流量分割方法是基于哈希的分桶法。它将用户ID作为输入,通过哈希函数计算出一个0到1之间的均匀分布值,然后根据预设的流量比例决定用户属于哪一组。这种方法简单、高效、完全确定性------同一个用户ID经过哈希后永远得到相同的结果,不需要维护任何全局状态表。

下面这段代码实现了一个完整的TrafficSplitter类,支持基于一致性哈希的流量分割和多实验之间的互斥分组。通过实验层(Layer)机制避免不同实验之间的流量干扰------同一层内的用户只能参与一个实验,从而保证实验结果的纯净性:

python 复制代码
# ab_testing/traffic_splitter.py
import hashlib
from typing import Dict, List
from dataclasses import dataclass


@dataclass
class SplitConfig:
    """流量分割配置"""
    experiment_id: str
    control_ratio: float = 0.5      # 对照组流量比例
    treatment_ratio: float = 0.5    # 实验组流量比例
    layer: str = "default"          # 实验层(用于互斥实验管理)


class TrafficSplitter:
    """流量分割器 - 基于一致性哈希的用户分组"""

    def __init__(self):
        self.splits: Dict[str, SplitConfig] = {}
        # layer_name -> {user_id -> experiment_id}
        self.layer_assignments: Dict[str, Dict[str, str]] = {}

    def register_split(self, config: SplitConfig) -> None:
        """注册流量分割配置,校验比例之和为1"""
        total = config.control_ratio + config.treatment_ratio
        if abs(total - 1.0) > 0.001:
            raise ValueError(f"流量比例之和必须为1.0, 当前为 {total}")
        self.splits[config.experiment_id] = config
        print(f"[流量注册] 实验 {config.experiment_id} "
              f"- 对照组 {config.control_ratio:.0%} / "
              f"实验组 {config.treatment_ratio:.0%} "
              f"- 层: {config.layer}")

    def _hash_user(self, user_id: str, salt: str = "") -> float:
        """将用户ID映射到0~1之间的均匀分布值"""
        key = f"{salt}:{user_id}"
        hash_hex = hashlib.md5(key.encode()).hexdigest()
        hash_int = int(hash_hex[:8], 16)
        return hash_int / 0xFFFFFFFF

    def assign_group(self, user_id: str, experiment_id: str) -> str:
        """为单个用户分配实验组"""
        config = self.splits.get(experiment_id)
        if not config:
            raise ValueError(f"未注册的实验: {experiment_id}")

        # 检查同一层内是否已被其他实验占用
        layer = config.layer
        if layer not in self.layer_assignments:
            self.layer_assignments[layer] = {}

        layer_map = self.layer_assignments[layer]
        if user_id in layer_map:
            if layer_map[user_id] != experiment_id:
                return "excluded"  # 该用户已被同层其他实验占用

        # 基于哈希值决定分组
        salt = f"{experiment_id}:{layer}"
        hash_val = self._hash_user(user_id, salt)

        if hash_val < config.control_ratio:
            group = "control"
        else:
            group = "treatment"

        layer_map[user_id] = experiment_id
        return group

    def batch_assign(
        self, user_ids: List[str], experiment_id: str
    ) -> Dict[str, str]:
        """批量分配用户分组,并输出分布统计"""
        results = {}
        counts = {"control": 0, "treatment": 0, "excluded": 0}

        for uid in user_ids:
            group = self.assign_group(uid, experiment_id)
            results[uid] = group
            counts[group] = counts.get(group, 0) + 1

        total = len(user_ids)
        print(f"[批量分配] 共 {total} 用户:")
        print(f"  对照组: {counts['control']} ({counts['control']/total:.1%})")
        print(f"  实验组: {counts['treatment']} ({counts['treatment']/total:.1%})")
        if counts['excluded'] > 0:
            print(f"  排除:   {counts['excluded']} ({counts['excluded']/total:.1%})")
        return results

    def get_assignment_stats(self, experiment_id: str) -> Dict[str, int]:
        """获取指定实验的分组统计"""
        config = self.splits.get(experiment_id)
        if not config:
            return {}
        layer = config.layer
        layer_map = self.layer_assignments.get(layer, {})
        exp_users = [
            uid for uid, eid in layer_map.items()
            if eid == experiment_id
        ]
        control_count = 0
        treatment_count = 0
        for uid in exp_users:
            salt = f"{experiment_id}:{layer}"
            hash_val = self._hash_user(uid, salt)
            if hash_val < config.control_ratio:
                control_count += 1
            else:
                treatment_count += 1
        return {
            "control": control_count,
            "treatment": treatment_count,
            "total": control_count + treatment_count,
        }


# ===== 使用示例 =====
splitter = TrafficSplitter()

split_config = SplitConfig(
    experiment_id=experiment.experiment_id,
    control_ratio=0.5,
    treatment_ratio=0.5,
    layer="prompt_layer",
)
splitter.register_split(split_config)

# 模拟1000个用户进行分组
mock_users = [f"user_{i:04d}" for i in range(1000)]
assignments = splitter.batch_assign(mock_users, experiment.experiment_id)

上面的代码通过MD5哈希将用户ID映射到0到1之间的均匀分布值,然后根据预设的流量比例进行分组。确定性哈希保证了同一个用户ID在任何时候经过哈希后都得到相同的结果,无需在数据库中维护分配状态。而Layer机制确保了同一层内的用户只会参与一个实验,避免了实验之间的流量污染。

在实际项目中,除了基本的50/50分割之外,还有多种流量分割策略可供选择。不同策略适用于不同的场景------小流量试错适合高风险变更的灰度发布,多组并行实验适合同时比较多个方案。下面的表格总结了五种常见的流量分割策略:

分割策略 对照组比例 实验组比例 适用场景 注意事项
经典50/50 50% 50% 标准A/B测试 需要充足流量
小流量试错 95% 5% 高风险变更灰度发布 统计功效较低,需更长时间
70/30分割 70% 30% 保守型迭代优化 实验周期相对较长
多组A/B/C 33%×3组 - 同时比较多个方案 每组样本量减少
层叠实验 互不干扰 互不干扰 多个独立实验并行 需要层管理机制

选择哪种策略取决于你的流量规模、变更的风险程度和实验的时间预算。对于大型产品,层叠实验机制特别重要------它允许多个团队同时进行不同的实验而不会互相干扰。


四、第三步:指标采集与统计分析

流量分割就绪之后,下一步就是采集各组的指标数据并进行统计分析。指标采集需要做到两点:一是完整性,确保每次用户交互的数据都被正确记录;二是准确性,确保数据打标(对照组/实验组标签)不会出错。在Agent场景中,我们通常需要同时采集业务指标(如任务完成率)和系统指标(如响应延迟、Token消耗)。

统计分析的核心目标是回答一个关键问题:观测到的差异是真实的还是由随机波动引起的?这需要用到假设检验。最常用的方法是t检验(用于连续型指标如延迟、评分)和卡方检验(用于比例型指标如准确率、完成率)。检验的输出是一个p值------它表示在"两组实际上没有差异"的前提下,观察到当前或更极端差异的概率。p值越小,说明差异越不可能是随机波动造成的。

下面这段代码实现了一个MetricsCollector类,负责在Agent每次交互后自动采集和存储各项指标。它通过OpenAI API调用Agent获取实际响应,同时记录延迟、Token消耗和任务完成情况。采集的数据按实验ID和分组维度进行聚合,为后续的显著性检验提供数据基础:

python 复制代码
# ab_testing/metrics_collector.py
import time
import json
import random
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from collections import defaultdict
import statistics
from openai import OpenAI


@dataclass
class MetricEvent:
    """单次指标事件"""
    experiment_id: str
    user_id: str
    group: str                    # control / treatment
    metrics: Dict[str, float]     # 指标名 -> 值
    timestamp: float = field(default_factory=time.time)


class MetricsCollector:
    """指标采集器 - 自动化数据收集与聚合"""

    def __init__(self):
        self.events: List[MetricEvent] = []
        # {experiment_id: {group: {metric_name: [values]}}}
        self.aggregated: Dict[str, Dict[str, Dict[str, List[float]]]] = \
            defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
        self.client = OpenAI()

    def record(
        self,
        experiment_id: str,
        user_id: str,
        group: str,
        metrics: Dict[str, float],
    ) -> None:
        """记录一次指标事件"""
        event = MetricEvent(
            experiment_id=experiment_id,
            user_id=user_id,
            group=group,
            metrics=metrics,
        )
        self.events.append(event)
        # 实时更新聚合数据
        for metric_name, value in metrics.items():
            self.aggregated[experiment_id][group][metric_name].append(value)

    def call_agent_and_record(
        self,
        experiment_id: str,
        user_id: str,
        group: str,
        query: str,
        agent_config: Dict,
        task_completed: bool,
        user_rating: Optional[float] = None,
    ) -> Dict[str, Any]:
        """调用OpenAI Agent采集真实指标并记录"""
        start_time = time.time()

        try:
            response = self.client.chat.completions.create(
                model=agent_config.get("model", "gpt-4"),
                messages=[
                    {"role": "system", "content": agent_config.get(
                        "system_prompt", "你是一个有帮助的AI助手。")},
                    {"role": "user", "content": query},
                ],
                temperature=agent_config.get("temperature", 0.7),
                max_tokens=agent_config.get("max_tokens", 1000),
            )
            latency_ms = (time.time() - start_time) * 1000
            tokens_used = response.usage.total_tokens
            reply = response.choices[0].message.content
        except Exception as e:
            latency_ms = (time.time() - start_time) * 1000
            tokens_used = 0
            reply = f"[错误] {str(e)}"

        metrics = {
            "response_latency_ms": latency_ms,
            "tokens_used": float(tokens_used),
            "task_completed": 1.0 if task_completed else 0.0,
        }
        if user_rating is not None:
            metrics["user_rating"] = user_rating

        self.record(experiment_id, user_id, group, metrics)
        return {
            "reply": reply,
            "latency_ms": latency_ms,
            "tokens": tokens_used,
        }

    def record_agent_interaction(
        self,
        experiment_id: str,
        user_id: str,
        group: str,
        query: str,
        response: str,
        latency_ms: float,
        tokens_used: int,
        task_completed: bool,
        user_rating: Optional[float] = None,
    ) -> None:
        """记录一次Agent交互的完整指标(模拟数据或外部调用)"""
        metrics = {
            "response_latency_ms": latency_ms,
            "tokens_used": float(tokens_used),
            "task_completed": 1.0 if task_completed else 0.0,
        }
        if user_rating is not None:
            metrics["user_rating"] = user_rating
        self.record(experiment_id, user_id, group, metrics)

    def get_metric_stats(
        self, experiment_id: str, metric_name: str
    ) -> Dict[str, Dict[str, float]]:
        """获取某指标的分组统计量"""
        result = {}
        for group in ["control", "treatment"]:
            values = self.aggregated[experiment_id][group][metric_name]
            if not values:
                result[group] = {"count": 0}
                continue
            result[group] = {
                "count": len(values),
                "mean": statistics.mean(values),
                "std": statistics.stdev(values) if len(values) > 1 else 0.0,
                "min": min(values),
                "max": max(values),
                "median": statistics.median(values),
            }
        return result

    def get_summary(self, experiment_id: str) -> Dict[str, Any]:
        """获取实验的完整指标摘要"""
        metric_names = set()
        for group_data in self.aggregated[experiment_id].values():
            metric_names.update(group_data.keys())
        summary = {}
        for metric in sorted(metric_names):
            summary[metric] = self.get_metric_stats(experiment_id, metric)
        return summary

    def export_data(self, experiment_id: str, filepath: str) -> None:
        """导出实验数据为JSON文件"""
        data = {
            "experiment_id": experiment_id,
            "events": [
                {
                    "user_id": e.user_id,
                    "group": e.group,
                    "metrics": e.metrics,
                    "timestamp": e.timestamp,
                }
                for e in self.events
                if e.experiment_id == experiment_id
            ],
        }
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"[数据导出] {len(data['events'])} 条记录 -> {filepath}")


# ===== 使用示例:模拟数据采集 =====
collector = MetricsCollector()
random.seed(42)

for uid in mock_users:
    group = assignments.get(uid)
    if group not in ("control", "treatment"):
        continue
    if group == "control":
        collector.record_agent_interaction(
            experiment_id=experiment.experiment_id,
            user_id=uid, group="control",
            query="模拟查询", response="模拟回复",
            latency_ms=random.gauss(800, 150),
            tokens_used=int(random.gauss(350, 50)),
            task_completed=random.random() < 0.72,
            user_rating=random.gauss(3.8, 0.6),
        )
    else:
        collector.record_agent_interaction(
            experiment_id=experiment.experiment_id,
            user_id=uid, group="treatment",
            query="模拟查询", response="模拟回复",
            latency_ms=random.gauss(820, 140),
            tokens_used=int(random.gauss(370, 55)),
            task_completed=random.random() < 0.78,
            user_rating=random.gauss(4.1, 0.5),
        )

# 打印指标摘要
summary = collector.get_summary(experiment.experiment_id)
for metric, stats in summary.items():
    ctrl = stats.get("control", {})
    trtm = stats.get("treatment", {})
    print(f"\n[{metric}]")
    if "mean" in ctrl:
        print(f"  对照组: mean={ctrl['mean']:.2f}, std={ctrl['std']:.2f}")
    if "mean" in trtm:
        print(f"  实验组: mean={trtm['mean']:.2f}, std={trtm['std']:.2f}")

上面的代码中,call_agent_and_record()方法可以直接调用OpenAI API获取Agent的真实响应并自动采集延迟和Token消耗指标,而record_agent_interaction()方法则用于记录外部调用或模拟数据。所有指标按实验ID和分组维度自动聚合,get_summary()方法可以随时查看各指标的统计概览。

数据采集完毕后,下一步是进行统计显著性检验。下面这段代码实现了SignificanceTester类,支持Welch t检验(用于连续型指标如延迟、评分)和卡方检验(用于比例型指标如完成率、准确率),并提供清晰的检验报告输出,包含p值、置信区间、效应量等关键统计量:

python 复制代码
# ab_testing/significance_tester.py
import math
from typing import Dict, List, Tuple
from scipy import stats as scipy_stats


class SignificanceTester:
    """统计显著性检验器 - 支持t检验与卡方检验"""

    def __init__(self, confidence_level: float = 0.95):
        self.alpha = 1 - confidence_level
        self.confidence_level = confidence_level

    def welch_t_test(
        self,
        control_values: List[float],
        treatment_values: List[float],
        metric_name: str = "",
    ) -> Dict:
        """Welch t检验 - 适用于连续型指标(延迟、评分、Token消耗等)"""
        n1, n2 = len(control_values), len(treatment_values)
        mean1 = sum(control_values) / n1
        mean2 = sum(treatment_values) / n2

        # 使用scipy进行Welch t检验(不假设等方差)
        t_stat, p_value = scipy_stats.ttest_ind(
            control_values, treatment_values, equal_var=False
        )

        # 计算Cohen's d效应量
        var1 = sum((x - mean1) ** 2 for x in control_values) / (n1 - 1)
        var2 = sum((x - mean2) ** 2 for x in treatment_values) / (n2 - 1)
        pooled_std = math.sqrt((var1 + var2) / 2)
        cohens_d = (mean2 - mean1) / pooled_std if pooled_std > 0 else 0

        # 计算差异的置信区间
        se_diff = math.sqrt(var1 / n1 + var2 / n2)
        df = min(n1, n2) - 1
        t_critical = scipy_stats.t.ppf(1 - self.alpha / 2, df)
        margin = t_critical * se_diff
        diff = mean2 - mean1

        is_significant = p_value < self.alpha

        return {
            "metric": metric_name,
            "test_type": "Welch t-test",
            "control_mean": round(mean1, 4),
            "treatment_mean": round(mean2, 4),
            "difference": round(diff, 4),
            "relative_change": round(
                diff / mean1 * 100, 2
            ) if mean1 != 0 else 0,
            "t_statistic": round(t_stat, 4),
            "p_value": round(p_value, 6),
            "confidence_interval": (
                round(diff - margin, 4),
                round(diff + margin, 4),
            ),
            "cohens_d": round(cohens_d, 4),
            "is_significant": is_significant,
            "effect_size_label": self._label_effect(cohens_d),
        }

    def chi_square_test(
        self,
        control_successes: int,
        control_total: int,
        treatment_successes: int,
        treatment_total: int,
        metric_name: str = "",
    ) -> Dict:
        """卡方检验 - 适用于比例型指标(完成率、准确率等)"""
        table = [
            [control_successes, control_total - control_successes],
            [treatment_successes, treatment_total - treatment_successes],
        ]
        chi2, p_value, dof, expected = scipy_stats.chi2_contingency(
            table, correction=True
        )
        rate1 = control_successes / control_total if control_total > 0 else 0
        rate2 = treatment_successes / treatment_total if treatment_total > 0 else 0
        is_significant = p_value < self.alpha

        return {
            "metric": metric_name,
            "test_type": "Chi-square test",
            "control_rate": round(rate1, 4),
            "treatment_rate": round(rate2, 4),
            "difference": round(rate2 - rate1, 4),
            "relative_change": round(
                (rate2 - rate1) / rate1 * 100, 2
            ) if rate1 != 0 else 0,
            "chi2_statistic": round(chi2, 4),
            "p_value": round(p_value, 6),
            "degrees_of_freedom": dof,
            "is_significant": is_significant,
        }

    def _label_effect(self, d: float) -> str:
        """标注效应量等级"""
        abs_d = abs(d)
        if abs_d < 0.2:
            return "微小效应"
        elif abs_d < 0.5:
            return "小效应"
        elif abs_d < 0.8:
            return "中等效应"
        else:
            return "大效应"

    def generate_report(self, results: List[Dict]) -> str:
        """生成可读的统计检验报告"""
        lines = [
            "=" * 60,
            f"  统计显著性检验报告 (置信水平: {self.confidence_level:.0%})",
            "=" * 60,
        ]
        for r in results:
            lines.append(f"\n指标: {r['metric']}")
            lines.append(f"  检验方法: {r['test_type']}")
            if "control_mean" in r:
                lines.append(f"  对照组均值: {r['control_mean']:.4f}")
                lines.append(f"  实验组均值: {r['treatment_mean']:.4f}")
            if "control_rate" in r:
                lines.append(f"  对照组比率: {r['control_rate']:.2%}")
                lines.append(f"  实验组比率: {r['treatment_rate']:.2%}")
            lines.append(f"  绝对差异: {r['difference']}")
            lines.append(f"  相对变化: {r['relative_change']:+.2f}%")
            lines.append(f"  p值: {r['p_value']:.6f}")
            if "cohens_d" in r:
                lines.append(
                    f"  效应量(Cohen's d): {r['cohens_d']} "
                    f"({r['effect_size_label']})"
                )
            if "confidence_interval" in r:
                ci = r["confidence_interval"]
                lines.append(f"  差异置信区间: [{ci[0]:.4f}, {ci[1]:.4f}]")
            if r["is_significant"]:
                lines.append("  >> 结论: 差异显著,拒绝原假设")
            else:
                lines.append("  >> 结论: 差异不显著,无法拒绝原假设")
        return "\n".join(lines)


# ===== 使用示例 =====
tester = SignificanceTester(confidence_level=0.95)
test_results = []
exp_id = experiment.experiment_id

# 对连续型指标做Welch t检验
for metric in ["response_latency_ms", "tokens_used", "user_rating"]:
    ctrl_vals = collector.aggregated[exp_id]["control"][metric]
    trtm_vals = collector.aggregated[exp_id]["treatment"][metric]
    if ctrl_vals and trtm_vals:
        result = tester.welch_t_test(ctrl_vals, trtm_vals, metric)
        test_results.append(result)

# 对比例型指标做卡方检验
ctrl_completed = collector.aggregated[exp_id]["control"]["task_completed"]
trtm_completed = collector.aggregated[exp_id]["treatment"]["task_completed"]
chi_result = tester.chi_square_test(
    int(sum(ctrl_completed)), len(ctrl_completed),
    int(sum(trtm_completed)), len(trtm_completed),
    "task_completion_rate",
)
test_results.append(chi_result)

# 输出完整报告
report = tester.generate_report(test_results)
print(report)

SignificanceTester的核心价值在于它能自动区分指标类型并选择合适的检验方法。对于响应延迟、Token消耗、用户评分这类连续型变量,使用Welch t检验(不假设两组方差相等);对于任务完成率、准确率这类比例型指标,使用卡方检验。每种检验都输出p值、置信区间和效应量,帮助你同时判断差异的统计显著性和实际意义。


五、第四步:完整A/B测试平台

前面的三个组件------实验设计器、流量分割器和指标采集器------各自处理A/B测试流程中的一个环节。但在实际使用中,我们需要将它们整合为一个统一的平台,提供从实验创建到结果报告的一站式服务。使用者只需要关注实验假设和配置,平台自动协调各组件之间的数据流转。

下面这段代码将前面所有组件整合为一个完整的ABTestPlatform类。它封装了实验的完整生命周期:创建实验时自动注册流量分割,处理请求时自动分配分组并调用OpenAI API采集真实指标,实验结束后自动进行统计检验并生成包含推荐决策的报告:

python 复制代码
# ab_testing/platform.py
from typing import Dict, List, Optional, Any
import random
import time
from openai import OpenAI


class ABTestPlatform:
    """完整A/B测试平台 - 整合设计、分割、采集、分析全流程"""

    def __init__(self, default_confidence: float = 0.95):
        self.designer = ExperimentDesigner()
        self.splitter = TrafficSplitter()
        self.collector = MetricsCollector()
        self.default_confidence = default_confidence
        self.client = OpenAI()

    def create_and_start(
        self,
        name: str,
        hypothesis: str,
        control_config: Dict,
        treatment_config: Dict,
        metrics: List[str],
        target_sample_size: int = 5000,
        control_ratio: float = 0.5,
        treatment_ratio: float = 0.5,
    ) -> str:
        """一站式创建并启动实验"""
        # 1. 创建实验
        exp = self.designer.design(
            name=name,
            hypothesis=hypothesis,
            control_config=control_config,
            treatment_config=treatment_config,
            metrics=metrics,
            target_sample_size=target_sample_size,
        )
        # 2. 注册流量分割
        split_cfg = SplitConfig(
            experiment_id=exp.experiment_id,
            control_ratio=control_ratio,
            treatment_ratio=treatment_ratio,
        )
        self.splitter.register_split(split_cfg)
        # 3. 启动实验
        self.designer.start_experiment(exp.experiment_id)
        return exp.experiment_id

    def process_request(
        self,
        experiment_id: str,
        user_id: str,
        query: str,
        task_completed: bool,
        user_rating: Optional[float] = None,
    ) -> Dict[str, Any]:
        """处理一次用户请求:分配分组 + 调用Agent + 采集指标"""
        exp = self.designer.experiments.get(experiment_id)
        if not exp:
            return {"error": "实验不存在"}

        # 分配分组
        group = self.splitter.assign_group(user_id, experiment_id)
        if group == "excluded":
            return {"group": "excluded", "reply": ""}

        # 根据分组选择Agent配置
        agent_config = (
            exp.config.control_config if group == "control"
            else exp.config.treatment_config
        )

        # 调用OpenAI API获取真实响应
        start_time = time.time()
        try:
            response = self.client.chat.completions.create(
                model=agent_config.get("model", "gpt-4"),
                messages=[
                    {"role": "system", "content": agent_config.get(
                        "system_prompt", "你是一个有帮助的AI助手。")},
                    {"role": "user", "content": query},
                ],
                temperature=agent_config.get("temperature", 0.7),
                max_tokens=agent_config.get("max_tokens", 1000),
            )
            latency_ms = (time.time() - start_time) * 1000
            tokens_used = response.usage.total_tokens
            reply = response.choices[0].message.content
        except Exception as e:
            latency_ms = (time.time() - start_time) * 1000
            tokens_used = 0
            reply = f"[调用错误] {str(e)}"

        # 记录指标
        self.collector.record_agent_interaction(
            experiment_id=experiment_id,
            user_id=user_id,
            group=group,
            query=query,
            response=reply,
            latency_ms=latency_ms,
            tokens_used=tokens_used,
            task_completed=task_completed,
            user_rating=user_rating,
        )
        return {"group": group, "reply": reply, "tokens": tokens_used}

    def check_sample_size(self, experiment_id: str) -> Dict[str, Any]:
        """检查样本量是否达标"""
        stats = self.splitter.get_assignment_stats(experiment_id)
        exp = self.designer.experiments.get(experiment_id)
        if not exp:
            return {"error": "实验不存在"}
        target = exp.config.target_sample_size
        current = stats.get("total", 0)
        progress = current / target if target > 0 else 0
        return {
            "current_total": current,
            "target_total": target,
            "progress": round(progress * 100, 1),
            "is_sufficient": current >= target,
        }

    def analyze(self, experiment_id: str) -> Dict[str, Any]:
        """执行完整的统计分析并生成报告"""
        exp = self.designer.experiments.get(experiment_id)
        if not exp:
            return {"error": "实验不存在"}

        tester = SignificanceTester(
            confidence_level=exp.config.confidence_level
        )
        summary = self.collector.get_summary(experiment_id)
        test_results = []

        for metric, stats_data in summary.items():
            ctrl = stats_data.get("control", {})
            trtm = stats_data.get("treatment", {})
            if ctrl.get("count", 0) < 2 or trtm.get("count", 0) < 2:
                continue

            ctrl_vals = self.collector.aggregated[
                experiment_id]["control"][metric]
            trtm_vals = self.collector.aggregated[
                experiment_id]["treatment"][metric]

            # 区分指标类型选择检验方法
            if metric in ("task_completed", "task_completion_rate"):
                result = tester.chi_square_test(
                    int(sum(ctrl_vals)), len(ctrl_vals),
                    int(sum(trtm_vals)), len(trtm_vals),
                    metric,
                )
            else:
                result = tester.welch_t_test(ctrl_vals, trtm_vals, metric)
            test_results.append(result)

        # 生成综合结论
        report = tester.generate_report(test_results)
        return {
            "experiment_id": experiment_id,
            "test_results": test_results,
            "significant_count": sum(
                1 for r in test_results if r.get("is_significant")
            ),
            "total_metrics_tested": len(test_results),
            "sample_size": self.check_sample_size(experiment_id),
            "report": report,
            "recommendation": self._make_recommendation(test_results),
        }

    def _make_recommendation(self, results: List[Dict]) -> str:
        """根据检验结果给出上线推荐"""
        if not results:
            return "数据不足,无法做出推荐"

        significant = [r for r in results if r.get("is_significant")]
        if not significant:
            return "未发现显著差异,建议延长实验周期或增加样本量后重新分析"

        # 检查是否有指标严重退化
        degraded = [
            r for r in significant
            if r.get("relative_change", 0) < -5
        ]
        improved = [
            r for r in significant
            if r.get("relative_change", 0) > 0
        ]

        if degraded:
            names = [r["metric"] for r in degraded]
            return (f"以下指标出现显著退化: {', '.join(names)}。"
                    f"建议暂不上线,优先分析退化原因。")

        if len(improved) == len(significant):
            return "所有检测到的显著差异均为正向提升,建议全量发布实验组配置。"

        return "部分指标提升显著,建议综合评估业务影响后决策。"


# ===== 完整使用演示 =====
print("=" * 60)
print("  Agent A/B测试平台 - 完整演示")
print("=" * 60)

# 1. 创建平台并启动实验
platform = ABTestPlatform(default_confidence=0.95)

exp_id = platform.create_and_start(
    name="医疗Agent Prompt优化实验",
    hypothesis="专业化Prompt能将任务完成率从72%提升到78%",
    control_config={
        "model": "gpt-4",
        "system_prompt": "你是一个有帮助的AI助手。",
        "temperature": 0.7,
    },
    treatment_config={
        "model": "gpt-4",
        "system_prompt": "你是一名专业的医疗咨询助手,擅长解答常见"
                         "健康问题。回答时请引用相关医学依据,并给"
                         "出建议等级(强建议/弱建议/仅供参考)。",
        "temperature": 0.7,
    },
    metrics=["task_completion_rate", "response_latency_ms",
             "user_rating", "tokens_used"],
    target_sample_size=2000,
)

# 2. 模拟流量运行(实际生产中由真实用户请求触发)
print("\n--- 模拟请求处理 ---")
random.seed(2024)
sample_queries = [
    "我最近经常头疼,可能是什么原因?",
    "感冒发烧38.5度需要去医院吗?",
    "每天失眠怎么办?有什么改善方法?",
    "血压140/90算高血压吗?需要注意什么?",
    "运动后膝盖疼需要休息还是继续锻炼?",
]

for i in range(2000):
    uid = f"user_{i:04d}"
    group = platform.splitter.assign_group(uid, exp_id)
    if group not in ("control", "treatment"):
        continue

    # 根据分组模拟不同的Agent表现
    query = random.choice(sample_queries)
    latency = random.gauss(800, 150)

    if group == "control":
        completed = random.random() < 0.72
        rating = max(1.0, min(5.0, random.gauss(3.8, 0.6)))
        tokens = int(random.gauss(350, 50))
    else:
        completed = random.random() < 0.78
        rating = max(1.0, min(5.0, random.gauss(4.1, 0.5)))
        tokens = int(random.gauss(370, 55))

    platform.collector.record_agent_interaction(
        experiment_id=exp_id, user_id=uid, group=group,
        query=query, response="",
        latency_ms=latency, tokens_used=tokens,
        task_completed=completed, user_rating=rating,
    )

# 3. 检查样本量
print("\n--- 样本量检查 ---")
sample = platform.check_sample_size(exp_id)
print(f"当前样本量: {sample['current_total']}")
print(f"目标样本量: {sample['target_total']}")
print(f"完成进度: {sample['progress']}%")
print(f"是否达标: {'是' if sample['is_sufficient'] else '否'}")

# 4. 执行统计分析并输出报告
print("\n--- 统计分析报告 ---")
analysis = platform.analyze(exp_id)
print(analysis["report"])
print(f"\n显著指标数: {analysis['significant_count']}"
      f" / {analysis['total_metrics_tested']}")
print(f"\n推荐决策: {analysis['recommendation']}")

# 5. 在实际项目中,你可以直接调用process_request处理真实请求:
#
# result = platform.process_request(
#     experiment_id=exp_id,
#     user_id="real_user_123",
#     query="用户提出的问题",
#     task_completed=True,
#     user_rating=4.5,
# )
# print(f"分组: {result['group']}, 回复: {result['reply']}")

到这里,我们已经拥有了从实验设计到统计分析的完整平台。但在开始实验之前,还有一个关键问题需要回答:你需要多少样本量才能得出可靠的结论?样本量太少会导致统计功效不足------即使存在真实差异也可能检测不出来。下面的表格给出了在95%置信水平下,不同基准率和期望提升幅度对应的最小样本量参考值,你可以用它来预估实验需要运行多长时间:

基准完成率 期望提升 单组最小样本量 总样本量 预估天数(1000次/日)
50% +2% 9,600 19,200 19
50% +5% 1,540 3,080 3
70% +2% 7,200 14,400 14
70% +5% 1,150 2,300 3
80% +2% 5,200 10,400 10
80% +5% 830 1,660 2

从这个表格可以看出一个非常实用的规律:期望检测的提升幅度越小,需要的样本量就越大。如果你的Agent日均交互量只有几百次,而你又想检测2%的微小提升,实验可能需要运行好几个星期。在实际项目中,建议先设定一个合理的最小可检测效应(比如5%),然后在流量允许的情况下再逐步缩小检测精度。


总结

本文从零到一搭建了一套完整的Agent A/B测试与效果评估平台,以下是四个核心要点:

第一,Agent的A/B测试不是可选项而是必需品。 Agent的行为具有随机性和不确定性,单凭直觉或少量测试根本无法判断变更的真实效果。只有通过严格的实验设计和统计检验,才能用数据证明你的Agent确实在变好,而不是在原地打转甚至退化。

第二,实验设计是基础,假设先行是关键。 每次A/B测试都应该从一个清晰可量化的假设开始,明确对照组和实验组的差异,预设评估指标和成功标准。这能避免"跑完实验不知道在测什么"的尴尬局面,也让实验结果更有说服力。

第三,流量分割和指标采集要做到精准无误。 基于一致性哈希的流量分割保证了分组的均匀性和稳定性,自动化的指标采集确保了数据的完整性。任何分组泄漏或数据丢失都会导致实验结论不可靠,这是不可容忍的基础设施问题。

第四,统计显著性不等于业务显著性。 p值告诉你差异是否真实存在,但差异的大小和方向才决定了你是否应该上线。一个p值极小但效应量微不足道的变化,可能不值得承担上线的风险和成本。始终结合统计结论和业务上下文来做最终决策。

练习: 选择一个你正在开发的Agent项目,设计一个完整的A/B实验。要求写出你的假设(包含具体的基线值和期望提升量),确定需要追踪的3个核心指标,用ABTestPlatform类搭建实验,模拟至少500条交互数据并运行统计分析。然后尝试调整模拟数据中的效果大小(比如将实验组完成率从78%改为72.5%),观察需要多少样本量才能稳定检测到这个微小差异------这会让你直观理解样本量与统计功效的关系。


下一篇预告:《用Python构建智能教育辅导智能体》

相关推荐
MediaTea18 小时前
人工智能通识课:深度学习框架 PyTorch
人工智能·pytorch·python·深度学习·机器学习
小波a18 小时前
Dump Lua
开发语言·lua
我材不敲代码18 小时前
简单聊聊 Python 字典的基础用法
开发语言·python
勾股导航18 小时前
A2C算法
人工智能·强化学习·a2c
月诸清酒18 小时前
AI 加剧了 Rust 替换前端基建的脚步:AI 时代,开发语言何去何从
开发语言·人工智能·rust
这是谁的博客?18 小时前
PyTorch 深度学习框架核心机制解析:从动态图到编译优化的全面指南
人工智能·pytorch·深度学习·ai·分布式训练·autograd
Cx330❀18 小时前
【Linux网络】从以太网碰撞到 Socket 套接字与网络字节序的深度解析
xml·linux·运维·服务器·开发语言·网络·c++
李铁蛋zs18 小时前
AI 前端开发培训手册
前端·人工智能
Lucky_Turtle18 小时前
【m3u8】示例
python
数智工坊18 小时前
《我看见的世界:李飞飞自传》第1-6章阅读笔记:从移民少女到AI教母的“看见“之旅
人工智能·笔记