12-Prompt回归测试

要点

  • 上一节讲了测试集的设计和运行,这一节讲怎么把测试接入日常开发流程
  • 回归测试的目的:每次修改 Prompt 之后,确认没有引入退化
  • 三层防线:本地开发时跑、CI 里跑、线上监控
  • Prompt 回归测试和代码回归测试的区别:模型输出有不确定性,同一输入可能每次不同
  • 处理不确定性的策略:多次运行取通过率、设置容忍阈值
  • 回归测试结果需要记录和可视化——方便追溯历史趋势

1. 三层防线

Prompt 回归测试分三层,每层的目的和运行时机不同。

┌─────────────────────────────────────────────────────────┐
│ 第三层:线上监控                                          │
│ 目的:发现测试集未覆盖的问题                               │
│ 时机:持续运行                                            │
│ 手段:日志分析、用户反馈、异常告警                          │
├─────────────────────────────────────────────────────────┤
│ 第二层:CI 回归测试                                       │
│ 目的:阻止有退化的 Prompt 修改上线                          │
│ 时机:每次提交 PR / 每次 Prompt 版本变更                    │
│ 手段:跑测试集,检查通过率                                  │
├─────────────────────────────────────────────────────────┤
│ 第一层:本地开发测试                                      │
│ 目的:快速反馈,加速开发迭代                               │
│ 时机:修改 Prompt 后,提交前                              │
│ 手段:跑子集测试,几秒出结果                               │
└─────────────────────────────────────────────────────────┘

第一层:本地开发测试

修改 Prompt 之后,在本地跑一遍测试集(或子集),快速确认效果没有明显退化。

// package.json
{
  "scripts": {
    "prompt:test": "tsx scripts/prompt-test.ts",
    "prompt:test:quick": "tsx scripts/prompt-test.ts --quick"
  }
}
// scripts/prompt-test.ts
import { runTestSuite } from '@/services/prompt/test-runner'
import { orderQuerySuite } from '@/tests/prompt/suites/order-query'
 
const isQuick = process.argv.includes('--quick')
 
async function main() {
  const suites = isQuick
    ? [filterHighPriority(orderQuerySuite)]  // quick 模式只跑高优先级用例
    : [orderQuerySuite]
 
  for (const suite of suites) {
    console.log(`\n运行测试集: ${suite.name}`)
    const result = await runTestSuite(suite, getPrompt, buildMessages)
    printResult(result)
 
    if (result.failed > 0) {
      process.exit(1)  // 有失败用例,退出码非 0
    }
  }
}
 
main()

本地测试的关键是。如果跑一次需要 5 分钟,开发者就不会经常跑。可以通过以下方式加速:

  • 只跑高优先级用例(--quick 模式)
  • 用缓存避免重复调用 API(输入相同的用例不重复调)
  • 并行调用(多个用例可以同时发给 API)

第二层:CI 回归测试

每次提交 PR 或修改 Prompt 版本时,CI 自动跑完整的测试集。通过率低于阈值就阻止合并。

# .github/workflows/prompt-regression.yml
name: Prompt 回归测试
 
on:
  pull_request:
    paths:
      - 'src/services/prompt/**'  # Prompt 相关代码变更时触发
      - 'tests/prompt/**'          # 测试集变更时触发
 
jobs:
  regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
 
      - run: pnpm install
      - run: pnpm prompt:test
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
 
      - name: 上传测试结果
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: prompt-test-results
          path: test-results/

CI 中的质量门禁:

// scripts/prompt-regression-gate.ts
const PASS_RATE_THRESHOLD = 0.95  // 通过率必须 >= 95%
const MAX_TOKEN_INCREASE = 1.2    // Token 消耗增幅不超过 20%
const MAX_LATENCY_INCREASE = 1.5  // 延迟增幅不超过 50%
 
async function checkGate(result: SuiteResult, previousResult?: SuiteResult) {
  const errors: string[] = []
 
  // 通过率检查
  if (result.summary.passRate < PASS_RATE_THRESHOLD) {
    errors.push(
      `通过率 ${(result.summary.passRate * 100).toFixed(1)}% ` +
      `低于阈值 ${(PASS_RATE_THRESHOLD * 100).toFixed(1)}%`
    )
  }
 
  // 和上一次结果对比
  if (previousResult) {
    // Token 消耗对比
    const tokenRatio = result.summary.totalTokens / previousResult.summary.totalTokens
    if (tokenRatio > MAX_TOKEN_INCREASE) {
      errors.push(
        `Token 消耗增幅 ${(tokenRatio * 100 - 100).toFixed(1)}% ` +
        `超过阈值 ${((MAX_TOKEN_INCREASE - 1) * 100).toFixed(0)}%`
      )
    }
 
    // 延迟对比
    const latencyRatio = result.summary.avgLatencyMs / previousResult.summary.avgLatencyMs
    if (latencyRatio > MAX_LATENCY_INCREASE) {
      errors.push(
        `平均延迟增幅 ${(latencyRatio * 100 - 100).toFixed(1)}% ` +
        `超过阈值 ${((MAX_LATENCY_INCREASE - 1) * 100).toFixed(0)}%`
      )
    }
 
    // 新增失败用例检查
    const newFailures = result.results.filter(
      (r) => !r.passed && previousResult.results.find((pr) => pr.caseId === r.caseId)?.passed
    )
    if (newFailures.length > 0) {
      errors.push(
        `新增 ${newFailures.length} 个失败用例: ` +
        newFailures.map((f) => f.caseName).join(', ')
      )
    }
  }
 
  if (errors.length > 0) {
    console.error('质量门禁未通过:')
    errors.forEach((e) => console.error(`  - ${e}`))
    process.exit(1)
  }
 
  console.log('质量门禁通过')
}

