AB
AiBoss
Tutorials

用 Vitest 与 Claude 搭建 LLM-as-Judge 评测流水线:把提示词质量纳入 CI

Tutorials

用 Vitest 与 Claude 搭建 LLM-as-Judge 评测流水线:把提示词质量纳入 CI

LLM 应用的输出是非确定性的,assertEquals 抓不住提示词微调带来的质量滑坡。这篇教程用 Vitest 加 Anthropic Claude 实现 LLM-as-Judge 模式:写一个打分函数,把输出质量转成 1~5 分,在测试里做阈值断言,再接入 GitHub Actions,让提示词改动在 CI 阶段就被量化拦截。含完整代码、超时配置、成本控制与阈值调优建议。

LLM 应用上线之后,最容易踩的坑不是「跑不起来」,而是「悄悄变差」。提示词只改了三行,单元测试全绿,用户满意度却在接下来一周里缓慢下滑,等到有人发现已经过去好几天。原因在于大模型的输出是非确定性的:同一个输入,每次返回的措辞都略有不同,用完全相等去断言必然失败;而退一步只检查「字符串里包含某个词」,又完全捕捉不到质量的下降。

真正需要的是一套把「输出到底有多好」转换成可比较分数的机制,再让 CI 拿这个分数和阈值比大小。这就是 LLM-as-Judge(用大模型当裁判)模式:让另一个模型按照给定标准给输出打分,比如 1 到 5 分,测试代码只负责判断分数有没有跌破及格线。下面从零实现一条基于 Vitest 与 Anthropic Claude 的评测流水线,尽量少依赖外部服务,以能跑起来的代码为主。

准备工作

开始之前先确认几件事:

  • Node.js 与包管理器:需要能运行 Vitest 的 Node 环境,示例使用 pnpm 作为包管理器,用 npm 或 yarn 也可以,命令自行替换。
  • Anthropic API 密钥:评测过程要真实调用模型,因此需要一个可用的 API Key。密钥的申请方式、可用模型、计费规则请以 Anthropic 官网当前信息为准。
  • 两个不同档位的模型:一个负责被评测(生成输出),一个负责打分。通常让更强的模型当裁判、更便宜的模型当被评测对象,这样能在控制成本的同时保住评测精度。
  • CI 环境:示例用 GitHub Actions,其他 CI 平台思路一致,把密钥配成 secret 即可。

先安装依赖:

pnpm add -D vitest
pnpm add @anthropic-ai/sdk

然后在项目根目录的 .env 里放上密钥:

ANTHROPIC_API_KEY=sk-ant-...

务必把 .env 加进 .gitignore,避免密钥被提交进仓库。CI 里则通过仓库 secret 注入同名环境变量。

操作步骤

第一步:理解为什么普通断言不够用

先看一个典型的错误写法:

// 这样写必然失败
expect(await summarize(article)).toBe("TypeScript 是一门静态类型语言。");

大模型输出是非确定性的,即使输入完全相同,每次结果也会有细微差别,温度参数一开就更容易崩。反过来,如果只断言「输出里包含某个关键词」,那提示词改坏之后输出依然可能包含那个词,质量劣化照样漏过去。

所以评测的目标不是「相等」,而是「有多贴切」。把贴切程度映射成 0 到 100(或 1 到 5)的分数,再做阈值比较,才是可维护的做法。LLM-as-Judge 就是实现这个映射的手段:把输入、输出和评分标准一起交给裁判模型,要求它返回结构化分数和一句理由。

一个关键设计是裁判模型与被评测模型分开:裁判用能力更强的模型保证判断质量,被评测对象用更小更便宜的模型控制成本。如果让弱模型去当裁判,打分精度会明显下降。

第二步:实现裁判函数

新建 src/eval/judge.ts,封装一个打分函数:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

export interface JudgeResult {
  score: number; // 1-5
  reasoning: string;
}

