你换了个新 Prompt,离线测试感觉不错,就直接上线了。两小时后,用户投诉量翻倍。你懵了:测试集上明明更好的啊?
这不是个例。LLM 应用和传统软件的一个核心区别在于:离线评测和线上表现的 gap 可以非常大。用户的真实输入比你的测试集复杂、边缘情况更多,模型的随机性让同一个 Prompt 在不同时刻产出不同结果。
A/B 测试是填平这个 gap 的工程手段。但 LLM 的 A/B 测试不是普通 Web 功能的 A/B 测试------你不能只看点击率,不能用简单的二项分布,也不能无脑把新版 Prompt 灰度给 10% 用户就完事了。
这篇文章从工程角度讲清楚:
- 为什么 LLM A/B 测试比想象的难
- 用户分桶的正确姿势(避免会话污染和 novelty effect)
- LLM 应用专属的指标体系
- 统计显著性的坑与解法
- 自动熔断与快速回滚机制
- 完整的 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 测试的核心不是工具,而是纪律------在充满不确定性(随机输出、主观评估、用户行为噪声)的环境里,坚持用数据说话。
从今天起可以做的最小实践:
- 给你的 Prompt 打上版本标签(v1/v2)
- 下次改 Prompt 前,先在 5% 流量上跑 7 天实验
- 定义好你的主指标:用户反馈率还是 LLM-as-judge 评分?
- 设置一个 guard rail:错误率 > 5% 自动回滚
你不需要一开始就有完整的实验平台。一个 Redis key 存分桶状态,一个 Langfuse 实例收集指标,就够你跑前 10 个实验。先做起来,系统会跟着成熟。