指出Agent评测应关注工具调用决策而非基准分数,提供了构建自动化Eval Suite的具体方法,包括测试工具选择、参数传递、风险行为检测等。
每隔一周,似乎就会有一款新模型带着某个"相信我,数据是真的"基准测试的光鲜分数出现。数字一路攀升,人们说它更聪明了,突然间你就准备换用它了。如果你在这些模型之上构建应用,你可能会理所当然地认为,更高的基准分数就意味着更好的 AI 智能体表现。但基准测试衡量的是受控条件下的狭窄技能,而你的 AI 智能体有特定的工作要做。如果你正在构建一个客服 AI 智能体,你不会关心推理测试上多出的几分。你关心的是它是否调用了正确的工具、传递了正确的参数、避免了危险或冗余的操作。这才是真正重要的行为。
测试一个工具调用 AI 智能体意味着测试它的决策,而不仅仅是它的措辞。一个 AI 智能体可以在悄然搞砸一切的同时听起来完全令人信服。当它回答"您的退款已处理"时,回复听起来没问题——但它查询了正确的订单了吗?退了正确的金额了吗?是否不小心两次调用了退款 API?最终的回复不会告诉你这些。
你真正需要的是一套 eval 套件:一组自动化的测试,对 AI 智能体的表现打分。Eval 是 evaluation(评估)的缩写:每个测试在一个场景中运行 AI 智能体,向评分器提问"这个行为正确吗?",然后将答案转换为一个可以跟踪和门控的分数。传统软件测试和 prompt-and-response 评估在这里都不够用。单元测试断言一个函数对于给定输入返回正确的值。然而 AI 智能体会做出一系列决策(调用哪个工具、传递什么参数、按什么顺序),每一步决策都是非确定性的。同一个 prompt 每次运行都可能产生不同的工具调用路径。一次手动测试告诉你的是 AI 智能体能做什么,而不是它通常做什么。
所以 eval 套件应该回答三个问题:
选择(Selection):AI 智能体是否为请求挑选了正确的工具?
轨迹(Trajectory):它是否以正确的顺序、正确的参数调用了正确的工具,没有浪费或危险的调用?
结果(Outcome):最终答案是否正确,且基于工具返回的内容?
在本指南中,你将精确构建这样一个套件。使用 Mastra,你将创建一个客服 AI 智能体,它可以查询订单、处理退款,以及升级给人工。然后你将为它编写一套分层的评估套件:
快速检查(Quick Checks):零 LLM、确定性断言,如"AI 智能体必须调用 lookup_order"和"不允许任何工具调用出错"
门控与裁决(Gates and verdicts):硬性通过/失败要求,一旦失败则整个运行立即终止
轨迹评分器(Trajectory scorers):验证完整的工具调用序列,包括参数、步骤预算和黑名单工具
LLM-as-a-judge 评分器:用于精确匹配过于僵化的情况的语义评分
Vitest 套件:使整个套件在每次变更时在 CI 中运行
在代码之前:什么是 eval
在我们开始敲代码之前,让我们先理清一个心智模型,因为这正是本指南(以及 Mastra 的 API)所假设的词汇。一个 eval 由三部分组成:一个测试用例、一个预期行为和一个评分器。
输入(Input):"我想退订订单 1001"
预期行为(Expected behavior):查询订单 1001,然后处理退款。
评分器(Grader):这些事情是否按顺序发生了,订单 ID 是否正确?
分数(Score):正确得 1 分,不正确得 0 分。
与传统的单元测试不同,你通常会让 AI 智能体多次运行相同的输入,因为 AI 智能体的行为不是确定性的。同一个 prompt 可能在不同运行中调用不同的工具,所以你会对多次运行打分并取平均值,而不是信任单次通过。
大量术语不断出现,现在就把它们固定下来:
其中两个比其余的更重要,让我们单独指出。门控(Gate)是一个硬性要求:如果不通过,整个运行就失败,立即停止。阈值(Threshold)是一个更软的质量线,比如"平均相关性高于 0.8",运行可以miss掉这个指标但仍然可用。记住这个区别;我们将在下面用一整节来讨论它。
最后一个原则,可以说也是本指南中最重要的教训:评分器只有在相对于被评分的场景时才有意义。评分器"AI 智能体必须调用 lookup_order"对于订单状态查询是完全正确的,但它应该对"法国的首都是什么?"这个问题失败——而那个失败不是 AI 智能体的 bug。它只是意味着这个评分器属于一个不同于这个离题问题的场景。正确的做法从来不是更弱、更模糊的检查器;而是组织你的测试用例,让每个场景获得与其自身预期相匹配的评分器。
为什么测试工具调用 AI 智能体是不同的
当用户向 AI 智能体发送请求时,一系列决策按顺序发生。AI 智能体决定调用哪个工具(如果有的话)、构建参数、执行调用、读取结果,然后要么调用另一个工具,要么综合一个最终答案。每个步骤都会引入一种独特的失败模式:
工具选择错误。用户要求退款,AI 智能体调用了 lookup_order 但从未调用 process_refund,然后声称退款已处理。
参数错误。AI 智能体调用了正确的工具,但传递了 orderId: "100" 而不是 "1001",得到了空结果,然后在上面产生幻觉。
顺序错误。AI 智能体在验证订单存在之前就退款了,因为 process_refund "更容易"先调用。
低效或循环的轨迹。AI 智能体用相同的参数重复调用同一个工具,消耗 token 和延迟。
综合错误。每个工具调用都成功了,但最终答案与工具返回的内容相矛盾。
注意,如果你只测试最终响应,上面只有最后一个是可见的。这就是为什么更广泛的 AI 智能体评估领域收敛到了一种分层方法:对于可以客观检查的一切(工具名称、参数、调用顺序、错误率),使用确定性的、基于代码的评分器;只为语义问题(这个工具选择是否合适?答案是否有帮助?)保留 LLM-as-a-judge 评分器。确定性检查是免费、即时且可复现的,所以它们应该承担你套件中尽可能多的部分。
本指南中的示例 AI 智能体是有意做得很小的,但上面的失败模式正是 τ-bench 和 τ²-bench 在规模上衡量的内容:工具使用 AI 智能体在现实的客户服务场景中导航。
在开始之前,请确保你拥有:
Node.js:版本 22 或更高。从 nodejs.org 下载。
OpenAI API 密钥:AI 智能体和 LLM 评分器都通过 Mastra 的模型路由器使用 OpenAI 模型。在 platform.openai.com 创建一个密钥。任何其他提供商也可以工作;只需更改模型字符串。
创建一个新目录并初始化:
mkdir support-agent-evals && cd support-agent-evals
npm init -y && npm pkg set type=module
安装依赖:
npm install @mastra/core @mastra/evals zod
npm install --save-dev vitest tsx
@mastra/core:Mastra 的核心包,包含 AI 智能体、工具和 runEvals 评估管道。
@mastra/evals:评估包,包含快速检查和所有内置评分器。
vitest:你将用来在 CI 中运行评估的测试运行器。任何 ESM 兼容的运行器(Jest、Mocha)都可以工作。
创建一个包含你的 OpenAI 密钥的 .env 文件:
OPENAI_API_KEY=your_openai_key_here
构建待测试的 AI 智能体
你将测试的 AI 智能体是一个在线商店的客服 AI 智能体。它有三个工具:查询订单、处理退款和升级给人工。退款需要先查询订单——这是一个真实的业务规则,给你一些有意义的东西来测试。
创建一个 src/agent.ts 文件:
import { Agent } from '@mastra/core/agent'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
// 内存中的订单"数据库"
const orders: Record<string, { id: string; status: string; total: number }> = {
'1001': { id: '1001', status: 'delivered', total: 59.99 },
'1002': { id: '1002', status: 'shipped', total: 129.0 },
'1003': { id: '1003', status: 'processing', total: 24.5 },
}
const refundedOrders = new Set<string>()
export const lookupOrderTool = createTool({
id: 'lookup_order',
description: '通过 ID 查询订单以获取状态和总金额',
inputSchema: z.object({
orderId: z.string().describe('订单 ID,例如 "1001"'),
}),
outputSchema: z.object({
found: z.boolean(),
order: z.object({ id: z.string(), status: z.string(), total: z.number() }).optional(),
}),
execute: async ({ orderId }) => {
const order = orders[orderId]
return { found: Boolean(order), order }
},
})
```typescript
export const processRefundTool = createTool({
id: 'process_refund',
description: 'Process a refund for a delivered order. Always look up the order first.',
inputSchema: z.object({
orderId: z.string().describe('The order ID to refund'),
}),
outputSchema: z.object({
refunded: z.boolean(),
reason: z.string().optional(),
}),
execute: async ({ orderId }) => {
const order = orders[orderId]
if (!order) return { refunded: false, reason: 'order_not_found' }
if (order.status !== 'delivered') return { refunded: false, reason: 'order_not_delivered' }
if (refundedOrders.has(orderId)) return { refunded: false, reason: 'already_refunded' }
refundedOrders.add(orderId)
return { refunded: true }
},
})
export const escalateTool = createTool({
id: 'escalate_to_human',
description: 'Escalate to a human agent when the customer is upset or the request is outside policy',
inputSchema: z.object({
reason: z.string().describe('Why the conversation is being escalated'),
}),
outputSchema: z.object({
escalated: z.boolean(),
ticketId: z.string(),
}),
execute: async ({ reason }) => {
return { escalated: true, ticketId: `TICKET-${Math.floor(Math.random() * 10000)}` }
},
})
export const supportAgent = new Agent({
id: 'support-agent',
name: 'Support Agent',
instructions: `You are a customer support agent for an online store.
Rules:
- Always use the lookup_order tool to verify an order before answering questions about it.
- To process a refund, first call lookup_order to confirm the order exists and is delivered, then call process_refund.
- Only delivered orders can be refunded.
- If the customer is upset or asks for a human, use the escalate_to_human tool.
- Never make up order information. Only report what the tools return.`,
model: 'openai/gpt-5-mini',
tools: {
lookup_order: lookupOrderTool,
process_refund: processRefundTool,
escalate_to_human: escalateTool,
},
})
上述代码做了以下事情:
带 schema 的工具:每个工具都通过 createTool 创建,输入和输出都带有 Zod schema。这些 schema 对后续测试很重要:当智能体传递格式错误的参数时,Mastra 会根据 inputSchema 进行校验,并将工具错误记录下来,测试可以检测到这些错误。
有副作用的有状态操作:process_refund 会修改 refundedOrders 集合,并且拒绝为未送达的订单退款。这让你的评估有了真实可验证的东西:智能体是否在正确的时机、用正确的 ID 调用了它?
指令中的策略:系统提示词编码了退款策略("先查询,再退款")。你的评估套件实际上是该策略的可执行版本:提示词中的每条规则都应该至少映射到一个测试用例。
模型:'openai/gpt-5-mini' 使用 Mastra 的模型路由格式(provider/model-name)。更换前缀即可使用 Anthropic、Google 或任何其他支持的 provider。
快速检查是一组可组合的微评分器,用于常见断言:"输出包含 X"、"智能体调用了工具 Y"、"没有工具报错"。它们不产生 LLM 调用,因此在微秒内完成且零成本。这是整个评估套件的基础。
创建一个 src/evals.ts 文件:
import { runEvals } from '@mastra/core/evals'
import { checks } from '@mastra/evals/checks'
import { supportAgent } from './agent.js'
const result = await runEvals({
target: supportAgent,
data: [
{ input: 'Where is my order 1002?' },
{ input: 'Has my package 1003 shipped yet?' },
{ input: "What's the capital of France?" },
],
scorers: [checks.calledTool('lookup_order'), checks.noToolErrors()],
})
console.log(result.scores)
console.log(`Processed ${result.summary.totalItems} items`)
npx tsx src/evals.ts
输出如下:
{
"check-called-tool": 0.6666666666666666,
"check-no-tool-errors": 1
}
Processed 3 items
上述代码做了以下事情:
runEvals:来自 @mastra/core/evals 的评估管道。它将 data 中的每个条目通过 target 智能体运行,并用 scorers 中的每个评分器对每次运行打分。它返回每个评分器的平均分数以及一个摘要。
checks.calledTool('lookup_order'):如果智能体在运行期间至少调用了一次 lookup_order,则得 1 分,否则得 0 分。注意上面的平均值是约 0.67,而不是 1:智能体正确地没有对无关的法国首都问题调用任何工具,因此该项得分为 0。这是评估设计中最重要习惯:一个检查在某种情况下应该通过,在另一种情况下就会合理地失败。继续往下看:解决方案是按场景分组,而不是使用更宽松的检查。
checks.noToolErrors():如果每次工具调用都无错误完成,则得 1 分。这会捕获格式错误的参数和 schema 违规,而不管调用了哪些工具,因此可以安全地应用于每个场景。
组织数据的自然方式是将测试用例按场景分组,并为每个场景设置各自的预期:
const result = await runEvals({
target: supportAgent,
data: [
// 订单状态问题必须触发查询
{ input: 'Where is my order 1002?' },
{ input: 'Has my package 1003 shipped yet?' },
],
scorers: [checks.calledTool('lookup_order'), checks.noToolErrors()],
})
// 无关问题是一个单独场景,预期相反
const offTopic = await runEvals({
target: supportAgent,
data: [{ input: "What's the capital of France?" }],
scorers: [checks.usedNoTools()],
})
这里 checks.usedNoTools() 断言智能体在不调用任何工具的情况下回答。像这样的负面测试可以捕捉到过度热情调用不需要的工具的智能体——这是生产环境中真实的成本和延迟问题。
除了"调用了正确的工具"之外,最有价值的工具调用测试通常关乎智能体避免了什么。完整的工具调用快速检查列表:
应用于退款场景:
const refund = await runEvals({
target: supportAgent,
data: [{ input: 'I want a refund for order 1001' }],
scorers: [
checks.toolOrder(['lookup_order', 'process_refund']),
checks.didNotCall('escalate_to_human'),
checks.maxToolCalls(3),
checks.noToolErrors(),
],
})
console.log(refund.scores)
// {
// 'check-tool-order': 1,
// 'check-did-not-call': 1,
// 'check-max-tool-calls': 1,
// 'check-no-tool-errors': 1
// }
其中每一项都对应一种生产故障模式。toolOrder 强制执行系统提示词中的"先验证再退款"策略。didNotCall('escalate_to_human') 捕获那些放弃并将常规请求升级的智能体。maxToolCalls(3) 捕获重试循环。注意 toolOrder 检查的是相对顺序:智能体可能在预期的调用之间插入额外的调用。如果你需要精确序列验证,那就是轨迹评分器的用武之地,见下文。
平均值对追踪质量趋势很有用,但 CI 需要更明确的信号。门控是必须所有数据条目平均得分为 1.0 的评分器;如果任何门控下滑,整个运行就会得到"失败"的裁定。普通评分器可以携带阈值,运行结果会报告三种状态的裁定:
const result = await runEvals({
target: supportAgent,
data: [
{ input: 'Where is my order 1002?' },
{ input: 'I want a refund for order 1001' },
],
gates: [checks.noToolErrors()],
scorers: [checks.includes('order')],
})
console.log(result.verdict) // 'passed' | 'scored' | 'failed'
console.log(result.gateResults) // [{ id: 'check-no-tool-errors', passed: true, score: 1 }]
要看看失败是什么样的,假设智能体停止调用工具导致回归。门控输出使原因一目了然:
verdict: failed
gateResults: [{ "id": "check-called-tool", "passed": false, "score": 0 }]
心智模型:门控用于不变式(永远不报错、永远不调用危险工具、永远先查询再退款),而阈值用于质量指标(相关性高于 0.8)。门控几乎应该是确定性检查,因为你不希望 CI 门控依赖 LLM 评判者的情绪。
快速检查回答有关单个工具调用的问题。轨迹评分器回答有关序列的问题:智能体是否走了合理的路径,用了正确的参数,在合理的调用预算内?runEvals 自动提取轨迹并将其交给 trajectory 键下注册的任何评分器。
有两种风格:
createTrajectoryAccuracyScorer 代码版:确定性比较实际轨迹与预期步骤。免费、即时、可复现。
createTrajectoryAccuracyScorerLLM:由 LLM 判断该路径是否必要、顺序是否正确、是否完整,并给出书面推理。当不存在唯一正确路径时使用此方法。
对于每个场景的期望,在每个数据项上放置 expectedTrajectory:
import { createTrajectoryAccuracyScorerCode } from '@mastra/evals/scorers/prebuilt'
const trajectoryScorer = createTrajectoryAccuracyScorerCode()
const result = await runEvals({
target: supportAgent,
data: [
{
input: 'I want a refund for order 1001',
expectedTrajectory: {
steps: [
{ stepType: 'tool_call', name: 'lookup_order', toolArgs: { orderId: '1001' } },
{ stepType: 'tool_call', name: 'process_refund' },
],
},
},
{
input: 'Where is my order 1002?',
expectedTrajectory: {
steps: [{ stepType: 'tool_call', name: 'lookup_order' }],
},
},
],
scorers: { trajectory: [trajectoryScorer] },
})
console.log(result.scores.trajectory) // { 'code-trajectory-accuracy-scorer': 1 }
上述代码执行以下操作:
逐项期望:每个数据项声明其应产生的步骤。评分器默认按相对顺序匹配实际步骤(中间有额外步骤时会给予小幅度扣分)。如需精确序列匹配,可向评分器构造函数传入 comparisonOptions: { strictOrder: true }。
参数验证:当期望步骤包含 toolArgs 时,评分器会将其与实际参数进行比较。这正是捕获"调了正确的工具,但 orderId 错误"这种失败模式的方法,这是任何最终答案评估都无法执行的检查。
分数语义:评分器在宽松模式下返回 0–1(匹配到的期望步骤比例,减去扣分),在严格模式下返回二元 0/1。
对于更广泛的护栏,createTrajectoryScorerCode 一次评估四个维度:步骤准确性、效率预算、黑名单工具和工具失败模式:
import { createTrajectoryScorerCode } from '@mastra/evals/scorers/prebuilt'
const guardrails = createTrajectoryScorerCode({
defaults: {
blacklistedTools: ['escalate_to_human'], // 在这些场景中永不升级
maxSteps: 4, // 超过 4 步的运行判定失败
noRedundantCalls: true, // 重复相同工具调用的运行判定失败
maxRetriesPerTool: 2, // 每个工具最多容忍 2 次重试
},
})
const result = await runEvals({
target: supportAgent,
data: [
{
input: 'I want a refund for order 1001',
expectedTrajectory: {
steps: [
{ stepType: 'tool_call', name: 'lookup_order' },
{ stepType: 'tool_call', name: 'process_refund' },
],
},
},
],
scorers: { trajectory: [guardrails] },
})
评分器每次运行都会生成人类可读的推理理由,这正是你在凌晨 2 点 CI 失败时需要的东西:
Score: 1
Accuracy (1): 2/2 expected steps matched.
Efficiency (1): all budgets met, no redundant calls.
黑名单违规会强制将分数设为 0,无论其他维度如何,并且会明确说明:
Score: 0
Blacklist violation: forbidden tools used: escalate_to_human.
确定性评分器回答"智能体是否调用了 lookup_order?"它们无法回答"对这个请求调用 lookup_order 是否是恰当的响应?"要回答这个问题,你需要一位理解意图的裁判。Mastra 提供了工具调用和轨迹评分器的 LLM 版本:
import {
createToolCallAccuracyScorerLLM,
createAnswerRelevancyScorer,
} from '@mastra/evals/scorers/prebuilt'
const toolAppropriateness = createToolCallAccuracyScorerLLM({
model: 'openai/gpt-5-mini',
availableTools: [
{ name: 'lookup_order', description: 'Look up an order by its ID' },
{ name: 'process_refund', description: 'Process a refund for a delivered order' },
{ name: 'escalate_to_human', description: 'Escalate to a human agent' },
],
})
const relevancy = createAnswerRelevancyScorer({ model: 'openai/gpt-5-mini' })
const result = await runEvals({
target: supportAgent,
data: [
{ input: 'Where is my order 1002?' },
{ input: 'I want a refund for order 1001' },
],
scorers: [
{ scorer: toolAppropriateness, threshold: 0.8 },
{ scorer: relevancy, threshold: 0.8 },
],
})
console.log(result.thresholdResults)
// [
// { id: 'llm-tool-call-accuracy-scorer', passed: true, averageScore: 0.9, threshold: 0.8 },
// { id: 'answer-relevancy-scorer', passed: true, averageScore: 0.87, threshold: 0.8 },
// ]
LLM 工具调用评分器根据用户请求和可用工具目录评估每次调用,并返回带有书面推理的分项分数,包括标记本应调用但未调用的工具。{ scorer, threshold } 包装器将平均值转换为通过/失败信号:数字表示"至少达到此值",{ max: 0.3 } 则将其反转,适用于分数越高越不好的评分器(幻觉、毒性)。
能用小的就用小的。LLM 裁判消耗 token 且本身具有非确定性。能表达为确定性检查的内容都应该用确定性检查;裁判只用于那些真正需要语义理解的残余部分。
校准后再信任它。读取裁判在一组样本运行上的推理,并与你的判断进行比较。如果你时不时不同意裁判的说法,在将其接入 CI 之前,先收紧评分规则或工具描述。
以上所有内容都可以组合成一个标准的测试文件。模式:每个场景一个 it 块,不变式用门控,质量指标用阈值,最终判定用断言。创建 src/agent.eval.test.ts:
import { describe, it, expect } from 'vitest'
import { runEvals } from '@mastra/core/evals'
import { checks } from '@mastra/evals/checks'
import { createTrajectoryScorerCode } from '@mastra/evals/scorers/prebuilt'
import { supportAgent } from './agent.js'
const trajectoryGuardrails = createTrajectoryScorerCode({
defaults: { maxSteps: 4, noRedundantCalls: true },
})
describe('Support Agent', () => {
it('looks up orders before answering status questions', async () => {
const result = await runEvals({
target: supportAgent,
data: [{ input: 'Where is my order 1002?' }, { input: 'Has my package 1003 shipped yet?' }],
gates: [checks.calledTool('lookup_order'), checks.noToolErrors()],
scorers: { trajectory: [trajectoryGuardrails] },
})
expect(result.verdict).toBe('passed')
})
it('verifies orders before refunding them', async () => {
const result = await runEvals({
target: supportAgent,
data: [
{ input: 'I want a refund for order 1001' },
{ input: 'Refund order 1004 please' },
],
gates: [checks.calledTool('lookup_order'), checks.noToolErrors()],
scorers: { trajectory: [trajectoryGuardrails] },
})
expect(result.verdict).toBe('passed')
})
it('does not use blacklisted tools', async () => {
const result = await runEvals({
target: supportAgent,
data: [
{ input: 'Where is my order 1002?' },
{ input: 'I want a refund for order 1001' },
],
gates: [checks.noToolErrors()],
scorers: {
trajectory: [
createTrajectoryScorerCode({
defaults: { blacklistedTools: ['escalate_to_human'] },
}),
],
},
})
expect(result.verdict).toBe('passed')
})
})
运行测试:
npx vitest run src/agent.eval.test.ts
现在,每当你对支持智能体进行修改时,你都会收到以下问题的即时反馈:它是否对所有场景都遵循正确的轨迹?它是否避免了禁用工具?轨迹是否符合效率预算?当在凌晨 2 点有人推动破坏性变更时,这正是你需要的那种反馈。
评估工具调用智能体需要在三个维度上进行系统测试:
确定性评分器覆盖前两个维度,LLM 裁判处理第三个维度中需要语义理解的部分。在 Vitest 中运行这些评估,将它们接入 CI,并在每次有人修改智能体逻辑时自动获取反馈。
这样,你就有了一个持续运转的系统来捕捉回归,而不是依赖手动测试或等待用户报告问题。