export async function judgeResponse(
  input: string,
  output: string,
  criteria: string,
): Promise<JudgeResult> {
  const message = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 256,
    messages: [
      {
        role: "user",
        content: `你是一名 LLM 输出评审员。请按下面的标准给输出打分。

【评分标准】
${criteria}

【输入】
${input}

【输出】
${output}

只返回 JSON,不要有其他内容:
{"score": <1-5 的整数>, "reasoning": "<一句话理由>"}

5 分 = 完美,1 分 = 完全跑偏。`,
      },
    ],
  });

  const text =
    message.content[0].type === "text" ? message.content[0].text : "";

  const match = text.match(/\{[\s\S]*?\}/);
  if (!match) {
    throw new Error(`裁判没有返回 JSON:${text}`);
  }

  return JSON.parse(match[0]) as JudgeResult;
}

这里有两个容易忽略的点:

  • 裁判要用更强的模型。用最便宜的小模型去打分,精度会掉得很快,评测结果本身就不值得信任。
  • 模型偶尔会把 JSON 包在代码块里返回,所以这里用正则把花括号包裹的片段抽出来再解析,而不是直接 JSON.parse 整段文本。解析失败时抛出带原文的错误,方便排查。

第三步:编写评测测试

新建 src/eval/summarize.eval.test.ts。文件里既包含被评测的函数,也包含调用裁判的断言:

import { describe, it, expect } from "vitest";
import Anthropic from "@anthropic-ai/sdk";
import { judgeResponse } from "./judge";

const client = new Anthropic();

async function summarize(text: string): Promise<string> {
  const msg = await client.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 256,
    messages: [
      {
        role: "user",
        content: `请用三句话以内总结以下内容:\n\n${text}`,
      },
    ],
  });
  return msg.content[0].type === "text" ? msg.content[0].text : "";
}

const PASS_THRESHOLD = 3; // 5 分制,3 分及以上算通过

const TEST_CASES = [
  {
    name: "技术文章摘要",
    input:
      "TypeScript 是 Microsoft 开发的静态类型语言,是 JavaScript 的超集。它在编译期检测类型错误,提升大型应用的可维护性。自 2012 年发布以来,被 React、Angular、Node.js 生态广泛采用。",
    criteria: "是否准确抓住原文内容,并控制在三句话以内",
  },
  {
    name: "错误信息摘要",
    input:
      "ECONNREFUSED: Connection refused at 127.0.0.1:5432.\nThe PostgreSQL database is not running or the port is incorrect.",
    criteria: "是否简洁地包含问题原因和处理方式",
  },
];

describe("summarize() — LLM-as-Judge 评测", () => {
  for (const tc of TEST_CASES) {
    it(
      tc.name,
      async () => {
        const output = await summarize(tc.input);
        const result = await judgeResponse(tc.input, output, tc.criteria);

        console.log(
          `[${tc.name}] 得分:${result.score}/5 — ${result.reasoning}`,
        );
        console.log(`输出:${output}`);

        expect(
          result.score,
          `质量分低于阈值 ${PASS_THRESHOLD}:${result.reasoning}`,
        ).toBeGreaterThanOrEqual(PASS_THRESHOLD);
      },
      30_000,
    ); // LLM 调用给 30 秒超时
  }
});

把分数和理由用 console.log 打出来是有意为之:CI 日志里能直接看到「哪条用例掉分了、裁判给的理由是什么」,定位劣化原因比只看一个失败状态快得多。断言里把裁判理由拼进错误消息,失败时也能立刻读到原因。

第四步:配置 Vitest 超时与文件范围

LLM 调用不可能在 Vitest 默认的 5 秒超时内完成,不配置必然失败。新建 vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    testTimeout: 60_000,
    hookTimeout: 30_000,
    include: ["**/*.eval.test.ts"],
    reporters: ["verbose"],
  },
});

用 *.eval.test.ts 这个命名约定把评测用例和普通单元测试(*.test.ts)分开,两者就能各自独立运行:日常开发跑快速的单元测试,评测只在需要时跑。测试文件内部的单条超时(30 秒)和全局超时(60 秒)是两层保险,前者防止单条用例卡死,后者兜住整个文件。

第五步:接入 GitHub Actions

新建 .github/workflows/eval.yml:

name: LLM Eval

