适合读者:已经有 LLM 应用跑在生产、改过 prompt 但不确定是否真的更好的工程师。
上周我们团队改了一个用于客服摘要的 prompt,当时用 10 个样本手动测了一下,"感觉更好",就直接推了。
三天后数据反馈:用户满意度下降了 4 个百分点,原因是新 prompt 对某类投诉话术的处理变差了------这类输入正好没在那 10 个样本里。
回滚很快,但这次教训让我认真研究了一件事:LLM 应用的 prompt 和模型变更,到底该怎么科学验证?
为什么 LLM 的变更验证比传统软件难
传统 Web 应用 A/B 测试相对好做:按钮颜色变更,指标是点击率,结果确定,几万次请求就能判断。
LLM 应用不一样,难点集中在三处:
1. 输出非确定性
同一个 prompt,同一个输入,两次调用可能输出不同结果(temperature > 0 时尤为明显)。这导致「对照」的概念就变复杂了------你不是在比较两个函数的输出,而是两个概率分布。
2. 评估指标是多维的
传统 A/B 测试通常只有一两个核心指标。LLM 的变更牵涉到:
| 维度 | 指标示例 |
|---|---|
| 输出质量 | Factuality、Coherence、任务完成度 |
| 延迟 | P50/P95/P99 response time |
| 成本 | 每次请求的 token 消耗 |
| 业务指标 | 用户满意度、续读率、转化率 |
改一个 prompt,可能质量提升 5%,但延迟增加 30%,成本上升 20%------你选哪个?没有统一的「优胜」标准。
3. 输入分布的长尾
用户的输入是一个长尾分布。你手工挑的 10 个测试样本,大概率是"正常"输入,覆盖不到边缘 case。而 prompt 的变更往往在边缘 case 上出问题。
工程框架:三层验证
基于这些挑战,一个合理的验证架构需要三层配合:
yaml
┌─────────────────────────────────────────────────────────┐
│ Layer 1: 离线 Eval(Golden Dataset) │
│ → 在 CI 里运行,必须通过才能进入下一层 │
├─────────────────────────────────────────────────────────┤
│ Layer 2: 金丝雀部署(Canary) │
│ → 5-10% 流量,观察实时质量评分和业务指标 │
├─────────────────────────────────────────────────────────┤
│ Layer 3: 全量 A/B 实验 │
│ → 50/50 流量,统计显著性判断后决策 │
└─────────────────────────────────────────────────────────┘
关键原则:三层是串行门控,不是并行选项。每一层通过才能进入下一层。
Layer 1:离线 Eval------安全网
离线 Eval 是守门人。在任何代码合并之前,它必须先通过。
构建 Golden Dataset
Golden Dataset 的核心要求:
- 覆盖边缘 case:不只是"正常"输入,要包含历史上出过问题的输入
- 有期望输出标注:可以是人工标注,也可以是「上一个稳定版本的输出」(baseline)
- 定期更新:每次线上发现新的问题,要加入 dataset
一个最小可行的 golden dataset 结构:
json
[
{
"id": "case_001",
"input": "用户投诉:我的快递已经 7 天没到了,之前催了 3 次没用",
"expected_output_criteria": {
"should_contain": ["歉意", "具体时间承诺", "升级处理"],
"should_not_contain": ["无法保证", "不清楚"],
"min_length": 50
},
"tags": ["complaint", "logistics", "repeat_contact"],
"source": "production_incident_2026_03_15"
}
]
用 promptfoo 跑离线对比
promptfoo 是目前最成熟的离线 prompt 测试工具,在多家主流大模型厂商内部广泛使用,支持直接在 CI 里运行。
yaml
# promptfooconfig.yaml
description: "客服摘要 prompt A/B"
prompts:
- id: prompt-v2
raw: |
你是一个专业的客服摘要助手。用户的问题是:{{input}}
请提供:
1. 问题核心(1 句话)
2. 情绪评级(1-5,5 最紧急)
3. 建议处理方式
- id: prompt-v3
raw: |
你是一个专业的客服摘要助手。
分析以下用户反馈:{{input}}
输出格式(严格遵循 JSON):
{
"core_issue": "<1 句话核心问题>",
"urgency": <1-5>,
"action": "<建议处理方式>"
}
providers:
- deepseek:deepseek-chat
tests:
- vars:
input: "我的快递已经 7 天没到了,之前催了 3 次没用"
assert:
- type: contains
value: "urgency"
- type: llm-rubric
value: "回复应该识别出这是重复联系的高优先级投诉"
- vars:
input: "谢谢你们,快递很快就到了"
assert:
- type: llm-rubric
value: "urgency 应该是 1 或 2,因为用户表示满意"
defaultTest:
assert:
- type: latency
threshold: 3000 # 3s 以内
运行:
bash
npx promptfoo eval
npx promptfoo view # 打开 Web UI 查看对比结果
promptfoo 会为每个 case 跑所有 prompt 变体,输出对比表格,包括通过率、延迟、token 消耗。
在 CI 里集成:
yaml
# .github/workflows/prompt-eval.yml
name: Prompt Eval Gate
on:
pull_request:
paths:
- 'prompts/**'
- 'src/llm/**'
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run promptfoo eval
run: |
npx promptfoo eval --config promptfooconfig.yaml \
--output eval-results.json
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Check pass rate
run: |
PASS_RATE=$(cat eval-results.json | jq '.results.stats.passRate')
echo "Pass rate: $PASS_RATE"
# 要求 ≥ 90% 通过率
python3 -c "
import json, sys
with open('eval-results.json') as f:
data = json.load(f)
pass_rate = data['results']['stats']['passRate']
if pass_rate < 0.90:
print(f'FAILED: pass rate {pass_rate:.1%} < 90%')
sys.exit(1)
print(f'PASSED: pass rate {pass_rate:.1%}')
"
Layer 2:金丝雀部署------实战预热
离线 Eval 通过后,进入金丝雀阶段:把 5-10% 的真实流量切给新 prompt,观察 24-48 小时。
流量切分的正确姿势
关键:要用 user_id(或 session_id)哈希切分,而不是随机切分。
python
import hashlib
def get_prompt_variant(user_id: str, experiment_name: str, traffic_pct: float = 0.05) -> str:
"""
基于 user_id 和实验名做一致性哈希。
同一用户在同一实验中始终看到同一 prompt 变体。
"""
hash_input = f"{user_id}:{experiment_name}"
hash_val = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
bucket = (hash_val % 10000) / 10000.0 # 0.0 ~ 1.0
if bucket < traffic_pct:
return "variant_b" # 新 prompt
return "variant_a" # 对照
为什么用 user_id 哈希而不是随机?
- 一致性:同一用户在一次会话里不会看到两个不同版本,避免混淆体验
- 可复现:方便 debug,知道某个用户看的是哪个版本
- 可控 :调整
traffic_pct就能精确控制流量比例
在线指标监控
金丝雀阶段需要实时监控两类指标:
质量代理指标(用 LLM-as-judge 自动评分):
python
# 此处使用你接入的大模型客户端(以 OpenAI 兼容接口为例)
from openai import OpenAI
import json
client = OpenAI(base_url="https://api.deepseek.com/v1", api_key="<your-key>")
def score_response(input_text: str, response: str, variant: str) -> dict:
"""用 LLM 作为 judge 给响应打分"""
judge_prompt = f"""
你是一个客服质量评估专家。评估以下客服回复的质量:
用户输入:{input_text}
AI 回复:{response}
请以 JSON 格式输出评分:
{{
"completeness": <1-5, 回复是否完整解答了问题>,
"empathy": <1-5, 是否表现出同理心>,
"actionability": <1-5, 是否提供了可操作的下一步>,
"overall": <1-5>
}}
只输出 JSON,不要其他文字。
"""
result = client.chat.completions.create(
model="deepseek-chat", # 用小模型做 judge 以控制成本
messages=[{"role": "user", "content": judge_prompt}],
response_format={"type": "json_object"}
)
scores = json.loads(result.choices[0].message.content)
scores["variant"] = variant
return scores
指标追踪结构:
python
import time
from dataclasses import dataclass
from typing import Optional
@dataclass
class ExperimentLog:
request_id: str
user_id: str
variant: str # "variant_a" or "variant_b"
prompt_version: str
input_tokens: int
output_tokens: int
latency_ms: float
quality_score: Optional[float]
cost_usd: float
timestamp: float = None
def __post_init__(self):
if self.timestamp is None:
self.timestamp = time.time()
def log_experiment(log: ExperimentLog):
"""写入你的观测系统(Langfuse / 自建 / DataDog 等)"""
# 示例:写入 Langfuse
from langfuse import get_client
langfuse = get_client()
langfuse.trace(
name="ab_experiment",
metadata={
"variant": log.variant,
"prompt_version": log.prompt_version,
"quality_score": log.quality_score,
},
input={"request_id": log.request_id},
output={
"latency_ms": log.latency_ms,
"input_tokens": log.input_tokens,
"output_tokens": log.output_tokens,
"cost_usd": log.cost_usd
}
)
Layer 3:全量 A/B------统计决策
金丝雀观察 24-48h 无异常后,升级为全量 A/B(50/50 流量),等待统计显著性。
LLM 实验的统计挑战
问题:LLM 质量评分不是正态分布。
传统 A/B 测试常用 t-test,但 t-test 假设数据近似正态分布。LLM 评分(1-5 分)更接近有序分类数据,且方差较大,直接用 t-test 结果不可靠。
推荐使用 Mann-Whitney U test(非参数检验,不假设数据分布):
python
import numpy as np
from scipy import stats
from typing import Tuple
def analyze_ab_experiment(
scores_a: list[float], # variant A 的质量评分列表
scores_b: list[float], # variant B 的质量评分列表
alpha: float = 0.05, # 显著性水平
min_effect_size: float = 0.1 # 最小有意义效应量(Cohen's d)
) -> dict:
"""
分析 A/B 实验结果。
返回统计检验结果和决策建议。
"""
n_a, n_b = len(scores_a), len(scores_b)
mean_a, mean_b = np.mean(scores_a), np.mean(scores_b)
# Mann-Whitney U test
u_stat, p_value = stats.mannwhitneyu(
scores_a, scores_b,
alternative='two-sided'
)
# 效应量(Cohen's d)
pooled_std = np.sqrt(
(np.var(scores_a) * n_a + np.var(scores_b) * n_b) / (n_a + n_b - 2)
)
cohen_d = (mean_b - mean_a) / pooled_std if pooled_std > 0 else 0
is_significant = p_value < alpha
effect_meaningful = abs(cohen_d) >= min_effect_size
# 决策逻辑
if is_significant and effect_meaningful:
if mean_b > mean_a:
decision = "ADOPT_B"
reason = f"B 显著更好:均值 {mean_a:.3f} → {mean_b:.3f},效应量 d={cohen_d:.3f}"
else:
decision = "KEEP_A"
reason = f"B 显著更差:均值 {mean_a:.3f} → {mean_b:.3f},效应量 d={cohen_d:.3f}"
elif is_significant and not effect_meaningful:
decision = "KEEP_A"
reason = f"统计显著但效应量太小(d={cohen_d:.3f} < {min_effect_size}),不值得切换"
else:
decision = "INCONCLUSIVE"
reason = f"p={p_value:.4f} ≥ {alpha},未达显著性,需要更多数据(当前 n_a={n_a}, n_b={n_b})"
return {
"n_a": n_a, "n_b": n_b,
"mean_a": float(mean_a), "mean_b": float(mean_b),
"delta": float(mean_b - mean_a),
"p_value": float(p_value),
"cohen_d": float(cohen_d),
"is_significant": is_significant,
"effect_meaningful": effect_meaningful,
"decision": decision,
"reason": reason
}
# 使用示例
result = analyze_ab_experiment(
scores_a=[3.2, 4.1, 3.8, 2.9, 4.5, 3.1, 4.0, 3.7],
scores_b=[4.0, 4.3, 3.9, 4.2, 4.8, 4.1, 4.4, 4.0],
)
print(f"决策:{result['decision']}")
print(f"原因:{result['reason']}")
最小样本量估算
在实验开始之前,先算好需要多少样本,否则很容易陷入「不停等待、一直 INCONCLUSIVE」的困境:
python
from statsmodels.stats.power import TTestIndPower
def estimate_sample_size(
effect_size: float = 0.2, # Cohen's d,预期最小效应量
alpha: float = 0.05, # 显著性水平
power: float = 0.80 # 统计功效(1 - beta)
) -> int:
"""
估算每个变体需要的最小样本量。
经验参考:
- 小效应 d=0.2 → 约 394 个/组
- 中效应 d=0.5 → 约 64 个/组
- 大效应 d=0.8 → 约 26 个/组
"""
analysis = TTestIndPower()
n = analysis.solve_power(
effect_size=effect_size,
alpha=alpha,
power=power,
alternative='two-sided'
)
return int(np.ceil(n))
# 实际估算
for d in [0.1, 0.2, 0.3, 0.5]:
n = estimate_sample_size(effect_size=d)
print(f"效应量 d={d}: 每组需要 {n} 个样本(总计 {n*2} 次请求)")
# 输出:
# 效应量 d=0.1: 每组需要 1571 个样本(总计 3142 次请求)
# 效应量 d=0.2: 每组需要 394 个样本(总计 788 次请求)
# 效应量 d=0.3: 每组需要 176 个样本(总计 352 次请求)
# 效应量 d=0.5: 每组需要 64 个样本(总计 128 次请求)
实际意味着什么?
如果你的应用每天有 500 次 LLM 调用,预期效应量是「中等」(d=0.3),那每组需要 176 个样本。50/50 流量下,两组各积累 176 个约需 0.7 天,这是合理的。
如果效应量很小(d=0.1),每组需要 1571 个,也就是 6.3 天。这时候你得问自己:这么小的效应值得等吗?
工具选型对比
| 工具 | 定位 | 优势 | 适合场景 |
|---|---|---|---|
| promptfoo | 离线 eval + CI 集成 | 开源、声明式配置、CI 友好 | Layer 1 离线评测 |
| Braintrust | 离线 eval + 实验追踪 | 质量评分丰富、可视化好 | 团队协作、研究型实验 |
| Langfuse | 在线监控 + prompt 管理 | 开源可自部署、生产追踪强 | Layer 2-3 在线实验 |
| Statsig | 在线 A/B 实验平台 | 统计功能完整、企业级 | 已有 Statsig 基础设施的团队 |
| DeepEval | 离线 eval + pytest 集成 | 50+ 评估指标、pytest 生态 | Python 项目、质量维度丰富 |
最实用的组合(小团队):promptfoo(离线/CI)+ Langfuse(在线监控)
已有基础设施的大团队:接入 Statsig/Amplitude 等现有实验平台,复用流量分配和统计分析能力。
完整流程 Checklist
变更前
- 确认 golden dataset 包含历史问题 case
- 用 promptfoo 跑离线对比,pass rate ≥ 90%
- 估算所需样本量(基于预期效应量)
- 设定实验终止条件(样本量/时间/异常指标阈值)
金丝雀阶段(5-10% 流量,24-48h)
- 质量评分趋势未下降
- 延迟 P95 未显著上升
- 成本增量在预算内
- 无明显用户投诉信号
全量 A/B(50/50 流量)
- 达到预设样本量后跑统计检验
- 决策:ADOPT_B / KEEP_A / INCONCLUSIVE + 扩大样本
- 记录实验结论和决策依据
- 更新 golden dataset(加入本次发现的边缘 case)
一个真实的教训
我最近用上面这个框架重新做了文章开头提到的客服摘要 prompt 升级:
- 离线 Eval:promptfoo 运行 golden dataset,新 prompt 在「投诉升级」类别上通过率 78%(< 90%),CI 直接拦截
- 问题定位:发现新 prompt 的 JSON 强制输出格式导致某些 edge case 下 urgency 判断失准
- 修复后:通过率 94%,进入金丝雀
- 金丝雀观察:24h 后,LLM-as-judge 质量评分从 3.72 升到 3.89,延迟 P95 从 1.8s 降到 1.4s(JSON 格式减少了 hallucination)
- 全量 A/B:积累 400+ 样本后,Mann-Whitney p=0.003,d=0.31,决策:ADOPT_B
整个过程 3 天,比之前「手测 10 个感觉好就推」更慢,但:这次没有翻车。
总结
LLM 应用的 prompt 和模型变更,本质上是在一个高维、非确定性的空间里做决策。纯靠直觉和小样本验证,运气好的时候没事,运气差的时候直接影响用户体验。
三层验证框架的核心逻辑很简单:
- 离线 Eval 是安全网,挡住明显的退步
- 金丝雀 是风险控制,用真实流量验证、但把爆炸半径控制在小范围
- 全量 A/B 是统计判决,用足够的数据做有信心的决策
每一层都有它的成本,但比起线上翻车的代价,这个成本是值得的。
工具方面,开源的 promptfoo + Langfuse 就能覆盖 80% 的场景,不需要付费平台也能跑起来一套完整的实验体系。
本文所有代码已在 Python 3.11 + promptfoo 0.81.x 环境下验证,可直接使用。统计分析部分依赖 scipy 和 statsmodels 标准库。
附:完整的 A/B 实验 Python 工具类
把上面所有片段整合成一个可直接复用的工具类:
python
"""
llm_ab_experiment.py
生产级 LLM A/B 实验工具类
依赖:openai(兼容客户端), langfuse, scipy, statsmodels, numpy
"""
import hashlib
import time
import numpy as np
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from enum import Enum
from scipy import stats
from statsmodels.stats.power import TTestIndPower
class Decision(Enum):
ADOPT_B = "ADOPT_B"
KEEP_A = "KEEP_A"
INCONCLUSIVE = "INCONCLUSIVE"
@dataclass
class ExperimentConfig:
name: str
variant_a: str
variant_b: str
traffic_pct: float = 0.10
full_ab_pct: float = 0.50
alpha: float = 0.05
power: float = 0.80
min_effect_size: float = 0.2
max_wait_days: float = 7.0
@dataclass
class ExperimentRecord:
request_id: str
user_id: str
variant: str
latency_ms: float
input_tokens: int
output_tokens: int
cost_usd: float
quality_score: Optional[float] = None
timestamp: float = field(default_factory=time.time)
class LLMExperiment:
"""
LLM A/B 实验管理器。
使用示例:
exp = LLMExperiment(ExperimentConfig(
name="cs_summary_v3",
variant_a="prompt_v2",
variant_b="prompt_v3",
))
variant = exp.route(user_id="user_123")
exp.record(ExperimentRecord(..., variant=variant, quality_score=4.2))
result = exp.analyze()
print(result["decision"], result["reason"])
"""
def __init__(self, config: ExperimentConfig):
self.config = config
self.records: List[ExperimentRecord] = []
self._required_n = self._calc_required_n()
def _calc_required_n(self) -> int:
analysis = TTestIndPower()
n = analysis.solve_power(
effect_size=self.config.min_effect_size,
alpha=self.config.alpha,
power=self.config.power,
alternative='two-sided'
)
return int(np.ceil(n))
def route(self, user_id: str, phase: str = "canary") -> str:
"""基于 user_id 哈希路由到 A 或 B。"""
pct = self.config.traffic_pct if phase == "canary" else self.config.full_ab_pct
h = int(hashlib.md5(f"{user_id}:{self.config.name}".encode()).hexdigest(), 16)
bucket = (h % 10000) / 10000.0
return self.config.variant_b if bucket < pct else self.config.variant_a
def record(self, rec: ExperimentRecord):
self.records.append(rec)
def status(self) -> Dict[str, Any]:
a = [r for r in self.records if r.variant == self.config.variant_a]
b = [r for r in self.records if r.variant == self.config.variant_b]
return {
"n_a": len(a), "n_b": len(b),
"required_n": self._required_n,
"progress_pct": min(100.0, min(len(a), len(b)) / self._required_n * 100),
"avg_quality_a": float(np.mean([r.quality_score for r in a if r.quality_score])) if a else None,
"avg_quality_b": float(np.mean([r.quality_score for r in b if r.quality_score])) if b else None,
}
def analyze(self) -> Dict[str, Any]:
"""运行统计分析,返回决策结果。"""
a_scores = [r.quality_score for r in self.records
if r.variant == self.config.variant_a and r.quality_score is not None]
b_scores = [r.quality_score for r in self.records
if r.variant == self.config.variant_b and r.quality_score is not None]
n_a, n_b = len(a_scores), len(b_scores)
if n_a < 10 or n_b < 10:
return {"decision": Decision.INCONCLUSIVE.value,
"reason": f"样本量不足 n_a={n_a}, n_b={n_b},需要至少 10 个/组"}
mean_a, mean_b = np.mean(a_scores), np.mean(b_scores)
_, p_value = stats.mannwhitneyu(a_scores, b_scores, alternative='two-sided')
pooled_std = np.sqrt(
(np.var(a_scores) * n_a + np.var(b_scores) * n_b) / (n_a + n_b - 2)
)
cohen_d = (mean_b - mean_a) / pooled_std if pooled_std > 0 else 0
sig = p_value < self.config.alpha
meaningful = abs(cohen_d) >= self.config.min_effect_size
if sig and meaningful:
decision = Decision.ADOPT_B if mean_b > mean_a else Decision.KEEP_A
dir_str = "更好" if mean_b > mean_a else "更差"
reason = f"B 显著{dir_str}:{mean_a:.3f}→{mean_b:.3f}, p={p_value:.4f}, d={cohen_d:.3f}"
elif sig:
decision = Decision.KEEP_A
reason = f"统计显著但效应量过小 d={cohen_d:.3f} < {self.config.min_effect_size}"
elif n_a >= self._required_n and n_b >= self._required_n:
decision = Decision.KEEP_A
reason = f"已达目标样本量,p={p_value:.4f} 不显著,两版本无实质差异"
else:
decision = Decision.INCONCLUSIVE
reason = f"p={p_value:.4f} 未显著,样本 {min(n_a,n_b)}/{self._required_n},继续收集"
return {
"decision": decision.value, "reason": reason,
"n_a": n_a, "n_b": n_b,
"mean_a": float(mean_a), "mean_b": float(mean_b),
"delta_pct": float((mean_b - mean_a) / mean_a * 100) if mean_a != 0 else 0,
"p_value": float(p_value), "cohen_d": float(cohen_d),
}
常见坑与解法
坑 1:用时间段切分而不是用户哈希
❌ 上午用 A、下午用 B------时间段效应会污染结果(下午用户本来就更不耐烦)。
✅ 基于 user_id 哈希,同一用户在实验期间始终看到同一版本。
坑 2:多个变量同时改
❌ 新 prompt + 新模型 + temperature 一起推------你不知道效果变化来自哪里。
✅ 每次只改一个变量。多变量并行测试用正交实验设计或多臂 Bandit。
坑 3:看到早期数据好就立刻停止
这是「偷窥问题」(Peeking Problem):反复检查并在显著时停止,实际假阳性率远高于 5%。
解法:提前设定终止条件(样本量或时间),或使用序贯检验框架。
坑 4:只看平均分,忽略尾部分布
即使平均分 B 更高,若 B 的低分尾部(P10)变差了,这对用户体验是灾难性的。
python
def check_tail_risk(scores_a, scores_b, percentile=10):
p_a = np.percentile(scores_a, percentile)
p_b = np.percentile(scores_b, percentile)
if p_b < p_a * 0.9:
print(f"⚠️ 尾部风险:P{percentile} {p_a:.2f}→{p_b:.2f},B 长尾更差")
return True
return False
坑 5:多重比较导致假阳性累积
5 个实验各用 p < 0.05,至少一个假阳性的概率约 22.6%。
解法:Bonferroni 校正(alpha_adjusted = 0.05 / n_tests),或在实验平台层面统一管理。
何时可以跳过全量 A/B
可以只做离线 Eval 的情况:
- 修复明确 Bug(输出格式错误、拼写错误),delta 足够大
- 低流量应用(< 100 次/天),积累样本需要太长时间
- 紧急回滚,等不起 A/B 周期
必须做严格 A/B 的情况:
- 改动影响核心业务指标(满意度、转化率、续费率)
- 模型切换(行为差异不可预测)
- Prompt 结构性重写(不只是措辞调整)
- 高流量场景(1000+ 次/天)