AIREITER

Agent 도구 설명 드리프트: 선언을 단일 소스로 만들어야 하는 이유

마지막 업데이트: 2026-07-31 07:35:05

Agent에 새 도구를 하나 붙였다고 해보자. 예를 들어 특정 플랫폼의 공개 게시물을 가져오는 기능이다. 2주 동안 운영에서는 아무 문제 없이 돌아갔다. 그러다 함수의 limit 기본값을 25에서 20으로 바꾸고, sort enum에 새 값을 추가했다. 코드도 수정했고 테스트도 통과했고, 그대로 머지했다.

3일 뒤부터 운영에서 간헐적인 오류가 나기 시작한다. 모델은 지난주에 삭제한 enum 값을 넣어 도구를 호출했고, 런타임 검증은 이를 거부했다. 스택 트레이스는 dispatch 레이어를 가리킨다. 거기 코드를 30분 넘게 들여다보지만 잘못된 줄은 하나도 없다. 문제는 전혀 다른 곳에 있다. 함수 시그니처는 바꿨지만 모델이 읽는 도구 설명은 그대로 뒀다. 모델은 이전 스키마를 기준으로 호출을 만들고, 당연히 이제는 실제 인터페이스와 맞지 않는다.

이것이 도구 설명 드리프트다. Agent 엔지니어링에서 가장 흔하면서도 추적하기 까다로운 버그 유형이다. 이유는 명확하다. 오류가 발생하는 위치와 근본 원인이 있는 위치가 다르다. 실행 레이어에서 에러가 터지지만, 원인은 아무도 열어보지 않는 JSON 파일에 있다. 이 글은 “동기화를 잊지 말자”는 이야기가 아니다. 어긋날 두 번째 사본을 구조적으로 없애는 방법에 관한 이야기다.

도구 설명은 왜 어긋나는가

문제를 뜯어보면 원인은 단순하다. 진실의 원천을 두 개 관리하고 있기 때문이다.

첫 번째는 실제로 실행되는 코드다. 함수 시그니처, 인자 검증, 기본값, enum 제약이 여기에 속한다. 이쪽은 엄격하다. 잘못되면 즉시 큰 소리로 실패한다.

두 번째는 모델이 읽는 도구 설명이다. name, description, parameters JSON schema가 그것이다. 이쪽은 느슨하다. 틀려도 당장 무언가가 폭발하지 않는다. 모델이 잘못된 호출을 만들고, 그 호출이 한참 아래 실행 레이어에서 실패할 뿐이다.

사람이 이 둘의 일치를 책임지는 한, 드리프트는 발생 시점의 문제일 뿐이다. 코드의 인자를 바꾸고 설명을 빼먹을 수 있다. 설명만 고치고 코드를 놓칠 수도 있다. 둘 다 바꿨지만 의미가 미묘하게 어긋날 수도 있다. 이런 문제는 수정 순간에는 드러나지 않는다. 모델이 차이를 건드리는 호출을 우연히 생성할 때까지 기다렸다가 나타난다. 그때쯤이면 2주 전에 무엇을 수정했는지 이미 잊은 뒤다. 해결 방향은 하나뿐이다. 두 사본을 하나로 줄여야 한다.

단일 소스의 원칙: 선언 자체가 인터페이스다

여기서 관점을 바꿔야 한다. 사실 별도의 도구 설명 JSON은 필요 없다.

함수의 파서 선언과 docstring에는 도구 설명에 필요한 정보가 이미 모두 들어 있다. 일반적인 argparse 선언은 다음처럼 생겼다.

subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
    "--sort",
    choices=("hot", "new", "top", "rising", "controversial"),
    default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)

help는 명령의 한 줄 설명이다. choices는 enum 제약이고, default는 기본값이다. type은 파라미터 타입이며 위치 인자는 필수 필드가 된다. 모델이 도구를 호출하는 데 필요한 정보, 즉 명령의 역할과 인자, 필수 여부, 가능한 enum 값, 기본값이 모두 여기에 있다. 게다가 이 선언은 런타임이 파싱과 검증에 사용하는 바로 그 선언이다. 실행 로직 자체이므로 실행 로직과 어긋날 수 없다.

그러니 두 번째 도구 설명을 작성하지 말아야 한다. 더 정확히는 그 문서 자체가 존재하지 않아야 한다. 코드만 있고, 도구 설명이 필요할 때 코드를 투영해 생성한다. 흐름은 코드에서 설명으로만 향하며, 반대 방향은 없다.

