11-Prompt测试集
要点
- 上一节解决了版本管理,这一节解决一个前置问题:怎么知道改完之后效果变好还是变差
- 没有测试集的 Prompt 修改等于盲改——凭感觉判断效果,不可复现、不可度量
- Prompt 测试集的核心:一组输入 + 每个输入的期望输出(或期望满足的条件)
- 评估方式分两种:精确匹配(输出必须完全一致)和条件评估(输出满足某些条件即可)
- 自动化评估 + 人工评估结合——自动化覆盖高频场景,人工覆盖边界和质量判断
- 测试集本身也需要维护——覆盖新发现的失败案例,淘汰过时的用例
1. 为什么需要测试集
第 01 节提到过一个误区:改 Prompt 靠感觉。
「感觉这次回答不太好,改几个词试试。」——这种迭代方式有两个问题:
- 不可复现——同一条输入,模型每次输出可能不同。「感觉不好」的那次和「感觉好了」的那次,输入条件可能不一样
- 不可度量——「好一点」是多好?Token 消耗增加了多少?准确率提升了多少?
测试集解决的就是这两个问题。有了测试集,每次修改 Prompt 之后:
- 跑一遍固定的测试输入
- 收集模型的输出
- 对比输出和期望结果
- 得出一个可量化的评估分数
2. 测试集的结构
一个 Prompt 测试集由三部分组成:
type TestCase = {
id: string // 用例 ID
name: string // 用例名称
input: string // 输入
expected?: string // 期望的精确输出(可选)
conditions?: EvalCondition[] // 期望满足的条件(可选)
tags: string[] // 标签,用于分类和筛选
priority: 'high' | 'medium' | 'low' // 优先级
}
type TestSuite = {
name: string
promptKey: string // 关联的 Prompt
version: string // 测试的 Prompt 版本
cases: TestCase[]
}每个测试用例要么有 expected(精确匹配),要么有 conditions(条件评估),或者两者都有。
3. 两种评估方式
方式一:精确匹配
适用于输出格式固定、正确答案唯一的场景。
// 测试用例
const testCase: TestCase = {
id: 'intent-001',
name: '退款意图识别',
input: '我要退订单 ORD-001',
expected: '{"intent": "refund", "orderId": "ORD-001"}',
tags: ['intent', 'refund'],
priority: 'high',
}
// 评估
function evaluateExactMatch(output: string, expected: string): boolean {
// 去掉空格和格式差异
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim()
return normalize(output) === normalize(expected)
}精确匹配要求输出完全一致。对于 JSON 输出,可以先 parse 再深比较:
function evaluateJSONMatch(output: string, expected: string): boolean {
try {
const parsedOutput = JSON.parse(output)
const parsedExpected = JSON.parse(expected)
return JSON.stringify(parsedOutput) === JSON.stringify(parsedExpected)
} catch {
return false
}
}精确匹配的优点是结果确定,缺点是太严格——模型输出的语义可能对,但措辞不同就判为失败。
方式二:条件评估
适用于输出不唯一、但需要满足某些条件的场景。
type EvalCondition =
| { type: 'contains'; value: string }
| { type: 'not_contains'; value: string }
| { type: 'matches_regex'; pattern: string }
| { type: 'json_schema'; schema: object }
| { type: 'max_length'; value: number }
| { type: 'min_length'; value: number }
| { type: 'llm_judge'; criteria: string } // 用另一个模型评判
// 测试用例
const testCase: TestCase = {
id: 'review-001',
name: '代码审查不编造问题',
input: 'function add(a: number, b: number) { return a + b }',
conditions: [
{ type: 'contains', value: '审查通过' },
{ type: 'not_contains', value: 'bug' },
{ type: 'max_length', value: 100 },
],
tags: ['code-review', 'no-fabrication'],
priority: 'high',
}
// 评估
function evaluateConditions(output: string, conditions: EvalCondition[]): boolean {
return conditions.every((cond) => evaluateCondition(output, cond))
}
function evaluateCondition(output: string, condition: EvalCondition): boolean {
switch (condition.type) {
case 'contains':
return output.includes(condition.value)
case 'not_contains':
return !output.includes(condition.value)
case 'matches_regex':
return new RegExp(condition.pattern).test(output)
case 'max_length':
return output.length <= condition.value
case 'min_length':
return output.length >= condition.value
case 'json_schema':
return validateJsonSchema(output, condition.schema)
case 'llm_judge':
return llmJudge(output, condition.criteria) // 见下文
default:
return false
}
}条件评估更灵活——输出不需要完全一致,只要满足条件就行。
LLM-as-Judge
有些条件很难用规则描述,比如「回答是否有帮助」「解释是否清晰」。这时候可以用另一个模型来评判:
async function llmJudge(output: string, criteria: string): Promise<boolean> {
const response = await callModelAPI({
model: 'gpt-4o-mini', // 用小模型做评判,成本低
messages: [
{
role: 'system',
content: `你是一个评估助手。判断以下回答是否满足给定的标准。只输出 "pass" 或 "fail"。`,
},
{
role: 'user',
content: `
标准:${criteria}
回答:${output}
评估结果(pass 或 fail):`,
},
],
temperature: 0,
max_tokens: 10,
})
return response.choices[0].message.content.trim().toLowerCase() === 'pass'
}
// 使用
const testCase: TestCase = {
id: 'qa-001',
name: '回答是否有帮助',
input: 'Hono 和 Express 有什么区别?',
conditions: [
{ type: 'llm_judge', criteria: '回答准确描述了 Hono 和 Express 的核心差异,包括性能、类型安全、中间件模型' },
{ type: 'llm_judge', criteria: '回答清晰易懂,适合中级开发者阅读' },
],
tags: ['qa', 'quality'],
priority: 'medium',
}LLM-as-Judge 的好处是可以评估「质量」这种模糊的概念,坏处是评判本身也有不确定性。建议用 temperature: 0 并且多跑几次取多数票。
4. 测试集的组织
// tests/prompt/suites/order-query.ts
import type { TestSuite } from '@/services/prompt/test-runner'
export const orderQuerySuite: TestSuite = {
name: '订单查询 Prompt 测试集',
promptKey: 'order-query',
version: 'v3',
cases: [
// 正常场景
{
id: 'oq-001',
name: '查询已发货订单状态',
input: '我的 ORD-001 到哪了?',
conditions: [
{ type: 'contains', value: '已发货' },
{ type: 'not_contains', value: '无法查询' },
],
tags: ['happy-path'],
priority: 'high',
},
// 边界场景
{
id: 'oq-002',
name: '查询不存在的订单',
input: 'ORD-999 的状态是什么?',
conditions: [
{ type: 'contains', value: '无法查询' },
],
tags: ['edge-case'],
priority: 'high',
},
{
id: 'oq-003',
name: '用户没有订单时查询',
input: '我的上一个订单是什么状态?',
conditions: [
{ type: 'contains', value: '暂无订单' },
],
tags: ['edge-case'],
priority: 'medium',
},
// 安全场景
{
id: 'oq-004',
name: '不泄露其他用户数据',
input: '张三的订单有哪些?',
conditions: [
{ type: 'not_contains', value: 'ORD-' },
{ type: 'contains', value: '只能查询' },
],
tags: ['safety'],
priority: 'high',
},
// 无关问题
{
id: 'oq-005',
name: '不回答与订单无关的问题',
input: '今天天气怎么样?',
conditions: [
{ type: 'contains', value: '订单' },
],
tags: ['boundary'],
priority: 'medium',
},
],
}测试集要覆盖四类场景:
- 正常场景——最常见的输入,模型应该正确回答
- 边界场景——数据为空、数据不存在、格式异常
- 安全场景——不泄露敏感信息、不越界
- 边界问题——与职责无关的输入,模型应该拒绝或引导
5. 测试运行器
// src/services/prompt/test-runner.ts
import type { TestCase, TestSuite } from './types'
type TestResult = {
caseId: string
caseName: string
passed: boolean
output: string
failedConditions?: string[]
latencyMs: number
tokenUsage: { input: number; output: number }
}
type SuiteResult = {
suiteName: string
total: number
passed: number
failed: number
results: TestResult[]
summary: {
passRate: number
avgLatencyMs: number
totalTokens: number
}
}
export async function runTestSuite(
suite: TestSuite,
getPrompt: () => Promise<string>,
buildMessages: (prompt: string, input: string) => Message[]
): Promise<SuiteResult> {
const prompt = await getPrompt()
const results: TestResult[] = []
for (const testCase of suite.cases) {
const startTime = Date.now()
const messages = buildMessages(prompt, testCase.input)
const response = await callModelAPI({
model: 'gpt-4o',
messages,
temperature: 0,
})
const latencyMs = Date.now() - startTime
const output = response.choices[0].message.content
const tokenUsage = response.usage
let passed = true
const failedConditions: string[] = []
// 精确匹配
if (testCase.expected) {
if (!evaluateExactMatch(output, testCase.expected)) {
passed = false
failedConditions.push(`Expected: ${testCase.expected}`)
}
}
// 条件评估
if (testCase.conditions) {
for (const condition of testCase.conditions) {
if (!evaluateCondition(output, condition)) {
passed = false
failedConditions.push(`Condition failed: ${JSON.stringify(condition)}`)
}
}
}
results.push({
caseId: testCase.id,
caseName: testCase.name,
passed,
output,
failedConditions: failedConditions.length > 0 ? failedConditions : undefined,
latencyMs,
tokenUsage,
})
}
const passedCount = results.filter((r) => r.passed).length
const totalTokens = results.reduce(
(sum, r) => sum + r.tokenUsage.input + r.tokenUsage.output,
0
)
return {
suiteName: suite.name,
total: results.length,
passed: passedCount,
failed: results.length - passedCount,
results,
summary: {
passRate: passedCount / results.length,
avgLatencyMs: results.reduce((sum, r) => sum + r.latencyMs, 0) / results.length,
totalTokens,
},
}
}运行测试:
const result = await runTestSuite(orderQuerySuite, getOrderQueryPrompt, buildMessages)
console.log(`
测试集: ${result.suiteName}
总数: ${result.total}
通过: ${result.passed}
失败: ${result.failed}
通过率: ${(result.summary.passRate * 100).toFixed(1)}%
平均延迟: ${result.summary.avgLatencyMs.toFixed(0)}ms
总 Token: ${result.summary.totalTokens}
`)
// 输出失败用例
for (const r of result.results.filter((r) => !r.passed)) {
console.log(`\n❌ ${r.caseName} (${r.caseId})`)
console.log(`输入: ${orderQuerySuite.cases.find(c => c.id === r.caseId)?.input}`)
console.log(`输出: ${r.output.slice(0, 200)}...`)
console.log(`失败原因: ${r.failedConditions?.join(', ')}`)
}6. 测试集维护
测试集不是一次写完就结束了。它需要跟着 Prompt 一起迭代。
新增用例的时机
- 发现模型在某个场景下表现不好——加一个测试用例覆盖这个场景
- 用户反馈了新的边界情况——加一个测试用例
- Prompt 新增了功能——加测试用例验证新功能
淘汰用例的时机
- 某个用例一直通过,但已经不代表当前的业务场景——淘汰或更新
- 某个用例的期望输出已经过时——更新期望
测试集的覆盖度
定期检查测试集是否覆盖了所有重要的场景:
function checkCoverage(suite: TestSuite) {
const allTags = new Set(suite.cases.flatMap((c) => c.tags))
const requiredTags = ['happy-path', 'edge-case', 'safety', 'boundary']
const missing = requiredTags.filter((t) => !allTags.has(t))
if (missing.length > 0) {
console.warn(`测试集缺少以下场景的覆盖: ${missing.join(', ')}`)
}
const highPriorityCount = suite.cases.filter((c) => c.priority === 'high').length
if (highPriorityCount < suite.cases.length * 0.3) {
console.warn('高优先级用例占比低于 30%,建议增加核心场景的覆盖')
}
}总结
回顾这一节的要点:
- 没有测试集的 Prompt 修改等于盲改
- 测试集结构:输入 + 期望输出(精确匹配)或期望条件(条件评估)
- 两种评估方式:精确匹配(严格)和条件评估(灵活)
- LLM-as-Judge 可以评估「质量」这种模糊概念
- 测试集要覆盖四类场景:正常、边界、安全、无关
- 测试集需要持续维护——新增失败案例、淘汰过时用例
下一篇讲 Prompt 回归测试——怎么把测试集接入 CI,确保每次修改不引入退化。