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。
成本控制策略:
- 分层运行——本地只跑高优先级子集(10 个用例),CI 跑完整测试集(50 个用例)
- 缓存——输入和 Prompt 版本都没变的用例,复用上次结果
- 用小模型——非关键用例的测试可以用
gpt-4o-mini代替gpt-4o - 定时跑——不是每次提交都跑,而是每天定时跑一次完整测试集
// 缓存策略
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 被注入、被越狱。