LLM 应用的A/B 测试工程:如何在生产中安全切换模型、Prompt 和参数

你换了个新 Prompt,离线测试感觉不错,就直接上线了。两小时后,用户投诉量翻倍。你懵了:测试集上明明更好的啊?

这不是个例。LLM 应用和传统软件的一个核心区别在于:离线评测和线上表现的 gap 可以非常大。用户的真实输入比你的测试集复杂、边缘情况更多,模型的随机性让同一个 Prompt 在不同时刻产出不同结果。

A/B 测试是填平这个 gap 的工程手段。但 LLM 的 A/B 测试不是普通 Web 功能的 A/B 测试------你不能只看点击率,不能用简单的二项分布,也不能无脑把新版 Prompt 灰度给 10% 用户就完事了。

这篇文章从工程角度讲清楚:

  1. 为什么 LLM A/B 测试比想象的难
  2. 用户分桶的正确姿势(避免会话污染和 novelty effect)
  3. LLM 应用专属的指标体系
  4. 统计显著性的坑与解法
  5. 自动熔断与快速回滚机制
  6. 完整的 Python 实现示例

一、为什么 LLM A/B 测试比 Web 功能测试难 3 倍

1.1 输出的随机性让指标方差极高

普通 Web 功能:用户点击按钮,要么成功要么失败,方差小,统计显著性收敛快。

LLM:同一个 Prompt,相同模型,不同时刻输出不同。temperature=0.7 时,同一 query 可能给出质量差异显著的两个答案。这意味着:

  • 你需要更大的样本量才能检测到相同大小的效果
  • 评估函数本身也是概率性的(LLM-as-judge 也有自己的随机性)

Statsig 在其 LLM 实验框架中明确指出,需要使用功效分析(power analysis)来确定样本量,而不是拍脑袋决定灰度比例。

1.2 评估标准不是二元的

Web A/B 测试:转化率、留存率------要么转化要么没有,好算。

LLM A/B 测试:「这个回答好不好」是个连续量,没有客观标准。你需要:

  • LLM-as-judge(另一个模型打分)
  • 用户显式反馈(thumbs up/down)
  • 代理指标(会话继续率、追问率、退出率)

每一种都有噪声,组合使用才可靠。

1.3 实验组之间的交互效应

同时跑多个实验时------比如同时测试新 Prompt 和新模型------两个变量的交互效应会让结论失真。你以为是 Prompt 更好,但其实是模型和 Prompt 组合的效果。

标准做法:同一时段最多跑一个影响同一 LLM 调用路径的实验。如果必须并行,要做分层实验设计(factorial design),显式建模交互项。

1.4 Novelty Effect

用户在看到「新事物」时会多互动,不管它是否更好。一个新 Prompt 风格可能在前 3 天看起来用户更满意,但第 7 天效果会回落。

对策:实验至少跑 7 天,不要在前 3 天就决定 winner。


二、用户分桶:避免会话污染的正确实现

2.1 三种分桶方式对比

分桶方式 优点 缺点 适用场景
随机(每次请求) 实现简单 同一用户体验不一致,会话内污染 禁止使用
基于 user_id hash 用户体验一致,可重现 新用户分布可能有偏 标准选择
基于 session_id hash 会话内一致 同一用户不同会话看到不同版本 短会话场景

推荐:基于 user_id 的一致性 hash 分桶

python 复制代码
import hashlib

def get_experiment_variant(user_id: str, experiment_id: str, traffic_pct: float = 0.1) -> str:
    """
    基于 user_id 和 experiment_id 做一致性哈希分桶。
    traffic_pct: 进入实验的流量比例 (0.0 ~ 1.0)
    返回 'control' 或 'treatment'
    """
    # 组合 user_id 和 experiment_id,保证不同实验独立分桶
    hash_input = f"{experiment_id}:{user_id}".encode("utf-8")
    hash_value = int(hashlib.md5(hash_input).hexdigest(), 16)
    
    # 映射到 [0, 10000) 的整数桶
    bucket = hash_value % 10000
    
    # 前 traffic_pct * 10000 个桶进入实验
    threshold = int(traffic_pct * 10000)
    
    if bucket >= threshold:
        return "control"  # 未进入实验的流量走 control
    
    # 在实验内部,50/50 分 control/treatment
    split_threshold = threshold // 2
    return "treatment" if bucket < split_threshold else "control"


