没 eval 的 prompt = 玄学;改 prompt 凭感觉 = 用 vibes 做工程。
问 100 个写 prompt 的人「你怎么知道改完比改前好」,99 个会说「我跑了几个例子感觉不错」。这就是为什么大多数 AI 产品上线后越调越糟——没有 eval 的 prompt 工程不是工程,是巫术。Hamel Husain 那句被工业界引爆的话——「Your AI product needs evals」——背后的真实意思是:模型只是 commodity,你的 eval suite 才是护城河。这一期不讲 MMLU / HumanEval 那种公开基准(基本和你的真实场景无关),讲资深用户该会的四件事:怎么从 0 搭一个能跑的最小 eval、为什么 LLM-as-judge 必须做去偏才能信、怎么把 prompt 当代码做 regression test,以及 Anthropic 自己怎么用 evals 框架开发 Claude。读完你应该能在 1 小时内给手头任意 prompt 建一个真能挡 regression 的评测系统。
大多数人不做 eval 的真实理由不是「不知道重要」,而是「以为要先搭一套基础设施」。这是被 Weights & Biases / Braintrust / Langfuse 这些工具页面误导的结果。Hamel Husain 在 Your AI Product Needs Evals 里给出了 dead simple 的起手式:挑 20 条覆盖典型 + 边缘的真实输入,写一段 Python 跑 prompt 拿输出,再写一段 Python 给输出打分。这就是 MVP eval,多一个文件都不要。
Eval 的核心不是工具,是数据集质量。20 条精心挑过的例子(10 条 happy path + 5 条 edge case + 5 条 adversarial),价值远大于 10000 条从生产 log 里随手抓的。Andrej Karpathy 在多次 talk 里强调过同样的事——「dataset is the new code」,你的 golden set 就是 eval 的 source of truth,要像维护核心代码一样维护它(diff / review / version)。
Scorer 分三档,先用最便宜的:
给一个「从邮件里抽取会议时间」的 prompt 做 MVP eval,全部不到 60 行:
# eval_meeting_extractor.py — 真能跑的最小 eval
import json, re, anthropic
from dataclasses import dataclass
from datetime import datetime
client = anthropic.Anthropic()
# ① Golden Set —— 20 条手挑数据;每条都是「输入 + 期望」
GOLDEN = [
{"id":"happy_01", "input":"明天下午 3 点会议室 A 开周会",
"expect":{"time":"15:00", "location":"会议室 A"}},
{"id":"edge_tz", "input":"Mon 9am PT sync",
"expect":{"time":"09:00", "tz":"PT"}},
{"id":"adv_noop", "input":"今天天气不错",
"expect":{"time":None}}, # 不该幻觉
# ... 17 条更
]
PROMPT = """You are a meeting time extractor. Output strict JSON:
{"time": "HH:MM" or null, "location": str or null, "tz": str or null}
Input: {text}"""
def run(text):
r = client.messages.create(model="claude-opus-4-7", max_tokens=200,
messages=[{"role":"user","content":PROMPT.format(text=text)}])
return json.loads(r.content[0].text)
# ② Scorer —— 全是 code-based,便宜稳定
def score(pred, expect):
for k, v in expect.items():
if pred.get(k) != v: return 0
return 1
# ③ Runner —— 一行 for loop
def evaluate():
fails = []
for case in GOLDEN:
try:
pred = run(case["input"])
if score(pred, case["expect"]) == 0:
fails.append((case["id"], pred, case["expect"]))
except Exception as e:
fails.append((case["id"], "ERROR", str(e)))
pass_rate = 1 - len(fails) / len(GOLDEN)
print(f"PASS {pass_rate:.0%} ({len(GOLDEN)-len(fails)}/{len(GOLDEN)})")
for f in fails: print(" FAIL", f)
return pass_rate
if __name__ == "__main__": evaluate()
这就够上线了。每次改 PROMPT 跑一遍,从 17/20 跌到 14/20 就是 regression。三个月后你会拥有 200 条 golden set,到那时再考虑 Braintrust / Langfuse。
当任务是开放式生成(摘要质量、回答相关性、改写忠实度),code-based 失效,多数人转向 LLM-as-judge:给 judge 一段 instruction 让它给生成结果打分或挑赢家。问题是 LLM judge 本身带着系统性偏见,不去偏直接用,你测出来的是 judge 的偏好,不是模型的能力。
Zheng 等人 2023 的 Judging LLM-as-a-Judge(MT-Bench 论文,NeurIPS Datasets & Benchmarks)系统性证明了四类偏见,几乎所有后续研究都建立在这之上:
这四个 bias 不是 prompt 问题,是 RLHF 训练目标的副产物,无法靠「我提醒 judge 别偏」消除——必须用结构性方法去偏。
# —— 1. 位置交换(Position Swap)——
# Pairwise 永远跑两次:(A,B) 和 (B,A),两次都赢才算赢;不一致记 TIE
score_ab = judge(q, ans_a, ans_b) # A 在前
score_ba = judge(q, ans_b, ans_a) # B 在前
winner = "A" if score_ab == "A" and score_ba == "B" else \
"B" if score_ab == "B" and score_ba == "A" else "TIE"
# —— 2. 长度归一(Verbosity Control)——
# 在 judge prompt 里硬约束:长度差异 > 30% 时优先看质量
JUDGE_PROMPT = """Compare A and B. CRITICAL: do not reward verbosity.
If lengths differ by >30%, explicitly check whether the longer one
adds substance or just padding. Penalize padding."""
# —— 3. 异家族交叉评 (Cross-family Judging) ——
# 测 Claude 输出用 GPT 当 judge,测 GPT 输出用 Claude 当 judge
# 或者拿 3 个不同模型 judge 取多数
judges = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
votes = [judge_with(m, q, a, b) for m in judges]
winner = max(set(votes), key=votes.count) # majority
# —— 4. Rubric 化(替代单一分数)——
# 不让 judge 给「整体分」,让它按维度打 0/1
RUBRIC = """Score each independently (0 or 1):
- factual_correct: all stated facts verifiable from source?
- completeness: covers all parts of the question?
- format_valid: matches required JSON schema?
- no_hallucination: no info absent from source?
Return JSON: {factual_correct: 0/1, ...}"""
# —— 5. 人工校准(Calibration Set)——
# 50 条人工已标注的样本,每次跑 judge 先在这 50 条上测一致率
# judge ↔ human 一致率 < 80% → judge prompt 不可信,回去改
human_labels = load("calibration_50.json")
judge_labels = [judge(c) for c in human_labels]
agreement = sum(h == j for h, j in zip(human_labels, judge_labels)) / 50
assert agreement > 0.80, f"Judge unreliable: {agreement:.0%}"
关键直觉:把 judge 当成「你雇来打分的实习生」。你不会让一个实习生只看完一段就拍一个总分——你给他 rubric、让他写理由、抽样复核他的打分。LLM judge 同理。
Prompt 改一行,行为可以彻底变。这不是缺点,是 LLM 的基本属性。问题是大多数团队对待 prompt 的态度还停留在「散落在 Slack 截图和 Jupyter 单元格里」,结果就是:上周改的某个 prompt 让某个边缘 case 从 pass 变 fail,没有人发现,直到客户投诉。
资深做法是把 prompt 当代码全栈对待:
prompts/*.md 或 prompts/*.txt 文件,进 git。变量用 {{var}} 占位(Jinja / f-string)。永远不要把 prompt 写在 Python 字符串里——diff 时眼睛看不到换行变化。tests/test_*.py 文件,里面是 §1 的 golden set。改 prompt 必须跑 regression。(prompt_version, input_hash, output, score) 入 SQLite / DuckDB,事后可以「上次 v3 → v4 在哪几类 case 上变了」精确归因。核心心智模型——prompt 不是配置,是行为代码。配置改了顶多业务参数变,prompt 改了模型决策可能整个翻转。所以应该用比代码更严格的 review 流程,而不是更松。
# —— 仓库结构 ——
prompts/
meeting_extractor.md # prompt 文本,用 {{var}} 占位
meeting_extractor.meta.yml # 模型、温度、max_tokens、min_pass_rate
tests/
golden_meeting.jsonl # golden set
test_meeting.py # pytest + 调 §1 的 evaluate()
.github/workflows/
eval.yml # CI
# —— prompts/meeting_extractor.meta.yml ——
model: claude-opus-4-7
temperature: 0
max_tokens: 200
min_pass_rate: 0.90 # 跌破这个 CI 直接红
allow_regression: [] # 显式声明允许 fail 的 case_id
# —— tests/test_meeting.py ——
import pytest, yaml
from evaluator import evaluate
def test_meeting_regression():
meta = yaml.safe_load(open("prompts/meeting_extractor.meta.yml"))
result = evaluate("meeting_extractor")
assert result.pass_rate >= meta["min_pass_rate"], \
f"Pass rate dropped: {result.pass_rate:.0%} < {meta['min_pass_rate']:.0%}"
# 检查没有新 fail 进入
new_fails = set(result.failed_ids) - set(meta["allow_regression"])
assert not new_fails, f"New regressions: {new_fails}"
# —— .github/workflows/eval.yml ——
name: prompt-eval
on: [pull_request]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest tests/ -v
env: { ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} }
有了这个最小集,下次有人在 PR 里改 prompt,CI 会自动跑 20 条 golden 给出 pass rate 和 diff。一周内你的团队就再也回不到「凭感觉改 prompt」的旧时代。
Anthropic 在多篇博客和文档里透露了内部 eval 体系的轮廓(Claude's Constitution、Core views on AI safety、Anthropic Cookbook 中的 evals 例子)。关键洞察是把 eval 分两层:
普通团队的错误是只做其中一种:
正确做法是双层 + 可追溯:task eval 失败时,能下钻到具体哪个 capability 挂了。比如客服 agent 漏单——是 instruction following 挂(没按 SOP 走)、还是 entity extraction 挂(没抽到订单号)、还是 refusal 挂(错误拒答)?三种都要分别有 capability eval。
evals/
├── capability/ # 原子能力,模型/prompt 改时跑
│ ├── instruction_following.jsonl # 200 条「按格式输出」
│ ├── entity_extraction.jsonl # 150 条「抽 JSON」
│ ├── refusal_accuracy.jsonl # 80 条「该拒就拒」
│ ├── long_context_recall.jsonl # 100 条「needle in haystack」
│ └── tool_selection.jsonl # 120 条「选对 tool」
└── task/ # 端到端业务,每次发布前跑
├── customer_support.jsonl # 50 条真实工单 → 期望分类 + 回复
└── code_review.jsonl # 30 个 PR → 期望发现的 bug 列表
# —— 双向追溯:task fail 时关联到 capability ——
def diagnose(task_failure):
"""task eval fail → 自动跑相关 capability 看哪个低于阈值"""
related = TASK_TO_CAPABILITY[task_failure.task_name]
# e.g. customer_support 关联 [instruction_following, entity_extraction]
diag = {}
for cap in related:
diag[cap] = run_capability_eval(cap).pass_rate
return sorted(diag.items(), key=lambda x: x[1]) # 最低的最可疑
# —— 用法 ——
if task_eval.pass_rate < 0.85:
print("Task regressed. Capability diagnosis:")
for cap, rate in diagnose(task_eval): print(f" {cap}: {rate:.0%}")
# Output:
# instruction_following: 0.62 ← 罪魁祸首
# entity_extraction: 0.94
这个结构还有一个隐藏好处——换模型时可解释。Claude Opus 4.7 升 4.8、或者切到 Sonnet,你能看到「instruction_following +2%,entity_extraction -5%」,而不是只有一个糊涂的「整体好像差不多」。模型选型不再凭感觉。
把 4 节串成一个能交付的工作流。挑你手头跑得最多的一个 prompt——比如「邮件摘要 + 提取 todo」——按以下 7 步走:
tests/golden_xxx.jsonl。90 分钟后你拥有的不是「一个 eval 脚本」,而是这个 prompt 未来一年的护城河。下次模型升级、prompt 改写、团队成员换人,这套 eval 都在那里替你守门。这就是 Anthropic / OpenAI / Cursor 工程团队相对普通团队最大的能力差距——不是模型用得好,是 eval 做得早。