이미 사용 중인 테스트 러너에서 LLM 출력 검증하기
요약
LLM 호출 결과를 기존 유닛 테스트 환경에서 효과적으로 검증하는 방법을 소개합니다. 복잡한 JSON 파싱, 필드 누락 확인 등 LLM 출력의 특성을 고려하여 `expect-llm` 라이브러리를 사용하면, 기존 테스트 러너 내에서 강력하고 직관적인 매처 세트를 통해 출력을 검증할 수 있습니다.
핵심 포인트
- `expect-llm`은 LLM 출력을 위한 전용 `expect()` 매처를 제공합니다.
- JSON 유효성 검사, 필수 필드 포함 여부 등 복잡한 검증 로직을 간결하게 처리합니다.
- 기존의 `vitest`/`jest` 환경에 종속성을 추가하지 않고 통합할 수 있습니다.
제품에 LLM 호출이 있고, 이를 실행하는 테스트가 있습니다. 이제 출력을 검증해야 합니다. 만약 이것이 대부분의 LLM 출력과 같다면, 다음과 같이 보일 것입니다:
{"total": 42, "currency": "USD"}
JSON 형식이며 아마도 유효할 것이고, 필요한 필드와 보고 싶지 않은 구문으로 감싸져 있을 수 있습니다. 그래서 직접 검증 코드를 작성합니다:
let parsed: unknown;
try {
parsed = JSON.parse(raw.replace(/```
?json\n?|
?```/g, ""));
} catch {
throw new Error("not valid JSON"); // 실제 오류는 이제 사라짐
}
...
이 모든 줄은 작은 고통입니다. try/catch 블록은 파서의 메시지를 삼켜버립니다. expect(result.success).toBe(true)는 Zod가 계산한 모든 필드별 이유를 버려버립니다. includes 검사는 어떤 문자열이 누락되었는지 알려주지 못합니다. 그리고 다음 테스트에서는 약간 다르게 이 코드를 다시 작성하게 될 것입니다.
일반적인 해결책은 eval 프레임워크(promptfoo, DeepEval, Braintrust, Evalite)를 채택하는 것입니다. 이것들은 실제 평가 스위트에는 훌륭한 도구입니다. 하지만 '내 유닛 테스트에서 이 응답을 검증한다'는 목적에 대해서는 CLI, 설정 파일, 어쩌면 계정, 그리고 이미 사용하고 있는 vitest/jest 실행 환경 외부에 존재하는 두 번째 정신 모델이 필요합니다.
중간 옵션이 있습니다.
expect-llm
expect-llm은 LLM 출력이 실제로 잘못하는 것들을 위한 expect(...) 매처 세트입니다. 한 번 등록하면, 이미 실행 중인 러너 내에서 인라인으로 검증할 수 있습니다:
import { expect } from "vitest";
import { z } from "zod";
import { llmMatchers } from "expect-llm";
...
위의 모든 고통스러운 코드 블록이 이 코드로 압축됩니다. 그리고 무언가 실패할 때, 메시지는 무엇인지 알려줍니다—누락된 항목, 일치하지 않는 필드, 파서의 실제 오류—단순히 expected false to be true라는 메시지가 아닙니다.
이것은 실행 시 종속성이 전혀 없으며(zero runtime dependencies), ESM + CJS + types를 배포하고 Node >= 18에서 실행되며, 크기는 최소 brotli 압축 기준으로 약 1.41 kB입니다. .not은 모든 매처(matcher)에서 작동합니다.
매처들 (The matchers)
총 여덟 개의 매처가 있습니다. 그중 일곱 개는 결정론적(deterministic)이며, 하나는 선택적 판단자(opt-in judge)입니다.
toBeValidJSON(options?): 값이 파싱 가능한 JSON 문자열인지 확인합니다. 주변의 마크다운 코드 펜스(markdown code fence)를 먼저 제거하려면{ allowFences: true }를 전달하세요. 이는 모델이 JSON을 감싸는 방식과 정확히 일치합니다.toContainAll/toContainNone/toContainAny: (문자열로 변환된) 값에 대한 부분 문자열 검사입니다.toContainNone(["as an AI", "TODO"])은 수동으로 작성하기 쉬운 금지 구문 가드(banned-phrase guard)이며, 실패 시 유출된 구문을 이름으로 알려줍니다.toBeOneOf(allowed): 값이 닫힌 집합 중 하나와 깊이 동등한지(deep-equals) 확인합니다. 라우팅 또는 분류 결정의 경우:toBeOneOf(["refund", "escalate", "deny"]).toMatchStructure(reference): 참조 값과 동일한 키와 값 유형(type)을 재귀적으로 가지는지 확인하며, 값을 무시하고 배열 길이도 무시합니다. 프롬프트 리팩터링으로 인해 숫자가 변경되어도 살아남는 구조 스냅샷입니다.toMatchSchema(schema): Zod 스키마나 모든 Standard Schema를 사용하여 유효성을 검사합니다. 문자열 값은 먼저 JSON 파싱을 거치며, 펜스를 허용하므로 원본 출력에 대해 단언할 수 있습니다.toSatisfy(rubric, judge): 아래의 선택적 판단자입니다.
펜스 등 모든 것을 포함하여 원본 출력에 대한 검증
toBeValidJSON과 toMatchSchema는 코드 펜스를 허용하고 문자열을 파싱해주기 때문에, 모델이 반환한 정확한 내용—사전 클리닝 없이—에 대해 단언할 수 있습니다.
const out = "```json\n{\"total\":42,\"currency\":\"USD\"}\n```";
expect(out).toBeValidJSON({ allowFences: true }); // 통과 (passes)
expect(out).toMatchSchema(Invoice); // 파싱하고, 펜스를 제거한 다음, 유효성 검사 수행
값(values)이 아닌 구조(Shape)
toMatchStructure는 제가 가장 많이 사용하는 매처입니다. 이 매처는 응답의 구조(키와 유형)를 확인하므로, 모델이 다른 숫자를 선택할 때마다 테스트가 깨지는 것을 방지해 줍니다.
기본적으로 결정론적입니다. 판정기(judge)는 선택 사항입니다
앞서 언급된 일곱 개의 매처(matcher)는 순수하고 동기적이며 네트워크 호출을 수행하지 않습니다. 이 중 어느 하나라도 fetch를 건드리면 가드 테스트가 빌드를 실패시킵니다. 따라서 유효한 JSON, 스키마 형태, 필수 및 금지 콘텐츠, 닫힌 결정 집합, 안정적인 구조 등 객관적으로 검증 가능한 모든 것에 사용하세요.
'이것이 정중한 거절인가?'와 같은 주관적 검사의 경우에는 toSatisfy가 있으며, 이는 의도적으로 사용자가 직접 모델을 가져오도록(bring your own model) 설계되었습니다. expect-llm은 SDK, 키 처리 방식, 기본 제공업체(default provider)를 포함하지 않습니다. 사용자가 모델 호출을 전달해야 합니다:
import type { Judge } from "expect-llm";
const judge: Judge = async (output, rubric) => {
...
이러한 설계에서 두 가지 특징이 도출됩니다. 첫째, 결정론적 매처는 토큰을 소비하지 않으므로 CI가 빠르고 재현 가능하게 유지되며 모델 호출은 사용자가 선택한 경우에만 발생합니다. 둘째, 판정기(judge)가 일반 함수이기 때문에 스텁(stub) 판정기를 사용하여 네트워크 호출 없이 CI에서 _연결 구조(wiring)_를 테스트할 수 있으며, 어설션이 실패했을 때 판정기의 reason이 실패 메시지에 표시됩니다.
Vitest 또는 Jest — 동일한 매처 사용
설정 파일에 한 번 등록하면 됩니다. 진입점만 다릅니다:
// Vitest — vitest.setup.ts
import { expect } from "vitest";
import { llmMatchers } from "expect-llm";
...
// Jest — jest.setup.ts
import { llmMatchers } from "expect-llm/jest";
expect.extend(llmMatchers);
Vitest 진입점은 Vitest의 expect 타입을 확장하고, Jest 진입점은 jest.Matchers를 확장합니다. 매처 세트는 동일하며 양쪽에서 타입이 지정됩니다.
coerce-json 및 zod와 함께 사용 가능
expect-llm은 제가 유지하는 의존성 제로(zero-dependency) LLM 개발 도구 중 하나인 어설션(assertion) 기능을 제공합니다. 구조화된 출력 흐름(structured-output flow)에서 이 기능은 거의 유효한 모델 출력을 스키마에 맞게 복구하고 변환(coerce)하는 coerce-json 바로 다음에 위치합니다. 먼저 coerce를 수행한 다음, 그 결과를 어설션해야 합니다 — toMatchSchema는 동일한 Zod 스키마를 사용합니다:
import { coerce } from "coerce-json/zod";
const { value, ok } = coerce(rawModelOutput, Invoice);
...
전체 도구 모음은 각 부분이 의존성 제로이며 독립적으로 유용합니다:
fetch -> SSE (sse-wire) -> 부분 JSON 파싱 (trickle-json) -> 스키마로 변환 (coerce-json) -> 어설션 (expect-llm)
- sse-wire — LLM 스트림용 fetch 기반 SSE 클라이언트.
- trickle-json — 해당 스트림을 위한 증분 부분 JSON 파서.
- coerce-json — 스키마로 복구 및 변환하며, 모든 수정 사항을 기록합니다.
- 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는 선택적 피어(optional peers)입니다 — 사용하는 것만 가져오세요.
만약 매처(matcher)의 실패 메시지가 대체한 수동 구현 어설션보다 덜 유용하다면, 이슈를 열어주세요 — 읽기 쉬운 실패가 핵심 목적입니다. 만약 이 도구가 복잡한 try/catch 코드를 줄여준다면 별점을 주시면 감사하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기