# 使用示例
def route_llm_request(user_id: str, query: str) -> dict:
    variant = get_experiment_variant(
        user_id=user_id,
        experiment_id="prompt-v2-test",
        traffic_pct=0.2  # 20% 流量进入实验,10% control vs 10% treatment
    )
    
    if variant == "treatment":
        prompt = PROMPT_V2  # 新 Prompt
        model = "gpt-4o-mini"
    else:
        prompt = PROMPT_V1  # 当前生产 Prompt
        model = "gpt-4o-mini"
    
    return {
        "variant": variant,
        "prompt": prompt,
        "model": model
    }

关键点experiment_id 参与 hash,保证同一 user 在不同实验里被独立分桶,不产生实验间的用户重叠偏差。

2.2 实验配置中心化

不要把实验参数硬编码进业务代码。用配置文件或 feature flag 服务管理实验:

python 复制代码
# experiment_config.py
from dataclasses import dataclass
from typing import Optional
import json

@dataclass
class ExperimentConfig:
    experiment_id: str
    name: str
    enabled: bool
    traffic_pct: float        # 进入实验的总流量比例
    treatment_split: float    # 实验流量中 treatment 的比例 (default 0.5)
    control_config: dict      # control 侧配置
    treatment_config: dict    # treatment 侧配置
    start_time: Optional[str] = None
    end_time: Optional[str] = None
    kill_switch: bool = False  # 紧急关闭
    
    def is_active(self) -> bool:
        from datetime import datetime, timezone
        if not self.enabled or self.kill_switch:
            return False
        now = datetime.now(timezone.utc).isoformat()
        if self.start_time and now < self.start_time:
            return False
        if self.end_time and now > self.end_time:
            return False
        return True


# 实验配置(可以存在 Redis/DB/文件中,支持热更新)
EXPERIMENTS = {
    "prompt-v2-test": ExperimentConfig(
        experiment_id="prompt-v2-test",
        name="客服 Prompt 重写实验",
        enabled=True,
        traffic_pct=0.2,
        treatment_split=0.5,
        control_config={
            "prompt_version": "v1",
            "model": "gpt-4o-mini",
            "temperature": 0.7,
        },
        treatment_config={
            "prompt_version": "v2",
            "model": "gpt-4o-mini",
            "temperature": 0.7,
        },
        kill_switch=False,
    ),
    "model-upgrade-test": ExperimentConfig(
        experiment_id="model-upgrade-test",
        name="gpt-4o vs gpt-4o-mini 质量实验",
        enabled=True,
        traffic_pct=0.1,
        treatment_split=0.5,
        control_config={
            "prompt_version": "v1",
            "model": "gpt-4o-mini",
            "temperature": 0.7,
        },
        treatment_config={
            "prompt_version": "v1",
            "model": "gpt-4o",
            "temperature": 0.7,
        },
    ),
}

2.3 中间件集成:把实验逻辑从业务代码剥离

python 复制代码
# experiment_middleware.py
import time
import uuid
from typing import Callable, Any

class LLMExperimentMiddleware:
    """
    LLM 请求级实验路由中间件。
    负责:分桶、配置注入、指标收集。
    """
    
    def __init__(self, experiments: dict, metrics_sink):
        self.experiments = experiments
        self.metrics = metrics_sink
    
    def run(
        self, 
        user_id: str, 
        query: str, 
        llm_caller: Callable,
        active_experiment_id: str = None
    ) -> dict:
        start_time = time.time()
        request_id = str(uuid.uuid4())
        
        # 确定走哪个实验(如果有)
        variant = "control"
        experiment_config = None
        
        if active_experiment_id and active_experiment_id in self.experiments:
            exp = self.experiments[active_experiment_id]
            if exp.is_active():
                variant = get_experiment_variant(
                    user_id, active_experiment_id, exp.traffic_pct
                )
                experiment_config = (
                    exp.treatment_config if variant == "treatment" 
                    else exp.control_config
                )
        
        # 取默认配置(没进实验的流量)
        if experiment_config is None:
            experiment_config = {
                "prompt_version": "v1",
                "model": "gpt-4o-mini",
                "temperature": 0.7,
            }
            variant = "holdout"
        
        # 实际 LLM 调用
        try:
            response = llm_caller(query=query, config=experiment_config)
            latency_ms = (time.time() - start_time) * 1000
            
            # 记录实验指标
            self.metrics.record({
                "request_id": request_id,
                "user_id": user_id,
                "experiment_id": active_experiment_id,
                "variant": variant,
                "model": experiment_config["model"],
                "prompt_version": experiment_config["prompt_version"],
                "latency_ms": latency_ms,
                "input_tokens": response.get("usage", {}).get("prompt_tokens", 0),
                "output_tokens": response.get("usage", {}).get("completion_tokens", 0),
                "error": None,
            })
            
            return {
                "response": response,
                "request_id": request_id,
                "variant": variant,
            }
            
        except Exception as e:
            self.metrics.record({
                "request_id": request_id,
                "user_id": user_id,
                "experiment_id": active_experiment_id,
                "variant": variant,
                "error": str(e),
                "latency_ms": (time.time() - start_time) * 1000,
            })
            raise

