将 prompt 迭代从「改一下跑一个例子感觉更好就发版」改为:固定测试集→候选变体→每个变体×每个测试→确定性 grader 打分 pass/fail+分数,引入回归门防止改动破坏已有能力。
改进 Prompt 的默认方式是:调一下,跑一下你恰好关心的那一条输入,扫一眼输出,觉得"感觉好点了",就发版。这套流程只在一种情况下有效,而且一旦你转去做别的事,就会被忘得一干二净。你永远看不到这次改动破坏了哪条输入,也无法公平地比较两个候选方案,"更好"只是一种感觉——既没法向队友解释,也没法向一周后的自己交代。
Prompt 评测(evals)用测量取代了感觉检查:固定一个测试集,写几个候选变体,让每个变体跑遍每条测试,再让确定性评分器把每个(变体 × 测试)单元格转成通过/失败和分数。这就是"promptfoo 风格"的循环,也是所有其他流程的基础。
确定性评分器不需要 LLM
大多数断言都是廉价、快速、100% 可重复的:输出是否完全匹配、是否包含某个字符串、是否匹配某个正则、是否能解析为 JSON?每个都返回 {pass, score}——二元评分器的分数就是 1 或 0。
function grade(type, output, assertion) {
const out = String(output);
if (type === "exact_match") { const p = out.trim() === String(assertion).trim(); return { pass:p, score:p?1:0 }; }
if (type === "contains") { const p = out.toLowerCase().includes(String(assertion).toLowerCase()); return { pass:p, score:p?1:0 }; }
if (type === "regex") { let re; try { re = new RegExp(assertion); } catch { return { pass:false, score:0 }; } const p = re.test(out); return { pass:p, score:p?1:0 }; }
if (type === "json_valid") { try { JSON.parse(out); return { pass:true, score:1 }; } catch { return { pass:false, score:0 }; } }
return rubric(out, assertion);
}
rubric 评分器可以给出部分分数——按包含的必关键字数比例计算——这样平均分就能区分通过率相同但表现不同的两个变体。
跑矩阵、选赢家、然后设守卫
这个测试框架的循环很简单:对每个变体,在每条测试上运行 prompt 并评分,然后把每列缩减为通过率和平均分。
async function runEval(variants, tests, callModel) {
return Promise.all(variants.map(async (v) => {
const cells = await Promise.all(tests.map(async (t) => {
const output = await callModel(v.system, t.input); // ← 这里是真实的 LLM 调用
return grade(t.grader, output, t.assertion);
}));
const passes = cells.filter(c => c.pass).length;
return { id: v.id, passRate: passes / tests.length,
avgScore: cells.reduce((a, c) => a + c.score, 0) / tests.length, cells };
}));
}
选赢家是机械的:先看通过率最高,持平再比平均分。但这只是工作的一半——而且是没那么重要的一半。
真正能救你的部分
一次 Prompt 改动几乎永远不会严格支配另一个。以 demo 为例,V3 通过 few-shot 示例终于捕获了 V2 漏掉的紧急情况(通过率从 60% 升到 80%)——但同样的示例让它在一条友好的感谢消息上过度触发了紧急分类,导致一个 V2 通过的测试退化了。整体数字在涨,但某个具体行为却在变差。单一的平均分会把这个问题藏起来。
修复方法是逐单元格与基准线做 diff,任何出现通过 → 失败的情况都会阻断:
function regressions(base, cand, tests) {
return tests.filter((t, i) => base.cells[i].pass && !cand.cells[i].pass);
}
const dropped = regressions(baseRow, candRow, tests);
if (dropped.length) throw new Error(`${dropped.length} regression(s) — blocked`);
把这套逻辑接进 CI——自己实现或通过带 assert.type 评分器的 promptfooconfig.yaml——一个净正向的改动就不可能悄无声息地发布出一个净负向的意外。保留一个你从不调优的 holdout 集,这样分数就不会说谎;同时报告通过率(作为门控)和平均分(作为微调依据),质量就只会往上走。
切换评分器,实时观察矩阵、通过率、赢家和回归 diff 如何重新计算:https://dev48v.infy.uk/prompt/day57-prompt-evals.html