Agent开发 7.1 - 智能体评估体系全景:单元测试、黄金数据集与 LLM 裁判偏置校准

2026-10-30 | 177分钟 | yrobot | Agent评估体系, 黄金数据集, LLM-as-a-Judge, 位置偏置, 双向置换, 工具打桩, Promptfoo, Prisma, Langfuse | 系列:Agent开发

在传统的软件工程中,写单元测试和集成测试通常是一件确定性极强的事情:给定一组输入参数,调用业务函数,用 expect(result).toBe(...) 断言输出结果是否等于预期即可。

但当系统引入了具备自主推演能力的智能体(Agent)之后,工程团队往往会陷入一种无所适从的困惑:

  • Agent 每次生成的回答用词都不完全一致,断言怎么写?
  • 优化了一句系统提示词(Prompt)或者调整了某个工具的描述,怎么确切知道整个 Agent 的表现是变好了还是变差了?
  • 会不会原本修复了一个长尾问题,却悄悄改崩了之前几十个能够稳定运行的核心业务场景?

面对充满不确定性的 Agent 系统,如果缺乏科学的自动化评估体系,每一次迭代上线都无异于盲人摸象。本文将首先讲透现代 Agent 质量评估的三层金字塔与裁判防偏置原理;随后,以一套标准的全栈技术栈(TypeScript + Next.js + Prisma + PostgreSQL)为例,完整呈现从线上真实业务 Trace 采集、清洗沉淀为黄金数据集,到测试回放与 CI/CD 门禁阻断的真实端到端工程闭环。

在实战展开部分,每个环节都将先采用最底层的原生代码实现完整业务逻辑,随后展示现代化三方库是如何简化样板代码的,帮助读者同时吃透底层机制与现代选型。


从玩具教材到生产现实:Agent 评测的断层

在平时的入门教程或本地演示中,大家验证 Agent 的方式往往非常随性:打开终端或网页聊天窗口,手动输入一两句测试指令(如“帮我查一下去英国留学的条件”),肉眼扫一眼输出觉得回答通顺、调用了搜索工具,就认为功能开发完成并合并代码了。

但在真实的商业生产环境里,这种依赖人工“肉眼抽查”的方式会迅速遭遇严重困境:

  1. 不可复现与高昂耗时:复杂的业务 Agent 往往包含 5~10 步工具调用链条。依赖人工逐条验证,耗时极长,且评测人员的主观情绪、注意力波动会导致评估标准严重漂移。
  2. 缺乏防倒退保护:软件工程迭代讲究“防倒退测试(Regression Testing)”。每次调整底层模型版本、微调 Prompt 或修改工具接口定义,靠人工抽查几条样例,根本无法发现数十个边缘场景是否已经静默失效。
  3. 无法融入 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)与四大偏置校准

当推演结束、生成最终自然语言回答后,针对没有唯一定值的开放性要求,我们需要引入裁判模型。然而,直接让大模型随意打分往往会得到失真结果。学术界与工业界研究表明,未经校准的裁判大模型存在四大顽固偏置:

  1. 位置偏置(Position Bias):大模型在同一个 Prompt 中比对候选 A 和候选 B 时,受自回归注意力影响,天然偏爱排在前面的选项(首因效应)。
    • 校准算法:双向置换校验(Pairwise Swap)。将 A/B 顺序对调测试两轮。由于对称性,如果真实判定一致,两轮选出的位置标记必然相反(一轮选 A,一轮选 B);如果两轮选了同一个位置代号(如两次都选 A),证明大模型纯受位置驱动,直接判定平局(失效)。
  2. 长度偏置(Verbosity Bias):大模型容易误把“篇幅更长、废话更多”等同于“全面深刻”。
    • 校准算法:事实清单打勾制(Fact Checklist)。放弃开放式主观打分,将标准答案拆解为独立的原子事实点(覆盖一项得一分),并辅以冗余灌水扣分项。
  3. 自我偏好偏置(Self-Enhancement Bias):同家族模型(如 GPT-4 评 GPT-3.5)存在词表与行文风格偏好。
    • 校准算法:抹去生成模型身份的双盲评审,或引入跨厂商模型(如 GPT-4o 与 Claude 3.5 Sonnet)交叉加权。
  4. 打分膨胀偏置(Scale Compression):在 1100 连续打分时模型普遍当“老好人”,扎堆打 8090 分。
    • 校准算法:采用基于 Rubric 的离散等级制或二值通过制(Pass / Fail)。

二、 全栈工程实战:TS + Next.js + Prisma 评估体系完整闭环

讲透了原理之后,我们来看这套体系在真实全栈项目中的端到端闭环。整个实战分为两个不可分割的部分:

  1. 黄金数据集的生命周期:真实业务中,Trace 是怎么写入数据库的?又是怎么清洗提取为评测文件的?
  2. 测试体系的执行落地:提取出来的数据集,如何在代码单测、环境打桩、裁判打分与 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 eval

Promptfoo 会自动执行测试集,未达标直接返回非 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 评估体系是保障复杂业务持续迭代的基石:

  1. 测试驱动从执行流出发:黄金数据集的核心价值在于各步骤的工具打桩(Mock)。顺着推演执行流做好环境模拟,才能摆脱外部网络波动和破坏性操作的限制。
  2. 底层原理与工具生态并重:理解纯手写的依赖注入与格式校验,能让我们在遇到底层偶发 Bug 时迅速定位;而在实际工程交付中,善用 Zod、MSW、Vercel AI SDK 与 Promptfoo 等现代三方库,能成倍减少胶水代码,大幅提升流水线的稳健度。
  3. 分级门禁兼顾效率与成本:通过 PR 快速冒烟锁死主干质量,结合夜间批处理(Batch API)全量扫除长尾风险,让大模型应用像传统软件一样可度量、可回归、可持续交付。