三、LLM 应用专属的指标体系

这是 LLM A/B 测试最容易踩坑的地方。用传统 Web 指标(点击率、PV)会完全看不出 LLM 质量差异。你需要一套多层指标:

3.1 四层指标框架

复制代码
Layer 1: 业务指标(最重要,但最难快速收集)
  - 任务完成率(用户的目标是否达成)
  - 用户满意度(NPS、会话结束后调研)
  - 付费转化、续订率(如果是付费产品)

Layer 2: 代理行为指标(快速收集,间接反映质量)
  - 会话继续率(用户看到回复后是否继续对话)
  - 用户反馈信号(thumbs up/down,复制、分享)
  - 追问率(用户继续追问 = 回答不完整?)
  - 会话放弃率(用户收到回复后直接关闭)

Layer 3: 自动化质量指标(LLM-as-judge)
  - 相关性(Relevance):回复是否切题
  - 忠实性(Faithfulness):是否有幻觉
  - 完整性(Completeness):是否回答了所有问题
  - 简洁性(Conciseness):是否有无效内容

Layer 4: 操作指标(工程关心)
  - 首 Token 延迟(TTFT)
  - 总延迟(end-to-end latency)
  - Token 消耗量(input + output)
  - 每次对话成本
  - 错误率(5xx、timeout)

原则:主指标用 1 个,辅助指标不超过 3 个,guard rail 指标单独设阈值。

主指标(primary metric)的选择决定实验结论。不同场景的推荐:

应用场景 推荐主指标 理由
客服机器人 首次响应解决率 (FCR) 最直接反映有效性
代码助手 代码接受率 用户接受 = 代码有用
RAG 知识库 追问率(反向) 追问少 = 第一次就答全了
创意写作 用户编辑量 编辑少 = 输出质量高
摘要生成 人工评分(抽样) 质量主观,自动指标不可靠

3.2 用 LLM-as-Judge 实现自动化质量评估

python 复制代码
# llm_judge.py
from openai import OpenAI
import json

client = OpenAI()

JUDGE_PROMPT = """你是一个 LLM 响应质量评估专家。请对以下 LLM 响应进行客观评估。

## 用户问题
{query}

## LLM 响应
{response}

## 评估维度(每项 1-5 分)

请用 JSON 格式返回评估结果:
{{
  "relevance": <1-5>,       // 相关性:回复是否切题、直接回答了问题
  "completeness": <1-5>,   // 完整性:是否涵盖了问题的所有方面
  "faithfulness": <1-5>,   // 忠实性:是否有明显的幻觉或不实信息
  "conciseness": <1-5>,    // 简洁性:是否简洁没有废话
  "overall": <1-5>,        // 综合评分
  "reasoning": "<简短说明>"
}}

只返回 JSON,不要其他内容。"""