선언에서 전체 카탈로그 만들기

선언이 인터페이스라는 원칙을 받아들였다면, 도구 설명도 손으로 작성할 일이 없다. 모든 설명은 deriver가 생성해야 한다.

deriver의 작업은 기계적이다. 모든 플랫폼 컨텍스트를 순회하고, 각 파서를 import한 뒤 argparse action 목록을 읽어 Platform, Command, Parameter라는 세 불변 구조로 옮긴다. 각 Parameter에는 이름, 타입, 필수 여부, enum 값, 기본값, help 텍스트가 담긴다. 코드만으로부터 만들어진 인터페이스 read-model이다.

이 read-model이 있으면 출력 형식은 모두 그 하위 결과물이 된다. describe --format json은 Agent의 도구 선택에 넣을 전체 머신 리더블 인터페이스를 내보낸다. render_skill()은 사람과 모델 모두 읽을 수 있는 기능 카탈로그를 만든다. 카탈로그의 명령 수 역시 손으로 입력한 상수가 아니다. 즉석에서 sum(len(platform.commands))로 계산한다. 현재는 플랫폼 컨텍스트 22개와 명령 241개이며, 카탈로그에 손으로 입력한 것은 하나도 없다.

이 구조가 주는 장점은 편안하다. 플랫폼을 추가하면 플랫폼 컨텍스트만 추가하면 되고, 카탈로그는 그 명령을 자동으로 가져간다. 파라미터를 바꾸면 파서 선언만 수정하면 되며, 카탈로그의 enum과 기본값도 함께 갱신된다. “새 명령을 만들고 등록을 빼먹었다”거나 “파라미터는 바꿨는데 카탈로그가 낡았다”는 상황을 만날 일이 없다. 등록이라는 행위 자체가 없기 때문이다. 카탈로그는 관리하는 것이 아니라 계산하는 결과물이다.

(유지보수 대신 파생을 택하는 이 감각은 언어 간 마이그레이션이 실제로 얼마나 완료됐는지 집합 연산으로 판별할 때도 그대로 나타난다. 관련 내용은 크로스 언어 마이그레이션 글에서 다뤘다.)

CI로 드리프트를 커밋 시점에 막기

파생 방식은 “새 명령이 카탈로그에 자동으로 들어간다”는 문제를 해결한다. 하지만 빈틈이 하나 남는다. 누군가 파서 선언을 수정한 뒤 deriver를 다시 실행하지 않고, 재생성된 카탈로그도 커밋하지 않는 경우다. 그러면 저장소 안의 사본은 다시 낡고, 드리프트는 옆문으로 되돌아온다.

마지막 관문은 CI에 둔다. 핵심은 assertion 하나다.

docs-check:
	$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
	  path = Path("skill/SKILL.md"); \
	  assert path.read_text(encoding="utf-8") == render_skill(), \
	  "skill/SKILL.md is out of sync with the code; run make docs"'

저장소에 커밋된 카탈로그와 현재 코드에서 다시 생성한 카탈로그를 바이트 단위로 비교한다. 문자 하나라도 다르면 CI는 실패하고, 카탈로그가 코드와 동기화되지 않았으니 make docs를 실행하라는 메시지를 띄운다.

이 한 줄의 가치는 드리프트를 발견하는 시점을 옮긴다는 데 있다. 과거의 드리프트는 런타임의 유령이었다. 2주 뒤 운영에서 터지고, 스택 트레이스는 엉뚱한 곳을 가리켰다. 이제는 커밋 시점의 빨간 X다. pull request에서 바로 멈추고, 오류 메시지는 카탈로그가 오래됐다고 알려주며, 재생성하면 해결된다. “추적하기 가장 어려운 버그”였던 드리프트가 명령 하나로 해소하는 컴파일 오류 수준으로 내려온다. 이것이 interface-as-code의 완결된 루프다. 선언은 소스이고, 기능 카탈로그는 빌드 산출물이며, CI는 타입 검사다. 빌드 산출물을 손으로 작성하지 않고 소스와 불일치하는 결과물을 용납하지 않듯, 도구 설명도 똑같이 다뤄야 한다.

코드로 고정할 것과 모델에 맡길 것

