传统 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:
- Network 标签页 → 找到 SSE 请求(通常是
EventStream类型) - 点击请求 → EventStream 子标签页
- 可以看到每条推送的数据
5.4 Prompt 调试:是 Prompt 的问题还是模型的问题?
这是 Agent 开发中最头疼的问题。模型输出不符合预期时,原因可能是:
- Prompt 写得不够清晰 --- 模型没理解你要什么
- 模型能力不足 --- 理解了但做不到
- 上下文太长被截断 --- 关键信息在截断部分
- 温度参数太高 --- 输出随机性过大
排查流程
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 测试的核心思路:
- 能自动化的必须自动化 --- 格式验证、JSON 解析、基本质量检查
- 不能自动化的要有流程 --- 语义质量评估建立 Golden Set,定期人工 review
- 调试要分层 --- 先确认格式、再确认内容、最后优化质量
- 打印实际 Prompt 是第一优先级 --- 你以为发的和实际发的经常不一样
- 流式响应多加日志 --- WebSocket 问题 90% 是命名空间或事件名不匹配
- RAG 要单独验证 --- 检索质量直接决定生成质量
- 监控要从第一天开始 --- 至少记录成功率、耗时、token 用量
一句话总结: Agent 测试的黄金法则是 --- 信任模型的能力,但验证它的每一次输出。
下一章:第六章:常见坑 & 最佳实践