测试与调试:怎么验证你的 AI Agent 真的能用

传统 API 返回固定 JSON,写个断言就能测。Agent 的输出是非确定性的自然语言或半结构化数据 --- 同样的输入,跑两次可能得到不同的结果。这让测试变成了一门"艺术"。

本章从实战出发,教你如何系统性地验证 Agent 的可用性,快速定位问题根因。


5.1 Agent 测试的核心难题

先说清楚为什么 Agent 测试和传统后端测试不一样:

对比维度 传统 API Agent
输出确定性 同输入 → 同输出 同输入 → 不同输出
验证方式 精确断言 (assert.equal) 模式匹配 + 人工审查
失败模式 报错 / 返回错误码 输出"看起来对但实际不对"
调试信号 堆栈跟踪、日志 Prompt 语义、模型行为
耗时 毫秒级 秒 ~ 分钟级
成本 几乎免费 每次调用都花钱

这意味着你不能简单地用 Jest 写一堆 expect(result).toBe(expected) 就完事。你需要分层测试策略


5.2 分层测试策略

第一层:输入输出格式验证(必须自动化)

不管模型生成什么内容,格式必须正确。这是可以精确断言的部分。

typescript 复制代码
// 验证生成的测试用例格式是否正确
function validateTestCaseFormat(testCase: any): boolean {
  // 必须有这些字段
  const requiredFields = ['title', 'steps', 'expectedResult', 'priority']
  for (const field of requiredFields) {
    if (!testCase[field]) {
      console.error(`缺少必要字段: ${field}`)
      return false
    }
  }

  // priority 必须是合法枚举值
  const validPriorities = ['P0', 'P1', 'P2', 'P3']
  if (!validPriorities.includes(testCase.priority)) {
    console.error(`非法优先级: ${testCase.priority}`)
    return false
  }

  // steps 必须是数组且非空
  if (!Array.isArray(testCase.steps) || testCase.steps.length === 0) {
    console.error('步骤不能为空')
    return false
  }

  return true
}

在我们的项目中,validate.step.ts 就是专门做这件事的 --- 在保存到数据库之前,强制校验每个生成结果的 JSON Schema:

typescript 复制代码
// apps/server/src/modules/generation/pipeline/steps/validate.step.ts
// 实际项目中的验证逻辑
for (const testCase of generatedCases) {
  if (!testCase.requirementId) {
    // 如果模型没返回 requirementId,自动补上主需求 ID
    testCase.requirementId = context.primaryRequirementId
  }
}

关键原则: 格式验证不依赖模型行为,100% 可自动化,必须做。


第二层:JSON 解析的防御性处理

模型返回的 JSON 经常"差一点点"就对了 --- 多个逗号、少个括号、混入了 Markdown 代码块标记。你需要多层解析策略:

typescript 复制代码
function parseModelJSON(raw: string): any {
  // 第一层:直接解析
  try {
    return JSON.parse(raw)
  } catch (e) {
    // 继续尝试
  }

  // 第二层:清理常见问题后解析
  try {
    let cleaned = raw
      .replace(/```json\s*/g, '')   // 去掉 Markdown 代码块标记
      .replace(/```\s*/g, '')
      .replace(/,\s*([}\]])/g, '$1') // 去掉尾逗号
      .trim()
    return JSON.parse(cleaned)
  } catch (e) {
    // 继续尝试
  }

  // 第三层:用正则提取 JSON 对象
  try {
    const match = raw.match(/\[[\s\S]*\]/)
    if (match) {
      return JSON.parse(match[0])
    }
  } catch (e) {
    // 放弃
  }

  throw new Error('无法解析模型返回的 JSON')
}

这不是过度防御 --- 这是生产环境的必需品。我们在实际项目中见过各种奇怪的 JSON 格式问题,三层解析策略把成功率从 ~85% 提升到了 ~98%。


第三层:语义质量的人工 + 半自动评估

输出内容"对不对"只能靠人看,但你可以用一些启发式规则做初步过滤:

typescript 复制代码
// 启发式质量检查
function quickQualityCheck(testCases: TestCase[], requirement: string): QualityReport {
  const issues: string[] = []

  // 检查 1:数量是否合理
  if (testCases.length < 3) {
    issues.push(`只生成了 ${testCases.length} 个用例,可能覆盖不足`)
  }
  if (testCases.length > 50) {
    issues.push(`生成了 ${testCases.length} 个用例,可能存在重复或过于细碎`)
  }

  // 检查 2:是否有重复标题
  const titles = testCases.map(tc => tc.title)
  const uniqueTitles = new Set(titles)
  if (uniqueTitles.size < titles.length) {
    issues.push(`存在 ${titles.length - uniqueTitles.size} 个重复标题`)
  }

  // 检查 3:优先级分布是否合理
  const p0Count = testCases.filter(tc => tc.priority === 'P0').length
  if (p0Count > testCases.length * 0.5) {
    issues.push(`${p0Count}/${testCases.length} 个用例标为 P0,优先级通胀`)
  }

  // 检查 4:步骤是否过于简单
  const shortSteps = testCases.filter(tc =>
    tc.steps.length === 1 && tc.steps[0].length < 10
  )
  if (shortSteps.length > 0) {
    issues.push(`${shortSteps.length} 个用例的步骤过于简单`)
  }

  return {
    passed: issues.length === 0,
    issues,
    summary: issues.length === 0
      ? '质量检查通过'
      : `发现 ${issues.length} 个潜在问题`
  }
}

5.3 流式响应的调试技巧

Agent 开发中很大一部分用了流式响应(Streaming)--- 模型一边生成一边往前端推送。这带来了独特的调试挑战。

WebSocket 调试

我们的项目用 socket.io 做实时推送。调试时最常见的问题是:消息发了但前端没收到

排查步骤:

scss 复制代码
1. 确认 WebSocket 连接是否建立
   → 浏览器 DevTools → Network → WS 标签页
   → 看到 "connected" 就是建立了

2. 确认事件名是否匹配
   → 后端 emit('generationTaskUpdate', data)
   → 前端 on('generationTaskUpdate', callback)
   → 大小写必须完全一致!

3. 确认命名空间是否一致
   → 后端 @WebSocketGateway({ namespace: '/tasks' })
   → 前端 io('http://localhost:3000/tasks')
   → 少写 /tasks 就收不到任何消息

4. 确认序列化问题
   → socket.io 默认用 JSON 序列化
   → Date 对象、BigInt、循环引用会导致消息丢失

实用调试代码:

typescript 复制代码
// 后端:加一行日志确认消息已发送
this.server.emit('generationTaskUpdate', data)
console.log('[WS] Emitted generationTaskUpdate:', JSON.stringify(data).slice(0, 200))

// 前端:监听所有事件(调试时用)
socket.onAny((eventName, ...args) => {
  console.log(`[WS] Received: ${eventName}`, args)
})

SSE (Server-Sent Events) 调试

如果你用的是 SSE 而不是 WebSocket,调试方式略有不同:

typescript 复制代码
// 后端 NestJS SSE 端点
@Sse('stream')
stream(): Observable<MessageEvent> {
  return new Observable(subscriber => {
    // 每次 emit 都记日志
    const emit = (data: any) => {
      console.log('[SSE] Sending:', JSON.stringify(data).slice(0, 100))
      subscriber.next({ data: JSON.stringify(data) })
    }

    // ... 业务逻辑
  })
}

浏览器 DevTools 看 SSE:

  1. Network 标签页 → 找到 SSE 请求(通常是 EventStream 类型)
  2. 点击请求 → EventStream 子标签页
  3. 可以看到每条推送的数据

5.4 Prompt 调试:是 Prompt 的问题还是模型的问题?

这是 Agent 开发中最头疼的问题。模型输出不符合预期时,原因可能是:

  1. Prompt 写得不够清晰 --- 模型没理解你要什么
  2. 模型能力不足 --- 理解了但做不到
  3. 上下文太长被截断 --- 关键信息在截断部分
  4. 温度参数太高 --- 输出随机性过大

排查流程

markdown 复制代码
输出不符预期
    │
    ├─ 换一个更强的模型试试 → 换了就好 → 模型能力问题
    │                        → 换了也不行 → 继续排查
    │
    ├─ 简化 Prompt(只保留核心指令)→ 简化就好 → Prompt 太复杂/有歧义
    │                               → 简化也不行 → 继续排查
    │
    ├─ 检查实际发送的 Prompt(打日志)→ 发现上下文被截断 → 调整 token 限制
    │                                → 内容完整 → 继续排查
    │
    └─ 降低 temperature(设为 0)→ 稳定了 → 随机性问题
                                → 还是不行 → 需要根本性改 Prompt 结构