파생과 CI는 인터페이스 설명의 정확성을 보장한다. 하지만 그보다 앞서 판단해야 할 일이 있다. 어떤 기능을 고정된 코드로 만들고, 어떤 기능을 모델이 그때그때 오케스트레이션하도록 둘 것인가다. 이 구분을 잘못하면 인터페이스가 정확해도 Agent는 구제되지 않는다.

기능은 세 계층으로 나눠보면 된다.

저수준 primitive는 한 종류의 데이터를 읽거나 하나의 명확한 동작을 수행한다. 입력은 안정적이고 출력은 구조화되어 있으며, 단독으로 테스트할 수 있다. 이 계층은 순수 코드로 구성하고 추론 비용을 전혀 쓰지 않는다. 241개 명령 중 대부분이 여기에 속한다.

결정론적 workflow는 하나의 플랫폼 안에서 강한 순서를 갖는 프로세스다. 상태를 공유하고 성공 조건도 분명하다. 예를 들어 creative-pipeline이라는 크리에이티브 파이프라인은 기회 탐색, Top Ads, 크리에이터 매칭, 크리에이티브 브리프, 생성 preflight 순으로 실행된다. 단계의 순서와 의존성이 이미 정해져 있다. 이 계층 역시 코드로 고정해야 한다. 순서가 결정돼 있는데 모델이 매번 다시 계획하게 하면 느려지고 안정성도 떨어진다. 표시도 한 줄이면 된다. 명령에 set_defaults(_command_level="workflow")를 지정하면 된다. 코드베이스에서 이 줄이 들어간 곳은 여기 하나뿐이며, 카탈로그가 workflow와 primitive를 두 계층으로 나누어 보여주는 이유도 이것이다.

Agent 오케스트레이션은 플랫폼을 넘나드는 리서치, 실시간 트레이드오프, 실패 이후의 재라우팅을 다루는 계층이다. 직전 쿼리의 결과에 따라 다음에 조회할 대상이 달라지므로, 이 계층은 모델에 맡겨야 한다. 미리 코드로 전부 적어둘 수 없기 때문이다.

판별 기준은 꽤 명확하다. 안정적인 단계 상태, 공유 컨텍스트, 생성 부작용이 필요하다면 코드로 고정한다. 쿼리 확장, 크로스 플랫폼 검증, 실패 후 재라우팅이 필요하다면 모델에 맡긴다. 어느 쪽으로 잘못해도 비용이 든다. 리서치 가설을 클라이언트에 하드코딩하면 과도한 고정이 되고, 플랫폼이 바뀌는 날 다시 코드를 수정해야 한다. 반대로 정해진 순서를 매번 모델이 조립하게 하면 고정이 부족한 상태가 된다. 모델 판단 하나를 아끼려다 불안정성만 쌓게 된다.

모델이 올바르게 성능을 낮출 수 있게 하는 6가지 단계 상태

오케스트레이션 계층이 판단하려면 하위 계층이 반환하는 결과를 모델이 읽을 수 있어야 한다. 불투명한 성공·실패 boolean만으로는 부족하다. 모델에 success: false만 넘기면 다음 행동을 추측할 수밖에 없다.

그래서 workflow의 각 단계는 boolean 대신 단계 상태를 반환한다. 상태는 completed, empty, ready, skipped, unavailable, blocked의 여섯 가지다. 핵심 정보는 진행되지 못한 상태들을 구분하는 데 있다.

  • skipped는 운영자가 의도적으로 단계를 껐다는 뜻이다. 예를 들어 한 수집 경로의 cap을 0으로 설정한 경우다. 오류가 아니며, 모델도 재시도해서는 안 된다.

  • unavailable은 이 단계가 의존하는 무언가를 일시적으로 사용할 수 없다는 뜻이다. 인터페이스가 오류를 내거나 세션이 없는 경우가 여기에 해당한다. 모델은 해당 단계를 건너뛰고 계속 진행하거나, 새 세션을 요청한 뒤 돌아올 수 있다.

  • blocked는 전제 조건이 충족되지 않았다는 뜻이다. 리서치 증거가 비어 있거나 preflight가 실패한 경우다. 모델은 다음 단계를 억지로 밀어붙이면 안 된다. 돌아가서 증거를 채워야 한다.

