JangBaGeum.gif

AI 에이전트 테스트 본문

Backend/개발 방법론 & 디자인 패턴

AI 에이전트 테스트

장바금 2026. 5. 25. 17:39

AI를 이용한 프로덕션 에이전트를 운영하기는 정말 힘들다. AI 민원 처리 에이전트를 개발하였고 현재 운영 중이다. 새로운 기능 개발 / 개선은 지금도 계속 이어가고 있다. 어제 잘 동작하던 에이전트가 오늘 갑자기 다른 게 처리하거나 답을 하는 경우가 많다. 코드는 한 줄도 바꾸지 않았는데, 운영에서 사용자 컴플레인이 들어오거나 QA 테스트 중 뜬금없는 곳에서 확인 요청이 온다. 추적해 보면 결국 프롬프트 한 줄 수정, 도구 추가, 모델 버전 업그레이드 중 하나다.

 

처음에는 이게 무서웠다. 내가 통제하지 못하는 변수가 너무 많아서, 어디서부터 손을 대야 할지 막막했다. 단위 테스트 패턴은 그대로 안 통하고, 옵서버빌리티 도구는 이미 일어난 일만 보여줄 뿐이다. 그래서 한동안은 "이건 그냥 받아들이는 수밖에 없나"싶었다.

 

"Building AI agents days Production: 6 months." 라는 r/LangChain의 한 게시글이 정확히 이 어려움을 짚는다.

"you ship it and when something breaks you've got no clue what actually happened. no clean logs of every tool call or decision branch or token spend. silent failures everywhere."

 

빌드는 며칠이면 끝나지만, 운영은 6개월짜리 싸움이다. 그리고 그 싸움의 8할은 테스트와 회귀 검출의 문제다.

 

이전에 Cluade Code를 슬기롭게 이용하기라는 글을 쓰면서 자동 검증 환경을 만들어야 한다고 썼고, RESTful API 설계 원칙 글에서는 명세만으로 동작이 예측되어야 한다고 썼다. 두 글에는 공통적인 메시지를 담고 있다. 지금 하려는 이야기도 비슷하다. 사람이 매번 판단하지 않아도 일관된 결과가 나오는 구조를 만들어야 한다.

 

그런데 AI 에이전트는 이게 안 된다. 같은 입력에도 다른 trace가 나오고, 같은 코드에도 다른 결과가 나온다. 기존 단위 테스트 패턴은 통하지 않는다. 그렇다고 옵저버빌리티 도구로 운영에서 잡으려 하면 이미 늦는다 생각한다. 우리에게는 PR이 병합되기 전에 회귀를 잡는 새로운 패턴이 필요하다.

 

이 글은 직접 AI 에이전트 서비스를 운영하며 느낀 "AI 에이전트 테스트 원칙"을 간략하게 정리하려고 한다.

 


 

이야기할 것들은

1. 비결정성을 측정하지 않으면 어떤 테스트도 무의미하다
2. trajectory는 outcome보다 더 많은 정보를 담는다
3. PR-time 게이트, 모니터링, 평가는 다른 레이어다
4. tool 시퀀스는 정확, 인자는 구조, 응답은 의미로 매칭한다
5. LLM judge는 디폴트가 아니라 opt-in이다
6. 실패 trajectory는 분류되어야 다음 PR을 막는다
7. 회귀 테스트는 프로덕션 trace에서 자동 흡수한다
8. 프롬프트 변경은 코드 변경이다
9. 도구 추가는 컨텍스트 오염이다
10. 멀티턴 테스트는 단일턴의 합이 아니다
11. 모델 업그레이드는 행동 회귀다
12. 비용은 테스트 결과의 일부다

 


비결정성을 적으로 생각하지 마라

LLM 에이전트는 본질적으로 확률적이다. `temperature=0`으로 설정해도 같은 입력에 다른 도구 호출 순서가 나오는 경우가 있다. 모델 내부의 sampling, KV(key-value) 캐시 효과, 배치 처리 같은 요인들이 미세한 차이를 만든다. 이건 우리가 풀어야 할 버그가 아니라, 우리가 받아들여야 할 환경이다.

 

테스트는 변동을 0으로 만드는 것이 아니라 변동을 측정하고 게이트 하는 것이어야 한다. 처음에는 이걸 받아들이지 못하고 "왜 똑같이 안 나오지?"를 붙들고 있었다. 프롬프트를 점점 더 단단히 잡고, 도구 인자 스키마를 strict 하게 바꾸고, 그래도 안 되면 모델을 핀 했다. 그러나 어느 순간부터 몇 % 흔들리는지를 수치로 잡는 것이 첫 번째 일이 되었다. 변동을 없애려는 마음이 아니라 재는 마음으로 바꿨더니, 그제야 안정적인 회귀 테스트를 짤 수 있었다.

 