第三层:线上监控

测试集覆盖的毕竟是已知的场景。线上真实用户的输入可能有测试集没覆盖到的情况。

线上监控要做的:

// src/middleware/prompt-monitor.ts
 
// 记录每次 Prompt 调用的输入输出
async function logPromptCall(params: {
  promptKey: string
  promptVersion: string
  input: string
  output: string
  latencyMs: number
  tokenUsage: { input: number; output: number }
  userId: string
}) {
  await db.query(
    'INSERT INTO prompt_call_logs ...',
    [params.promptKey, params.promptVersion, params.input, params.output,
     params.latencyMs, params.tokenUsage.input, params.tokenUsage.output,
     params.userId, new Date().toISOString()]
  )
}
 
// 定时分析异常模式
async function detectAnomalies() {
  // 1. 错误率突增
  const recentErrors = await db.query(
    'SELECT COUNT(*) as error_count FROM prompt_call_logs WHERE created_at > datetime("now", "-1 hour") AND status = "error"'
  )
  if (recentErrors[0].error_count > ERROR_THRESHOLD) {
    await sendAlert('Prompt 错误率突增', recentErrors)
  }
 
  // 2. 延迟突增
  const recentLatency = await db.query(
    'SELECT AVG(latency_ms) as avg_latency FROM prompt_call_logs WHERE created_at > datetime("now", "-1 hour")'
  )
  if (recentLatency[0].avg_latency > LATENCY_THRESHOLD) {
    await sendAlert('Prompt 平均延迟突增', recentLatency)
  }
 
  // 3. 输出长度异常
  const longOutputs = await db.query(
    'SELECT COUNT(*) as count FROM prompt_call_logs WHERE created_at > datetime("now", "-1 hour") AND LENGTH(output) > ?',
    [OUTPUT_LENGTH_THRESHOLD]
  )
  if (longOutputs[0].count > LONG_OUTPUT_THRESHOLD) {
    await sendAlert('Prompt 输出长度异常', longOutputs)
  }
}

2. 处理输出的不确定性

代码回归测试是确定的——同样的输入,同样的代码,结果一定相同。

Prompt 回归测试是不确定的——同样的输入,同样的 Prompt,模型每次输出可能不同。

这意味着:跑一次测试集,通过率可能是 90%;再跑一次,可能变成 85%。

策略一:多次运行取平均

async function runWithRetry(
  suite: TestSuite,
  runs: number = 3
): Promise<SuiteResult> {
  const allResults: SuiteResult[] = []
 
  for (let i = 0; i < runs; i++) {
    const result = await runTestSuite(suite, getPrompt, buildMessages)
    allResults.push(result)
  }
 
  // 一个用例只有在所有运行中都失败了,才算真正失败
  const caseResults = suite.cases.map((testCase) => {
    const passedCount = allResults.filter((r) =>
      r.results.find((cr) => cr.caseId === testCase.id)?.passed
    ).length
    return {
      caseId: testCase.id,
      passed: passedCount > runs / 2,  // 多数通过
      passRate: passedCount / runs,
    }
  })
 
  // ... 汇总结果
}

代价是 API 调用量翻了 N 倍。适合核心场景的测试,不适合全量测试。

策略二:设置容忍阈值

不要求 100% 通过,允许一定比例的失败。

// CI 质量门禁
const PASS_RATE_THRESHOLD = 0.90  // 允许 10% 的不确定性失败
const CONSISTENCY_THRESHOLD = 0.70  // 每个用例至少 70% 的运行通过

阈值的设定需要经验:先跑几次,看看通过率一般在什么范围。如果正常情况下通过率在 85%-95%,那阈值设为 80% 是合理的。

策略三:区分确定性用例和非确定性用例

type TestCase = {
  // ...
  deterministic: boolean  // 是否确定性用例
}
 