앞서 든 크리에이티브 파이프라인을 보자. 여기서는 “플랫폼 preflight 준비 완료”와 “리서치 증거 준비 완료”를 별개로 판단하고, 마지막에 ready = platform_ready and research_ready로 결합한다. 둘 중 하나라도 실패하면 생성 단계는 무엇에 막혔는지 알려주는 blockers 목록과 함께 blocked를 반환한다. 모든 상업 검색 결과가 비어 있다면 생성 작업도 제출하지 않는다.

이 설계가 모델에게 유리한 이유는 무엇일까. 오케스트레이션 모델이 seedance_generation: blocked와 blockers: [research_evidence_empty]를 읽으면, 제출을 재시도하는 대신 증거를 다시 확보해야 한다는 것을 안다. organic_discovery: skipped를 보면 사용자 의도이지 장애가 아니라는 것을 이해하고 그대로 둔다. unavailable 상태의 단계를 보면 우회하거나 성능을 낮춰 진행할 수 있음을 안다. 의도적으로 꺼진 상태, 일시적 장애, 전제 조건 미충족을 분리해야 모델이 적절한 저하 경로를 선택할 수 있다. 셋을 모두 false로 뭉개면 강력한 모델도 제자리에서 맴돌 뿐이다.

계층별로 맞는 모델을 고르기

위 스택은 계층마다 모델에 완전히 다른 능력을 요구한다. (4단계 리버스 엔지니어링 글에서도 리버스 엔지니어링 맥락에서 같은 4계층 표를 다뤘는데, 여기서는 이를 Agent 스택에 적용한다.) 계층별로 배정하면 불필요한 성능 낭비를 줄일 수 있다.

Agent 스택의 작업

필요한 역량

선택

model id

도구 선택을 위해 241개 명령의 describe json을 컨텍스트에 로드

긴 컨텍스트, 전체 카탈로그를 한 번에 읽기

Kimi K3

kimi-k3

오케스트레이션: 단계 상태와 blockers를 읽고 저하, 재라우팅, 진행 여부 결정

강한 추론 능력, 상태에 맞는 올바른 판단

Claude Opus 5

claude-opus-5

docstring에서 모델 친화적인 도구 설명 텍스트를 대량 생성

저렴한 비용, 높은 동시성으로 수백 건 호출

Claude Sonnet 5

claude-sonnet-5

도구 호출 오류 원인 판별: 오류와 선언을 읽고 드리프트인지 업스트림 변경인지 판단

중간 수준의 추론, 구체적 필드를 근거로 설명

GPT-5.6 Sol

gpt-5.6-sol

특히 살펴볼 계층은 오케스트레이션이다. 다음 행동을 결정하기 위해 blocked와 skipped를 읽는 일은, 모델을 바꿨을 때 결과 차이가 눈에 띄게 드러나는 유일한 단계다. 여기서 검증하는 것은 정확히 상태를 보고 올바른 판단을 내릴 수 있는가다. 약한 모델은 skipped를 실패로 보고 재시도하거나, blocked를 보고도 그대로 제출한다. 강한 추론 모델은 blockers를 읽고 정확하게 재라우팅한다. 핑거프린팅 글에서 반증 섹션이 정말 자기 주장을 반박하고 있는지 판단하는 문제와도 같다. 후보를 만드는 일은 누구나 할 수 있지만, 어려운 부분은 판단이다.

이 차이는 직접 테스트할 수 있다.

  1. 자신의 workflow에서 실제 반환된 stages와 blockers를 가져온다. 또는 blockers: [research_evidence_empty]와 함께 blocked를 반환하는 응답을 만들어도 된다.

  2. 그 응답과 기능 카탈로그인 describe json, 그리고 다음 행동을 결정하라는 지시를 claude-opus-5와 gpt-5.6-sol에 각각 전달한다.

  3. 한 가지만 본다. 모델이 제안한 다음 행동이 blocked는 증거 확보로 돌아가야 하고, skipped는 사용자 의도이므로 건드리지 말아야 하며, unavailable은 세션을 얻거나 우회해야 한다는 점을 올바르게 구분하는가? 아니면 skipped를 실패처럼 재시도하는가?

  4. 올바른 저하 경로의 비율이 모델 선택 기준이다. 실제 장애 상황에서 Agent가 제자리에서 맴돌지, 스스로 우회할지를 결정하는 지표다.

진짜 걸림돌은 모델 교체 비용이다

