11-Prompt测试集

要点

  • 上一节解决了版本管理,这一节解决一个前置问题:怎么知道改完之后效果变好还是变差
  • 没有测试集的 Prompt 修改等于盲改——凭感觉判断效果,不可复现、不可度量
  • Prompt 测试集的核心:一组输入 + 每个输入的期望输出(或期望满足的条件)
  • 评估方式分两种:精确匹配(输出必须完全一致)和条件评估(输出满足某些条件即可)
  • 自动化评估 + 人工评估结合——自动化覆盖高频场景,人工覆盖边界和质量判断
  • 测试集本身也需要维护——覆盖新发现的失败案例,淘汰过时的用例

1. 为什么需要测试集

第 01 节提到过一个误区:改 Prompt 靠感觉。

「感觉这次回答不太好,改几个词试试。」——这种迭代方式有两个问题:

  1. 不可复现——同一条输入,模型每次输出可能不同。「感觉不好」的那次和「感觉好了」的那次,输入条件可能不一样
  2. 不可度量——「好一点」是多好?Token 消耗增加了多少?准确率提升了多少?

测试集解决的就是这两个问题。有了测试集,每次修改 Prompt 之后:

  1. 跑一遍固定的测试输入
  2. 收集模型的输出
  3. 对比输出和期望结果
  4. 得出一个可量化的评估分数

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',
    },
  ],
}

测试集要覆盖四类场景:

  1. 正常场景——最常见的输入,模型应该正确回答
  2. 边界场景——数据为空、数据不存在、格式异常
  3. 安全场景——不泄露敏感信息、不越界
  4. 边界问题——与职责无关的输入,模型应该拒绝或引导

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,确保每次修改不引入退化。

11-Prompt测试集 - Hono AI 项目知识体系 - AI共学社