// 确定性用例:输出固定,必须 100% 通过
// 例如:JSON 格式校验、安全规则
const deterministicCases = suite.cases.filter((c) => c.deterministic)
 
// 非确定性用例:输出有变化,允许多次运行中的通过率 >= 阈值
const nonDeterministicCases = suite.cases.filter((c) => !c.deterministic)

安全规则、格式校验这类用例应该是确定性的——如果模型连格式都无法保证,说明 Prompt 有严重问题。

3. 测试结果记录和可视化

测试结果需要记录,方便追溯历史趋势。

// 存储测试结果
async function saveTestResult(result: SuiteResult, metadata: {
  promptVersion: string
  gitCommit: string
  triggeredBy: string  // 'local' | 'ci' | 'manual'
}) {
  await db.query(
    'INSERT INTO prompt_test_results ...',
    [result.suiteName, result.summary.passRate, result.summary.totalTokens,
     result.summary.avgLatencyMs, metadata.promptVersion, metadata.gitCommit,
     metadata.triggeredBy, new Date().toISOString()]
  )
 
  // 存储每个用例的详细结果
  for (const r of result.results) {
    await db.query(
      'INSERT INTO prompt_test_case_results ...',
      [result.suiteName, r.caseId, r.passed, r.latencyMs, r.output, ...]
    )
  }
}

可视化报告:

// scripts/prompt-test-report.ts
async function generateReport(suiteName: string, days: number = 30) {
  const results = await db.query(
    'SELECT * FROM prompt_test_results WHERE suite_name = ? AND created_at > datetime("now", ?) ORDER BY created_at',
    [suiteName, `-${days} days`]
  )
 
  // 生成 Markdown 报告
  const report = `
# Prompt 测试报告
 
## ${suiteName}
 
### 趋势(最近 ${days} 天)
| 日期 | 版本 | 通过率 | Token | 延迟 | 触发 |
|---|---|---|---|---|---|
${results.map((r: any) =>
  `| ${r.created_at} | ${r.prompt_version} | ${(r.pass_rate * 100).toFixed(1)}% | ${r.total_tokens} | ${r.avg_latency_ms.toFixed(0)}ms | ${r.triggered_by} |`
).join('\n')}
 
### 最近失败的用例
${await getRecentFailures(suiteName, days)}
`
 
  return report
}

4. 一个完整的回归测试流程

把三层防线整合起来:

开发者修改 Prompt
       ↓
本地跑 prompt:test:quick(30 秒内出结果)
       ↓
  通过?──否──→ 继续修改
       ↓ 是
提交 PR
       ↓
CI 跑 prompt:test(完整测试集,3-5 分钟)
       ↓
  质量门禁通过?──否──→ PR 不可合并
       ↓ 是
合并到 main
       ↓
部署到预发布环境
       ↓
跑预发布测试集(用预发布环境的数据)
       ↓
  通过?──否──→ 回滚
       ↓ 是
灰度发布(10% 流量)
       ↓
线上监控 30 分钟
       ↓
  指标正常?──否──→ 回滚
       ↓ 是
全量发布

5. 回归测试的成本控制

Prompt 回归测试的主要成本是 API 调用费用。一个 50 个用例的测试集,跑一次可能消耗 10 万 Token,成本约 $0.01-0.05。

如果每天跑 10 次(每个开发者本地跑几次、CI 跑几次),一个月大约 $3-15。

成本控制策略:

  1. 分层运行——本地只跑高优先级子集(10 个用例),CI 跑完整测试集(50 个用例)
  2. 缓存——输入和 Prompt 版本都没变的用例,复用上次结果
  3. 用小模型——非关键用例的测试可以用 gpt-4o-mini 代替 gpt-4o
  4. 定时跑——不是每次提交都跑,而是每天定时跑一次完整测试集
// 缓存策略
async function runTestSuiteWithCache(suite: TestSuite) {
  const results: TestResult[] = []
 
  for (const testCase of suite.cases) {
    const cacheKey = computeCacheKey(testCase.input, getCurrentPromptVersion())
    const cached = await getFromCache(cacheKey)
 
    if (cached) {
      results.push(cached)  // 复用缓存
    } else {
      const result = await runSingleCase(testCase)
      await saveToCache(cacheKey, result, { ttl: 3600 })  // 缓存 1 小时
      results.push(result)
    }
  }
 
  return results
}

总结

回顾这一节的要点:

  • 三层防线:本地快速测试、CI 回归测试、线上监控
  • CI 质量门禁:通过率阈值、Token 增幅限制、延迟增幅限制、新增失败用例检查
  • 模型输出有不确定性——多次运行取平均、设置容忍阈值、区分确定性和非确定性用例
  • 测试结果需要记录和可视化——方便追溯历史趋势
  • 成本控制:分层运行、缓存、小模型、定时运行

下一篇讲 Prompt 安全防护——怎么防止 Prompt 被注入、被越狱。