도구가 아니라 패턴 언어를 만들어라

"agentevals를 쓰자", "scenario로 가자" 같은 도구 선택은 2차 결정이다. 1차 결정은 무엇을 어떻게 비교할 것인가다. 이게 정해지지 않으면 어떤 도구를 써도 만족스럽지 않다.

 

결국 중요한 건 "왜 이렇게 했는가"의 기록이다. 패턴 언어가 있으면 도구가 바뀌어도 일관성이 유지된다. 6개월 후에 agentevals 대신 새 도구가 나와도, 내가 무엇을 어떻게 비교하기로 했는지가 명확하면 마이그레이션 비용이 훨씬 작다 생각한다.

 


 

원칙 1. 비결정성을 측정하지 않으면 어떤 테스트도 무의미하다

처음에 회귀 테스트를 만들었을 때, 매일 50번 중 6~7번이 실패했다. 코드는 한 줄도 안 바꿨는데, 처음에는 테스트를 탓하면서 매처를 손보고 임계값을 조정하고 했다. 그런데 자세히 보니 에이전트가 일관성 없이 헷갈려하고 있었다.

 

문제는 단순했다. 같은 입력에 대한 N번 돌려보지 않은 상태에서 회귀 테스트를 구축한 것이다. 첫 실행 결과를 정답으로 박아두고, 그게 안 나오면 회귀라고 본 셈이다. 그러나 첫 실행 자체가 50개의 가능한 trace 중 하나였을 뿐이다. 같은 입력에 대해 N번 돌려보지 않으면, 어떤 테스트도 신호인지 노이즈인지 구분할 수 없다. 회귀 테스트의 0편은 측정이다.

 

새 에이전트를 테스트 대상으로 잡았다면, 테스트 코드를 짜기 전에 다음을 먼저 측정한다.

 

- 같은 입력으로 N=10회 실행

- tool call 시퀀스가 얼마나 일치하는가 (0~100%)

- 인자값이 얼마나 일치하는가

- 최종 응답의 의미적 유사도(임베딩 코사인) 분포

// variance-measure.ts
import { computeTrajectoryVariance } from "./variance";

async function measureAgentVariance(agent: Agent, input: string) {
  // 직렬 호출 — Promise.all로 동시 호출하면 KV 캐시·배치 효과 때문에
  // 운영의 실제 변동률과 다르게 측정된다
  const traces: Trace[] = [];
  for (let i = 0; i < 10; i++) {
    traces.push(await agent.run(input));
  }

  const variance = computeTrajectoryVariance(traces);

  console.log(`tool sequence consistency: ${variance.toolSeq}%`);
  console.log(`tool args consistency: ${variance.toolArgs}%`);
  console.log(`response similarity (cosine mean): ${variance.responseSim}`);
  console.log(`response similarity (cosine min): ${variance.responseSimMin}`);

  return variance;
}

 

여기서 핵심은 최솟값(min)도 함께 본다는 것이다. 평균값만 보면 outlier가 묻혀버린다. 평균 95%여도 가끔 50%로 떨어진다면 그건 위험이다.

 

5%를 게이트로 둔다

내 경험상 동일 시나리오에서 변동률이 5%를 넘기면 어떤 회귀 테스트도 신뢰할 수 없다. 이 경우 테스트를 늘리는 게 아니라 에이전트 자체를 다듬는 것이 먼저다. 프롬프트를 단단히 잡거나. 도구 인자 스키마를 엄격하게 하거나, 모델을 핀 하는 식이다.

 

5%는 절대적인 숫자가 아니다. 도메인에 따라 1% 이하여야 하는 곳도 있고 (e.g., 결제 확인 에이전트), 10%를 받아들이고 통계적 매칭으로 가는 곳도 있다.(e.g., 자유 형식 답변이 본질인 챗봇). 중요한 건 숫자를 알고 있는가 이다.

 

정리하면, 비결정성 측정은 테스트 인프라보다 먼저다. 측정 없이 만든 회귀 테스트는 거짓 신호를 팀의 신뢰를 갉아먹는다. 첫 번째 PR로 측정 하네스를 만들고, 그 위에 회귀 테스트를 쌓아 올리자.

 


원칙 2. trajectory는 outcome보다 더 많은 정보를 담는다

outcome - 에이전트가 최종적으로 사용자에게 돌려준 것

trajectory - 그 답을 만들기 위해 거쳐 간 모든 단계

 

같은 응답을 만들어내는 경로는 항상 변경된다.

