Skip to content

课 3 · Bad Case 分析与回归测试

本课目标

系统性分析失败案例,找到根因,并建立回归测试防止已修复的问题再次出现。

关键理解:Bad Case 不是"系统出错了",而是"系统在哪类输入上不工作"——找到规律才能针对性改进。

Bad Case 分类

不是所有失败都一样,分类后才能对症下药:

类型现象根因改进方向
召回失败文档里有答案,但没被检索到切块策略、Embedding 质量、索引问题改切块、换 Embedding 模型、优化索引
幻觉文档里没有,但答案编出来了prompt 约束不够、模型倾向加引用约束、拒答策略
不完整答案只涉及部分内容多跳推理失败、上下文窗口截断增加召回数量、改 prompt
答非所问找到了相关文档,但没回答问题问题理解错误、prompt 设计Query Rewrite、改 prompt
无答案未拒答知识库没有,但给了个"答案"拒答策略缺失加置信度判断、拒答

Bad Case 收集

typescript
// packages/eval-core/src/bad-case-collector.ts
import type { AnswerEvalResult } from './batch-eval'

export interface BadCase {
  questionId: string
  question: string
  answer: string
  contexts: string[]
  metrics: {
    faithfulness: number
    answerRelevance: number
    contextPrecision: number
  }
  type?: BadCaseType
  rootCause?: string
}

export type BadCaseType =
  | 'retrieval_failure'
  | 'hallucination'
  | 'incomplete_answer'
  | 'off_topic'
  | 'missing_rejection'

const THRESHOLDS = {
  faithfulness: 0.7,
  answerRelevance: 0.7,
  contextPrecision: 0.5,
}

export function collectBadCases(
  evalResults: AnswerEvalResult[],
  allCases: Array<{ id: string; question: string; answer: string; contexts: string[] }>,
): BadCase[] {
  return evalResults
    .filter((r) =>
      r.faithfulness < THRESHOLDS.faithfulness ||
      r.answerRelevance < THRESHOLDS.answerRelevance ||
      r.contextPrecision < THRESHOLDS.contextPrecision
    )
    .map((r) => {
      const c = allCases.find((c) => c.id === r.questionId)!
      return {
        questionId: r.questionId,
        question: c.question,
        answer: c.answer,
        contexts: c.contexts,
        metrics: {
          faithfulness: r.faithfulness,
          answerRelevance: r.answerRelevance,
          contextPrecision: r.contextPrecision,
        },
      }
    })
}

LLM 辅助根因分析

typescript
// packages/eval-core/src/root-cause-analyzer.ts
const rootCauseSchema = z.object({
  type: z.enum(['retrieval_failure', 'hallucination', 'incomplete_answer', 'off_topic', 'missing_rejection']),
  analysis: z.string(),
  suggestion: z.string(),
})

export async function analyzeRootCause(badCase: BadCase) {
  const { object } = await generateObject({
    model: getModel(),
    schema: rootCauseSchema,
    prompt: `
分析以下 RAG 失败案例的根本原因。

问题:${badCase.question}

检索到的文档片段:
${badCase.contexts.map((c, i) => `[${i + 1}] ${c.slice(0, 200)}`).join('\n')}

系统答案:${badCase.answer}

指标:
- Faithfulness: ${badCase.metrics.faithfulness.toFixed(2)}(答案与文档的一致性)
- Answer Relevance: ${badCase.metrics.answerRelevance.toFixed(2)}(答案与问题的相关性)
- Context Precision: ${badCase.metrics.contextPrecision.toFixed(2)}(检索文档的精准度)

根据这些信息,判断失败类型和根因,提供改进建议。
    `.trim(),
  })

  return object
}

Bad Case 报告

typescript
// scripts/generate-bad-case-report.ts
const badCases = collectBadCases(evalResults, testCases)

// 并行分析根因
const analyzed = await Promise.all(
  badCases.map(async (bc) => ({
    ...bc,
    ...(await analyzeRootCause(bc)),
  }))
)

// 按类型统计
const byType = analyzed.reduce((acc, bc) => {
  acc[bc.type] = (acc[bc.type] ?? 0) + 1
  return acc
}, {} as Record<string, number>)

console.log('=== Bad Case 分布 ===')
Object.entries(byType).forEach(([type, count]) => {
  console.log(`${type}: ${count} 条`)
})

// 输出报告
writeFileSync('datasets/bad-cases/report.json', JSON.stringify(analyzed, null, 2))

回归测试

修复了某类 Bad Case 后,要确保下次改动不再引入:

typescript
// packages/eval-core/src/regression-test.ts

interface RegressionSuite {
  name: string
  cases: Array<{
    question: string
    expectedMinFaithfulness?: number
    expectedMinRelevance?: number
    shouldContain?: string[]    // 答案应该包含的关键词
    shouldNotContain?: string[] // 答案不应该包含的词(防幻觉)
  }>
}

export async function runRegressionTest(suite: RegressionSuite) {
  const failures: string[] = []

  for (const c of suite.cases) {
    const { answer, contexts } = await runFullPipeline(c.question)

    if (c.expectedMinFaithfulness) {
      const { score } = await evaluateFaithfulness(answer, contexts)
      if (score < c.expectedMinFaithfulness) {
        failures.push(`[${c.question}] Faithfulness ${score.toFixed(2)} < ${c.expectedMinFaithfulness}`)
      }
    }

    if (c.shouldNotContain) {
      for (const word of c.shouldNotContain) {
        if (answer.includes(word)) {
          failures.push(`[${c.question}] 答案包含禁用词: "${word}"`)
        }
      }
    }
  }

  if (failures.length > 0) {
    console.error('回归测试失败:')
    failures.forEach((f) => console.error('  ✗', f))
    process.exit(1)
  } else {
    console.log(`✓ 回归测试通过(${suite.cases.length} 个用例)`)
  }
}

在 CI 中每次改动后自动运行:

bash
# package.json
"scripts": {
  "test:regression": "tsx scripts/run-regression.ts"
}

本节产物

packages/eval-core/src/
  bad-case-collector.ts     # Bad Case 收集
  root-cause-analyzer.ts    # LLM 根因分析
  regression-test.ts        # 回归测试框架
datasets/bad-cases/
  report.json               # Bad Case 报告
scripts/
  generate-bad-case-report.ts
  run-regression.ts

面试追问

线上效果变差后如何定位?

分三步:1)看指标趋势——Faithfulness、Answer Relevance 哪个先下降?缩小到答案质量问题还是检索问题;2)拉出近期的 Bad Case,看是否有明显规律(某类问题集中失败?某个文档的内容?);3)对比变更记录——最近改了什么(新文档导入、prompt 修改、模型切换)?回滚对比,定位具体改动。

如何解决幻觉问题?

幻觉主要来自"模型倾向于回答,而不是承认不知道"。改进方式:1)在 system prompt 里明确要求"如果答案不在文档里,直接说不知道";2)用结构化输出,要求模型先给出引用编号再生成答案,没有编号就不输出内容;3)答案后校验:检查答案中每个关键声明是否能在上下文中找到原文依据,低于阈值时拒绝返回或标记为不确定。

面向前端工程师和独立开发者的 AI 应用工程课程