expect-llm提供专门用于断言LLM输出的vitest matchers,自动处理markdown代码块包裹的JSON解析、Zod schema校验及幻觉关键词检测,省去大量样板代码。
你的产品里调用了 LLM,有测试用例覆盖了它。现在你得对输出做断言。LLM 的输出通常长这样:
{"total": 42, "currency": "USD"}
JSON 格式,大概合法,也许包了一层 markdown 围栏,有你需要的字段,也有你不想看到的措辞。于是你手动写断言:
let parsed: unknown;
try {
parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, ""));
} catch {
throw new Error("not valid JSON"); // 真正的错误信息已经丢失
}
const result = Invoice.safeParse(parsed);
expect(result.success).toBe(true); // 失败时只显示 "expected false to be true"
expect(raw.includes("total")).toBe(true);
expect(raw.includes("as an AI")).toBe(false);
每一行都是一个小伤口。try/catch 吞掉了解析器的报错信息。expect(result.success).toBe(true) 把 Zod 刚刚计算出的每个字段的错误原因全丢了。includes 检查无法告诉你缺少了哪个字符串。而且下个测试里你还得再写一遍,只是稍有不同。
常见的出路是引入一个 eval 框架——promptfoo、DeepEval、Braintrust、Evalite。对于真正的 eval 套件来说这些工具很棒。但对于「在我的单元测试里断言这一个响应」,它们意味着一个 CLI、一份配置文件、也许还有一个账号,外加一套独立于你已有的 vitest/jest 运行时的思维模型。
其实还有中间选项。
expect-llm 是一套 expect(...) 匹配器,专门对付 LLM 输出真正会出错的地方。注册一次,然后在已有的测试运行器里内联断言:
import { expect } from "vitest";
import { z } from "zod";
import { llmMatchers } from "expect-llm";
expect.extend(llmMatchers); // 在 setup 文件里注册一次
const Invoice = z.object({ total: z.number(), currency: z.string() });
expect(out).toBeValidJSON({ allowFences: true });
expect(out).toMatchSchema(Invoice); // out 可以是对象也可以是 JSON 字符串
expect(out).toContainAll(["total", "currency"]);
expect(out).toContainNone(["TODO", "as an AI"]);
expect(decision).toBeOneOf(["refund", "escalate", "deny"]);
开头的那些痛点全部压缩成了这些。失败时信息告诉你具体是哪里出了问题——缺失的项、不匹配的字段、解析器的实际错误——而不是一句 "expected false to be true"。
它零运行时依赖,发布 ESM + CJS + 类型,运行在 Node >= 18 上,压缩后约 1.41 kB。.not 在每个匹配器上都可用。
八个匹配器,七个是确定性的;一个是可选的 judge。
toBeValidJSON(options?) — 值是合法的可解析 JSON 字符串。传 { allowFences: true } 可以先去除周围的 markdown 围栏,这正是模型包装 JSON 的方式。
toContainAll / toContainNone / toContainAny — 在(字符串化后的)值上做子串检查。toContainNone(["as an AI", "TODO"]) 就是你一直在手动写的那种禁用词守卫;失败时会指出是哪个词泄漏了。
toBeOneOf(allowed) — 值深度等于某个有限集合中的一个。对于路由或分类决策:toBeOneOf(["refund", "escalate", "deny"])。
toMatchStructure(reference) — 递归地检查与参考对象有相同的键和值类型,忽略值和数组长度。这是一种形状快照,能在 prompt 重构改变数字时保持测试不崩溃。
toMatchSchema(schema) — 根据 Zod schema 或任何 Standard Schema 验证。字符串值会先 JSON 解析,围栏会被处理——所以你可以对原始输出做断言。
toSatisfy(rubric, judge) — 可选的 judge,见下文。
直接对原始输出做断言,包括围栏
因为 toBeValidJSON 和 toMatchSchema 容许代码围栏并自动解析字符串,所以你可以断言模型返回的确切内容——无需预先清理:
const out = "```json\n{\"total\":42,\"currency\":\"USD\"}\n```";
expect(out).toBeValidJSON({ allowFences: true }); // 通过
expect(out).toMatchSchema(Invoice); // 解析、去围栏、然后验证
toMatchStructure 是我最常用的。它检查响应的形状——键和类型——这样模型每次选不同的数字时测试不会崩溃:
const reference = { id: 1, name: "x", tags: ["a"], meta: { active: true } };
expect({ id: 9, name: "y", tags: ["p", "q"], meta: { active: false } })
.toMatchStructure(reference); // 通过:相同的键,相同的类型
expect({ id: "9", name: "y", tags: ["p"], meta: { active: true } })
.not.toMatchStructure(reference); // 失败:id 是字符串,不是数字
默认确定性;judge 是可选的
这七个匹配器是纯的、同步的、不发任何网络请求——守卫测试如果其中任何一个触发了 fetch 就会失败。用它们处理任何可以客观检查的东西,也就是大多数会出错的情况:合法 JSON、schema 形状、必需和禁止的内容、封闭的决策集、稳定的结构。
对于主观检查——「这是一个礼貌的拒绝吗?」——有 toSatisfy,而且它是故意设计成让你带自己的模型。expect-llm 不发任何 SDK、不处理 key、没有默认 provider。你传入模型调用:
import type { Judge } from "expect-llm";
const judge: Judge = async (output, rubric) => {
const verdict = await myModel(`Does this satisfy "${rubric}"?\n\n${output}\n\nAnswer yes or no.`);
return { pass: /yes/i.test(verdict), reason: verdict };
};
await expect(answer).toSatisfy("is a concise, polite refusal", judge);
这个设计有两点好处。一是确定性匹配器从不消耗 token,所以你的 CI 保持快速和可重现,模型调用只在你选择的地方发生。二是 judge 是一个普通函数,所以在 CI 中可以用 stub judge 测试连接而无需网络调用,当断言失败时 judge's reason 会显示在失败信息里。
Vitest 或 Jest——相同的匹配器
在 setup 文件里注册一次。唯一的区别是入口点:
// Vitest — vitest.setup.ts
import { expect } from "vitest";
import { llmMatchers } from "expect-llm";
expect.extend(llmMatchers);
// Jest — jest.setup.ts
import { llmMatchers } from "expect-llm/jest";
expect.extend(llmMatchers);
Vitest 入口扩展了 Vitest 的 expect 类型;Jest 入口扩展了 jest.Matchers。相同的匹配器集,两边都有类型。
它与 coerce-json 和 zod 配合
expect-llm 是我维护的一整套零依赖 LLM 开发工具的断言端。在结构化输出流程中,它紧跟在 coerce-json 之后,后者修复并强制转换几乎有效的模型输出以符合 schema。先强制转换,再对结果做断言——toMatchSchema 接受相同的 Zod schema:
import { coerce } from "coerce-json/zod";
const { value, ok } = coerce(rawModelOutput, Invoice);
expect(ok).toBe(true);
expect(value).toMatchSchema(Invoice);
expect(value).toContainAll(["total", "currency"]);
完整工具链,每个都零依赖且各自有用:
fetch -> SSE (sse-wire) -> 解析部分 JSON (trickle-json) -> 强制转换为 schema (coerce-json) -> 断言 (expect-llm)
sse-wire — 基于 fetch 的 SSE 客户端,用于 LLM 流。
trickle-json — 用于这些流的增量部分 JSON 解析器。
coerce-json — 修复并强制转换为你的 schema,记录每个修复。
expect-llm — 在 Vitest 或 Jest 中断言结果。(就是这个)
npm install -D expect-llm
npm: https://www.npmjs.com/package/expect-llm
GitHub: https://github.com/H1manshu01/expect-llm
零运行时依赖,ESM + CJS,完整类型,带 provenance 发布。vitest、jest 和 zod 是可选的 peer——只带你在用的那些。
如果某个匹配器的失败信息不如它替代的手写断言有用,请开 issue——可读的失败信息就是它的全部意义。如果你觉得它帮你省掉了一堆 try/catch 样板代码,给个 star 吧。