def evaluate_response(query: str, response: str) -> dict:
    """
    用 GPT-4o-mini 作为 judge 评估 LLM 响应质量。
    注意:judge 模型和被评估模型应该独立,避免自我评估偏差。
    """
    try:
        result = client.chat.completions.create(
            model="gpt-4o-mini",  # judge 模型固定,不参与实验
            messages=[{
                "role": "user",
                "content": JUDGE_PROMPT.format(query=query, response=response)
            }],
            temperature=0,  # judge 需要确定性
            response_format={"type": "json_object"},
        )
        
        scores = json.loads(result.choices[0].message.content)
        return {
            "relevance": scores.get("relevance"),
            "completeness": scores.get("completeness"),
            "faithfulness": scores.get("faithfulness"),
            "conciseness": scores.get("conciseness"),
            "overall": scores.get("overall"),
            "judge_reasoning": scores.get("reasoning"),
            "judge_model": "gpt-4o-mini",
            "judge_tokens": result.usage.total_tokens,
        }
        
    except Exception as e:
        return {"error": str(e)}


# 异步批量评估(不阻塞主链路)
import asyncio
from typing import List

async def batch_evaluate(samples: List[dict]) -> List[dict]:
    """
    对已记录的对话样本批量做 judge 评估。
    通常在请求完成后异步执行,不影响用户延迟。
    """
    results = []
    for sample in samples:
        score = evaluate_response(sample["query"], sample["response"])
        results.append({**sample, **score})
        # Rate limiting: judge 调用也要控速
        await asyncio.sleep(0.1)
    return results

重要提醒:LLM-as-judge 本身有偏差(position bias、verbosity bias、自家模型偏好)。使用时注意:

  • Judge 模型不参与实验,保持固定
  • 评估时随机化 control/treatment 的呈现顺序(消除 position bias)
  • 定期用人工标注校验 judge 的分数是否可信

四、统计显著性:LLM 实验的特殊挑战

4.1 需要多大的样本量?

传统 Web A/B 测试的样本量计算基于转化率(二项分布)。LLM 质量分数是连续变量,方差更大,需要更多样本。

python 复制代码
import numpy as np
from scipy import stats

def calculate_required_sample_size(
    baseline_mean: float,    # 控制组当前均值(如 overall 评分 = 3.5)
    min_detectable_effect: float,  # 最小可检测效果(如 0.3 分提升)
    std_dev: float,          # 评分的标准差(需要从历史数据估计)
    alpha: float = 0.05,     # 显著性水平
    power: float = 0.8,      # 统计功效
) -> int:
    """
    计算双侧 t 检验所需的单侧样本量。
    总样本 = 2 * 结果(control + treatment 各一份)。
    """
    effect_size = min_detectable_effect / std_dev  # Cohen's d
    
    # 使用 scipy 的功效分析
    from statsmodels.stats.power import TTestIndPower
    analysis = TTestIndPower()
    n = analysis.solve_power(
        effect_size=effect_size,
        power=power,
        alpha=alpha,
        alternative='two-sided',
    )
    return int(np.ceil(n))


# 示例计算
# 假设当前 overall 评分均值 3.5,标准差 1.0(从历史数据得到)
# 我们希望检测到 0.3 分的提升(8.6%)
n_per_group = calculate_required_sample_size(
    baseline_mean=3.5,
    min_detectable_effect=0.3,
    std_dev=1.0,
    alpha=0.05,
    power=0.8,
)
print(f"每组最少需要 {n_per_group} 个样本,总计 {n_per_group * 2} 个样本")
# 输出约: 每组最少需要 176 个样本,总计 352 个样本

实际建议:用历史日志估计你的评分标准差。如果标准差 = 1.0(满分 5 分的量表),检测 0.2 分提升需要约 400/组,检测 0.5 分提升约 64/组。

4.2 实验结束后的统计检验

python 复制代码
from scipy import stats
import numpy as np
from typing import Tuple

