평가와 테스트
단위/통합/E2E 에이전트 테스트, trajectory 평가, 비결정성 대응
에이전트 시스템은 "한 번 잘 동작했다"로 검증되지 않습니다. 평가의 목적은 모델 우열을 가리는 것이 아니라, 오케스트레이션 변경이 행동 계약을 깨지 않았는지 확인하는 것입니다.
평가 계층
| 계층 | 무엇을 검증하는가 | 예시 |
|---|---|---|
| Unit | 프롬프트, tool schema, reducer | schema 출력 형식, 규칙 함수 |
| Contract | agent 입력/출력 계약 | JSON schema, required field |
| Trajectory | step sequence와 handoff 품질 | 어떤 tool을 어떤 순서로 썼는지 |
| Scenario | 실제 업무 시나리오 | 환불 triage, 리서치 보고서 생성 |
| Online | 실제 운영 지표 | rejection rate, escalation rate |
평가 피라미드
아래에서 위로 올라갈수록 검증 범위가 넓어지고 비용이 커집니다. Unit과 Contract는 매 배포마다, Trajectory와 Scenario는 주기적으로, Online은 상시 모니터링합니다.
무엇을 측정해야 하는가
| 지표 | 설명 |
|---|---|
| Task success rate | 목표 달성 여부 |
| Contract compliance | 출력 스키마 준수율 |
| Tool accuracy | 올바른 tool 선택과 인자 정확도 |
| Handoff quality | 다음 단계가 충분한 정보를 받았는지 |
| Human override rate | 자동화 신뢰 수준 |
| Cost / latency | 운영 가능성 판단 |
trajectory 평가가 중요한 이유
최종 답변만 보면 시스템이 우연히 맞았는지, 좋은 경로를 통해 맞았는지 알 수 없습니다. 멀티에이전트 시스템에서는 어떤 step를 거쳤는지가 품질과 비용을 함께 결정합니다.
trajectory 평가 예시
| 항목 | 질문 |
|---|---|
| Routing | 올바른 agent가 선택되었는가 |
| Tool use | 불필요한 tool 호출이 있었는가 |
| Evidence | 근거가 handoff에 포함되었는가 |
| Recovery | 실패 후 적절한 fallback이 있었는가 |
| Final action | 승인 또는 중단 조건을 지켰는가 |
trajectory 비교: 좋은 경로 vs 나쁜 경로
같은 최종 결과라도 trajectory가 다르면 시스템 품질이 달라집니다.
위 예시에서 Bad trajectory는 라우팅 오류, tool 중복 호출, 근거 누락이 동시에 발생했습니다. 최종 답변이 비슷해 보여도 trajectory 평가에서 이 차이를 잡아야 합니다.
테스트 세트 구성 원칙
1. happy path만 넣지 않는다
다음과 같은 케이스가 반드시 포함되어야 합니다.
- 애매한 입력
- policy conflict
- tool timeout
- low confidence 분류
- human approval 필요 케이스
2. negative example을 많이 수집한다
라우팅과 평가 루프는 애매한 입력에서 가장 잘 망가집니다. 따라서 오답 사례, 고객 불만 사례, 운영 사고 사례를 테스트 세트에 넣는 편이 좋습니다.
3. 정답 하나보다 허용 범위를 정의한다
에이전트 출력은 비결정적일 수 있습니다. 따라서 완전 일치보다 "필수 필드 존재", "금지 행위 없음", "근거 포함" 같은 기준이 더 현실적입니다.
비결정성 대응
| 문제 | 대응 |
|---|---|
| 문장 표현이 매번 다름 | semantic/assertion 기반 검사 |
| tool 선택이 흔들림 | trajectory 평가와 deterministic pre-filter |
| 모델 변경 후 점수 변동 | frozen eval set 유지 |
| judge 편향 | rubric 공개, human spot check 병행 |
Trajectory 자동 수집 파이프라인
trajectory 평가를 자동화하려면 먼저 에이전트 실행 trace를 구조화된 trajectory로 수집해야 합니다. 수동 로깅은 누락과 형식 불일치가 잦으므로, 실행 환경에 계측(instrumentation)을 내장하는 것이 핵심입니다.
수집 아키텍처
TrajectoryStep 타입 정의
/** 에이전트 실행의 단일 단계를 표현합니다. */
type TrajectoryStep = {
/** 이 단계를 실행한 에이전트 이름 */
agent: string
/** 수행한 행위 — tool 호출, handoff, 응답 등 */
action: string
/** 에이전트에 주어진 입력 (프롬프트, 이전 결과 등) */
input: unknown
/** 에이전트가 반환한 출력 */
output: unknown
/** 실행 소요 시간 (ms) */
duration: number
/** 소비된 토큰 수 (prompt + completion) */
tokens: { prompt: number; completion: number }
}
type Trajectory = {
traceId: string
startedAt: string // ISO 8601
steps: TrajectoryStep[]
totalDuration: number // ms
totalTokens: number
success: boolean
}수집기 구현
import { randomUUID } from 'node:crypto'
class TraceCollector {
private steps: TrajectoryStep[] = []
private startTime = Date.now()
readonly traceId = randomUUID()
/** 각 에이전트 호출을 감싸는 wrapper */
async wrap<T>(
agent: string,
action: string,
input: unknown,
fn: () => Promise<T>,
): Promise<T> {
const stepStart = Date.now()
const tokensBefore = this.currentTokenUsage()
const result = await fn()
const tokensAfter = this.currentTokenUsage()
this.steps.push({
agent,
action,
input,
output: result,
duration: Date.now() - stepStart,
tokens: {
prompt: tokensAfter.prompt - tokensBefore.prompt,
completion: tokensAfter.completion - tokensBefore.completion,
},
})
return result
}
/** 전체 trajectory를 직렬화합니다. */
finalize(success: boolean): Trajectory {
const totalTokens = this.steps.reduce(
(sum, s) => sum + s.tokens.prompt + s.tokens.completion,
0,
)
return {
traceId: this.traceId,
startedAt: new Date(this.startTime).toISOString(),
steps: this.steps,
totalDuration: Date.now() - this.startTime,
totalTokens,
success,
}
}
private currentTokenUsage() {
// 실제 구현에서는 LLM 클라이언트의 usage 카운터를 읽습니다
return { prompt: 0, completion: 0 }
}
}사용 예시
const collector = new TraceCollector()
// 라우팅 단계 수집
const routeResult = await collector.wrap(
'router', 'classify_intent', userMessage,
() => routerAgent.run(userMessage),
)
// 실행 단계 수집
const execResult = await collector.wrap(
routeResult.targetAgent, 'execute', routeResult,
() => executeAgent.run(routeResult),
)
// 저장
const trajectory = collector.finalize(execResult.success)
await trajectoryStore.save(trajectory)분석 쿼리 패턴
수집된 trajectory에서 반복적으로 확인해야 하는 항목들입니다.
| 쿼리 | 목적 | 예시 필터 |
|---|---|---|
| 평균 step 수 | 불필요한 단계 탐지 | steps.length > threshold |
| tool 중복 호출률 | 비효율 패턴 발견 | 연속 동일 action 카운트 |
| handoff 누락률 | context 전달 품질 | output에 필수 필드 부재 |
| 에이전트별 토큰 분포 | 비용 최적화 포인트 | tokens.completion 상위 10% |
| 실패 직전 step 패턴 | 공통 실패 원인 | success === false인 trajectory의 마지막 2 step |
LLM-as-Judge 평가 패턴
사람이 모든 trajectory를 직접 평가하는 것은 현실적이지 않습니다. LLM을 judge로 사용하면 대규모 평가를 자동화하되, rubric과 spot check로 편향을 통제할 수 있습니다.
평가 구조
패턴 1: 정확성 평가 (Factual Correctness)
에이전트의 최종 응답이 사실에 부합하는지 평가합니다.
당신은 에이전트 응답의 사실 정확성을 평가하는 전문 평가자입니다.
## 입력
- 사용자 질문: {question}
- 에이전트 응답: {agent_response}
- 참조 정답 (있는 경우): {reference_answer}
- 에이전트가 사용한 도구 결과: {tool_outputs}
## 평가 기준
1. 응답에 포함된 사실이 도구 결과와 일치하는가
2. 도구 결과에 없는 내용을 지어내지 않았는가 (hallucination)
3. 핵심 정보가 누락되지 않았는가
4. 수치, 날짜, 고유명사가 정확한가
## 출력 형식 (JSON)
{
"score": 1-5,
"hallucination_found": boolean,
"missing_facts": ["누락된 사실 목록"],
"reasoning": "채점 근거를 2-3문장으로 설명"
}패턴 2: 도구 사용 적절성 (Tool Selection Relevance)
에이전트가 올바른 도구를 올바른 순서로 사용했는지 평가합니다.
당신은 에이전트의 도구 사용 품질을 평가하는 전문 평가자입니다.
## 입력
- 사용자 질문: {question}
- 사용 가능한 도구 목록: {available_tools}
- 에이전트의 도구 호출 기록:
{tool_call_sequence}
## 평가 기준
1. 질문 해결에 필요한 도구를 모두 사용했는가
2. 불필요한 도구를 호출하지 않았는가
3. 도구 호출 순서가 논리적인가 (의존 관계 준수)
4. 도구에 전달한 인자가 적절한가
5. 같은 도구를 반복 호출한 경우 그 이유가 합리적인가
## 출력 형식 (JSON)
{
"score": 1-5,
"unnecessary_calls": ["불필요한 호출 목록"],
"missing_calls": ["누락된 호출 목록"],
"order_issues": ["순서 문제 설명"],
"reasoning": "채점 근거를 2-3문장으로 설명"
}패턴 3: Trajectory 효율성 (Efficiency & Cost)
전체 trajectory가 비용 대비 효율적이었는지 평가합니다.
당신은 에이전트 trajectory의 효율성을 평가하는 전문 평가자입니다.
## 입력
- 사용자 질문: {question}
- 전체 trajectory:
{trajectory_steps}
- 총 소요 시간: {total_duration}ms
- 총 토큰 사용량: {total_tokens}
## 평가 기준
1. 동일한 결과를 더 적은 단계로 달성할 수 있었는가
2. 병렬 실행 가능한 단계를 순차 실행하지 않았는가
3. 불필요한 재시도(retry)가 있었는가
4. 토큰 사용량이 작업 복잡도에 비해 과도하지 않은가
5. 전체 소요 시간이 SLA 기준을 충족하는가
## 출력 형식 (JSON)
{
"score": 1-5,
"redundant_steps": ["제거 가능한 step 목록"],
"parallelizable_steps": ["병렬화 가능한 step 쌍"],
"token_efficiency": "적정 | 과다 | 매우 과다",
"reasoning": "채점 근거를 2-3문장으로 설명"
}채점 Rubric (1-5점 척도)
세 가지 평가 패턴 모두에 공통으로 적용할 수 있는 일반 rubric입니다.
| 점수 | 기준 | 설명 |
|---|---|---|
| 5 | Excellent | 개선할 점이 없음. 모든 기준을 완벽히 충족 |
| 4 | Good | 사소한 개선점이 있으나 결과에 영향 없음 |
| 3 | Acceptable | 결과는 맞으나 과정에 명확한 비효율 또는 누락 존재 |
| 2 | Poor | 핵심 기준 1개 이상 미달. 결과 신뢰도에 영향 |
| 1 | Fail | 핵심 기준 다수 미달. 재실행 또는 사람 개입 필요 |
Judge 결과 활용 전략
Judge 편향 주의
LLM judge는 장문 응답에 높은 점수를 주는 경향(verbosity bias)과 자기 모델 출력에 관대한 경향(self-bias)이 있습니다. 반드시 human spot check를 병행하고, 점수 분포가 한쪽으로 치우치면 rubric을 재조정하세요.
- 3점 이하 trajectory: 자동 플래그 → 사람 리뷰 큐로 전송
- 패턴별 교차 검증: 정확성 5점이지만 효율성 2점인 경우 → 비용 최적화 대상
- 시계열 추적: 동일 시나리오의 judge 점수 추이를 모니터링하여 회귀 감지
비결정성 대응 프레임워크
에이전트는 같은 입력에 대해 매번 다른 결과를 낼 수 있습니다. 이것은 버그가 아니라 LLM의 본질적 특성이므로, "정확한 답"을 기대하는 테스트가 아니라 "속성(property)"을 검증하는 테스트로 전환해야 합니다.
비결정성의 원천
전략 1: 다회 실행 합의 (Multi-Run Consensus)
같은 입력을 N회 실행하여 결과 분포를 확인합니다.
async function multiRunConsensus<T>(
fn: () => Promise<T>,
runs: number,
extractKey: (result: T) => string,
): Promise<{ majority: string; agreement: number; results: T[] }> {
const results = await Promise.all(
Array.from({ length: runs }, () => fn()),
)
const counts = new Map<string, number>()
for (const r of results) {
const key = extractKey(r)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1])
const [majority, majorityCount] = sorted[0]
return {
majority,
agreement: majorityCount / runs, // 0.0 ~ 1.0
results,
}
}
// 사용 예: 라우팅 결과가 5회 중 4회 이상 일치하는지 확인
const consensus = await multiRunConsensus(
() => routerAgent.classify(userMessage),
5,
(r) => r.targetAgent,
)
assert(consensus.agreement >= 0.8, '라우팅 합의율 80% 미달')전략 2: Temperature · Seed 고정
재현 가능한 테스트를 위해 모델 파라미터를 고정합니다.
| 파라미터 | 설정 | 효과 |
|---|---|---|
temperature | 0 | 가장 확률 높은 토큰만 선택 (greedy) |
seed | 고정값 (예: 42) | 동일 seed에서 동일 출력 (provider 지원 시) |
top_p | 1 | temperature 0과 함께 사용하면 결정적 출력 |
한계
temperature=0 + seed 고정은 단일 provider의 단일 모델 버전에서만 재현 가능합니다. 모델 업데이트, 인프라 변경, 캐시 상태에 따라 결과가 달라질 수 있으므로 이 전략은 개발 중 디버깅용으로만 사용하세요. 프로덕션 평가에서는 assertion-based testing이 더 안정적입니다.
전략 3: Assertion-Based Testing (Property 검증)
"정확한 답"이 아닌 "반드시 만족해야 하는 속성" 을 검증합니다. 이 방식은 비결정적 출력에서도 안정적으로 동작합니다.
Property 유형
| 유형 | 설명 | 예시 |
|---|---|---|
| Structural | 출력 구조가 스키마를 준수하는가 | 필수 필드 존재, 타입 일치 |
| Behavioral | 금지된 행위를 하지 않았는가 | PII 노출 없음, 승인 없이 결제 불가 |
| Semantic | 의미적으로 올바른가 | 응답이 질문 주제와 관련 있음 |
| Boundary | 제약 조건을 지켰는가 | 토큰 한도, 시간 제한, 재시도 횟수 |
구현 예시
import { describe, it, expect } from 'vitest'
/** 에이전트 출력의 속성을 검증하는 assertion 모음 */
const agentAssertions = {
/** 구조적 속성: 필수 필드가 존재하는가 */
hasRequiredFields(output: Record<string, unknown>, fields: string[]) {
for (const field of fields) {
expect(output).toHaveProperty(field)
}
},
/** 행위 속성: 금지 패턴이 출력에 없는가 */
noProhibitedPatterns(text: string, patterns: RegExp[]) {
for (const pattern of patterns) {
expect(text).not.toMatch(pattern)
}
},
/** 의미 속성: 응답이 주어진 주제와 관련 있는가 */
async isRelevantTo(response: string, topic: string, threshold = 0.7) {
const score = await semanticSimilarity(response, topic)
expect(score).toBeGreaterThanOrEqual(threshold)
},
/** 경계 속성: 수치가 허용 범위 안인가 */
withinBounds(value: number, min: number, max: number) {
expect(value).toBeGreaterThanOrEqual(min)
expect(value).toBeLessThanOrEqual(max)
},
}
describe('환불 처리 에이전트', () => {
it('응답에 필수 필드가 포함된다', async () => {
const result = await refundAgent.run(sampleRequest)
agentAssertions.hasRequiredFields(result, [
'decision',
'reasoning',
'amount',
])
})
it('PII를 노출하지 않는다', async () => {
const result = await refundAgent.run(sampleRequest)
agentAssertions.noProhibitedPatterns(result.reasoning, [
/\d{3}-\d{2}-\d{4}/, // SSN
/\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}/, // 카드번호
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+/, // 이메일
])
})
it('환불 금액이 원래 주문 금액을 초과하지 않는다', async () => {
const result = await refundAgent.run(sampleRequest)
agentAssertions.withinBounds(
result.amount,
0,
sampleRequest.orderTotal,
)
})
it('5회 실행 중 결정이 80% 이상 일치한다', async () => {
const consensus = await multiRunConsensus(
() => refundAgent.run(sampleRequest),
5,
(r) => r.decision,
)
expect(consensus.agreement).toBeGreaterThanOrEqual(0.8)
})
})전략 조합 가이드
| 상황 | 추천 전략 | 이유 |
|---|---|---|
| 개발 중 디버깅 | temperature=0 + seed 고정 | 재현 가능한 환경에서 빠르게 반복 |
| CI 파이프라인 | assertion-based | 비결정성에 강건하고 자동화 가능 |
| 라우팅 안정성 검증 | 다회 실행 합의 | 분류 결과의 일관성 수치화 |
| 프로덕션 모니터링 | assertion + LLM judge | 규모 확장 가능하면서 의미 평가 포함 |
| 모델 교체 검증 | 전체 조합 | frozen eval set에 모든 전략 적용 |
운영 전/후 평가 루프
| 단계 | 해야 할 일 |
|---|---|
| 개발 중 | unit/contract 테스트와 small eval set |
| 배포 전 | scenario eval, shadow run, canary |
| 배포 후 | rejection rate, incident review, drift detection |
예시: 평가 레코드
type EvalRecord = {
caseId: string
expectedAction: 'respond' | 'escalate' | 'abort'
contractPassed: boolean
toolErrors: number
humanOverride: boolean
score: number
}중요한 것은 완벽한 점수가 아니라, 회귀를 감지할 수 있는 일관된 기록 체계입니다.
안티패턴
| 안티패턴 | 문제 | 개선 |
|---|---|---|
| 최종 응답만 평가 | 과정 품질을 놓침 | trajectory 평가 추가 |
| 데모용 예제만 테스트 | 실제 운영 문제를 못 잡음 | negative/ambiguous case 포함 |
| 한 번 만든 eval set을 방치 | drift 감지 실패 | 분기별 갱신 |
| judge model 결과를 절대시 | 편향과 오판 위험 | rule + human sampling 병행 |
ADR 스타일 결론
Decision
평가는 최종 텍스트 품질만이 아니라, 라우팅, tool 사용, handoff, recovery를 포함한 trajectory 단위로 설계합니다. 비결정성을 감안해 exact match보다 계약 준수와 금지 행위 부재를 중심 지표로 사용하고, 운영 지표와 연결된 online eval을 함께 둡니다.
실무 체크리스트
- unit, contract, scenario eval이 분리되어 있는가
- negative example과 ambiguous case가 충분한가
- final output뿐 아니라 trajectory도 기록하는가
- human override rate를 보고 있는가
- 배포 전 canary 또는 shadow run이 있는가