在传统的软件工程中,写单元测试和集成测试通常是一件确定性极强的事情:给定一组输入参数,调用业务函数,用 expect(result).toBe(...) 断言输出结果是否等于预期即可。
但当系统引入了具备自主推演能力的智能体(Agent)之后,工程团队往往会陷入一种无所适从的困惑:
- Agent 每次生成的回答用词都不完全一致,断言怎么写?
- 优化了一句系统提示词(Prompt)或者调整了某个工具的描述,怎么确切知道整个 Agent 的表现是变好了还是变差了?
- 会不会原本修复了一个长尾问题,却悄悄改崩了之前几十个能够稳定运行的核心业务场景?
面对充满不确定性的 Agent 系统,如果缺乏科学的自动化评估体系,每一次迭代上线都无异于盲人摸象。本文将首先讲透现代 Agent 质量评估的三层金字塔与裁判防偏置原理;随后,以一套标准的全栈技术栈(TypeScript + Next.js + Prisma + PostgreSQL)为例,完整呈现从线上真实业务 Trace 采集、清洗沉淀为黄金数据集,到测试回放与 CI/CD 门禁阻断的真实端到端工程闭环。
在实战展开部分,每个环节都将先采用最底层的原生代码实现完整业务逻辑,随后展示现代化三方库是如何简化样板代码的,帮助读者同时吃透底层机制与现代选型。
从玩具教材到生产现实:Agent 评测的断层#
在平时的入门教程或本地演示中,大家验证 Agent 的方式往往非常随性:打开终端或网页聊天窗口,手动输入一两句测试指令(如“帮我查一下去英国留学的条件”),肉眼扫一眼输出觉得回答通顺、调用了搜索工具,就认为功能开发完成并合并代码了。
但在真实的商业生产环境里,这种依赖人工“肉眼抽查”的方式会迅速遭遇严重困境:
- 不可复现与高昂耗时:复杂的业务 Agent 往往包含 5~10 步工具调用链条。依赖人工逐条验证,耗时极长,且评测人员的主观情绪、注意力波动会导致评估标准严重漂移。
- 缺乏防倒退保护:软件工程迭代讲究“防倒退测试(Regression Testing)”。每次调整底层模型版本、微调 Prompt 或修改工具接口定义,靠人工抽查几条样例,根本无法发现数十个边缘场景是否已经静默失效。
- 无法融入 CI/CD 自动化流水线:现代工程团队要求每次提交代码合并请求(Pull Request)都能自动运行测试集,未达标直接阻断合并。纯人工测试无法形成量化指标,无法自动化卡门禁。
为了弥合这一断层,业界沉淀出了系统化的评估方案:
- 学术与工业基准集:如 GAIA、AgentBench 等公认评测基准,用来量化衡量模型在多工具调用与长程推理中的综合胜率;
- 可观测与追踪平台:如 LangSmith、Langfuse,用于采集真实推演轨迹(Traces)并组织自动化评估流水线;
- 大模型当裁判(LLM-as-a-Judge):用打分规则 Prompt,让能力更强的大模型(如 GPT-4o、Claude 3.5 Sonnet)替代人工,对照参考标准自动对输出进行打分。
一、 核心架构:Agent 评估的三层金字塔#
面对 Agent 的评估诉求,既不能因为“输出不确定”而放弃测试,也不能盲目走向另一个极端——“什么都丢给大模型当裁判打分”。一个健康、低成本且高置信度的 Agent 评估体系,通常呈现为金字塔结构:
┌──────────────────────────────┐
│ 三、大模型当裁判 │ 主观开放维度、语气与事实一致性
│ (LLM-as-a-Judge) │ (高成本、需偏置校准)
├──────────────────────────────┤
│ 二、黄金数据集回放 │ 单步工具命中率、参数正确性、轨迹步数
│ (Golden Dataset) │ (中成本、录制回放环境打桩)
├──────────────────────────────┤
│ 一、确定性代码单测 │ Schema 校验、状态合并纯函数、死循环熔断
│ (Unit Test) │ (零 Token、毫秒级响应、确定性 100%)
└──────────────────────────────┘1. 底层:确定性代码单测(Unit Test)#
- 评测对象:工具入参出参的强类型校验(如 Zod Schema)、多轮状态合并纯函数(如
profileMerger)、工具重复调用的死循环指纹拦截器(ToolCallRecovery)等纯逻辑模块。 - 特点:不需要调用大模型,毫秒级运行,Token 消耗为零,直接跑在 Jest 或 Vitest 中。
2. 中层:黄金数据集回放(Golden Dataset)#
黄金数据集(Golden Dataset)本质是一份由人工精心校验清洗的高质量标准题库。很多人在初次构建时,习惯只存一个 query(用户问题)和一个 referenceAnswer(参考答案)。
但在测试多步 Agent 时,如果仅有这两项,自动化测试是完全无法推进的。我们可以顺着一次真实的测试执行流程,推导黄金数据集到底需要记录什么:
- Step 1:测试框架把 Prompt 喂给 Agent,模型输出了一次工具调用:
searchFlights({ from: 'PEK', to: 'LHR' })。 - 此时测试框架面临一个两难的工程矛盾:如果让代码真正发起网络请求去调真实的航司 API,外部数据会随时变化(今天查到的价格与明天不同导致断言失效)、外部接口偶发抖动会导致 CI 飘红(Flaky Test),甚至如果调用的是下单扣款工具,还会产生破坏性的写入脏数据副作用。
- 自然推导出的解法:测试框架必须拦截这次工具执行,并立即注入一份事先准备好的确定性模拟返回值(Mock Data)。Agent 拿到这份模拟数据后,才能顺利进入 Step 2 继续推演。
由此,黄金数据集的完整契约必然包含工具调用的断言与打桩数据:
// 黄金数据集单条样本契约
export interface GoldenSample {
id: string;
category: "happy_path" | "edge_case" | "regression_bug"; // 场景分布:主干链路、边缘情况或历史缺陷
query: string; // 用户的原始输入
// 1. 各步骤工具调用的期望断言与模拟打桩数据(保证回放确定性)
toolExecutionMock: {
expectedToolCalls: Array<{
toolName: string;
expectedArgs?: Record<string, unknown>; // 验证关键参数是否传递正确
}>;
mockReturns: Record<string, unknown>; // 预设的确定性工具返回值,驱动后续步骤推演
};
// 2. 推演链路物理约束
trajectoryConstraints: {
maxSteps: number; // 预期多少步内必须收敛,防止死循环
forbiddenTools?: string[]; // 严禁误调用的高危工具
};
// 3. 终态事实核验清单(供大模型裁判打分)
factChecklist: Array<{
id: string;
description: string; // 必须覆盖的原子事实(如:“需明确告知不可退改规则”)
weight: number; // 该项分值
}>;
}3. 顶层:大模型当裁判(LLM-as-a-Judge)与四大偏置校准#
当推演结束、生成最终自然语言回答后,针对没有唯一定值的开放性要求,我们需要引入裁判模型。然而,直接让大模型随意打分往往会得到失真结果。学术界与工业界研究表明,未经校准的裁判大模型存在四大顽固偏置:
- 位置偏置(Position Bias):大模型在同一个 Prompt 中比对候选 A 和候选 B 时,受自回归注意力影响,天然偏爱排在前面的选项(首因效应)。
- 校准算法:双向置换校验(Pairwise Swap)。将 A/B 顺序对调测试两轮。由于对称性,如果真实判定一致,两轮选出的位置标记必然相反(一轮选 A,一轮选 B);如果两轮选了同一个位置代号(如两次都选 A),证明大模型纯受位置驱动,直接判定平局(失效)。
- 长度偏置(Verbosity Bias):大模型容易误把“篇幅更长、废话更多”等同于“全面深刻”。
- 校准算法:事实清单打勾制(Fact Checklist)。放弃开放式主观打分,将标准答案拆解为独立的原子事实点(覆盖一项得一分),并辅以冗余灌水扣分项。
- 自我偏好偏置(Self-Enhancement Bias):同家族模型(如 GPT-4 评 GPT-3.5)存在词表与行文风格偏好。
- 校准算法:抹去生成模型身份的双盲评审,或引入跨厂商模型(如 GPT-4o 与 Claude 3.5 Sonnet)交叉加权。
- 打分膨胀偏置(Scale Compression):在 1
100 连续打分时模型普遍当“老好人”,扎堆打 8090 分。- 校准算法:采用基于 Rubric 的离散等级制或二值通过制(Pass / Fail)。
二、 全栈工程实战:TS + Next.js + Prisma 评估体系完整闭环#
讲透了原理之后,我们来看这套体系在真实全栈项目中的端到端闭环。整个实战分为两个不可分割的部分:
- 黄金数据集的生命周期:真实业务中,Trace 是怎么写入数据库的?又是怎么清洗提取为评测文件的?
- 测试体系的执行落地:提取出来的数据集,如何在代码单测、环境打桩、裁判打分与 CI/CD 门禁中被使用?
项目的整体目录组织如下:
my-agent-app/
├── src/
│ ├── app/actions/ # Next.js Server Actions 业务入口
│ └── lib/
│ ├── agent/
│ │ ├── runtime.ts # Agent 核心循环与 Trace 落盘拦截
│ │ └── tools/ # 真实业务工具实现
│ └── prisma.ts
├── scripts/
│ └── export-golden-dataset.ts # 离线数据清洗与黄金数据集导出脚本
├── tests/
│ ├── unit/ # 确定性代码单测(Zod Schema 校验)
│ └── eval/ # 黄金数据集回放与裁判打分流水线
│ ├── fixtures/
│ │ └── golden-dataset.jsonl # 沉淀的黄金数据集文件
│ ├── mocks/
│ │ └── mock-tool-runner.ts # 工具打桩执行器
│ ├── judge/
│ │ └── llm-judge.ts # 强类型裁判打分器
│ └── smoke.eval.test.ts # CI 冒烟测试入口第一部分:黄金数据集的生命周期(从线上 Trace 到离线测试集)#
真实项目中的黄金数据集绝不是靠工程师凭空虚构的,它的全生命周期流转如下图所示:
【阶段一:线上生产运行期】
用户提问 ──> Next.js Server Action ──> Agent Runtime 执行
│ (每一步工具调用前后)
▼
写入 PostgreSQL Trace 表
(AgentConversation / AgentStepTrace)
│
用户点踩/打分 ─────────────────────────────▼
写入 ConversationFeedback 表
│
【阶段二:离线数据集清洗沉淀】 │
▼
运行 scripts/export-golden-dataset.ts ◄────┘ (按 6:4 筛选主干好例与客诉坏例)
│
▼
导出为 Git 追踪文件: tests/eval/fixtures/golden-dataset.jsonl
│
【阶段三:CI/CD 自动化回放验证】 │
▼
GitHub Actions / Vitest ◄──────────────────┘
(读取 JSONL -> 注入 MockToolRunner -> 回放推演 -> LLM 裁判打分 -> 阻断/合并)1. 线上运行时 Trace 落盘(PostgreSQL + Prisma)#
首先在 Prisma 中定义推演会话、单步工具流水与用户反馈表:
// prisma/schema.prisma
model AgentConversation {
id String @id @default(cuid())
userId String
query String // 用户原始提问
finalText String? // 最终回答
status String // SUCCESS | FAILED
createdAt DateTime @default(now())
feedback ConversationFeedback?
steps AgentStepTrace[]
}
model AgentStepTrace {
id String @id @default(cuid())
conversationId String
conversation AgentConversation @relation(fields: [conversationId], references: [id])
stepIndex Int // 步骤序号:1, 2, 3...
thought String? // 该步模型的思考推演
toolName String? // 调用的工具名,如 "searchFlights"
toolInput Json? // 模型生成的入参
toolOutput Json? // 工具执行后返回的真实数据(关键打桩源)
executionMs Int // 耗时
createdAt DateTime @default(now())
}
model ConversationFeedback {
id String @id @default(cuid())
conversationId String @unique
conversation AgentConversation @relation(fields: [conversationId], references: [id])
rating Int // 1 (点踩) | 5 (点赞)
comment String? // 用户评价
annotatedText String? // 运营或技术人员纠偏后的标准事实答案
createdAt DateTime @default(now())
}在 Next.js Server Action 中,真实业务请求进来时,代码是如何一边执行一边将 Trace 写入数据库的:
// src/app/actions/chat.ts
"use server";
import { prisma } from "@/lib/prisma";
import { runAgentLoop } from "@/lib/agent/runtime";
export async function handleUserMessage(userId: string, query: string) {
// 1. 初始化持久化会话记录
const conversation = await prisma.agentConversation.create({
data: { userId, query, status: "RUNNING" },
});
// 2. 运行 Agent,通过回调切面实时记录每一个微步(Trace)
const result = await runAgentLoop(query, {
onStepFinish: async (stepData) => {
await prisma.agentStepTrace.create({
data: {
conversationId: conversation.id,
stepIndex: stepData.index,
thought: stepData.thought,
toolName: stepData.toolName,
toolInput: stepData.toolInput,
toolOutput: stepData.toolOutput, // 真实数据,后续作为打桩数据
executionMs: stepData.duration,
},
});
},
});
// 3. 更新会话终态
await prisma.agentConversation.update({
where: { id: conversation.id },
data: { finalText: result.finalText, status: result.status },
});
return { conversationId: conversation.id, text: result.finalText };
}2. 离线清洗脚本:将 Trace 提取为标准黄金数据集#
有了生产运行数据,编写离线 ETL 脚本 scripts/export-golden-dataset.ts。按照 60% 核心主干好例 + 40% 线上客诉坏例 的配比提取数据,并将各步骤真实的 toolInput 与 toolOutput 组装为 Mock 桩,写入 Git 代码库文件:
// scripts/export-golden-dataset.ts
import { prisma } from "@/lib/prisma";
import fs from "node:fs/promises";
import path from "node:path";
import { GoldenSample } from "../tests/eval/fixtures/types";
async function exportGoldenDataset() {
// 1. 抓取被点踩且有人工纠错标准答案的 Bad Case(作为防倒退用例)
const badCases = await prisma.agentConversation.findMany({
where: { feedback: { rating: 1, annotatedText: { not: null } } },
include: { steps: { orderBy: { stepIndex: "asc" } }, feedback: true },
take: 40,
});
// 2. 抓取高分、步数正常的主干好例(作为基本盘)
const happyPaths = await prisma.agentConversation.findMany({
where: { feedback: { rating: 5 }, status: "SUCCESS" },
include: { steps: { orderBy: { stepIndex: "asc" } } },
take: 60,
});
const transformToSample = (conv: any, category: "happy_path" | "regression_bug"): GoldenSample => {
const mockReturns: Record<string, unknown> = {};
const expectedToolCalls: Array<{ toolName: string; expectedArgs?: Record<string, unknown> }> = [];
for (const step of conv.steps) {
if (step.toolName) {
expectedToolCalls.push({ toolName: step.toolName, expectedArgs: step.toolInput });
mockReturns[step.toolName] = step.toolOutput; // 固化真实返回数据为静态桩
}
}
return {
id: `sample-${conv.id}`,
category,
query: conv.query,
toolExecutionMock: { expectedToolCalls, mockReturns },
trajectoryConstraints: { maxSteps: conv.steps.length + 2 },
factChecklist: [
{
id: "fact-1",
description: conv.feedback?.annotatedText || "回答需准确涵盖工具查询到的客观事实",
weight: 100,
},
],
};
};
const allSamples = [
...badCases.map((c) => transformToSample(c, "regression_bug")),
...happyPaths.map((c) => transformToSample(c, "happy_path")),
];
// 3. 写入 Git 同源的 JSONL 文件
const targetPath = path.join(process.cwd(), "tests/eval/fixtures/golden-dataset.jsonl");
const fileContent = allSamples.map((s) => JSON.stringify(s)).join("\n") + "\n";
await fs.writeFile(targetPath, fileContent, "utf-8");
console.log(`成功从线上 Trace 提炼并生成 ${allSamples.length} 条黄金数据集样本至 ${targetPath}`);
}
exportGoldenDataset();3. 现代化三方平台优化:Langfuse / LangSmith#
- 它替代了什么:上述链路中,我们手写了 Prisma 表结构、在业务代码中埋点
onStepFinish、手写了从数据库查询并组装 JSONL 的清洗脚本。而在现代化大模型工程中,Langfuse 或 LangSmith 提供了开箱即用的替代方案:- 无侵入 Trace 记录:无需在数据库建表,只要包裹一层 SDK,自动无感收集每一次推演的步数、输入输出与延迟;
- 一键沉淀数据集:在 Web 仪表盘上,产品或运营人员排查线上会话时,遇到点踩的会话,直接点击按钮 “Add to Dataset”,并在界面填写纠错答案;
- 自动拉取测试集:在测试执行时,无需读取本地 JSONL,调用
langfuse.getDataset('flight-agent-golden')即可拉取最新数据集。
- 最简代码使用:
// 线上业务调用时:自动记录 Trace
import { observeOpenAI } from "langfuse";
import OpenAI from "openai";
const openai = observeOpenAI(new OpenAI()); // 一行代理包装,所有工具调用与步数自动落盘平台第二部分:基于黄金数据集的 4 维测试体系实现#
当黄金数据集 golden-dataset.jsonl 沉淀下来后,我们在工程中如何把它完整用起来?下面依次展开 4 个维度的代码实现。
实战维度 1:确定性代码单测(Unit Test 层)#
1.1 纯手写原生实现(不依赖任何第三方校验库)#
在最原始的实现中,我们要防止模型向工具传递乱七八糟的参数,只能靠原生 TypeScript 类型断言和 if-else 条件判断:
// 纯原生手动校验函数:臃肿、难以复用,且缺乏自动化错误汇总
export function validateSearchFlightsArgs(args: any): { isValid: boolean; error?: string } {
if (!args || typeof args !== "object") {
return { isValid: false, error: "参数必须为对象" };
}
if (typeof args.from !== "string" || args.from.length !== 3) {
return { isValid: false, error: "from 必须为三字代码,如 PEK" };
}
if (typeof args.to !== "string" || args.to.length !== 3) {
return { isValid: false, error: "to 必须为三字代码,如 LHR" };
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(args.departDate)) {
return { isValid: false, error: "departDate 格式必须为 YYYY-MM-DD" };
}
return { isValid: true };
}1.2 现代三方库优化:Zod + Vitest#
- 它替代了什么:Zod 彻底替代了手动写满屏的
typeof、length与正则匹配代码,提供声明式 Schema 校验,且自动推导强类型 TypeScript 接口;Vitest 替代了手写脚本组织测试逻辑,提供专业的断言语法与秒级监听运行。 - 最简代码使用:
// tests/unit/tool-schema.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
export const SearchFlightsSchema = z.object({
from: z.string().length(3, "必须为三字代码,如 PEK"),
to: z.string().length(3, "必须为三字代码,如 LHR"),
departDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "格式必须为 YYYY-MM-DD"),
});
export type SearchFlightsArgs = z.infer<typeof SearchFlightsSchema>;
describe("工具入参确定性单测", () => {
it("拦截非法格式参数,阻断无效模型输入", () => {
const invalidInput = { from: "BEIJING", to: "LHR", departDate: "2026/10/30" };
const result = SearchFlightsSchema.safeParse(invalidInput);
expect(result.success).toBe(false);
});
});实战维度 2:工具打桩与环境模拟(Mocking / Fixture 层)#
2.1 纯手写原生实现(依赖注入设计)#
为了在跑自动化测试时不触发真实的网络请求,我们在 Agent 运行时的参数中预留一个 toolExecutor 注入点,手写一个 MockToolRunner 维护调用队列:
// tests/eval/mocks/mock-tool-runner.ts
export interface ToolMockItem {
toolName: string;
expectedArgs?: Record<string, unknown>;
mockReturn: Record<string, unknown>;
}
export class MockToolRunner {
private callIndex = 0;
constructor(private mocks: ToolMockItem[]) {}
async execute(toolName: string, args: Record<string, unknown>) {
const expected = this.mocks[this.callIndex];
if (!expected || expected.toolName !== toolName) {
throw new Error(`[工具调用违规] 预期第 ${this.callIndex + 1} 步调用 ${expected?.toolName},实际调用了 ${toolName}`);
}
this.callIndex++;
return expected.mockReturn;
}
}2.2 现代三方库优化:MSW (Mock Service Worker)#
- 它替代了什么:上述手写
MockToolRunner属于“侵入式依赖注入”,要求业务代码的每个工具调用链路都必须把toolExecutor一层层透传进去。而 MSW 是在 Node.js 底层的网络请求层(全局fetch/http模块)进行拦截。业务代码和生产代码完全保持原样,无需感知任何 Mock 参数,MSW 会在底层透明接管指定的外部 API 请求并返回录制的 JSON 夹具(Fixtures)。 - 最简代码使用:
// tests/eval/mocks/msw-server.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const server = setupServer(
http.get("https://api.airline.com/v1/flights", ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get("from") === "PEK") {
return HttpResponse.json({ flights: [{ id: "CA937", price: 5200 }] });
}
return new HttpResponse(null, { status: 404 });
})
);实战维度 3:防偏置的大模型裁判(LLM-as-a-Judge 层)#
3.1 纯手写原生实现(原生 fetch + 正则提取 JSON)#
如果不借助任何工具,直接通过原生 fetch 调 OpenAI 接口并让其打分,代码非常容易在字符串解析上崩溃:
// 纯原生调用裁判:容易因为大模型多输出了 markdown 标记而导致 JSON.parse 崩溃
export async function rawJudgeCall(query: string, answer: string, facts: string[]) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o",
messages: [
{ role: "system", content: "请对照事实核验单打分,以 JSON 格式输出 { passed: boolean, score: number, reason: string }" },
{ role: "user", content: `问题: ${query}\n回答: ${answer}\n事实清单: ${facts.join(";")}` },
],
}),
});
const data = await res.json();
const rawText = data.choices[0].message.content;
const cleanedJson = rawText.replace(/```json/g, "").replace(/```/g, "").trim();
return JSON.parse(cleanedJson);
}3.2 现代三方库优化:Vercel AI SDK (ai) 的 generateObject#
- 它替代了什么:彻底替换了手写网络请求、手动组装 Header、手动正则清洗字符串与脆弱的
JSON.parse。它在底层原生对接了模型厂商的 Structured Outputs (JSON Schema) 特性,只要传入一个 Zod Schema,返回的就是强类型的 TypeScript 对象,格式 100% 稳定。 - 最简代码使用:
// tests/eval/judge/llm-judge.ts
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const ScorecardSchema = z.object({
passed: z.boolean().describe("是否合格覆盖全部核心事实"),
score: z.number().min(0).max(100).describe("得分 (0~100)"),
unmatchedFacts: z.array(z.string()).describe("遗漏的核心事实清单项"),
reason: z.string().describe("详细扣分说明"),
});
export async function judgeFactAccuracy(
query: string,
actualResponse: string,
factChecklist: Array<{ id: string; description: string; weight: number }>
) {
const { object } = await generateObject({
model: openai("gpt-4o"),
schema: ScorecardSchema,
prompt: `你是一个铁面无私的质检裁判。请根据核心事实清单,核查待测回答。
用户提问: ${query}
待测回答: ${actualResponse}
事实核验清单:
${factChecklist.map((f, i) => `${i + 1}. ${f.description} (分值: ${f.weight})`).join("\n")}`,
});
return object;
}配合前文介绍的双向置换判定逻辑 resolveSwapWinner,就能在代码层彻底消除位置偏置。
实战维度 4:自动化流水线与 CI/CD 门禁(Runner & CI/CD 层)#
4.1 纯手写原生实现(Node.js 原生脚本跑批与退出码控制)#
如果纯手工写自动化测试调度脚本,我们需要手动读取文件流、循环执行 Agent 并通过 process.exit(1) 控制 CI 流程阻断:
// scripts/manual-eval-runner.ts
import fs from "node:fs";
import readline from "node:readline";
import { runAgent } from "@/lib/agent/runtime";
import { MockToolRunner } from "../tests/eval/mocks/mock-tool-runner";
import { judgeFactAccuracy } from "../tests/eval/judge/llm-judge";
async function run() {
const fileStream = fs.createReadStream("tests/eval/fixtures/golden-dataset.jsonl");
const rl = readline.createInterface({ input: fileStream });
let passedCount = 0;
let totalCount = 0;
for await (const line of rl) {
if (!line.trim()) continue;
totalCount++;
const sample = JSON.parse(line);
// 1. 注入 Mock 执行
const mockRunner = new MockToolRunner(sample.toolExecutionMock.expectedToolCalls.map((c: any) => ({
toolName: c.toolName,
mockReturn: sample.toolExecutionMock.mockReturns[c.toolName],
})));
const agentResult = await runAgent(sample.query, {
toolExecutor: (name, args) => mockRunner.execute(name, args),
});
// 2. 裁判打分
const report = await judgeFactAccuracy(sample.query, agentResult.finalText, sample.factChecklist);
if (report.passed && report.score >= 90) {
passedCount++;
} else {
console.error(`[用例未通过] ID: ${sample.id}, 原因: ${report.reason}`);
}
}
const passRate = (passedCount / totalCount) * 100;
console.log(`评测完成:通过率 ${passRate.toFixed(1)}%`);
// 3. 未达标则返回非 0 退出码,硬性阻断 GitHub Actions
if (passRate < 90) {
console.error("通过率低于 90% 门禁线,阻断发布!");
process.exit(1);
}
}
run();4.2 现代三方库优化:Promptfoo#
- 它替代了什么:上述手写脚本需要自己写文件读取、并发限流控制(防止打爆 API 限制)、错误重试机制,并且终端只有简陋文本。Promptfoo 是专为 Agent 评测设计的成熟 CLI,替代了所有胶水代码。只需要一份简单的 YAML 配置文件,它就自动负责并发调度、大模型裁判调用、生成交互式 Web 对比报表,并自动在 GitHub Actions PR 下方留言打分卡。
- 最简代码使用:
在项目根目录下创建一个 promptfooconfig.yaml:
# promptfooconfig.yaml
description: "Agent 生产发布自动化回归评测"
prompts:
- "file://src/lib/agent/prompt-entry.ts"
providers:
- id: "openai:gpt-4o"
config:
temperature: 0
tests:
- description: "航班查询与咨询预约主干流程"
vars:
query: "帮我查询北京飞往伦敦最便宜的航班并预约明天下午的签证咨询"
assert:
- type: javascript
value: "output.toolCalls.some(t => t.name === 'searchFlights')"
- type: llm-rubric
value: "回答必须明确告知机票价格范围,且提醒用户准备签证所需的在职证明"在终端中只需执行一行命令:
npx promptfoo evalPromptfoo 会自动执行测试集,未达标直接返回非 0 退出码让 CI 报红,并能运行 npx promptfoo view 在本地启动可视化仪表盘。
研发全生命周期分级门禁#
有了上述工具与实现,我们就可以将不同测试分配到研发的各个阶段,兼顾研发反馈速度与 Token 成本:
| 研发阶段 | 触发时机 | 运行什么测试? | 耗时与成本 | 门禁动作与处理 |
|---|---|---|---|---|
| 1. 本地提交前 (Pre-commit) |
本地执行 git commit 时 |
确定性单测(Vitest + Zod):参数 Schema 校验、状态合并纯函数。 | 毫秒级 0 Token |
本地 Git 钩子拦截,阻止低级语法与类型错误进入仓库。 |
| 2. PR 提交时 (GitHub Actions) |
提交 Pull Request 代码合并请求时 | Smoke Eval(冒烟评估):抽取 golden-dataset.jsonl 中 30 条最核心与高危用例,使用 Mock 打桩工具执行 + 裁判打分。 |
1~2 分钟 约 $0.2 美元 |
硬门禁:工具调用命中率必须 100%,裁判得分 $\ge 90$。未通过则锁定 PR 的 Merge 按钮,并在评论区打印未覆盖事实详情。 |
| 3. 夜间定时全量 (Nightly Cron) |
每天凌晨定时执行 | Full Regression Eval(全量回归):运行完整的 500~1000 条长尾黄金数据集。 | 30 分钟 利用 Batch API 费用减半 |
输出完整的健康度报表,监控版本长期演进趋势,识别长尾退化。 |
| 4. 线上运行期 (Post-deploy) |
线上真实用户访问时 | 在线采样质检(Shadow / Online Eval):按 1% 比例抽样真实 Trace,异步交由裁判模型质检。 | 异步非阻塞 | 自动筛选低分或高耗时 Trace,打标沉淀回流,为扩充黄金数据集提供原料。 |
总结与架构权衡#
一套健全的 Agent 评估体系是保障复杂业务持续迭代的基石:
- 测试驱动从执行流出发:黄金数据集的核心价值在于各步骤的工具打桩(Mock)。顺着推演执行流做好环境模拟,才能摆脱外部网络波动和破坏性操作的限制。
- 底层原理与工具生态并重:理解纯手写的依赖注入与格式校验,能让我们在遇到底层偶发 Bug 时迅速定位;而在实际工程交付中,善用 Zod、MSW、Vercel AI SDK 与 Promptfoo 等现代三方库,能成倍减少胶水代码,大幅提升流水线的稳健度。
- 分级门禁兼顾效率与成本:通过 PR 快速冒烟锁死主干质量,结合夜间批处理(Batch API)全量扫除长尾风险,让大模型应用像传统软件一样可度量、可回归、可持续交付。