def analyze_experiment_results(
    control_scores: list,
    treatment_scores: list,
    control_costs: list,
    treatment_costs: list,
    alpha: float = 0.05,
) -> dict:
    """
    分析实验结果,输出显著性检验和效果估计。
    """
    control = np.array(control_scores)
    treatment = np.array(treatment_scores)
    
    # Mann-Whitney U 检验(不假设正态分布,更适合 LLM 评分)
    u_stat, p_value = stats.mannwhitneyu(
        treatment, control, alternative='two-sided'
    )
    
    # 效果大小:Cohen's d
    pooled_std = np.sqrt((np.std(control)**2 + np.std(treatment)**2) / 2)
    cohens_d = (np.mean(treatment) - np.mean(control)) / pooled_std
    
    # 置信区间(Bootstrap)
    n_bootstrap = 10000
    diffs = [
        np.mean(np.random.choice(treatment, len(treatment), replace=True)) -
        np.mean(np.random.choice(control, len(control), replace=True))
        for _ in range(n_bootstrap)
    ]
    ci_lower, ci_upper = np.percentile(diffs, [2.5, 97.5])
    
    # 成本分析
    cost_diff_pct = (np.mean(treatment_costs) - np.mean(control_costs)) / np.mean(control_costs) * 100
    
    return {
        "control": {
            "n": len(control),
            "mean": float(np.mean(control)),
            "std": float(np.std(control)),
            "median": float(np.median(control)),
        },
        "treatment": {
            "n": len(treatment),
            "mean": float(np.mean(treatment)),
            "std": float(np.std(treatment)),
            "median": float(np.median(treatment)),
        },
        "effect": {
            "absolute_diff": float(np.mean(treatment) - np.mean(control)),
            "relative_diff_pct": float((np.mean(treatment) - np.mean(control)) / np.mean(control) * 100),
            "cohens_d": float(cohens_d),
            "ci_95": [float(ci_lower), float(ci_upper)],
        },
        "significance": {
            "p_value": float(p_value),
            "is_significant": p_value < alpha,
            "test": "Mann-Whitney U",
        },
        "cost": {
            "control_avg": float(np.mean(control_costs)),
            "treatment_avg": float(np.mean(treatment_costs)),
            "diff_pct": float(cost_diff_pct),
        },
        "recommendation": _make_recommendation(p_value, alpha, cohens_d, cost_diff_pct),
    }


def _make_recommendation(p_value: float, alpha: float, cohens_d: float, cost_diff_pct: float) -> str:
    if p_value >= alpha:
        return "NO_WINNER: 未达到统计显著性,继续收集数据或接受无效果"
    
    if cohens_d > 0:
        quality_verdict = "TREATMENT_BETTER"
    else:
        quality_verdict = "CONTROL_BETTER"
    
    if quality_verdict == "TREATMENT_BETTER" and cost_diff_pct <= 10:
        return "SHIP_TREATMENT: 质量提升且成本可控,建议上线"
    elif quality_verdict == "TREATMENT_BETTER" and cost_diff_pct > 10:
        return "REVIEW_NEEDED: 质量提升但成本增加 >10%,需人工判断 ROI"
    else:
        return "KEEP_CONTROL: treatment 质量更差,保留 control"

4.3 多重检验问题

一个实验如果同时检验 5 个指标,每个用 p<0.05,误报率会从 5% 膨胀到 ~23%。解决方案:

  • Bonferroni 校正:将 alpha 除以指标数(严格,但保守)
  • 指定一个主指标,其余作为辅助和 guard rail(推荐)
  • Sequential testing(Wald/SPRT):允许持续监控而不膨胀误报率
python 复制代码
# Bonferroni 校正示例
def bonferroni_corrected_alpha(alpha: float, n_metrics: int) -> float:
    """如果检验 n 个指标,每个指标的 alpha 阈值应调整为:"""
    return alpha / n_metrics

# 检验 4 个指标时
adjusted_alpha = bonferroni_corrected_alpha(0.05, 4)
print(f"调整后每个指标的 alpha = {adjusted_alpha:.4f}")  # 0.0125

五、自动熔断:出问题了立刻停止

A/B 测试最大的风险是「实验跑坏了你却不知道」。你需要一个自动监控 + 熔断的机制,在 treatment 明显更差时自动切回 control。

python 复制代码
# auto_killswitch.py
import redis
import json
from datetime import datetime, timedelta

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

GUARD_RAILS = {
    "max_error_rate": 0.05,       # 错误率上限 5%
    "max_latency_p99_ms": 5000,   # P99 延迟上限 5s
    "min_quality_score": 2.5,     # 最低质量分(5分制)
    "max_cost_increase_pct": 50,  # 成本增幅上限 50%
}


