迭代与评估:小样本测试集、A/B、版本管理、常见反模式
把「感觉这版更好」变成「这版在十条测试上多对了三条」:建小样本测试集、跑 A/B 对比、给提示词上版本号,并认出最常见的几种反模式。
今日目标
- 能为一个提示词建一份十条左右的测试集,并说出每条测什么
- 能写一个 A/B 评估脚本,用同一批输入比较两版提示词的通过率
- 能给提示词做版本管理,并说出五种常见反模式各怎么识别
前三天我们一直在改提示词,但一直回避了一个问题:改完之后,怎么知道它变好了?在聊天窗口里试一两次「感觉不错」,放进生产里跑一千次可能有两百次翻车。今天把提示词当成代码来对待——有测试、有对比、有版本号。读完正文、做完实验之后,回到页面顶部把三条目标勾掉。
小白版讲解
凭感觉改提示词为什么会越改越差
新同事按你的便签办了一件事,结果不太对。你顺手在便签上加一句,他再办一次,这次对了。你很满意,直到一周后发现他把另外三件原本办得好的事也办错了——因为你那句新加的话,恰好跟另外三件事的要求冲突。你没发现,是因为你每次只看了「这一件事」。
提示词的迭代如果凭感觉,就是这个过程的加速版。你看到一条输入出错,改一句话,再跑那一条,对了,收工。但模型对提示词的响应是全局的:你为了修一个边界情况加的规则,会改变它对所有输入的行为。没有一份固定的输入集合让你每次都全跑一遍,你就永远只能看到「刚修的那一条」,看不到「被顺手弄坏的那几条」。于是提示词越改越长、行为越来越不可预测,最后没人敢动它。
这个问题在写代码时早就解决了:改动之前有测试,改动之后全跑,红了就知道哪里坏了。提示词缺的就是这个——一条基线。没有基线,「变好」和「变差」都只是感觉;有了基线,「这版在十条测试上从六条对变成九条对,但第十条原来对现在错了」才是可以讨论、可以决策的事实。今天要建的就是这条基线,它不需要很大,十条就够起步。
小样本测试集:十条够不够,每条该长什么样
先回答「十条够不够」。对正式产品当然不够,但对「把提示词从凭感觉改变成有依据改」这一步,十条已经能过滤掉绝大多数的盲改。原因是提示词的错误高度集中:模型在正常输入上几乎不会错,错的全在边界和刁难上。所以十条的价值不在数量,在分布。
一份好的小测试集大致三类各占三四条。正常输入,规范写法,两版都该对,它们的作用是「守底线」——新版如果把这些弄坏了就是严重回退。边界输入,需求里没写默认值、字段可选、路径带参数、写法不规范(「todos 的创建接口」而不是 POST /todos),它们测的是提示词有没有把默认规则说清楚。刁难输入,带干扰信息(先讲了一段线上事故再说需求)、中途改口(「是 A 接口——哦不对,是 B」)、夹带无关要求(「顺便把响应改成分页的」),它们测的是提示词能不能让模型抓住重点。
每条用例有三部分:输入、人工标注的标准答案、类别标签。标准答案是人工的,这一步没有捷径——你标错一条,整份评估就偏了,而且你会以为是提示词的问题去反复改提示词。每条写完自己再读一遍输入,确认答案唯一。以贯穿全课的任务为例,一条边界用例长这样(沿用 D3 的抽取任务,标准答案只标四个关键字段):
{
"id": "c04",
"kind": "edge",
"input": "POST /todos 补校验:title 必填,不超过 200 字符。(需求里没写失败返回什么)",
"expected": { "endpoint": "/todos", "method": "POST", "failureStatus": 400, "fields": ["title"] }
}它测的是一件很具体的事:需求没写状态码时,模型会不会按团队默认用 400,还是自己猜一个 422。D2 讲过的「模型不可能知道的样本」也可以放一两条进来,看它是老实说不知道还是编一个。
怎么判对错:精确匹配、字段比对与用模型当裁判
有了输入和标准答案,下一个问题是「怎么算对」。这一步的松紧直接决定评估有没有意义,太死会把写法差异算成错误,太松会放过真的错误。三种做法各有适用范围。
精确匹配:输出整个序列化后和标准答案完全相等。只适合输出是单个值的任务,比如分类结果就是一个标签。对结构化输出它太死了——模型把 POST 写成 post、把字段数组的顺序换一下,意思一样却判错,你会去修一个根本不存在的问题。
字段比对:逐字段比较,每个字段用自己的规则。方法忽略大小写;路径忽略末尾斜杠、缺前导斜杠视为可容忍;字段列表去重后忽略顺序和大小写;状态码精确相等。原则是比你在意的字段,容忍你不在意的差异。这是结构化输出的标配,也是 D3 把输出变成固定字段的最大回报——如果输出是自由文本,这一步根本做不了。判分函数返回的应该是「不匹配的字段名列表」而不是布尔值,因为汇总时你要知道错在哪:十条里有四条都是 failureStatus 错,问题就很明确是默认值没说清。
const norm = (s: string): string => s.trim().toLowerCase()
const normPath = (s: string): string => {
const p = norm(s).replace(/\/+$/, '')
return p.startsWith('/') ? p : `/${p}` // 缺前导斜杠是写法差异,不是理解错误
}
// 返回不匹配的字段名列表;空数组即通过。比布尔值多带了「错在哪」这层信息
function score(actual: Record<string, unknown>, expected: Expected): string[] {
const diffs: string[] = []
if (typeof actual.endpoint !== 'string' || normPath(actual.endpoint) !== normPath(expected.endpoint)) diffs.push('endpoint')
if (typeof actual.method !== 'string' || norm(actual.method) !== norm(expected.method)) diffs.push('method')
if (actual.failureStatus !== expected.failureStatus) diffs.push('failureStatus') // 状态码不容忍
const got = Array.isArray(actual.fields) ? [...new Set(actual.fields.map((f) => norm(String(f))))].sort() : null
const want = [...new Set(expected.fields.map(norm))].sort()
if (!got || got.length !== want.length || got.some((f, i) => f !== want[i])) diffs.push('fields')
return diffs
}def norm(s: str) -> str:
return s.strip().lower()
def norm_path(s: str) -> str:
p = norm(s).rstrip("/")
return p if p.startswith("/") else f"/{p}" # 缺前导斜杠是写法差异,不是理解错误
# 返回不匹配的字段名列表;空列表即通过。比布尔值多带了「错在哪」这层信息
def score(actual: dict, expected: dict) -> list[str]:
diffs: list[str] = []
if not isinstance(actual.get("endpoint"), str) or norm_path(actual["endpoint"]) != norm_path(expected["endpoint"]):
diffs.append("endpoint")
if not isinstance(actual.get("method"), str) or norm(actual["method"]) != norm(expected["method"]):
diffs.append("method")
if actual.get("failureStatus") != expected["failureStatus"]:
diffs.append("failureStatus") # 状态码不容忍
got = sorted({norm(str(f)) for f in actual["fields"]}) if isinstance(actual.get("fields"), list) else None
want = sorted({norm(f) for f in expected["fields"]})
if got != want:
diffs.append("fields")
return diffs用模型当裁判:输出是自由文本(一段摘要、一封邮件)时,没有字段可比,就让另一个模型按你写的评分标准打分。它能覆盖前两种做不到的任务,但代价不小:裁判本身会错、会偏向长的和格式漂亮的回答、评分标准写得含糊它就打得随意。用它的前提是评分标准要像便签一样具体(「摘要是否包含状态码、字段名、失败行为三个信息点,各一分」),并且要定期抽样人工核对裁判的判断。规律是:能用字段比对就不要用裁判;必须用裁判的地方,先用十条人工打过分的样本校准它。
A/B 对比:同一批输入、两版提示词、一张表看结果
判分有了,A/B 就只是一个循环:对测试集里每一条输入,分别用 A 版和 B 版的提示词跑一次抽取,各自判分,填进同一张表。表的每一行是一条用例,两列是两版的结果,通过的打勾,不通过的写出错在哪个字段。
case kind v1 v2
c01 normal ✅ ✅
c04 edge ❌ failureStatus ✅
c09 adversarial ❌ endpoint ✅
c10 adversarial ✅ ❌ failureStatus
通过率:v1 6/10(60%) v2 9/10(90%)
v2 修好的:c04, c05, c07, c09
v2 弄坏的:c10 ← 这些要写进 CHANGELOG 的「已知回退」表下面的汇总有三行,重要程度和多数人的直觉相反。通过率最不重要——它只告诉你总分。修好的告诉你这版改动确实解决了目标问题。弄坏的最重要:它告诉你新版引入了什么回退。上面这个例子里 v2 从六条对变成九条对,看起来大胜,但 c10 是原来对现在错的——v2 加了一条「没写状态码就用 400」,模型学得太死,需求里明明写了 422 也改成了 400。没有这张表,这个回退会静静地进入生产。
两个工程细节。一是同一批输入,两版必须跑完全相同的用例,换了用例就没法比。二是随机性:真模型对同一输入两次可能给不同结果,所以正式评估时每条跑三次取多数,或者把温度设为 0;离线练习时用固定的模拟输出即可。今天的实验在离线模式下用规则模拟了「v1 在哪些边界上错、v2 修了什么又弄坏了什么」,让你没有 API key 也能看到完整的现象。
版本管理:提示词是代码,要进仓库、要有变更记录
到这里,提示词已经具备了代码的全部特征:它是一个有参数的函数(D3),有一份测试集,有一个能跑出通过率的评估脚本。那就应该像代码一样管:进版本库、有版本号、有变更记录。
具体做法不复杂。提示词正文放在一个独立文件里(而不是散在业务代码的字符串拼接中),每一版有一个 id。旁边放一份变更记录,每一条写四件事:改了什么、为什么改(对应哪几条测试失败)、跑出来的通过率、已知回退。
## v2(当前)
- 改动:补了五条规则——默认状态码 400、方法大写与路径规范化、字段名原样保留、改口以最后为准、忽略顺带要求
- 原因:v1 在 c04(没写状态码猜成 422)、c05(口语路径还原错)、c07(漏了 pageSize)、c09(改口只认第一次)上失败
- 结果:v1 6/10 → v2 9/10
- 已知回退:c10——「没写就 400」学得太死,需求写了 422 也被改成 400。下一版把这条规则改成「写了就用需求里的」并放到规则列表最前面它和代码版本管理有一处不同值得说:代码的改动通常是「局部的」,改一个函数不影响另一个;提示词的改动是「全局的」,加一句话可能改变所有输入的行为。所以提示词的变更记录里**「已知回退」这一栏是必填项**,而代码的提交信息里通常没有这一栏。另一处不同是回滚的代价:提示词回滚只是换一个字符串,成本几乎为零,所以线上出问题时「先回滚到上一版、再慢慢查」是比修代码更理所当然的选择——前提是你有版本号可以回。
五种常见反模式
最后把前三天里陆续提到的坑收成一张清单。每条都给一个识别方法,因为反模式的难点不在改,在发现。
越写越长。每次出错就加一句,半年后提示词两千字,没人知道哪句还在起作用。识别:删掉一段跑测试集,通过率不变,那段就是死的。有了测试集,「删减」第一次变得安全。
万能角色。「你是一位精通前后端、安全、测试与文档的全栈专家」。角色越全信息量越低,模型不知道你想让它关注什么。识别:角色描述里能不能删掉一半而不影响任何一条测试。
否定式指令。「不要客套」「不要解释」「不要用 Markdown」堆一排。模型对光秃秃的否定执行得不稳定,而且否定句会把那个词带进上下文(写「不要提到竞品」有时反而让它提到)。识别:每条否定能不能改写成带理由的肯定(「直接从第一段开始,因为输出会被程序按段解析」)。
示例污染。D2 讲过的:示例的格式跟说明冲突、示例的类别分布有偏、示例里有「等等」。识别:把示例的输出部分单独拿出来跟格式栏逐字比。
只测一次。改完在聊天窗口试一条就上线。识别不需要方法——今天整篇讲的就是它。
源码导读
动手实验
这个实验没有 API key 也能做完全部五条。打开 labs/prompt-engineering-5days/day-04-prompt-ab-eval,先跑 solution/ 看完整的对比表,再回到 starter/:
- 在
solution/里MOCK=1 pnpm start,看清十条用例的三类分布、v1 与 v2 各自在哪几条上失败、以及「修好的」与「弄坏的」两行。 - 切到
starter/跑一次,注意现在只有 4 条用例、v1 四条全挂(因为判分过死)、汇总是占位文字——这三处就是三个代码练习。 - 补全
src/cases.ts到十条:3 条边界(口语化写法、PUT 全量更新、GET 查询参数)与 3 条刁难(带干扰信息、中途改口、夹带无关要求),每条人工标注 expected 并再读一遍确认答案唯一。 - 补全
score为逐字段比对(容忍大小写、末尾斜杠、字段顺序,状态码精确),再跑一次,v1 应通过 6/10 且失败的四条各报出字段名。 - 补全汇总(通过率、修好的、弄坏的、分类通过数),把数字填进
CHANGELOG.md的 v2 条目;然后改一版 v3 修 c10 的回退,再跑再记。
面试题
今天 3 道题在下方题库区,分别对应测试集的设计、提示词版本化与代码版本管理的差别、用模型当裁判的边界。第二题是海外面试里出现频率最高的提示词工程题,先自己推一遍再看分析。
检查清单与明日预告
- 能为一个提示词建一份十条左右的测试集,并说出每条测什么
- 能写一个 A/B 评估脚本,用同一批输入比较两版提示词的通过率
- 能给提示词做版本管理,并说出五种常见反模式各怎么识别
- A/B 评估实验的 5 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D5)是本课最后一天,回答一个你迟早会遇到的问题:同一份提示词,从一家模型搬到另一家,为什么会坏、坏在哪几处、怎么把系统提示组织成搬起来不痛的结构。你今天建的测试集会直接派上用场——迁移之后跑一遍,通过率就是「有没有退步」的答案。D5 结尾还会告诉你接下来该学 Claude 课还是 Codex 课,以及怎么选。
面试题库
怎么给一个提示词建测试集?十条样本该怎么挑,标准答案从哪来?How do you build a test set for a prompt? How would you choose ten samples, and where do the expected answers come from?
国内高频海外高频基础#evaluation#test-set分析过程 · 先想清楚再作答
- 这题在筛「有没有真的建过测试集」。答「多找一些输入跑一跑」的人没建过;建过的人第一句会说分布——因为提示词的错误全集中在边界上。
- 拆法:三类各占三四条。正常输入守底线,新版弄坏它们就是严重回退;边界输入(没写默认值、可选字段、不规范写法)测默认规则说清没说清;刁难输入(干扰信息、中途改口、夹带无关要求)测能不能抓住重点。可以再放一两条模型不可能知道的样本,看它是否老实说不知道。
- 标准答案只能人工标,这一步没有捷径;标错一条整份评估就偏,而且你会误以为是提示词的问题去反复改。每条写完再读一遍输入确认答案唯一。
- 结论:十条够起步,价值在分布不在数量;最好的来源是过去每一次「它又错了」的真实输入,一周就能攒出比想象出来的更真实的测试集。
- 可预期的追问:测试集怎么增长?每次想改提示词先把触发的那条输入加进去再改;以及「测试集会不会泄漏进提示词」——用例不能直接当 few-shot 示例,否则是在测记忆而不是泛化。
How to reason about it · think before answering
- This screens for whether the candidate has actually built one. 'Collect some inputs and run them' means no; people who have start with distribution, because prompt errors cluster at the edges.
- Three classes with three or four each: normal inputs guard the baseline; edge inputs (missing defaults, optional fields, informal phrasing) test whether default rules are explicit; adversarial inputs (distractors, mid-sentence corrections, unrelated asks) test focus. Add one or two unknowable items to check honesty.
- Expected answers are labeled by hand, no shortcut; one mislabeled case skews the whole evaluation and sends you chasing a phantom prompt bug. Re-read each input after labeling to confirm the answer is unique.
- Conclusion: ten is enough to start, value lies in distribution not count, and the best source is every real 'it failed again' input from the past week.
- Follow-ups: how does the set grow? Add the triggering input before every prompt change. And leakage — test cases must not double as few-shot examples, or you are measuring memorization rather than generalization.
答题要点
- 价值在分布不在数量:正常、边界、刁难三类各三四条,再放一两条模型不可能知道的
- 正常输入守底线,边界测默认规则,刁难测抓重点
- 标准答案人工标注、逐条复核,标错一条整份评估就偏
- 最好的来源是真实出错的输入;每次想改提示词先把那条加进测试集
Key points
- Distribution over count: three or four each of normal, edge and adversarial, plus a couple of unknowable items
- Normal cases guard the baseline, edge cases test defaults, adversarial cases test focus
- Expected answers are hand-labeled and re-checked; one wrong label skews everything
- Best source is real failures; add the triggering input before each prompt change
提示词版本化怎么做?它和代码版本管理有什么不同?线上出问题你先做什么?How do you version prompts? How does it differ from versioning code, and what is your first move when production misbehaves?
国内高频海外高频进阶#prompt-versioning#evaluation分析过程 · 先想清楚再作答
- 这题是海外面试里提示词工程方向出现频率最高的一道,考的是「有没有把提示词当成生产资产管理过」。答「放进 git」是最低分,面试官要听的是变更记录里写什么、以及为什么和代码不一样。
- 拆法:先说形态——提示词正文独立成文件、每版有 id、与业务代码解耦;再说变更记录四件事——改了什么、为什么改(对应哪几条测试失败)、跑出来的通过率、已知回退。通过率必须是脚本跑出来的数字。
- 不同点是区分度所在:代码改动通常是局部的,提示词改动是全局的——加一句话可能改变所有输入的行为,所以「已知回退」是必填项而代码提交信息里没有这一栏;另一点是回滚成本几乎为零,只是换一个字符串。
- 结论:线上出问题第一步是回滚到上一版,再拿触发问题的输入补进测试集慢慢查——前提是你有版本号可回、有测试集可跑。
- 追问:提示词版本要不要和模型版本绑定?要——同一份提示词在不同模型版本上通过率会变,记录里要写清是在哪个模型上测的;另一个追问是多环境怎么灰度,答案是按版本 id 分流并对比两版的线上指标,跟代码灰度一样。
How to reason about it · think before answering
- One of the most frequent prompt-engineering interview questions abroad; it tests whether you have managed prompts as production assets. 'Put it in git' is the floor; the interviewer wants the changelog contents and why prompts differ from code.
- Shape first: prompt text in its own file, an id per version, decoupled from business code. Then the changelog's four items: what changed, why (which test cases failed), the measured pass rate, known regressions. The pass rate must come from the script.
- The difference is the differentiator: code changes are usually local, prompt changes are global — one added sentence can shift behavior on every input, so 'known regressions' is mandatory where commit messages have no such field. Also rollback is nearly free, just swap a string.
- Conclusion: when production misbehaves, roll back to the previous version first, then add the triggering input to the test set and investigate — which only works if you have version ids and a test set.
- Follow-ups: bind prompt versions to model versions? Yes — pass rates shift across model versions, so record which model was used. And canarying: route by version id and compare live metrics, same as code.
答题要点
- 提示词正文独立成文件、每版有 id,变更记录写改动、原因、脚本跑出的通过率、已知回退
- 与代码的不同:改动是全局的,所以「已知回退」必填;回滚成本几乎为零
- 线上出问题先回滚上一版,再把触发输入加进测试集查
- 版本要记录在哪个模型上测的,换模型版本通过率会变
Key points
- Prompt text lives in its own file with a version id; the changelog records change, reason, measured pass rate, known regressions
- Unlike code, prompt changes are global, so known regressions are mandatory; rollback is nearly free
- On a production issue, roll back first, then add the triggering input to the test set
- Record which model version was tested, since pass rates shift across models
用模型给模型打分靠谱吗?什么时候可以用,什么时候必须人工看?Is using a model to grade another model's output reliable? When is it acceptable, and when must a human look?
国内高频海外高频进阶#evaluation#llm-as-judge分析过程 · 先想清楚再作答
- 这题在考「知道裁判也会错」。答「用更强的模型当裁判就行」的人没校准过裁判;答出「能用字段比对就不用裁判」才说明有判断力。
- 拆法:先分任务。输出是固定字段就用代码逐字段比对,不需要裁判;输出是自由文本(摘要、邮件、解释)才没有字段可比,这时裁判是唯一能规模化的办法。
- 再说裁判的偏差:偏向长的、格式漂亮的、和自己风格接近的回答;评分标准含糊时打分随意;对事实性错误不敏感。所以评分标准要像便签一样具体——列出信息点、每点一分——而不是「给这段摘要打 1 到 10 分」。
- 结论:裁判可以用,前提是先拿十条人工打过分的样本校准它,看它和人的一致率;上线后定期抽样复核;对涉及事实、安全、金额的输出必须人工看。
- 追问方向:裁判和被评的模型是同一家会怎样?会有自我偏好,尽量换一家或至少换一个版本;另一个追问是「裁判的成本」,每条评估都是一次完整调用,测试集大了要算钱,所以能用代码判的部分先用代码判掉。
How to reason about it · think before answering
- This tests whether you know the judge is fallible too. 'Use a stronger model as the judge' means you never calibrated one; 'prefer field comparison whenever possible' shows judgment.
- Split by task: structured output gets field-by-field code comparison, no judge needed; free text (summaries, emails, explanations) has no fields, and a judge is the only scalable option.
- Judge biases: longer and prettier answers score higher, stylistic similarity gets rewarded, vague rubrics produce noisy scores, factual errors are under-penalized. So the rubric must be concrete — list the information points, one point each — not 'rate this summary 1 to 10'.
- Conclusion: usable once calibrated against ten human-scored samples with an agreement rate you accept; spot-check regularly; anything involving facts, safety or money still gets human review.
- Follow-ups: same vendor for judge and judged? Expect self-preference, so switch vendor or at least version. And cost — every judgment is a full call, so let code handle whatever it can first.
答题要点
- 能用字段比对就不用裁判;裁判只用于没有字段可比的自由文本
- 裁判偏向长的、格式漂亮的回答,评分标准含糊就打得随意,所以标准要具体到信息点
- 先用十条人工打分样本校准裁判,上线后定期抽样复核
- 涉及事实、安全、金额的输出必须人工看;裁判尽量换一家或换版本以避免自我偏好
Key points
- Prefer field comparison; reserve the judge for free text with nothing to compare
- Judges favor long, well-formatted answers and score noisily on vague rubrics, so rubrics must list concrete points
- Calibrate against ten human-scored samples first, then spot-check regularly
- Facts, safety and money always get human review; use a different vendor or version to avoid self-preference
评论
登录后即可参与讨论
还没有评论,来说第一句。