더 많은 LLM을 호출하지 않고 AI 에이전트를 테스트하는 방법
요약
AI 에이전트 테스트 시 비용이 많이 드는 LLM 호출을 줄이기 위해 오케스트레이션 정확성과 언어 품질을 분리하여 테스트하는 전략을 제안합니다. 결정론적 기술과 의존성 주입을 활용하여 빠르고 재현 가능한 테스트 피라미드를 구축하는 방법을 다룹니다.
핵심 포인트
- 오케스트레이션 정확성과 언어 품질을 분리하여 테스트할 것
- 도구 호출, 입력 검증 등 결정론적 요소는 LLM 없이 테스트 가능
- 의존성 주입을 통해 모델과 도구를 인터페이스 뒤에 배치하여 테스트
- 단위 테스트 중심의 테스트 피라미드 구조를 구축할 것
AI 에이전트(AI-agent) 테스트는 종종 비용이 많이 드는 루프로 시작됩니다. 에이전트를 호출하고, 그 답변을 다른 모델로 보내 품질 점수를 요청한 다음, 그 점수가 CI(지속적 통합)에 충분히 안정적이기를 바라는 방식입니다.
LLM 판사(LLM judges)는 진정으로 의미론적인(semantic) 질문에는 유용할 수 있습니다. 하지만 도구 순서(tool order), 재시도 제한(retry limits), 검증(validation), 예산(budgets), 상태 전이(state transitions) 또는 오류 처리(error handling)를 확인하는 데 있어서는 기본값으로 사용하기에 부적절합니다. 이러한 동작들은 명시적인 계약(contracts)을 가지고 있으며, 일반적으로 일반적인 결정론적(deterministic) 기술로 테스트할 수 있습니다.
가장 효과적인 에이전트 테스트 스위트는 **오케스트레이션 정확성(orchestration correctness)**과 **언어 품질(language quality)**을 분리하는 것입니다. 첫 번째 요소는 가짜(fakes), 추적(traces), 규칙을 사용하여 모든 커밋마다 테스트하십시오. 두 번째 요소는 더 작고 교정된 모델 및 인간 워크플로(model-and-human workflow)로 평가하십시오.
무엇이 결정론적(Deterministic)일 수 있는가?
에이전트는 예측 가능한 실행 계약을 따르면서도 가변적인 언어를 생성할 수 있습니다. 유용한 결정론적 체크 항목은 다음과 같습니다:
- 모델이나 도구 호출 전에 입력 검증(Input validation)이 실행됨.
- 현재 상태에서 승인된 도구만 사용 가능함.
- 생성(generation) 단계에서 결과를 사용하기 전에 검색(Retrieval)이 완료됨.
- 도구 인자(Tool arguments)가 스키마(schema)와 일치함.
- 재시도(Retries)가 설정된 제한 이후에 중단됨.
- 타임아웃(timeout) 또는 취소(cancellation)로 실행이 종료됨.
- 승인된 오류 카테고리에 대해서만 폴백(fallback)이 사용됨.
- 토큰(Token) 및 도구 호출(tool-call) 예산이 강제됨.
- 시작된 모든 스팬(span)이 정확히 한 번 완료됨.
- 민감한 페이로드(payloads)가 추적(trace)에서 제외됨.
이 질문들 중 그 어떤 것도 두 번째 모델을 필요로 하지 않습니다. 대부분은 첫 번째 모델조차 필요로 하지 않습니다.
에이전트를 위한 테스트 피라미드(Testing Pyramid) 사용
| 계층 | 모델 액세스 | 목적 |
|---|---|---|
| 순수 단위 테스트 (Pure unit tests) | 없음 | 라우팅(Routing), 검증(validation), 예산 책정(budgeting), 상태 리듀서(state reducers) |
| ... |
하위 계층이 테스트 스위트의 대부분을 포함해야 합니다. 이들은 빠르고, 재현 가능하며, 실행 가능합니다. 상위 계층도 가치가 있지만, 의도적으로 더 작게 구성해야 합니다.
모델과 도구를 인터페이스 뒤에 배치하기
의존성 주입(Dependency injection)을 사용하면 네트워크 호출 없이도 오케스트레이션을 테스트할 수 있게 됩니다.
type ModelReply =
| { type: 'tool_call'; tool: string; args: unknown }
| { type: 'final'; text: string; usage: { input: number; output: number } };
...
프로덕션 코드(Production code)는 실제 구현체를 전달받습니다. 테스트는 알려진 동작을 가진 스크립트된 구현체(scripted implementations)를 제공합니다.
모델 동작을 생성하는 대신 스크립트로 작성하기
스크립트된 모델은 미리 정의된 시퀀스(sequence)를 반환하며, 에이전트가 예상치 못한 추가 호출을 수행하면 실패합니다.
class ScriptedModel implements ModelClient {
private index = 0;
...
이 가짜(fake) 객체는 모델의 지능을 모방하지 않습니다. 대신 오케스트레이션(orchestration)이 처리해야 하는 분기(branch)를 제어합니다. 하나의 스크립트는 도구 호출(tool call)을 요청한 뒤 최종 답변을 반환할 수 있고, 다른 스크립트는 종료(termination)를 검증하기 위해 반복적으로 잘못된 도구 호출을 요청할 수 있습니다.
테스트에 친화적인 트레이스(Trace) 기록하기
트레이스는 테스트를 산문 형태의 출력(prose output)에 결합하지 않고도 동작을 드러내야 합니다.
type TraceStep = {
sequence: number;
name: string;
...
인과 관계에 대한 단언(causal assertions)을 위해 기록기(recorder)가 할당한 단조 증가 시퀀스(monotonic sequence)를 사용하세요. 연속된 이름으로부터 재시도(retry) 동작을 추론하지 마세요. 병렬 작업(parallel work)은 이벤트가 뒤섞일 수 있으며, 서로 관련 없는 단계들이 시도(attempt) 사이에 끼어들 수 있습니다.
트레이스 스키마(trace schema)를 안정적으로 유지하고 메타데이터를 우선시하세요. 테스트는 원시 프롬프트(raw prompts)나 완전한 도구 결과에 의존하는 것이 아니라, 실행 계약(execution contracts)을 단언(assert)해야 합니다.
재사용 가능한 트레이스 단언(Trace Assertions) 구축하기
작은 도메인 특화 헬퍼(domain-specific helpers)를 사용하면 일반적인 배열 비교보다 실패 원인을 더 쉽게 이해할 수 있습니다.
function stepIndex(trace: AgentTrace, name: string): number {
return trace.steps.findIndex((step) => step.name === name);
}
...
작업이 병렬로 실행될 때는 전체 순서(total ordering)보다는 부모 관계(parentage)와 필수 의존성(required dependencies)을 단언하세요. 두 개의 형제 도구(sibling tools)는 어떤 순서로 완료되어도 상관없으며 둘 다 정답일 수 있습니다.
생성 전 검색(Retrieval) 테스트하기
test('답변을 생성하기 전에 정책을 검색한다', async () => {
const model = new ScriptedModel([
{
...
테스트는 무엇이 실패했는지 정확히 알려줍니다: 누락된 단계, 잘못된 의존성 순서, 또는 예상치 못한 모델 호출(model call) 등입니다. 품질 점수(quality score)는 필요하지 않습니다.
시도 횟수에 따른 테스트 재시도 제한
test('두 번의 실패 시도 후 도구(tool)를 중단한다', async () => {
const tools = new FakeTools({
lookup_invoice: [
...
이는 실제 서비스나 모델을 기다리지 않고도 무한 재시도 루프(unbounded retry loop)를 잡아냅니다.
제어 흐름(Control Flow)으로서의 테스트 가드레일(Guardrails)
차단된 요청은 후속 모델(downstream model)이나 도구(tool)의 활동을 생성하지 않아야 합니다.
test('외부 호출 전에 권한이 없는 내보내기를 차단한다', async () => {
const { trace } = await runExportAgent(
{ model: modelThatMustNotRun, tools: toolsThatMustNotRun, clock: fakeClock },
...
이 테스트는 모델에게 특정 악성 문구를 인식하도록 요청하는 대신 구조화된 입력(structured input)을 사용합니다. 별도의 보안 스위트(security suite)를 통해 대표적인 텍스트 픽스처(text fixtures)로 프롬프트 인젝션(prompt-injection) 저항성을 테스트할 수 있습니다.
시간을 결정론적(Deterministic)으로 만들기
실제 시간(Wall-clock)에 기반한 단언(assertion)은 CI 부하 상황에서 종종 불안정(flaky)합니다. 가짜 시계(fake clock)나 스케줄러(scheduler)를 주입하고 이를 의도적으로 진행시키세요.
class FakeClock implements Clock {
private value = 0;
...
통합 경계(integration boundaries)를 위해 소수의 실제 타이밍 테스트는 유지하십시오. 모든 단위 테스트(unit test)가 임의의 밀리초(milliseconds) 내에 종료되어야 하는 과부하된 러너(runner)에 의존하게 만들지 마십시오.
집행 지점(Enforcement Point)에서의 테스트 예산
모델 페이크(Model fakes)는 명시적인 사용량(usage) 값을 반환할 수 있습니다. 이를 통해 토큰(token) 비용을 지불하지 않고도 예산 로직을 검증할 수 있습니다.
test('세션 토큰 예산을 초과하기 전에 중단한다', async () => {
const model = new ScriptedModel([
{ type: 'final', text: 'first', usage: { input: 700, output: 200 } },
...
이는 집행 동작(enforcement behavior)을 증명합니다. 별도의 프로바이더 통합 테스트(provider integration test)를 통해 프로덕션 사용량 필드가 내부 토큰 모델로 올바르게 매핑되는지 확인할 수 있습니다.
회귀 테스트를 위한 트레이스(Traces) 재생
기록된 메타데이터 트레이스(metadata traces)를 사용하면 민감한 콘텐츠를 재생하거나 모델을 호출하지 않고도 테스트를 수행할 수 있습니다. 모델의 결정, 도구(tool) 결과, 사용량 및 예상되는 불변성(invariants)을 설명하는 압축된 픽스처(fixture)를 저장하세요.
전체 트레이스를 바이트 단위로 일치해야 하는 스냅샷으로 취급하는 것은 피해야 합니다. ID, 타임스탬프 및 무해한 구현 세부 사항은 스냅샷을 취약하게 만듭니다. 트레이스를 정규화(Normalize)하고 지속 가능한 속성을 검증하세요:
- 필수 및 금지된 단계
- 부모-자식 관계
- 시도 횟수 제한
- 종료 상태 (Terminal status)
- 예산 총계
- 정책 결과
행동 계약(behavioral contract)이 의도적으로 변경될 때만 픽스처를 업데이트하세요.
해피 패스(Happy Paths)뿐만 아니라 테스트 실패 경로도 테스트하기
에이전트는 경계 조건에서 실패합니다. 다음과 같은 상황에 대한 픽스처를 포함하세요:
- 잘못된 모델 도구 인자 (Invalid model tool arguments)
- 도구 타임아웃 및 속도 제한 (Rate limits)
- 빈 검색 결과 (Empty retrieval results)
- 스트리밍 중 취소
- 부분적인 도구 성공
- 권한이 없는 도구 선택
- 컨텍스트 예산 소진 (Context-budget exhaustion)
- 트레이스 싱크(Trace-sink) 실패
- 폴백(Fallback) 실패
원래의 오류 범주가 보존되는지, 정리(cleanup) 작업이 실행되는지, 그리고 종료 상태 이후에 추가적인 모델 또는 도구 작업이 발생하지 않는지 확인하세요.
실제 모델이 여전히 필요한 곳
결정론적(Deterministic) 테스트는 답변이 명확한지, 근거가 있는지(grounded), 정중한지, 또는 의미론적으로 올바른지는 증명할 수 없습니다. 그러한 질문들에 대해서는 실제 모델 평가가 여전히 유용합니다.
큐레이션된 데이터셋과 명시적인 루브릭(rubric)을 사용하세요. 인간의 레이블(human labels)을 기준으로 판사(judge) 점수를 보정하고, 불일치를 추적하며, 임계값 실패를 검토하세요. 모델의 행동은 변할 수 있으므로, 단일 실행의 단일 점수를 의심할 여지 없는 사실로 취급하기보다 분포와 추세를 비교해야 합니다.
실용적인 일정은 다음과 같습니다:
| 트리거 | 권장 확인 사항 |
|---|---|
| 로컬 저장 (Local save) | 유닛 테스트 및 스크립트 기반 오케스트레이션 테스트 |
| ... |
비용이나 신뢰성 요구 사항이 더 엄격한 팀은 실제 모델 테스트를 풀 리퀘스트(pull requests)에서 완전히 제외할 수 있습니다. 중요한 점은 그러한 트레이드오프(trade-off)를 명시적으로 만드는 것입니다.
마지막 생각
에이전트의 출력(output)은 확률적(probabilistic)이지만, 에이전트 아키텍처(architecture)가 불투명할 필요는 없습니다. 검증(validation), 도구 액세스(tool access), 재시도(retries), 폴백(fallbacks), 예산(budgets), 상태 전이(state transitions), 그리고 트레이스 구조(trace structure)는 모두 결정론적 계약(deterministic contracts)을 가질 수 있습니다.
이러한 계약들을 주입 가능한 인터페이스(injectable interfaces) 뒤에 배치하고, 스크립트로 작성된 의존성(scripted dependencies)으로 구동하며, 안정적인 메타데이터 트레이스(metadata trace)를 통해 동작을 단언(assert)하십시오. 실제 모델과 LLM 판사(LLM judges)는 일반적인 코드가 답할 수 없는 의미론적(semantic) 질문을 위해 아껴두어야 합니다.
다음 기사에서는 이러한 아이디어들을 재사용 가능한 트레이스 규칙(trace rules), 베이스라인 비교(baseline comparison), 그리고 실행 가능한 실패 보고서(actionable failure reports)를 갖춘 빠른 CI 품질 게이트(quality gates)로 전환하는 방법을 다룰 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기