def check_and_enforce_killswitch(experiment_id: str) -> dict:
    """
    检查实验的 guard rail 指标,超阈值自动触发熔断。
    应在每次指标更新后调用(如每 5 分钟一次)。
    """
    # 从 Redis 读取最近 30 分钟的实验数据
    now = datetime.utcnow()
    window_start = (now - timedelta(minutes=30)).isoformat()
    
    treatment_stats = _get_variant_stats(experiment_id, "treatment", window_start)
    control_stats = _get_variant_stats(experiment_id, "control", window_start)
    
    violations = []
    
    # 检查错误率
    if treatment_stats["error_rate"] > GUARD_RAILS["max_error_rate"]:
        violations.append({
            "rule": "max_error_rate",
            "value": treatment_stats["error_rate"],
            "threshold": GUARD_RAILS["max_error_rate"],
        })
    
    # 检查延迟
    if treatment_stats["p99_latency_ms"] > GUARD_RAILS["max_latency_p99_ms"]:
        violations.append({
            "rule": "max_latency_p99_ms",
            "value": treatment_stats["p99_latency_ms"],
            "threshold": GUARD_RAILS["max_latency_p99_ms"],
        })
    
    # 检查质量分(与 control 比较)
    quality_degradation = control_stats["avg_quality"] - treatment_stats["avg_quality"]
    if quality_degradation > 0.5:  # treatment 比 control 差 0.5 分以上
        violations.append({
            "rule": "quality_degradation",
            "value": quality_degradation,
            "threshold": 0.5,
        })
    
    if violations:
        _trigger_killswitch(experiment_id, violations)
        return {"status": "KILLED", "violations": violations}
    
    return {"status": "OK", "treatment_stats": treatment_stats}


def _trigger_killswitch(experiment_id: str, violations: list):
    """触发熔断:将实验标记为 killed,后续所有流量走 control。"""
    key = f"experiment:{experiment_id}:kill_switch"
    r.set(key, json.dumps({
        "triggered_at": datetime.utcnow().isoformat(),
        "violations": violations,
    }))
    r.expire(key, 86400 * 7)  # 7天后自动清理
    
    # 告警(可以接 PagerDuty / 飞书)
    _send_alert(
        f"⚠️ 实验 {experiment_id} 已自动熔断!\n"
        f"原因: {[v['rule'] for v in violations]}\n"
        f"所有流量已切回 control。"
    )


def is_killed(experiment_id: str) -> bool:
    """在分桶逻辑前检查:实验是否已被熔断?"""
    return r.exists(f"experiment:{experiment_id}:kill_switch") > 0


def _get_variant_stats(experiment_id: str, variant: str, since: str) -> dict:
    # 实际实现从你的指标存储(ClickHouse/BigQuery/Prometheus)查询
    # 这里省略具体实现
    pass


def _send_alert(message: str):
    # 接你的告警系统
    pass

在分桶逻辑里加入熔断检查:

python 复制代码
def get_experiment_variant_safe(user_id: str, experiment_id: str, traffic_pct: float) -> str:
    # 先检查熔断
    if is_killed(experiment_id):
        return "control"  # 熔断后全走 control
    
    return get_experiment_variant(user_id, experiment_id, traffic_pct)

六、Langfuse 实战:用开源工具管理实验

自建指标系统成本高,实际项目可以先用 Langfuse(开源,支持自部署)来管理 Prompt 版本和 A/B 测试数据。

6.1 核心用法

python 复制代码
from langfuse import get_client
from langfuse.openai import openai  # drop-in replacement

langfuse = get_client()

def run_with_langfuse_ab(user_id: str, query: str) -> str:
    # 1. 获取两个 Prompt 版本(在 Langfuse UI 里管理)
    prompt_a = langfuse.get_prompt("customer-service", label="prod-a")
    prompt_b = langfuse.get_prompt("customer-service", label="prod-b")
    
    # 2. 基于 user_id 做一致性分桶
    variant = get_experiment_variant(user_id, "langfuse-ab-test", 0.3)
    selected_prompt = prompt_b if variant == "treatment" else prompt_a
    
    # 3. 调用 LLM,自动关联 Prompt 版本(Langfuse 会记录每个版本的指标)
    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": selected_prompt.compile()},
            {"role": "user", "content": query},
        ],
        langfuse_prompt=selected_prompt,  # 关键:关联 prompt 版本
    )
    
    return response.choices[0].message.content

Langfuse 会自动按 Prompt label(prod-a vs prod-b)聚合:

  • 延迟分布
  • Token 消耗
  • 成本
  • 你附加的 evaluation scores

在 Langfuse Dashboard 里可以直接对比两个版本的所有指标,不需要自己写聚合查询。