이 네 모델은 세 벤더에 걸쳐 있고, 특히 function calling에서는 전환 비용이 매우 크다. OpenAI의 tools / tool_calls와 Anthropic의 tool_use / tool_result는 서로 다른 형식이다. 오케스트레이션 계층에서 판단력이 더 나은 모델로 교체하려면 도구 dispatch와 오류 파싱 경로 전체를 다시 작성해야 한다. 많은 사람이 단계 상태를 자주 잘못 읽는 모델을 쓰면서도 오케스트레이션 계층에 하나의 모델을 고정하는 진짜 이유가 이것이다.

AIReiter는 이 전환 레이어를 없앤다. 키 하나, OpenAI 호환 인터페이스 하나, 그 뒤에는 네 모델이 있으며 요청 본문의 model 필드만 바꾸면 전환할 수 있다.

# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
  }'

# Generate tool-description text in bulk: change the model field, leave the rest
#   "model": "claude-sonnet-5"
# Error attribution:
#   "model": "gpt-5.6-sol"

네이티브 function calling은 tools 배열만 추가하면 된다. OpenAI 도구 프로토콜은 이 인터페이스를 그대로 통과하므로, 모델 전환 역시 한 필드 수정으로 끝난다. 이미 OpenAI SDK를 사용 중이라면 base_url을 https://aireiter.com/api/v1로 지정하고 나머지는 바꾸지 않아도 된다. Anthropic SDK에서는 같은 키로 POST /api/v1/messages를 호출하면 된다.

가격 측면에서 Claude 모델은 정가 대비 30% 할인, GPT 모델은 반값이며, Kimi K3도 같은 키로 사용할 수 있다. 이 스택에서는 할인이 중요한 지점에 정확히 적용된다. 오케스트레이션 계층이 한 단계 더 나아갈 때마다 추론 티어 호출이 하나 더 발생한다. 따라서 이 계층이 전체 Agent에서 가장 빈번하고 비용이 큰 티어이며, Claude 할인은 바로 여기에 적용된다. 241개 docstring에서 도구 설명을 대량 생성하는 작업도 높은 동시성의 Sonnet 작업이고, 이 역시 할인된다. 이 두 작업이 비용의 대부분을 차지한다. 오류 원인 판별에 쓰는 GPT-5.6 호출은 훨씬 적다.

  • API 키 받기

  • 가입 없이 사용해 보기: 몇 차례 직접 실행해 보고, 같은 blocked 응답을 두 모델에 모두 넣어 보자. 오케스트레이션 계층에 연결하기 전에 어느 모델이 올바르게 성능을 낮추는지 직접 확인할 수 있다.

마무리

도구 설명 드리프트는 “동기화를 기억하자”로 해결할 문제가 아니다. 그것은 구조적 결함을 개인의 주의력 문제로 축소하는 접근일 뿐이다. 진짜 해결책은 두 소스 구조를 없애는 것이다. 파서 선언과 docstring을 유일한 소스로 두고, 기능 카탈로그는 거기서 파생한 빌드 산출물로 만들며, CI assertion 하나를 타입 검사로 둔다. 그러면 드리프트는 런타임의 유령에서 커밋 시점의 빨간 X로 바뀐다.

다만 파생은 설명이 정확하다는 것만 보장한다. 계층 구성이 올바른지까지 보장하지는 않는다. 어떤 기능을 코드로 고정하고 어떤 기능을 모델 오케스트레이션에 맡길지, 그리고 모델이 “재시도할지 우회할지” 읽을 수 있도록 만드는 여섯 단계 상태가 Agent의 자율 실행 여부를 결정한다. 이 스택에서 모델이 맡는 구체적인 일은 둘이다. 오케스트레이션 계층에서 트레이드오프를 판단하고, 도구 호출이 실패했을 때 원인을 판별하는 일이다. 기능을 고정할지와 어떤 저하 경로를 택할지는 모델이 아니라, 설계한 단계 상태와 작성한 CI가 결정한다.

이는 집합 기반 마이그레이션 정합성 검증과 통합 응답 Model을 만들지 않는 이유를 다룬 글에서 취한 태도와도 같다. AI는 한 단계에 걸리는 시간을 줄여줄 뿐이며, 최종 판단은 하드코딩한 제약 안에 남는다. 전체가 매끄럽게 돌아가기 시작하면 남는 마찰은 모델 전환뿐이다. 이는 인프라 문제이며 하나의 통합 인터페이스로 해결할 수 있다.