打印实际发送的 Prompt

这是最重要的调试手段。你以为发送的 Prompt 和实际发送的 Prompt 经常不一样:

typescript 复制代码
// 在调用 AI 之前,完整打印 Prompt
async generateTestCases(context: GenerationContext) {
  const messages = this.buildMessages(context)

  // 调试时打开这行
  console.log('[AI Debug] Full prompt:')
  console.log(JSON.stringify(messages, null, 2))
  console.log(`[AI Debug] Total chars: ${JSON.stringify(messages).length}`)

  const response = await this.client.chat(messages)
  return response
}

我们项目中踩过的一个坑:RAG 检索到的"相似需求"太多,导致 Prompt 超过了模型的上下文窗口,关键的生成规则(Rules)被截断了。解决方法是把 Rules 放在 Prompt 最前面,保证不被截断。

对比不同模型的输出

typescript 复制代码
// 快速对比工具:同一个 Prompt 发给不同模型
async function compareModelOutputs(prompt: string) {
  const models = ['claude-sonnet-4-5-20250929', 'doubao-seed-2-0-pro', 'gpt-4o']

  for (const model of models) {
    console.log(`\n===== ${model} =====`)
    const start = Date.now()
    const result = await callModel(model, prompt)
    const elapsed = Date.now() - start

    console.log(`Time: ${elapsed}ms`)
    console.log(`Output length: ${result.length} chars`)
    console.log(`Preview: ${result.slice(0, 500)}`)
    console.log('='.repeat(50))
  }
}

在我们的实际测试中,同一个需求文档:

  • Claude Sonnet 4.5 生成了 608 行测试用例
  • 豆包 2.0 Pro 生成了 225 行

数量差 3 倍不代表质量差 3 倍 --- 需要人工评估覆盖率和准确性。


5.5 RAG 检索质量验证

RAG(检索增强生成)的效果很大程度取决于检索质量。检索到的内容不相关,模型生成的结果就会跑偏。

相似度阈值调优

typescript 复制代码
// 我们项目中的相似度搜索
const results = await this.embeddingService.searchSimilar(
  'Requirement',
  queryText,
  {
    threshold: 0.65,  // 相似度阈值,0-1 之间
    limit: 5,         // 最多返回几条
    projectId
  }
)

阈值怎么调?

阈值 效果 适用场景
0.5 召回多但噪声大 数据量少,宁可多给上下文
0.65 平衡点(推荐默认值) 数据量中等,多数场景适用
0.8 精确但容易漏 数据量大,只要高度相关的

手动验证检索质量

写一个简单的脚本,输入查询看看检索到了什么:

typescript 复制代码
// 检索质量验证脚本
async function testSearchQuality() {
  const testQueries = [
    '扫码跳转小程序',
    '用户登录异常处理',
    '支付超时重试'
  ]

  for (const query of testQueries) {
    console.log(`\n🔍 Query: "${query}"`)
    const results = await embeddingService.searchSimilar(
      'Requirement', query, { threshold: 0.5, limit: 10 }
    )

    for (const r of results) {
      const emoji = r.similarity > 0.7 ? '✅' : r.similarity > 0.6 ? '🟡' : '🔴'
      console.log(`  ${emoji} [${r.similarity.toFixed(3)}] ${r.title}`)
    }

    if (results.length === 0) {
      console.log('  ⚠️ 没有找到任何相关结果')
    }
  }
}

常见问题及解决:

现象 原因 解决方案
明显相关的文档搜不到 Embedding 还没生成 运行 backfill 接口
搜到的都不相关 查询文本太短或太泛 扩展查询词,加上下文
中文搜索效果差 Embedding 模型对中文支持弱 换用中文优化的模型
长文档匹配分数低 文本被截断,只 embed 了开头部分 实现分段 embedding

Embedding 覆盖率检查

typescript 复制代码
// 检查有多少数据还没有 embedding
async function checkEmbeddingCoverage() {
  const total = await prisma.requirement.count()
  const withEmbedding = await prisma.requirement.count({
    where: { embedding: { not: null } }
  })

  const coverage = ((withEmbedding / total) * 100).toFixed(1)
  console.log(`Embedding 覆盖率: ${withEmbedding}/${total} (${coverage}%)`)

  if (coverage < 80) {
    console.log('⚠️ 覆盖率过低,建议运行 backfill')
  }
}