6.2 添加用户反馈信号

python 复制代码
# 记录用户显式反馈(thumbs up/down)
def record_user_feedback(request_id: str, is_positive: bool):
    langfuse.score(
        trace_id=request_id,
        name="user_feedback",
        value=1 if is_positive else 0,
        comment="thumbs_up" if is_positive else "thumbs_down",
    )

七、完整流程回顾与决策框架

实验生命周期

复制代码
1. 定义假设(具体、可测量)
        ↓
2. 确定主指标 + guard rail 指标
        ↓
3. 计算所需样本量(功效分析)
        ↓
4. 配置实验(分桶比例、时间窗口、熔断阈值)
        ↓
5. 上线实验(小流量起步,如 5%-10%)
        ↓
6. 持续监控(自动熔断 guard rail)
        ↓
7. 实验运行至少 7 天(消除 novelty effect)
        ↓
8. 统计检验 + 成本核算
        ↓
9. 决策:上线 / 回滚 / 继续优化
        ↓
10. 文档化:记录结论和学习,更新 Prompt 版本

一张表:何时用哪种实验策略

场景 推荐策略 原因
小改动 Prompt(措辞优化) A/B,5%-10% 流量,7天 效果小,需要足够样本
大改动 Prompt(结构重写) 先离线 eval,再 A/B 先过滤明显更差的变体
升级模型(同系列,如 mini→4o) A/B,10% 流量,关注成本 质量和成本同时变化
跨系列模型切换 先 shadow mode 采集,再 A/B 行为差异大,先观察
temperature 调整 Bandit(Multi-Armed Bandit) 连续参数空间,Bandit 更高效
System Prompt 完全替换 串行测试(不同周期) 避免交互效应

常见失败模式

错误 后果 修正
每次请求随机分桶 同一用户体验不一致,会话内结论混乱 改用 user_id hash
只看 LLM 质量分,不看业务指标 质量分提升但用户留存下降 同时追踪业务代理指标
实验跑 2 天就下结论 Novelty effect 未消退,结论不可靠 至少跑 7 天
同时跑多个重叠实验 无法区分哪个变量导致结果 同路径实验串行
没有 guard rail 实验把线上搞坏了才发现 实验上线即配置自动熔断

结语

LLM A/B 测试的核心不是工具,而是纪律------在充满不确定性(随机输出、主观评估、用户行为噪声)的环境里,坚持用数据说话。

从今天起可以做的最小实践:

  1. 给你的 Prompt 打上版本标签(v1/v2)
  2. 下次改 Prompt 前,先在 5% 流量上跑 7 天实验
  3. 定义好你的主指标:用户反馈率还是 LLM-as-judge 评分?
  4. 设置一个 guard rail:错误率 > 5% 自动回滚

你不需要一开始就有完整的实验平台。一个 Redis key 存分桶状态,一个 Langfuse 实例收集指标,就够你跑前 10 个实验。先做起来,系统会跟着成熟。

相关推荐
曾阿伦2 小时前
端口扫描工具横向对比
网络·tcp/ip·安全
QYR_112 小时前
双刃安全剃须刀片市场分析:2025年10.22亿美元规模与2.1%增长趋势
安全·市场调研
腾视科技AI2 小时前
安全驾驶 智在掌控|腾视科技ES06车载智能终端,为车辆运营赋能
大数据·人工智能·科技·安全·ai·边缘计算·车载智能终端
志栋智能3 小时前
超自动化安全的文化挑战:如何推动安全团队变革?
运维·网络·人工智能·安全·自动化
鼎讯信通3 小时前
DXM 系列频谱分析仪:小型化检测装备守护能源行业电磁环境安全防线
安全·能源·信息与通信
飞函安全3 小时前
飞函Webhook能力如何帮助企业把监控告警、设备异常第一时间推到对应群组
网络·数据库·安全·私有化im
●VON3 小时前
AtomGit Flutter鸿蒙客户端:安全JSON解析
安全·flutter·华为·json·harmonyos·鸿蒙
网络研究院3 小时前
人工智能行政命令为新的网络安全指令铺平道路
网络·人工智能·安全·指令·创新
MicroTech20253 小时前
量子威胁倒逼区块链安全革新,微算法科技(MLGO)量子原生区块链技术突破
科技·安全·区块链