on:
  pull_request:
    paths:
      - "src/**/*.ts"
      - "prompts/**"

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - name: Run LLM evals
        run: pnpm vitest run --config vitest.config.ts
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

paths 过滤器是成本控制的关键:只有改动到 TypeScript 源码或提示词目录的 PR 才触发评测。如果每个 PR 都无条件跑一遍,调用费用会迅速累积。密钥通过仓库 secret 注入,不要写进工作流文件。

一个完整示例

把上面的片段串起来,从零到能在 CI 里拦住一次提示词劣化,完整流程如下。

目录结构:

project/
├── src/
│   └── eval/
│       ├── judge.ts
│       └── summarize.eval.test.ts
├── prompts/
├── vitest.config.ts
├── .env
└── .github/
    └── workflows/
        └── eval.yml

第一步,安装依赖并配置密钥:

pnpm add -D vitest
pnpm add @anthropic-ai/sdk
echo ".env" >> .gitignore

在 .env 中写入 ANTHROPIC_API_KEY=sk-ant-...。

第二步,落地 src/eval/judge.ts,内容即上文第二步的裁判函数。它对外只暴露一个 judgeResponse(input, output, criteria),返回 { score, reasoning }。

第三步,落地 src/eval/summarize.eval.test.ts,内容即上文第三步。注意被评测函数 summarize() 在真实项目里应该从业务代码里 import 进来,示例为了自包含才写在测试文件内。

第四步,落地 vitest.config.ts,把 include 限定为 **/*.eval.test.ts,超时设为 60 秒。

第五步,本地先跑一次:

pnpm vitest run --config vitest.config.ts

预期输出里会看到每条用例的得分与裁判理由,例如 [技术文章摘要] 得分:4/5 — 摘要准确覆盖了原文要点且控制在三句以内。如果得分低于 3,测试失败,错误消息里会带上裁判给出的理由。

第六步,提交工作流文件,在仓库 Settings 里添加名为 ANTHROPIC_API_KEY 的 secret,然后开一个改动 src/ 或 prompts/ 的 PR,观察 Actions 是否触发评测。

至此,一次提示词改动如果让输出质量掉到阈值以下,PR 会直接变红,而不是等到一周后从用户满意度里发现。

注意事项

阈值从 3/5 起步。一上来就要求 4/5,会因为模型输出的非确定性频繁出现偶发失败(flaky),让 CI 变得不可信。先跑几周收集数据,观察正常改动的分数分布,再逐步抬高阈值。

测试用例用 CSV 管理。把用例硬编码在测试文件里,数量一多 PR 就会变得又长又乱。改成读取 eval/cases.csv 的方式,非工程角色也能直接补充用例,评审时 diff 也干净。

成本要有概念。用「强模型当裁判 + 小模型被评测」的组合,单个测试用例的调用成本大致在千分之几美元的量级,二十条用例跑一轮的总成本可以压得很低。具体单价随模型与用量变化,请以 Anthropic 官网当前信息为准。真正推高成本的是「每个 PR 都无条件跑」,所以 paths 过滤器不能省。

把分数按时间序列存下来。只看 CI 日志看不出趋势。把每轮评测的分数写入数据库或 JSON 文件,和提示词改动记录对齐,就能一眼看出「是哪次改动把分数拉下来的」。这一步是事后归因的关键,比单次通过与否更有价值。

裁判返回格式要防御性处理。模型可能把 JSON 包在代码块里,也可能返回多余文字。用正则抽取花括号片段、解析失败时抛出带原文的错误,是必要的兜底。如果对稳定性要求更高,可以改用结构化输出能力,具体支持情况以官网当前信息为准。

评测文件与单元测试分离。评测要真实调用外部 API,慢且花钱,不应该混进日常的快速测试循环。用文件名后缀区分,让两者各跑各的。

LLM 应用的质量问题,本质上是「好不好」而不是「能不能跑」。在 assertEquals 失效的地方引入一个模型作为裁判,CI 就能对提示词改动做出量化的接受或拒绝。从写好 judge.ts 这一个文件开始,提示词改动就不再是只能靠感觉判断的事了。