5.6 常见报错排查速查表

API 调用类错误

vbnet 复制代码
❌ Error: 401 Unauthorized
   → API Key 配置错误或过期
   → 检查 Setting 表中的 api_key 配置
   → 注意:不同模型供应商的 key 是独立的

❌ Error: 429 Too Many Requests
   → 触发了速率限制
   → 添加请求间隔:await sleep(1000)
   → 或降低并发数

❌ Error: Request timeout (30s)
   → 模型响应太慢(通常是输入太长)
   → 增大超时时间
   → 或减少输入 token 数

❌ Error: context_length_exceeded
   → 输入 + 输出超过了模型的上下文窗口
   → 减少 RAG 检索数量
   → 或截断过长的需求文档
   → 或换用更大上下文窗口的模型

JSON 解析类错误

javascript 复制代码
❌ SyntaxError: Unexpected token
   → 模型返回的 JSON 格式不正确
   → 用多层解析策略(见 5.2 节)
   → 检查是否混入了 Markdown 代码块标记

❌ 返回了空数组 []
   → Prompt 中的约束可能太严格,模型"不敢"生成
   → 检查 temperature 是否为 0(太低可能导致保守输出)
   → 或者输入需求太模糊,模型无法提取测试点

WebSocket 类错误

arduino 复制代码
❌ 前端收不到消息
   → 检查 namespace 是否一致(/tasks)
   → 检查事件名大小写
   → 确认后端确实调用了 emit

❌ 连接频繁断开
   → 检查心跳间隔配置
   → 服务端是否有 CORS 限制
   → 是否有反向代理超时配置(nginx 默认 60s)

❌ 消息顺序混乱
   → 流式推送本身不保证顺序
   → 给每条消息加序号字段
   → 前端按序号排序后再渲染

Embedding 类错误

c 复制代码
❌ Embedding API 返回 400
   → 输入文本为空或超过 token 限制
   → 加入文本截断逻辑(推荐 8000 字符以内)

❌ 向量维度不匹配
   → 数据库定义的维度与 API 返回的维度不一致
   → 检查 pgvector 列定义:vector(1024)
   → 确认 Embedding 模型输出维度

❌ 相似度搜索无结果
   → 确认 pgvector 扩展已安装:CREATE EXTENSION vector
   → 确认目标表的 embedding 列有数据
   → 降低 threshold 试试

5.7 端到端测试策略

Agent 的端到端测试不是写一次就不管了 --- 你需要持续验证,因为:

  • 模型供应商可能更新了模型版本
  • Prompt 改动可能引入回归
  • 数据变化可能影响 RAG 检索质量

建立基准测试集(Golden Set)

typescript 复制代码
// golden-test.ts
interface GoldenTestCase {
  name: string
  input: {
    requirement: string
    rules: string[]
  }
  expectations: {
    minCaseCount: number       // 至少生成几个用例
    maxCaseCount: number       // 最多生成几个用例
    mustCoverKeywords: string[] // 输出中必须包含的关键词
    forbiddenKeywords: string[] // 输出中不应出现的内容
    formatValid: boolean        // 格式必须正确
  }
}

const goldenSet: GoldenTestCase[] = [
  {
    name: '登录功能 - 基础覆盖',
    input: {
      requirement: '用户输入手机号和验证码登录,支持记住登录状态',
      rules: ['覆盖正常和异常场景', '包含边界条件测试']
    },
    expectations: {
      minCaseCount: 5,
      maxCaseCount: 30,
      mustCoverKeywords: ['手机号', '验证码', '登录'],
      forbiddenKeywords: ['注册', '忘记密码'],  // 不应生成超出需求范围的用例
      formatValid: true
    }
  }
]

自动化回归验证