예를 들어 상품 등록/수정 에이전트가 있다고 하자. 어떤 날은 `lookup_product -> validate_product -> upsert_product` 3단계로 끝낸다. 어떤 날은 `lookup_product -> lookup_product -> lookup_product -> validate_product -> upsert_product` 같은 식으로 같은 도구를 3번 부른다. 어떤 날은 `validate_product를 건너뛰고 바로 등록/수정을 처리한다.

 

세 경우 다 최종 응답은 "상품이 등록됐습니다."일 수 있다. 그러나:

- 첫 번째: 정상

- 두 번째: 비용 폭증, 응답 지연

- 세 번째: 가격/카테고리 검증을 건너뛴 등록 사고 (0원 상품/잘못된 섹션 노출 등)

 

outcome만 보면 세 번째 사례를 영원히 못 잡는다. 운영에서 잘못된 상품이 노출돼 CS가 들어오거나 매출 정산이 어긋난 다음에야 알게 된다. LangChain의 agentevals도 같은 문제의식을 trajectory 매칭으로 풀고 있다. 경로 자체가 검증 대상이다.

 

검증 대상은 다음 3개를 함께 본다.

항목 검증 대상
trajectory tool call 시퀀스, 인자, 호출 횟수
intermediate state 그래프 노드 전이, 메모리 변경
outcome 최종응답

 

outcome만 보면 비용이 2배가 된 것과 불필요한 도구 호출이 추가된 것과 결정적인 검증 단계가 빠진 것을 모두 못 잡는다. 셋 다 운영에서 청구서나 사고 보고서를 보고서야 알게 된다. 그때는 이미 늦다.

- 참고 - agentevals - trajectory_match_mode

 

GitHub - langchain-ai/agentevals: Readymade evaluators for agent trajectories

Readymade evaluators for agent trajectories. Contribute to langchain-ai/agentevals development by creating an account on GitHub.

github.com

 


원칙 3. PR-time 게이트, 모니터링, 평가는 다른 레이어다

이 셋은 정말 자주 혼동된다. "LangSmith 쓰면 다 되는 거 아니야?", "DeepEval로 평가하면 회귀도 잡히지 않아?"같은 질문을 받는다. 결론부터 말하면, 셋 다 다른 시점에 다른 일을 하는 다른 도구다.

레이어 시점 역할 예시
PR-time 게이트 소스 병합 전 회귀를 막는다 (통과 / 실패 결정) agentevals, eval-view, attest
모니터링 운영 중 일어난 일을 관한다 LangSmith, Langfuse, Arize
평가 모델/프롬프트 변경 검토시 품질을 점수화한다 DeepEval,Promptfoo, Maxim

 

각 레이어는 다른 도구로 다른 시점에 다른 목적으로 작동해야 한다. 이걸 한 도구에 몰면 어느 것도 제대로 안 된다. 예를 들어 LangSmith로 PR 게이트를 만들려고 하면, 데이터셋 관리 + 평가 실행 + CI 통합까지 다 LangSmith 안에서 해야 해서 도구가 무거워진다. 반대로 agentevals로 운영 모니터링을 하려고 하면 trace 수집/시각화/알림 기능이 부족하다.

 

내가 세운 우선순위는 PR-time -> 모니터링 -> 평가 순이다. PR이 주된 회귀 진입로이기 때문이다. 체감상 회귀의 거의 대부분은 누군가 PR을 머지하면서 들어오고, 그걸 머지 전에 막는 게 가장 싸다. 모니터링은 PR이 못 막은 케이스를 잡는 안전망이고, 평가는 방향성을 결정하는 도구다. "프롬프트 A와 B 중 어느 게 더 좋은가"같은 의사결정에 쓴다.

 

이 셋의 경계가 흐려지면, 결국 모든 것을 한 SaaS로 사용해야 하는 상황이 된다. 그건 비싸고 락인되기 좋은 모양이다. 처음부터 레이어를 분리해서 각 시점에 맞는 도구 도입이 좋은 것 같다.


원칙 4. tool 시퀀스는 정확, 인자는 구조, 응답은 의미로 매칭한다

처음에는 모든 것을 문자열 비교로 했다. 결과는 false positive 폭증, tool 인자 안의 `request_id`나 timestamp 때문에 매번 깨졌다. 응답도 토씨 하나 다르면 유효성 검사 실패로 인한 오류가 발생했다. 과민한 회귀 참지만큼 빨리 신뢰를 잃는 것은 없다.

 

비교는 대상의 본질에 맞아야 한다. 시퀀스는 순서가 본질이고, 인자는 구조가 본질이고, 응답은 의미가 본질이다. 이것들이 섞이면 어디서 어떻게 깨졌는지도 알기 어렵다.

비교 대상 방식 비고
tool call 시퀀스 정확 매칭 (Levenshein 허용) 도구 순서가 행동의 본질
tool 인자 구조 매칭 + 비결정 필드 정규화 + 숫자 허용 오차 값보다 형태가 일치해야 함
최종 응답 임베딩 코사인 유사도 또는 LLM judge 표현은 달라도 의미만 같으면 OK

 

비결정 필드(timestamp, UUID, latency, request_id 등)는 비교 전에 정규화한다. agentevals의 `tool_args_match_overrides`로 도구별 커스텀 매처를 지정하거나, eval-view의 auto-variant 모드로 변동을 통계적으로 흡수하는 방식이 대표적이다.

 

코드로 보면 이런 모양이다.

// 도구별 매처 — agentevals
const evaluator = createTrajectoryMatchEvaluator({
  toolArgsMatchMode: "structural",
  toolArgsMatchOverrides: {
    upsert_product: (expected, actual) => {
      // request_id, timestamp는 무시, price는 ±1% 오차 허용
      return Math.abs(expected.price - actual.price) / expected.price < 0.01;
    },
  },
});

 

응답을 임베딩 유사도로 비교하면 의미적으로 같은 다른 표현을 통과시킨다. "상품이 등록됐습니다."와 "상품 등록이 정상 완료되었습니다"는 코사인 유사도가 0.95 이상으로 매칭된다. 하지만 임계값을 너무 낮추면 틀렸는데 통과시키는 경우가 생긴다. 0.92~0.95 사이에서 도메인별로 튜닝하는 것이 현실적이다. 자유 형식 챗봇은 0.88까지도 허용해야 할 수 있고, 등록 완료 같은 정형 응답은 0.97 이상이어야 할 수 있다.

 

정리하면, 3 티어 매칭이 핵심이다. 한 가지 방식으로 모두 비교하려 하면 어딘가 복잡해진다.

 


원칙 5. LLM judge는 디폴트가 아니라 opt-in이다.

LLM judge는 매력적이다. "AI가 AI를 평가한다"는 슬로건도 있고, 실제로 복잡한 응답 품질을 자동 평가하는 데 효과적이다. 하지만 두 가지 비용이 따라온다.

 

첫째, 돈. 100개 테스트 케이스를 매 PR마다 judge 하면 토큰 비용이 1주일에 수십만 원이 될 수 있다. PR이 자주 들어오는 조직이면 월 100만 원이 넘는다. 쉽지 않은 금액이다.

 

둘째, flakiness. judge 자체가 비결정적이다. 같은 trajectory를 두 번 judge 하면 다른 점수가 나온다. 테스트 자체가 비결정적이면 원칙 1이 무너진다. judge로 "통과/실패"를 결정하는 순간, judge의 변동이 에이전트의 변동과 합쳐져서 신호가 묻힌다.

 

이 두 비용을 받아들일 만한 가치가 있을 때만 judge를 쓴다.

- 디폴트: deterministic 매칭 (시퀀스 + 구조 + 임베팅)

- opt-in: 명시적으로 표기한 테스트에만 LLM judge

- judge가 필요한 경우라도, 동일 입력에 N=3회 judge 후 다수결

 

Attest Framework도 같은 사상으로, 8개 assertion 레이어를 "deterministic assertions first, LLM-as-judge last" 순으로 평가한다. Layer 1은 schema validation처럼 무료에 즉시, Layer 6의 LLM-judge는 마지막 수단이다. 이 graduated 접근이 비용/신뢰성 모두에게 합리적이다.

 

실용적인 디폴트는 이렇다. 80%의 테스트는 deterministic으로, 15% 정도는 임베딩 유사도, 5% 정도만 LLM judge. 이 비율로 시작해서 도메인에 맞춰 조정하면 될 것 같다. 처음부터 모든 테스트에 judge를 박아두면, 비용 통제나 flakiness 통제는 매우 어려울 것 같다.

- 참고 - Hamel Husain - Your AI product needs evals

 

Your AI Product Needs Evals –

How to construct domain-specific LLM evaluation systems.

hamel.dev

 


원칙 6. 실패 trajectory는 분류되어야 다음 PR을 막는다

운영 중, 에이전트가 실패하면, 같은 패턴이 반복된다. 그런데 사람이 매번 trace를 처음부터 읽는다. "왜 상품 등록이 안 됐지?" -> 30분 trace 분석 -> "아, tool 인자 스키마 위반이네". 그런데 가음 주에 다른 시나리오에서 또 같은 스키마 위반이 일어난다. 그리고 또 30분이 걸린다. 이게 누적되면 디버깅 시간이 폭증한다.

 

해결책은 단순하다. 실패는 "분류 가능한 카테고리"를 갖는다. 한 번 분류해 두면 다음에 같은 카테고리는 자동으로 잡을 수 있다. 사람의 시간은 "처음 마주한 분류"에 대해서만 쓴다.

 

운영하면서 마주할 수 있는 카테고리들은 대략 다음과 같을 것 같다.

- tool argument schema violation
- infinite loop / repeated calls
- wrong sub-agent routing
- context overflow
- hallucinated tool name
- missing required tool result
- silent timeout
- cost spike
- permission denied (인증 실패)
- hallucinated user data

 

각 케테고리에 휴리스틱 deector를 만들고, 못 잡는 케이스만 LLM judge로 fallback 한다. 휴리스틱은 정규식/구조 매칭 정도로도 70%~80%는 잡을 수 있다 생각이 된다.

// failure-classifiers.ts
export const classifiers = {
  toolArgSchemaViolation: (trace: Trace) =>
    trace.toolCalls.some(c => !validateSchema(c.tool, c.args)),

  infiniteLoop: (trace: Trace) => {
    const last5 = trace.toolCalls.slice(-5);
    if (last5.length < 5) return false;
    // 같은 도구 + 같은 인자 5번 = 폴링이 아니라 무한 루프
    // (도구 이름만 비교하면 정상적인 상태 폴링도 잡혀버린다)
    const sig = (c: ToolCall) => `${c.tool}:${JSON.stringify(c.args)}`;
    return last5.every(c => sig(c) === sig(last5[0]));
  },

  costSpike: (trace: Trace, baseline: number) =>
    trace.totalTokens > baseline * 1.5,

  // ... 나머지 카테고리
};

 

이렇게 만들어두면 운영에서 사고가 일어났을 때, 카테고리 라벨이 자동으로 붙고, 같은 라벨의 trace는 즉시 회귀 테스트 세트에 들어간다 (다음 원칙 7 참조). 사람이 같은 분류 작업을 반복하지 않아도 된다.

 

MLflow의 Automatic Issue Detection도 비슷한 아이디어를 클러스터링 + LLM 판단 조합으로 구현하고 있다. 도구 도입 여부와 별개로 분류 taxonomy를 자기 도메인에 맞게 정리해 두는 것이 했심이다.

 


원칙 7. 회귀 테스트는 프로적션 trace에서 자동 흡수한다.

테스트 케이스를 손으로 만들면 항상 부족하다. QA가 시나리오 50개를 짜둬도, 운영에서는 언제나 예상 못하는 케이스가 발생한다. 그게 사고로 이어졌을 때, 그 trace가 다음 사고를 막는 데 기여하지 않으면, 우리는 매번 같은 실수를 반복하게 된다.

운영에서 "이건 잘못됐다"라고 분류된 trace는 자도으로 회귀 테스트 세트에 들어가야 한다. 한 번 막힌 길은 다시 열리면 안 된다. "one-way ratchet" 패턴이다. 자물쇠를 채울 때마다 더 단단해지고, 절대 풀리지 않는다.

 

이러한 파이프라인은 도구가 잘 안 도와준다. 직접 만들어야 한다. 모니터링 도구의 webhook + Github PR 자동 생성 정도면 충분한 것 같다. 처음에는 수동으로 라벨 + 수동 추가로 시작해도 된다. 그러나 incident가 자주 일어나는 패턴이 보이면 자동화로 옮긴다.

한 가지 주의할 점은 의도적 변경이다. 운영 사고였던 trace가 사실은 새로운 정책에 의한 의도적 동작이었을 수 있다. 이런 경우 명시적으로 snapshot을 update 하고 왜 update 했는지 커밋 메시지에 남긴다.

 


원칙 8. 프롬프트 변경은 코드 변경이다

"프롬프트 한 줄 추가했어요, 별거 아님"이라는 말을 종종 들었다. 그리고 매번 그게 회귀의 원인이 되었고 또한 문제 발생 시, 추적도 어렵다. 프롬프트는 에이전트 코드다. 코드 한 줄 바뀌면 PR 리뷰하듯이, 프롬프트 한 줄 바뀌면 회귀 테스트가 돌아야 한다.

 

이게 받아들여지지 않는 가장 흔한 이유는, 프롬프트가 코드처럼 안 보이기 때문이다. 자연어로 적혀 있고, 누구나 고칠 수 있고, "그냥 한 문장 추가" 같은 느낌이다. 그러나 LLM에게 그 한 문장은 함수의 시그니처를 바꾼 것과 같은 무게다.

 

- 프롬프트는 코드 저장소에 파일로 둔다

- 프롬프트 파일에 변경이 일어나면 회귀 테스트 전체를 다시 돈다

- 변경된 부분만 영향 분석하지 않는다. - 프롬프트는 전역적으로 영향을 준다

- PR 템플릿에 "프롬프트 변경 여부" 체크박스를 준다

 

마지막 항목이 의외로 효과가 크다. PR작성자 스스로 프롬프트를 건드렸는지 인지할 수 있다. 그것만으로 회귀 테스트를 돌릴지 말지의 결정 기준이 명확해진다. 

github을 통한 프롬프트 관리 시, langfuse 등에 자동 적용이 필요하다면 서비스에서 제공하는 webhook들이 있을 테니 이용하면 코드와 같이 관리하며 배포도 신중히 진행 가능하다.

 


원칙 9. 도구 추가는 콘텍스트 오염이다.

도구를 추가하면 에이전트가 기존 작업도 다르게 한다. 도구 스펙이 시스템 프롬프트에 같이 들어가면서 콘텍스트가 변하기 때문이다. 도구 surface 스케일링 연구들에서도 도구 수 증가가 정확도와 비용 모두를 악화시킨다는 측정이 반복적으로 보고된다 (예: MCP Six-Tool Pattern, Synaptic Labs의 Meta-Tool Pattern).

 

쉽게 말해 이렇다. 상품 등록 에이전트에 할인 정책 조회 도구를 새로 추가했다고 하자. 의도는 "카테고리별 할인율을 자동 적용"이다. 그런데 추가 후, 에이전트가 모든 상품 등록에서 할인 정책을 조회하기 시작한다. 단순 상품 등록에도 1번씩 더 호출되니 비용이 30% 늘어난다. 더 나쁜 건, 어떤 시나리오에서는 정책 조회 결과를 무시하고 가격·카테고리 검증을 건너뛴다. 콘텍스트가 포화돼서 행동이 무너진 것이다.

 

도구 추가는 기능 추가가 아니라 컨텍스트 변경이다.

- 도구 추가 PR에는 기존 시나리오 회귀 테스트가 함께 돌아야 한다

- "이번에 추가한 도구만 테스트하면 되지"라는 사고는 위험하다

- 도구가 30개 가까이 가면 progressive tools 패턴 (메타툴 + 동적 로딩) 검토

 

마지막 항목이 중요하다. 30개라는 숫자는 절댓값이 아니라 *내가 운영하면서 그쯤부터 콘텍스트 포화로 기존 시나리오 회귀가 눈에 띄게 늘기 시작한 지점*이다. 도메인·모델에 따라 더 빨리 올 수도, 더 늦게 올 수도 있다. 그 시점이 오면 메타툴 2~3개(예: `search` + `execute`)로 줄이고, 필요한 도구만 동적으로 로딩하는 패턴으로 옮긴다. Cloudflare가 *2개 도구(`search` + `execute`)로 2,594개 엔드포인트*를 다루는 사례가 대표적이다. 도구를 *없애지 않고도* 컨텍스트 오염을 줄이는 길이다.

 


원칙 10. 멀티턴 테스트는 단일턴의 합이 아니다.

단일턴 5개를 통과해도 5 턴 대화에서 깨진다. 상태 누적이 행동을 바꾸기 때문이다. 컨텍스트 길이가 늘면서 모델 자체의 해동도 바뀐다. 이는 많이 경험해 봤을 것이다.

 

예를 들어, 상품 등록/수정 에이전트가 단일 턴 시나리오에서는 완벽하게 가격/카테고리 검증을 한다. 그런데 5 턴 대화 -"상품 목록 조회" -> "그중 12345 상품 가격 5000원으로 수정 가능?" -> "네 , 수정" - 같은 흐름에서, 누적된 콘텍스트 때문에 검증을 건너뛰는 일이 발생한다. 단일턴 테스트로는 절대 잡히지 않는다.

 

- 멀티턴 시나리오는 별도의 테스트 카테고리로 둔다.

- 각 턴 사이에 시물레이션 된 사용자가 있어야 한다. - 사람이 손으로 짠 스크립트만으로는 부족하다.

- langwtch/scenario의 user simulator 패턴 사용

 

scenario를 쓰면 멀티턴이 이런 모양으로 짜인다.

import scenario from "@langwatch/scenario";
import { openai } from "@ai-sdk/openai";

const result = await scenario.run({
  name: "유효하지 않은 가격으로 상품 수정 시나리오",
  description: "사용자가 0원 또는 음수 가격으로 상품 수정을 요청한다",
  agents: [
    myProductAgent,                 // 우리 에이전트 어댑터
    scenario.userSimulatorAgent(),  // 시뮬레이션된 사용자
    scenario.judgeAgent({
      model: openai("gpt-4.1-mini"),
      criteria: [
        "에이전트가 가격 검증 실패를 명확히 알린다",
        "상품이 실제로 수정되지 않는다",
      ],
    }),
  ],
});

 

user simulator가 스크립트 없이 자연스럽게 사용자를 시뮬레이션해 주고, judge agent가 성공/실패 조건을 평가한다. 사람이 5 턴 시나리오를 손으로 짤 필요 없고,  같은 시나리오의 변종도 자동으로 생성된다.

 

멀티턴 테스트는 비용이 크다. 매 PR마다 다 돌리면 시간, 특히 돈이 많이 소비된다. 나는 PR에는 대표 시나리오 5개 정도만 돌려도 충분하다 생각되고, 나이트리 빌드에서 전체를 돌려도 좋다. 이렇게 분리하면 PR 사이클을 늦추지 않으면서 야간에 깊은 검증을 돌릴 수 있다.

 


원칙 11. 모델 업그레이드는 행동 회귀

모델 버전 업그레이드는 우리가 *통제할 수 없는 변경이다. Anthropic·OpenAI가 모델 weight를 바꾸면, 우리 에이전트의 모든 행동이 영향받을 수 있다.

 

모델 업그레이드는 프로덕션 코드 변경과 같은 무게로 다뤄져야 한다.

- 운영 환경의 모델 버전을 코드에 핀 한다 (`model: "claude-opus-4-7"` 명시)

- 모델 버전 변경은 별도 PR로 분리한다

- 회귀 테스트 + 멀티턴 시나리오를 모두 돌리고 머지한다

- 비용 변화도 측정한다 (원칙 12 참조)

 

`auto` 같은 자동 업그레이드 모드는 운영의 주 트래픽에는 쓰지 않는다. 신모델을 빨리 검증하고 싶다면 실험·프리뷰 트래픽에 부분 적용해서 같은 회귀 테스트·멀티턴 시나리오를 돌려보는 정도가 안전한 선이다. 주 트래픽에서 통제할 수 없는 변경이 일어나는 건 받아들이기 어려운 리스크다.

// agent-config.ts
export const AGENT_CONFIG = {
  model: "claude-opus-4-7",  // 명시적 핀
  // model: "claude-opus-latest",  // ❌ 절대 안 됨
  temperature: 0,
};

 


 

원칙 12. 비용은 테스트 결과의 일부이다.

테스트의 통과 조건에 토큰 사용량과 호출 횟수가 들어가야 한다. 스냅숏에 다음을 함께 저장한다.

# __snapshots__/test_product_register.yaml
trajectory:
  tool_calls:
    - name: lookup_product
      args: { product_id: "12345" }
    - name: validate_product
      args: { product_id: "12345" }
    - name: upsert_product
      args: { product_id: "12345", price: 15000 }
final_response_similarity_threshold: 0.93

budget:
  total_tokens: 4500
  tool_calls: 3
  estimated_cost_usd: 0.012
  budget_threshold: 1.20  # 20% 이상 늘면 회귀

다음 PR에서 비용이 임계치 이상 올라가면 회귀로 분류한다. 임계치는 보통 +20%로 두지만 도메인에 따라 다르다. 사용자 컴플레인 응답 같은 복잡한 시나리오는 50%까지 허용해도 되고, 단순 조회 같은 정형 흐름은 5%만 넘어도 의심해야 한다.

 

비용 회귀가 자주 일어나는 패턴은 다음과 같다.

- 새 도구 추가로 모든 호출에 1번씩 더 부르기 시작 (원칙 9)

- 프롬프트에 새 지시사항 추가로 추론 chain이 길어짐 (원칙 8)

- 모델 업그레이드로 길게 답하는 경향이 강해짐 (원칙 11)

- 멀티턴에서 콘텍스트가 더 누적됨 (원칙 10)

 

위 4개와 모두 연결된다. 비용 게이트를 두면, 다른 회귀가 비용으로 먼저 드러나는 효과가 있다. 돈은 거짓말을 하지 않는다.

 


자주 헷갈리는 것들

설계하다 보면 사소하지만 매번 고민되는 부분들이 있다. 정답이 있다기보다는, 한번 정하면 일관되게 가져가면 되는 것들이다. 내가 어떻게 결정했는지 적어둔다.

 

비결정성 게이트를 0%로 두면 안 되나

'temperature=0' + 모델 핀 + 도구 핀까지 해도 0%는 현실적으로 안 나온다. 모델 내부의 sampling, KV캐시, 배치 효과 같은 미세한 비결정성이 항상 남아 있다. 안 나오는 길 목표로 두면 모든 테스트가 의미 없어진다. 5%는 실용적인 합의점이지 진리가 아니다. 자기 도메인에서 직접 측정해서 정해야 한다.

 

단위 테스트와 회귀 테스트의 경계는 어디인가

 

- 단위 테스트: tool 함수, normalizer, parser 같은 순수 함수 검증

- 통합 테스트: 도구 + LLM 호출이 결합된 작은 흐름 검증

- 회귀 테스트: 실제 LLM을 호출한 trajectory 비교

 

회귀 테스트만 실제 LLM에 의존한다. 그래서 비용이  들고 비결정성이다. 단위/통합 테스트는 항상 결정적이어야 한다.

 

어디까지 mock하나

LLM 자체는 mock 하지 않는다. mock 된 LLM 응답은 실제 모델의 행동과 다르다. mock 하면 해피 케이스만 검증할 수 있고, 예상하지 못한 시나리오는 영원히 잡을 수 없다.

 

대신 외부 도구는 mock 한다. 결제 게이트웨이, 외부 API, DB 등은 결정적인 응답을 주는 foke로 대체한다. 이렇게 하면 LLM의 비결정성과 외부 시스템의 비결정성을 분리할 수 있다.

 

 

 


 

 

이 원칙들은 절대적인 정답이 아니다. 반년 가까이 LangGraph 에이전트를 운영하면서 부딪히고 다시 다듬은, 말 그대로 나만의 경험과 기준이다. 아마 AI 에이전트를 개발하는 분들이라면 이미 경험했거나 고민을 했을 주제이다.

 

처음에 번거로워 보이는 규칙이 시간이 지날수록 큰 도움을 줄 때가 있다. AI  에이전트 테스트도 그렇다. 비결정을 측정한다, trajectory도 함께 본다, 프롬프트 변경을 코드 변경처럼 다룬다 같은 원칙들이 처음에는 과해 보일 수 있다. 그러나 운영을 해보면, 이게 없을 때 매번 불을 끄던 시간과 비교가 안 된다. 회귀 테스트가 안 짜여 있는 상태에서 프롬프트를 수정하는 건, 단위 테스트 없이 함수를 리펙토링 하는 것과 같다. 처음에는 빠르지만, 점점 손을 댈 수 없는 상태가 된다.

 

정리하면 핵심은 이렇다.

1. 비결정성을 측정한다 - 회귀 테스트의 0편

2. trajectory를 본다. - outcome만 보면 비용/경로 회귀를 놓친다

3. 레이어를 분리한다. - PR-time / 모니터링 / 평가

4. deterministic-first - LLM judge는 opt-in

5. 실패는 분류해 둔다 - 같은 카테고리는 자동으로 막힌다

6. 프롬프트/도구/모델 변경을 코드 변경처럼 다룬다 - 회귀 테스트가 돌아야 한다.

7. 비용도 테스트 대상이다 - 청구서가 회귀의 진짜 모습!

 

AI와 관련된 도구들 그리고 AI를 활용한 서비스들도 계속해서 나오고 있다. 지금은 정보가 많지 않고 비확정성이라는 측면 때문에 AI에이전트를 테스트하는 것은 시도하기 어렵다. 하지만 테스트 환경을 잘 조성해 두면 우리가 신경 쓸 것은 비용만 남을 것이라 생각이 된다.

 

참고

오픈소스

- langchain-ai/agentevals - https://github.com/langchain-ai/agentevals

- hidai25/eval-view - https://github.com/hidai25/eval-view 

- langwatch/scenario - https://github.com/langwatch/scenario 

- Attest Framework - https://github.com/attest-framework 

- LangChain Deep Agents - https://docs.langchain.com/oss/python/deepagents/context-engineering

- OpenInference 표준 - https://github.com/Arize-ai/openinference) 

 

외부 자료

- r/LangChain — "Building AI agents: days. Production: 6 months." - https://www.reddit.com/r/LangChain/comments/1srswn3/

- Hamel Husain — Your AI product needs evals - https://hamel.dev/blog/posts/evals/

- Eugene Yan — Patterns for Building LLM-based Systems & Products - https://eugeneyan.com/writing/llm-patterns/ 

- Chroma — Context Rot 연구] - https://www.trychroma.com/research/context-rot 

- MLflow — Automatic Issue Detection - https://mlflow.org/blog/issue-detection 

- MCP Bundles — Six-Tool Pattern - https://gziolo.pl/2026/04/09/research-architecting-tools-for-ai-agents-at-scale/ 

- Vitest 4.1 — AI Agent Reporter - https://www.infoq.com/news/2026/05/vitest-4-1-ai-agents/