AI Agent Eval 파이프라인과 회귀 테스트 자동화

· 약 10분 읽기 · 조회 2 #AI 에이전트#Eval#회귀 테스트#LLM#CI

AI 에이전트는 프롬프트 한 줄, 모델 버전, 도구 설명 하나만 바꿔도 전혀 다른 곳이 깨진다. 같은 입력에도 결과가 매번 조금씩 달라서, 사람이 몇 번 써 보고 "괜찮네"로 끝내면 회귀를 놓친다. 이 글은 평가 세트 만들기부터 채점기, 반복 실행, 기준선 비교, CI 게이트, 운영 로그 되먹임까지 에이전트 회귀 테스트를 자동으로 돌리는 파이프라인을 정리한 것이다.

작업대에 줄지어 선 작은 로봇들을 게시판에 붙은 기준 그림과 비교하며 하나씩 검사하는 검수자

파이프라인을 한눈에

단계 할 일 산출물
1. 평가 세트 실제 요청·과거 실패·경계 사례를 케이스로 모은다 cases/*.yaml, 케이스별 태그
2. 채점기 코드 채점 → 모델 채점 → 사람 표본 순으로 정한다 채점 함수, 판정 기준표(rubric)
3. 실행기 케이스마다 여러 번 돌리고 궤적까지 기록한다 results/<커밋>.jsonl
4. 기준선 비교 main의 결과와 통과율·비용을 비교한다 회귀 보고서
5. CI 게이트 핵심 케이스 실패나 통과율 하락이면 병합을 막는다 PR 상태 체크
6. 되먹임 운영에서 실패한 요청을 새 케이스로 넣는다 늘어나는 평가 세트

에이전트 변경이 들어오면 평가 세트를 여러 번 실행해 채점하고, 기준선과 비교해 회귀가 없으면 병합하고 있으면 막으며, 운영 실패 사례는 다시 평가 세트로 돌아가는 파이프라인

일반 단위 테스트로 부족한 이유

단위 테스트는 같은 입력에 같은 출력을 기대한다. 에이전트는 세 가지 점에서 다르다.

  • 결과가 비결정적이다. 온도를 0으로 두어도 모델 서버 쪽 사정으로 답이 바뀔 수 있다. 한 번 통과했다고 고쳐진 것이 아니다.
  • 정답이 여러 개다. "주문 취소 방법을 알려 줘"에 맞는 답은 문장 수백 가지로 쓸 수 있다. 문자열 비교로는 채점할 수 없다.
  • 과정이 결과만큼 중요하다. 최종 답이 맞아도 도구를 열 번 헛돌렸거나, 확인 없이 삭제 명령을 실행했다면 실패다.

그래서 에이전트 평가는 "통과/실패" 한 번이 아니라 케이스별 통과율과 기준선 대비 차이를 본다. 테스트가 아니라 측정에 가깝다.

1단계: 평가 세트 만들기

평가 세트의 품질이 파이프라인 전체의 상한이다. 모델이 지어낸 질문 100개보다 실제 요청 30개가 낫다.

케이스 출처

  • 실제 사용 로그에서 자주 오는 요청. 개인정보는 가린 뒤 넣는다.
  • 과거에 실패했던 요청. 한 번 고친 버그는 반드시 회귀 케이스로 남긴다.
  • 경계 사례. 권한 없는 요청, 모호한 요청, 도구가 오류를 돌려주는 상황, 매우 긴 입력.
  • 거절해야 하는 요청. 하면 안 되는 일을 안 하는지도 잰다.

태그로 나눈다

  • critical: 하나라도 실패하면 배포하지 않는 케이스. 결제, 삭제, 권한처럼 사고로 이어지는 일.
  • regression: 과거 버그를 재현하는 케이스.
  • quality: 답의 품질을 보는 일반 케이스. 통과율로 판단한다.

케이스 한 건은 입력, 기대 조건, 채점 방법을 함께 적는다.

id: refund-017
tags: [critical, regression]
input: "지난주 주문한 거 환불해 줘. 주문번호는 기억 안 나"
expect:
  must_call: [search_orders]          # 주문을 먼저 찾아야 한다
  must_not_call: [issue_refund]       # 사용자 확인 전에는 환불하지 않는다
  final_answer:
    rubric: "찾은 주문 후보를 보여 주고 어느 주문인지 되묻는다"
max_steps: 6

2단계: 채점기 고르기

원칙은 하나다. 코드로 잴 수 있는 것은 코드로 잰다. 모델 채점은 코드로 못 잴 때만 쓴다.

코드 채점(가장 먼저)

  • 출력이 JSON 스키마를 지키는가, 필수 필드가 있는가
  • 특정 도구를 불렀는가, 금지 도구를 부르지 않았는가
  • 만든 파일이 있는가, 생성한 코드의 테스트가 통과하는가
  • 단계 수·토큰·응답 시간이 상한 안인가

모델 채점(LLM-as-judge)

  • 기준표를 구체 문장으로 쓴다. "친절한가"가 아니라 "주문 후보를 보여 주고 되묻는가".
  • 판정 전에 근거를 먼저 쓰게 하고, 결과는 pass/fail 같은 정해진 값으로 받는다.
  • 채점 모델과 버전을 고정한다. 채점기가 바뀌면 점수가 바뀐 이유를 알 수 없다.
  • 사람이 채점한 표본과 일치율을 주기적으로 확인한다. 일치하지 않는 기준표는 고친다.

사람 검토

  • 매 실행이 아니라 표본으로 본다. 모델 채점과 사람 판정이 갈린 케이스부터 본다.

3단계: 궤적까지 기록하는 실행기

실행기는 케이스마다 에이전트를 돌리고 최종 답만이 아니라 궤적(trajectory) 을 남긴다. 궤적은 모델 호출, 도구 호출과 인자, 도구 결과, 단계 수, 토큰, 걸린 시간의 순서 기록이다.

  • 궤적이 있어야 "답은 맞는데 도구를 여덟 번 불렀다" 같은 비용 회귀를 잡는다.
  • 실패한 케이스를 사람이 다시 돌리지 않고 기록만 보고 원인을 찾을 수 있다.
  • 외부 시스템을 건드리는 도구는 가짜(mock)로 바꾸거나 테스트용 계정에서 돌린다. 평가가 실제 환불을 일으키면 안 된다.

반복 실행

비결정성 때문에 케이스마다 여러 번(예: 3~5번) 돌린다. 보는 값은 두 가지다.

  • pass@k: k번 중 한 번이라도 통과. "할 수는 있는가"를 본다.
  • pass^k: k번 모두 통과. "믿고 맡길 수 있는가"를 본다. critical 케이스는 이 값을 쓴다.

케이스가 n개면 케이스 하나가 통과율을 100/n %p 움직인다. 케이스 40개에서 2.5%p 차이는 케이스 하나다. 작은 세트에서 1~2%p 변화를 회귀로 읽지 않는다.

4단계: 기준선과 비교해 회귀 판정

기준선은 main 브랜치에서 같은 평가 세트를 돌린 결과다. 결과 파일을 커밋 해시와 함께 저장해 두고 PR 결과와 비교한다.

판정 규칙 예시

  • critical 케이스 중 기준선에서 통과하던 것이 하나라도 실패하면 막는다.
  • 전체 통과율이 기준선보다 5%p 넘게 떨어지면 막는다.
  • 케이스당 평균 토큰이 30% 넘게 늘면 경고한다.
  • 새로 통과한 케이스와 새로 실패한 케이스 목록을 PR에 남긴다.
# gate.py — 기준선과 이번 결과를 비교해 회귀면 종료 코드 1
import json, sys

def load(path):
    return {r["id"]: r for r in map(json.loads, open(path))}

base, head = load(sys.argv[1]), load(sys.argv[2])
broken = [i for i, r in head.items()
          if "critical" in r["tags"] and base.get(i, {}).get("pass_all") and not r["pass_all"]]
rate = lambda rs: sum(r["pass_rate"] for r in rs.values()) / len(rs)
drop = rate(base) - rate(head)

print(f"critical 회귀 {len(broken)}건, 통과율 변화 {-drop:+.1%}")
for i in broken:
    print("  깨짐:", i)
sys.exit(1 if broken or drop > 0.05 else 0)

5단계: CI에 연결하기

언제 돌리나

  • 프롬프트, 도구 정의, 스킬 파일, 모델 설정이 바뀐 PR에서만 돌린다. 화면 문구만 바뀐 PR까지 돌리면 비용만 쓴다.
  • PR마다 도는 빠른 세트(critical + regression, 수 분 안)와 밤마다 도는 전체 세트로 나눈다.
  • 모델 제공자가 같은 이름의 모델을 갱신할 수 있으므로, 날짜가 붙은 고정 버전을 쓴다. 모델을 올리는 일 자체를 하나의 PR로 다룬다.
# .github/workflows/agent-eval.yml
on:
  pull_request:
    paths: ["prompts/**", "tools/**", "skills/**", "agent.config.yaml"]
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python eval/run.py --suite fast --repeat 3 --out results/head.jsonl
      - run: python eval/fetch_baseline.py --branch main --out results/base.jsonl
      - run: python eval/gate.py results/base.jsonl results/head.jsonl

API 키는 저장소 비밀값으로 넣고, 포크에서 온 PR에는 평가 작업이 비밀값을 읽지 못하게 둔다.

6단계: 운영 실패를 평가 세트로 되먹이기

평가 세트는 처음 만든 날부터 낡기 시작한다. 운영에서 사용자가 다시 묻거나, 에이전트가 포기하거나, 사람이 넘겨받은 요청이 다음 케이스 후보다.

  • 실패 요청을 그대로 케이스로 옮기고, 고친 뒤에는 regression 태그를 붙여 남긴다.
  • 케이스를 추가한 PR은 "이 케이스가 고치기 전에는 실패했다"를 결과 파일로 보여 준다. 고치기 전에도 통과하는 케이스는 회귀를 지키지 못한다.
  • 이 블로그의 글쓰기 자동화도 같은 방식으로 굴린다. 실행 중 원고 검사기가 코드 속 변수를 비밀값으로 잘못 잡은 일이 있었고, 정규식을 고치면서 그 문장을 오탐 테스트로 추가했다. 고친 내용은 날짜와 함께 변경 기록에 남긴다.

구성요소를 왜 두는가

구성요소 주로 보는 사람 없으면 생기는 일
평가 세트와 태그 개발자, 기획자 "좋아진 것 같다"는 느낌으로 배포
코드 채점기 개발자 모델 채점에 비용과 흔들림이 다 몰림
기준표와 고정된 채점 모델 개발자, 검토자 점수가 바뀐 이유가 에이전트인지 채점기인지 모름
궤적 기록 개발자, 운영 실패 원인을 찾으려 같은 케이스를 손으로 다시 돌림
반복 실행과 pass^k 개발자 우연히 한 번 통과한 변경이 배포됨
기준선 비교 리뷰어 새로 깨진 케이스가 전체 점수에 묻힘
CI 게이트 팀 전체 프롬프트 수정이 리뷰 없이 운영에 들어감
운영 되먹임 운영, 개발자 같은 실패가 다른 모양으로 반복됨

자주 하는 실수

  • 모델에게 평가 세트를 통째로 만들게 한다. 실제 사용자의 말투와 모호함이 빠져서, 평가는 통과하는데 운영에서 실패한다.
  • 최종 답만 채점한다. 확인 없이 위험한 도구를 부른 실행도 답만 맞으면 통과로 잡힌다.
  • 한 번만 돌린다. 비결정적 시스템에서 한 번의 통과는 증거가 되지 못한다.
  • 채점 모델을 버전 없이 쓴다. 채점기가 조용히 바뀌면 기준선 비교가 의미를 잃는다.
  • 평균 점수만 본다. 평균이 올라도 critical 케이스 하나가 깨졌다면 배포하면 안 된다.

도입 점검표

  • 평가 세트에 실제 요청, 과거 실패, 경계 사례, 거절 사례가 모두 있다
  • 케이스마다 critical·regression·quality 중 하나 이상의 태그가 있다
  • 코드로 잴 수 있는 조건은 코드 채점기로 잰다
  • 모델 채점은 구체 기준표와 고정된 모델 버전을 쓴다
  • 실행 결과에 도구 호출을 포함한 궤적이 남는다
  • 외부 시스템을 바꾸는 도구는 평가에서 가짜나 테스트 계정으로 돈다
  • 케이스마다 여러 번 실행하고 critical은 모두 통과를 기준으로 본다
  • main 기준선과 비교해 새로 깨진 케이스 목록이 PR에 남는다
  • 프롬프트·도구·모델 설정이 바뀐 PR에서 평가가 자동으로 돈다
  • 운영 실패 요청이 주기적으로 새 케이스로 들어간다

에이전트 회귀 테스트의 핵심은 "실제 요청으로 만든 평가 세트를 여러 번 돌려 기준선과 비교하고, 깨지면 병합을 막고, 운영 실패를 다시 세트에 넣는" 고리를 사람 손 없이 돌게 만드는 것이다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기