typescript 复制代码
async function runGoldenTests() {
  let passed = 0
  let failed = 0

  for (const test of goldenSet) {
    console.log(`\n🧪 Running: ${test.name}`)

    const result = await generateTestCases(test.input)
    const issues: string[] = []

    // 格式验证
    if (test.expectations.formatValid) {
      for (const tc of result) {
        if (!validateTestCaseFormat(tc)) {
          issues.push('格式验证失败')
          break
        }
      }
    }

    // 数量验证
    if (result.length < test.expectations.minCaseCount) {
      issues.push(`用例数 ${result.length} < 最小要求 ${test.expectations.minCaseCount}`)
    }
    if (result.length > test.expectations.maxCaseCount) {
      issues.push(`用例数 ${result.length} > 最大限制 ${test.expectations.maxCaseCount}`)
    }

    // 关键词覆盖
    const allText = result.map(tc => tc.title + tc.steps.join('')).join(' ')
    for (const kw of test.expectations.mustCoverKeywords) {
      if (!allText.includes(kw)) {
        issues.push(`缺少关键词: "${kw}"`)
      }
    }
    for (const kw of test.expectations.forbiddenKeywords) {
      if (allText.includes(kw)) {
        issues.push(`包含禁止关键词: "${kw}"`)
      }
    }

    if (issues.length === 0) {
      console.log('  ✅ PASSED')
      passed++
    } else {
      console.log('  ❌ FAILED')
      issues.forEach(i => console.log(`     - ${i}`))
      failed++
    }
  }

  console.log(`\n📊 Results: ${passed} passed, ${failed} failed`)
}

5.8 生产环境监控要点

部署上线后,你需要持续关注这几个指标:

typescript 复制代码
// 建议监控的关键指标
const metrics = {
  // 可用性
  apiSuccessRate: '模型 API 调用成功率(目标 > 99%)',
  avgResponseTime: '平均响应时间(目标 < 30s)',

  // 质量
  formatValidRate: '返回格式合法率(目标 > 95%)',
  emptyResultRate: '空结果率(目标 < 5%)',

  // 成本
  avgTokensPerRequest: '每次请求的平均 token 数',
  dailyCost: '每日 API 调用费用',

  // 用户体验
  taskCompletionRate: '任务完成率(非取消/失败)',
  avgTaskDuration: '平均任务耗时'
}

最简单的监控方案 --- 日志分析:

typescript 复制代码
// 在每次 AI 调用完成后记录关键信息
function logAICall(params: {
  model: string
  inputTokens: number
  outputTokens: number
  duration: number
  success: boolean
  error?: string
}) {
  const logLine = JSON.stringify({
    timestamp: new Date().toISOString(),
    type: 'ai_call',
    ...params
  })

  // 写入日志文件,后续用脚本分析
  appendFileSync('logs/ai-calls.log', logLine + '\n')
}

5.9 本章小结

Agent 测试的核心思路:

  1. 能自动化的必须自动化 --- 格式验证、JSON 解析、基本质量检查
  2. 不能自动化的要有流程 --- 语义质量评估建立 Golden Set,定期人工 review
  3. 调试要分层 --- 先确认格式、再确认内容、最后优化质量
  4. 打印实际 Prompt 是第一优先级 --- 你以为发的和实际发的经常不一样
  5. 流式响应多加日志 --- WebSocket 问题 90% 是命名空间或事件名不匹配
  6. RAG 要单独验证 --- 检索质量直接决定生成质量
  7. 监控要从第一天开始 --- 至少记录成功率、耗时、token 用量

一句话总结: Agent 测试的黄金法则是 --- 信任模型的能力,但验证它的每一次输出。


下一章:第六章:常见坑 & 最佳实践

相关推荐
Div布局师3 小时前
实战篇:AI Agent 后端实现细节
aigc
量子位3 小时前
打败GPT-5.2,嵌入真实工业生产,这个大模型什么来头?
chatgpt·ai编程
量子位3 小时前
卡帕西开源Agent自进化训练框架,5分钟一轮实验,48h内揽星9.5k
aigc·agent
海上日出4 小时前
Claude Code 2.1 深度实测:1096 次提交背后的 AI 编程革命
ai编程
DigitalOcean4 小时前
如何在云端运行Kimi K2.5:从配置到部署全攻略
llm·aigc
架构技术专栏4 小时前
OpenClaw 个人 AI 助手本地部署与配置权威指南
aigc·openai·ai编程
yes的练级攻略4 小时前
装了 OpenClaw 后,信用卡被盗刷了...
aigc·ai编程
sorryhc5 小时前
我让 AI 帮我写了一个 Code Agent!
前端·openai·ai编程