- 들어가며 — 트레이스 6,780건에서 나온 중복 실행 8,042건
- 재시도 가능한 도구 호출 — 멱등성은 프레임워크가 주지 않는다
- 예산과 스텝 한도 — 두 가지 다른 실패
- 비결정적 제어 흐름의 관측 가능성
- 도구별 권한 경계
- 확신에 찬 오답과 에스컬레이션 경로
- 무엇에 알람을 걸 것인가
- 마치며 — 재시도 가능성은 프롬프트가 아니라 도구 계층의 속성이다
들어가며 — 트레이스 6,780건에서 나온 중복 실행 8,042건
며칠 전 GeekNews에 Ask GN: MCP 도구를 여러 개 붙여 에이전트를 운영하시는 분들이라는 글이 올라왔습니다. 작성자가 벤치마크 트레이스 6,780건을 분석해 중복 도구 실행 8,042건을 찾았고, 태스크 완료 선언처럼 무해한 반복을 걸러낸 뒤에도 4,249건이 남았습니다. 그중 엔티티 ID로 확인 가능한 것만 세면, 상태를 바꾸는 도구가 실제로 중복 리소스(문서, 스프레드시트 등)를 만든 사례가 159건입니다. 여기에 "Timed out while waiting for response...10.0 seconds" 형태의 타임아웃 시그니처가 붙은 199건, 그리고 리소스 충돌로 보이는 오류 74건이 더 있습니다.
작성자가 스스로 밝힌 한계가 중요합니다. 이 데이터는 전부 벤치마크 트레이스이고 실제 프로덕션 서비스가 아닙니다. 그리고 그가 지적하듯, 대부분의 공개 데이터셋은 애초에 중복을 탐지하도록 설계되지 않았습니다 — 정확도 벤치마크에서 중복은 그냥 오답으로 집계되기 때문입니다. 즉 이 숫자는 프로덕션 발생률의 추정치가 아니라, "이 실패 유형이 존재하며 아무도 세지 않고 있다"는 존재 증명에 가깝습니다.
댓글에서 세션 단위 멱등성 키를 쓰라는 제안이 나왔고, 작성자는 정확한 반박을 합니다 — Notion이나 GitHub 같은 서드파티 API에는 우리 쪽 키를 주입할 수 없다는 것입니다. 이 왕복이 이 글의 출발점입니다.
재시도 가능한 도구 호출 — 멱등성은 프레임워크가 주지 않는다
에이전트 루프의 구조가 문제를 만듭니다. 루프는 대략 이렇습니다 — 모델을 호출하고, 응답에 도구 호출이 있으면 실행하고, 결과를 대화에 붙이고, 다시 모델을 호출합니다. 여기서 재시도가 세 층에서 독립적으로 일어납니다.
HTTP 층. SDK가 429나 5xx나 연결 오류를 자동 재시도합니다. 대부분의 클라이언트가 기본 2회 재시도를 켜 두고 있습니다. 타임아웃도 재시도 대상이라, 최악의 경우 실제 대기 시간은 타임아웃 곱하기 재시도 횟수 더하기 1이 됩니다.
도구 층. 도구 구현체가 내부적으로 재시도합니다.
오케스트레이터 층. 실행 전체가 실패하면 처음부터 다시 돌립니다.
세 층이 서로를 모릅니다. 가장 위험한 조합은 세 번째입니다. 도구 세 개를 순서대로 부르던 중 두 번째가 타임아웃 나서 오케스트레이터가 전체 실행을 재시도하면, 첫 번째 도구는 이미 메일을 보냈고 두 번째는 실은 DB에 쓴 뒤 응답만 못 돌려준 상태일 수 있습니다. 재시도 후에는 메일 두 통과 레코드 두 개가 남습니다.
타임아웃이 특히 고약한 이유는 응답 부재가 실패를 뜻하지 않기 때문입니다. 위 분석에서 타임아웃 시그니처 199건이 별도로 집계된 것이 이 문제를 정확히 가리킵니다.
해법의 뼈대는 오래된 것입니다 — 부수 효과가 있는 모든 도구 호출에 결정론적 키를 붙이고, 실행 원장을 우리 쪽에 둡니다.
import hashlib, json
def idempotency_key(run_id: str, step_index: int, tool: str, args: dict) -> str:
# 1) 재시도 사이에 달라지는 필드는 반드시 제외한다.
# 타임스탬프, 요청 UUID, 재시도 카운터, "지금" 계산되는 값.
volatile = {"request_id", "timestamp", "now", "attempt", "trace_id"}
stable = {k: v for k, v in args.items() if k not in volatile}
# 2) 직렬화를 고정한다. sort_keys가 없으면 같은 인자도 다른 키가 된다.
material = json.dumps(
{"run": run_id, "step": step_index, "tool": tool, "args": stable},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
)
return hashlib.sha256(material.encode()).hexdigest()
step_index를 키에 넣을지 말지가 설계 판단입니다. 넣으면 "같은 실행의 같은 단계"만 중복 제거되므로, 모델이 의도적으로 같은 도구를 두 번 부르는 정상 동작을 막지 않습니다. 빼면 더 공격적으로 막지만, 정상적인 반복 호출까지 삼킵니다. 저는 넣는 쪽을 기본으로 두고, 되돌릴 수 없는 도구에 대해서만 실행 단위 전역 키를 씁니다.
서드파티 API 문제로 돌아가면, 키를 주입할 수 없을 때 쓸 수 있는 것은 세 가지입니다.
첫째, 우리 쪽 실행 원장. 도구를 부르기 전에 키로 원장을 조회하고, 이미 성공한 기록이 있으면 저장된 응답을 그대로 돌려줍니다. 창(window)은 24시간 정도가 무난합니다. 원장이 "호출 시작"과 "호출 완료"를 별도로 기록해야 타임아웃 케이스를 다룰 수 있습니다.
ledger[key] = {
state: started | succeeded | failed,
started_at: ...,
external_id: 생성된 리소스의 외부 ID (성공 시),
response: 캐시된 응답,
}
# state == started 인데 오래됐다면 = 우리는 결과를 모른다.
# 이때 재시도하면 중복, 안 하면 유실. 다음 항목으로 넘어간다.
둘째, 조회 후 생성(read-before-write). 대부분의 API가 멱등성 키는 안 받아도 검색은 받습니다. 문서를 만들기 전에 같은 제목과 부모를 가진 문서를 찾아보고, 있으면 그것을 반환합니다. 완벽하지 않지만(경합이 남습니다) 위 데이터의 159건 같은 케이스는 대부분 잡습니다.
셋째, 자연 키를 우리가 심기. 생성하는 리소스의 어딘가 — 제목 접미사, 설명 필드, 커스텀 속성, 라벨 — 에 실행 키를 넣습니다. 지저분하지만 조회 후 생성을 정확하게 만들어 줍니다.
그리고 도구를 처음 정의할 때 부수 효과 등급을 함께 선언해 두면 이후의 모든 정책이 이 등급에 매달립니다. 뒤의 표에서 다룹니다.
한 가지 더. 모델에게 도구 결과를 돌려줄 때, 실패한 호출도 반드시 결과로 돌려줘야 합니다. 오류를 조용히 삼키면 모델은 응답 없는 도구 호출을 보고 같은 호출을 반복합니다. 대부분의 API가 도구 결과에 오류 플래그를 달 수 있게 해 두었으니 그것을 씁니다. 병렬 도구 호출도 마찬가지입니다 — 한 응답에 여러 도구 호출이 들어 있으면 결과도 한 번에 묶어서 돌려줘야 합니다. 나눠 보내면 모델이 병렬 호출을 그만두는 방향으로 학습됩니다(Anthropic의 도구 사용 문서가 이 동작을 명시합니다).
예산과 스텝 한도 — 두 가지 다른 실패
에이전트는 두 방향으로 고장 납니다. 영원히 돌거나, 너무 일찍 포기하거나.
스텝 한도는 무한 루프에 대한 방어입니다. 루프 반복 횟수의 상한이고, 하드 컷입니다. 여기서 자주 저지르는 실수는 한도에 걸린 종료를 정상 종료와 같게 다루는 것입니다. 한도에 걸렸다는 것은 태스크가 끝나지 않았다는 뜻인데, 반환값만 보면 구분이 안 됩니다. 종료 사유를 명시적으로 반환하고, 한도 초과는 성공으로 집계하지 않아야 합니다.
예산은 성격이 다릅니다. 토큰, 비용, 벽시계 시간의 상한입니다. 그리고 여기에 최근 몇 년 사이 생긴 유용한 구분이 하나 있습니다 — 모델이 아는 예산과 모르는 한도입니다.
응답 토큰 상한 같은 것은 모델이 모르는 하드 컷입니다. 걸리면 문장 중간에서 잘립니다. 반면 태스크 예산은 모델에게 알려 주는 값이라, 남은 예산을 보며 스스로 페이스를 조절하고 마무리를 시도합니다. Anthropic API의 태스크 버짓이 이 방식이고(마이그레이션 문서에 설명이 있습니다) 최소값은 2만 토큰입니다. 둘의 차이는 실무적으로 큽니다 — 하드 컷은 잘린 산출물을 만들고, 알려 준 예산은 요약된 산출물을 만듭니다.
세 가지를 함께 걸어 두는 것을 권합니다.
- 스텝 한도: 무한 루프 방어. 초과 시 사유를 명시하고 실패로 집계.
- 비용 예산: 실행 단위 누적. 초과 임박 시 모델에게 알리고, 초과 시 중단.
- 벽시계 예산: 특히 사용자가 기다리는 동기 경로에서. 도구 하나의 타임아웃이 아니라 실행 전체의 데드라인이어야 합니다.
마지막 항목을 강조하고 싶습니다. HTTP 클라이언트의 타임아웃 설정은 대개 청크 단위 읽기 타임아웃이라 바이트가 조금씩이라도 오면 계속 리셋됩니다. 즉 총 소요 시간의 상한이 아닙니다. 실행 루프 바깥에서 단조 시계로 데드라인을 재고 직접 끊어야 합니다.
비결정적 제어 흐름의 관측 가능성
일반적인 서비스에서는 코드가 제어 흐름을 정의하므로 어떤 경로가 실행됐는지 코드를 보면 압니다. 에이전트는 모델이 매번 다른 경로를 고릅니다. 같은 입력으로 두 번 돌리면 도구 호출 순서가 다를 수 있습니다. 그래서 "무슨 일이 일어났는가"를 사후에 재구성할 수 있어야 하고, 이건 나중에 붙일 수 없습니다.
실행 하나가 트레이스 하나이고, 스텝마다 자식 스팬을 만듭니다. 최소한 이 정도는 남겨야 합니다.
# 실행 단위
run.id 재시도해도 동일. 실행 원장의 키 재료.
run.trigger user | schedule | webhook | retry
run.terminal_reason completed | step_limit | budget | error | escalated
# 스텝 단위 (루프 1회)
step.index 반복 회차
step.stop_reason 모델이 왜 멈췄나 (도구 호출 / 종료 / 토큰 한도 / 거부)
step.model_id 중간에 모델이 바뀌었는지 보려면 필요
step.input_tokens 캐시 읽기/쓰기와 분리해서
step.output_tokens
# 도구 호출 단위
tool.name
tool.side_effect none | read | write | irreversible
tool.idempotency_key
tool.attempt 1, 2, 3 ...
tool.outcome ok | error | deduped | denied | timeout
tool.latency_ms
tool.external_id 생성된 리소스가 있다면
# 누적
budget.cost_spent
budget.wall_clock_ms
escalation.reason 사람에게 넘겼다면 왜
몇 가지 설계 판단이 들어 있습니다.
tool.outcome에 deduped가 별도로 있는 이유는, 중복 제거가 작동한 횟수가 그 자체로 지표이기 때문입니다. 이 값이 갑자기 늘면 모델이 같은 도구를 반복 호출하는 방향으로 바뀌었거나 어딘가 재시도가 폭주하고 있다는 뜻입니다. 조용히 잘 처리되는 대신 보이지 않게 되는 것을 막습니다.
tool.side_effect를 스팬에 남기는 이유는 사후 분석 때문입니다. "되돌릴 수 없는 도구가 한 실행에서 몇 번 실행됐는가"를 물을 수 있어야 합니다.
프롬프트와 도구 입출력 본문을 남길 것인가는 별도 결정입니다. 디버깅에는 필수적이지만 개인정보와 비밀이 그대로 저장됩니다. 실무적으로는 기본은 메타데이터만 남기고, 실패한 실행에 대해서만 본문을 보존 기간 짧게 저장하는 절충이 무난합니다. 그리고 그 본문이 곧 재생 픽스처입니다 — 도구 응답까지 그대로 저장해 두면, 코드를 고친 뒤 같은 상황을 결정론적으로 재현할 수 있습니다. 프로덕션 장애 하나가 회귀 테스트 하나로 바뀝니다.
표준화 상황도 알아 둘 만합니다. OpenTelemetry의 GenAI 시맨틱 컨벤션은 2026년 중반 기준으로 여전히 개발 단계이고 1.0이 없습니다. 2026년 6월 12일 v1.42.0 릴리스에서 관련 속성과 스팬이 메인 저장소에서 전용 저장소로 분리됐는데, 이건 안정화가 아니라 분리입니다. 즉 속성 이름이 아직 바뀔 수 있습니다. 컨벤션을 따르되, 대시보드와 알람이 속성 이름에 직접 매달리지 않게 한 겹 두는 편이 안전합니다.
도구별 권한 경계
"에이전트에 도구 20개를 붙였다"는 문장에서 사고가 시작됩니다. 도구를 등급으로 나누고 등급마다 다른 정책을 걸어야 합니다.
| 등급 | 예시 | 기본 권한 | 재시도 정책 | 반드시 남길 것 |
|---|---|---|---|---|
| 읽기 전용 | 검색, 파일 읽기, 조회 API | 자동 허용 | 자유롭게 재시도 | 호출 인자와 결과 크기 |
| 격리된 쓰기 | 샌드박스 파일 쓰기, 임시 테이블 | 자동 허용 | 재시도 가능 | 변경된 경로 |
| 외부 쓰기 (되돌릴 수 있음) | 티켓 생성, 문서 작성, 브랜치 푸시 | 자동 허용 + 멱등성 키 필수 | 원장 확인 후 재시도 | 멱등성 키, 외부 리소스 ID |
| 외부 쓰기 (되돌리기 어려움) | 메일 발송, 메시지 전송, 결제, 배포 | 사람 승인 필요 | 재시도 금지. 실패는 데드레터 큐로 | 승인자, 승인 시각, 요청 전문 |
| 파괴적 | 삭제, 권한 변경, 프로덕션 설정 변경 | 도구 목록에서 제외 검토 | 재시도 금지 | 해당 없음 (기본적으로 노출 안 함) |
몇 가지 원칙이 있습니다.
권한은 에이전트가 아니라 도구에 붙입니다. "이 에이전트는 관리자"라고 정하면 도구 하나가 추가될 때마다 권한 표면이 조용히 넓어집니다. 도구마다 필요한 최소 권한의 자격 증명을 따로 발급하는 편이 낫습니다.
자격 증명은 모델의 컨텍스트에 들어가면 안 됩니다. 시스템 프롬프트나 도구 인자에 API 키를 넣는 구성이 여전히 흔한데, 그 값은 대화 이력에 영구히 남고 로그와 요약에도 실립니다. 키는 호출 경계 바깥에서 주입하고, 모델은 자리 표시자만 보게 합니다.
승인은 도구 호출 단위여야 합니다. "이번 세션에서 메일 발송을 허용" 같은 세션 단위 승인은 편하지만, 승인한 한 통과 승인하지 않은 열 통을 구분하지 못합니다. 승인 요청에는 실제로 나갈 내용이 그대로 포함돼야 합니다 — 수신자, 제목, 본문. 요약된 설명을 보고 승인하면 승인이 아니라 의례입니다.
서비스 계정으로 검색하는 구성은 권한 상승입니다. 사내 지식베이스나 파일 시스템에 붙는 에이전트에서 특히 그렇습니다. 질의자의 신원을 도구 호출까지 전파하고, 인가는 리소스에 가장 가까운 지점에서 강제해야 합니다.
확신에 찬 오답과 에스컬레이션 경로
가장 다루기 어려운 실패 유형은 에러가 아닙니다. 에이전트가 태스크를 완수했다고 보고하는데 실제로는 틀렸거나, 절반만 했거나, 요청하지 않은 일을 한 경우입니다. 에러는 알람을 걸 수 있지만 이건 걸 수 없습니다 — 모든 신호가 성공을 가리키니까요.
세 가지가 함께 필요합니다.
첫째, 주장과 증거를 분리시킵니다. 프롬프트 수준에서 "완료를 보고하기 전에 각 주장을 이번 실행의 도구 결과와 대조하고, 증거를 댈 수 있는 것만 보고하라"는 지시가 실제로 효과가 있습니다. 검증되지 않은 항목은 명시적으로 미검증이라고 말하게 합니다. 여기서 유용한 관측 지표가 하나 나옵니다 — 읽기 도구를 한 번도 부르지 않고 완료를 보고한 실행은 거의 항상 의심스럽습니다.
둘째, 종료 조건을 코드로 확인합니다. 에이전트의 자기 보고가 아니라 외부에서 확인 가능한 판정 기준을 둡니다. 테스트가 통과했는지, 티켓 상태가 바뀌었는지, 파일이 존재하는지. 이걸 할 수 없는 태스크라면 애초에 자율 실행 대상이 아닐 가능성이 높습니다.
셋째, 에스컬레이션을 정상 종료의 한 종류로 만듭니다. 많은 구현이 "사람에게 넘기기"를 실패 경로로 취급하는데, 그러면 모델은 넘기지 않는 방향으로 압력을 받습니다. 에스컬레이션은 성공만큼 정당한 종료 상태여야 하고, 넘길 때 다음이 함께 가야 합니다.
escalation = {
reason: "권한 부족" | "모순되는 정보" | "예산 초과" | "확신 부족" | "정책상 승인 필요",
what_was_done: 이미 완료된 부수 효과의 목록 (외부 리소스 ID 포함),
what_remains: 남은 작업,
blocking_fact: 막힌 구체적 지점,
run_id: 이어받을 수 있게,
}
what_was_done이 핵심입니다. 사람이 이어받을 때 가장 먼저 알아야 하는 것은 "무엇이 이미 실행됐는가"입니다. 이게 없으면 사람이 처음부터 다시 하고, 부수 효과가 두 번 일어납니다. 앞에서 실행 원장을 두라고 한 이유가 여기서도 회수됩니다.
에스컬레이션 조건은 명시적으로 씁니다. 사소한 판단(변수 이름, 동등한 두 접근 중 선택)은 스스로 정하고 기록만 남기게 하고, 범위 변경이나 되돌릴 수 없는 동작은 반드시 묻게 합니다. 이 구분을 주지 않으면 에이전트는 둘 중 한쪽 극단으로 갑니다 — 아무것도 안 묻거나, 모든 것을 묻거나.
무엇에 알람을 걸 것인가
대시보드는 많은데 알람이 없는 상태가 흔합니다. 실제로 페이지를 울려야 하는 것과 주간 리뷰에서 보면 되는 것을 나누면 이렇습니다.
즉시 알람
- 되돌릴 수 없는 등급 도구의 실행 횟수가 기준을 초과. 절대 건수로 겁니다. 비율이 아니라.
- 승인 없이 실행된 승인 필수 도구. 0이어야 하는 값이므로 1건이면 알람입니다.
deduped비율의 급증. 재시도 폭주나 루프 이상의 선행 지표입니다.- 에스컬레이션 큐의 적체 시간. 사람이 안 보고 있으면 에스컬레이션은 그냥 유실입니다.
- 실행당 비용의 상위 백분위 급등. 평균이 아니라 p95와 p99를 봅니다. 폭주하는 실행은 소수이고 평균에 묻힙니다.
추세로 보는 것
- 종료 사유의 분포.
step_limit과budget비중이 늘고 있다면 태스크가 어려워졌거나 프롬프트가 퇴화했습니다. - 실행당 도구 호출 수의 분포. 꼬리가 길어지면 루프가 헤매고 있습니다.
- 읽기 도구 없이 완료 보고한 실행의 비율.
- 재생 픽스처로 회귀 테스트를 돌렸을 때의 통과율.
알람을 걸지 말아야 할 것도 있습니다. 개별 도구 호출 실패는 알람 대상이 아닙니다. 에이전트는 도구 실패를 보고 다른 경로를 찾는 것이 정상 동작입니다. 실패 자체가 아니라 실패 후 회복하지 못한 실행에 알람을 겁니다.
마치며 — 재시도 가능성은 프롬프트가 아니라 도구 계층의 속성이다
에이전트 운영에서 반복해서 확인하게 되는 것은, 문제의 대부분이 모델 쪽이 아니라 그 아래 계층에 있다는 사실입니다. 모델이 같은 도구를 두 번 부르는 것은 그 자체로 버그가 아닙니다. 두 번 불렀을 때 리소스가 두 개 생기는 것이 버그입니다.
- 부수 효과가 있는 모든 도구에 결정론적 키와 실행 원장을 붙입니다. 서드파티가 멱등성 키를 안 받으면 조회 후 생성과 자연 키 심기로 대체합니다. 프레임워크가 이걸 대신 해 주지 않습니다.
- 한도는 두 종류입니다. 모델이 모르는 하드 컷은 잘린 산출물을 만들고, 모델에게 알려 준 예산은 요약된 산출물을 만듭니다. 둘 다 걸되 역할을 구분합니다.
- 관측 가능성은 나중에 붙일 수 없습니다. 실행 단위 트레이스와 도구 단위 스팬, 그리고 실패한 실행의 재생 픽스처를 처음부터 남깁니다. 중복 제거가 작동한 횟수도 지표입니다.
- 권한은 에이전트가 아니라 도구에 붙이고, 승인은 호출 단위로 실제 내용을 보여 주고 받습니다.
- 에스컬레이션을 정상 종료로 만듭니다. 넘길 때 이미 실행된 부수 효과의 목록이 함께 가지 않으면, 사람이 처음부터 다시 하면서 같은 부수 효과를 한 번 더 일으킵니다.
그리고 마지막으로, 위 GeekNews 글의 작성자가 스스로 붙인 단서를 다시 강조하고 싶습니다. 그 숫자들은 벤치마크 트레이스에서 나온 것이고, 프로덕션에서 이 실패가 얼마나 자주 일어나는지는 아무도 세고 있지 않기 때문에 알려져 있지 않습니다. 중복 실행은 정확도 벤치마크에서 그냥 오답으로 묻히고, 프로덕션에서는 사용자가 문서 두 개를 보고 하나를 지우면서 조용히 사라집니다. 세지 않는 실패는 존재하지 않는 실패처럼 보입니다.
현재 단락 (1/112)
며칠 전 GeekNews에 [Ask GN: MCP 도구를 여러 개 붙여 에이전트를 운영하시는 분들](https://news.hada.io/topic?id=31946)이라는 글이 올...