promptfoo : prompt 퀄리티 테스트 전략 분석
prompt 의 수행결과를 평가하는 오픈소스가 어떤 전략을 사용하는지를 정리 (확률관점에서 분산 줄이는 방향 )
promptfoo의 자연어 평가 전략
1. 이 글에 대해
이 글은 promptfoo의 prompt 평가 방식을 정리한다.
LLM 프롬프트 성능 평가 도구 중 promptfoo는 GitHub 20k+ 스타가 많고, 사용자 후기가 좋아서 관심이 생겼다.
레포지토리의 모든 영역을 다루지 않고 자연어 결과를 어떻게 평가할 것인가에 한정해 정리한다.
2. 분석 포인트 — 자연어 결과를 어떻게 평가하나
절대적인 기준을 가져오기보다는 , LLM에 의한 평가도 확률적이라는 관점에 근거하여, 결과의 분산을 줄이는 방법으로 진행한다.
방법 1 — 자연어 채점 기준을 평가 단계로 분해한 뒤, 단계를 따라 채점한다
채점자 LLM이 평가 기준에서 evaluation steps를 먼저 자동 생성한 뒤 그 steps를 따라 채점할 때, 점수를 직접 묻는 방식보다 인간 평가와의 Spearman/Kendall 상관이 일관되게 높다고 보고한다. 같은 모델이 채점 절차를 자기가 명시한 뒤 따르면 결과의 일관성이 올라간다는 가설이다
근거. G-Eval 논문(Liu et al., 2023, G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment, arXiv:2303.16634)
방법 2 — 각 단계를 여러 지표로 평가, 통과 기준을 사용자가 상황에 맞게 가중치를 선택.
여러 관점으로 평가한 결과의 평균은 한 관점의 결과보다 덜 흔들린다. 한 결과에 평가를 5개 두고 평균을 내면 한 평가가 그날따라 튀어도 다른 4개가 균형을 잡는다.
3. 한계 — 도구를 먼저 써볼 사람을 위해
메커니즘과 후기 둘 다, 이 도구는 "이거 하나로 모든 게 가능한" 수준의 분석 결과를 도출하지 않는다.
사용자 개입이 필요한 지점이 여러 곳이고, 그 개입의 질이 평가의 질을 결정한다.
평가가 정확하지 않은 느낌이 들면 먼저 두 가지를 시도해볼 만하다.
- 작업 단위를 더 작게 나누기 — 평가받을 프롬프트 자체의 역할을 쪼갠다.
한 프롬프트가 "글 작성"을 통째로 시키고 있으면 글감 선정 + 초안 작성과 표현·가독성 후처리를 두 단계로 나누는 식이다.
작업이 작아질수록 LLM 히트율도 올라가고, 단계마다 적용할 평가 기준이 명확해진다. - 평가 기준을 더 본질적인 표현으로 언어화하기 —
"가독성이 좋은 글", "2000자 미만" 같은 표면적인 라벨 대신, "위에서 아래로 읽었을 때 필요한 정보가 순서대로 확보되는가", "반복된 표현이나 축약 가능한 문장이 있는가" 식으로 본질을 가리키는 조건으로 풀어 적는다.
표면 라벨로 잘못 정하면 평가는 깔끔하게 통과하는데 결과물이 실제로 쓸모가 없는 일이 생긴다. 평가가 좀 러프하게 채점되더라도 본질을 가리키는 조건 쪽이 안전하다.
4. 방법 1 디테일 — 채점을 단계로 분해
자연어 채점을 한 번에 하지 않고, 단계 또는 카테고리로 쪼갠 뒤 그 단위로 채점한다.
promptfoo는 위의 방법 1을 두 평가로 구현한다 — g-eval과 factuality.
g-eval은 평가 단계를 LLM이 먼저 직접 만든 뒤 그 단계를 따라 채점하는 2단계 방식이다. factuality는 출력과 정답의 관계를 5개 카테고리(A~E)로 분류한 뒤, 카테고리를 점수로 매핑한다.
같은 모델 채점이라도 모두 분해를 쓰는 건 아니다. llm-rubric이나 model-graded-closedqa는 한 번 호출로 점수나 Y/N을 받아오는 옵션도 있다.
4-1. g-eval — 자연어 기준을 평가 단계로 분해
코드는 두 번 호출한다.
// src/matchers/llmGrading.ts:411-422 (Step 1: 평가 단계 생성)
const stepsPrompt = await loadRubricPrompt(stepsRubricPrompt, GEVAL_PROMPT_STEPS);
const promptSteps = await renderLlmRubricPrompt(stepsPrompt, { criteria });
const respSteps = await callProviderWithContext(textProvider, promptSteps, 'g-eval-steps', ...);
// → criteria("응답이 친절한가")로부터 LLM이 평가 단계 목록을 직접 생성
// src/matchers/llmGrading.ts:469-485 (Step 2: 단계를 따라 채점)
const evalPrompt = await loadRubricPrompt(evalRubricPrompt, GEVAL_PROMPT_EVALUATE);
const evalVars = { criteria, steps: steps.join('\n- '), maxScore: '10', input, output };
// → 같은 LLM이 자기가 만든 단계를 따라가며 0~10 점수
점수는 정규화된다.
// src/matchers/llmGrading.ts:532-537
return {
pass: rawScore / maxScore >= threshold,
score: rawScore / maxScore,
reason: result.reason,
tokensUsed,
};
기본 통과 임계치는 0.7 (src/assertions/geval.ts:24).
4-2. factuality — 이진 판정을 5단계 카테고리로 분해
factuality는 yes/no가 아니라 다섯 카테고리 중 하나를 고르게 한다.
// src/prompts/grading.ts:34-78 발췌, 번역
(A) 제출된 답이 정답의 부분집합이며, 정답과 완전히 일치한다.
(B) 제출된 답이 정답의 상위집합이며, 정답과 완전히 일치한다.
(C) 제출된 답이 정답과 같은 디테일을 모두 포함한다.
(D) 제출된 답과 정답 사이에 불일치가 있다.
(E) 두 답이 다르지만, 사실성 관점에서는 차이가 의미 없다.
코드 주석에 출처도 명시되어 있다 — // Based on https://github.com/openai/evals/blob/main/evals/registry/modelgraded/fact.yaml (src/prompts/grading.ts:32). OpenAI evals의 modelgraded fact 패턴을 가져왔다.
분해의 효과는 두 가지다.
(1) 이진 판정에서 사라지던 미세 차이를 잡는다. "정답보다 더 자세한 답(B)"과 "정답과 일치하는 답(C)"과 "사실은 같지만 표현이 다른 답(E)"을 한 묶음으로 처리하지 않는다.
(2) 카테고리→점수 매핑을 사용자가 도메인에 맞게 덮어쓸 수 있다.
// src/matchers/llmGrading.ts:48-55
function getFactualityScoreLookup(grading: GradingConfig): Record<string, number> {
return {
A: grading.factuality?.subset ?? 1,
B: grading.factuality?.superset ?? 1,
C: grading.factuality?.agree ?? 1,
D: grading.factuality?.disagree ?? 0,
E: grading.factuality?.differButFactual ?? 1,
};
}
기본은 D만 0점, 나머지는 1점이다. 사용자가 yaml에서 factuality.superset: 0.5로 적으면 "더 자세한 답(B)"의 점수를 깎을 수 있다. 분류는 LLM이 하고, 분류를 점수로 바꾸는 표는 사용자가 정한다. 모호함의 일부를 사용자에게 명시적으로 넘기는 구조다.
5. 방법 2 디테일 — 가중평균과 threshold
분해해도 답은 흔들린다. 그래서 한 셀에 평가를 여러 개 두고, 그 점수들의 평균을 사용자가 정한 기준선으로 자른다.
어서션은 종류가 달라도 0~1 사이의 score로 정규화된다.
g-eval: LLM이 010으로 답하고 코드가 01로 정규화factuality: 카테고리 → 점수 매핑(0~1)llm-rubric: LLM이 직접 0~1 score를 낸다- 결정론 어서션 (
contains,equals,is-json등): 보통 0 또는 1
이 점수들의 가중평균이 한 셀의 score다 (src/assertions/assertionsResult.ts:96-149). 어서션마다 weight로 비중을 조절할 수 있고, threshold(통과 기준선)는 어서션·그룹(assert-set)·테스트 세 층위에서 따로 줄 수 있다.
공식 문서의 예시 (site/docs/configuration/expected-outputs/index.md) — 두 어서션을 묶고 그룹 threshold를 0.5로 두면, 둘 중 하나만 통과해도 그룹이 pass된다.
tests:
- description: 'Test that the output is cheap or fast'
vars:
example: 'Hello, World!'
assert:
- type: assert-set
threshold: 0.5
assert:
- type: cost
threshold: 0.001
- type: latency
threshold: 200