"타임아웃은 로컬의 문제": 모델 호출과 재시도에 관한 5가지 오해
제 배치 작업이 03:40에 실패했습니다. 시스템은 정상이었고, 모델 역시 접속 가능했습니다. 문제는 제 재시도 로직이 잘못되었다는 것이었습니다.
저는 코드 리뷰에서 똑같은 다섯 가지 주장을 계속 접합니다. 각각의 주장들은 그럴듯하게 들립니다. 하지만 각각은 조용히 중복 작업을 생성하거나, 빈 출력을 내거나, 또는 영원히 멈춘 워커를 만듭니다.
그래서 저는 논쟁을 멈추고 프로브(probe)를 만들었습니다. 하나의 스크립트, 하나의 판정 테이블, 그리고 하나의 원장입니다. 여기에는 벤치마크가 없습니다. 무료 등급 조건은 너무 빨리 변해서 제 수치가 당신에게 도움이 되기 어렵습니다.
신화 1: "타임아웃은 호출이 일어나지 않았다는 의미다"
당신의 마감 시간(deadline)은 당신의 기계에서 측정됩니다. 요청은 이미 대기열에 있을 수도 있고, 혹은 이미 완료되었을 수도 있습니다.
로컬 클럭이 만료되었다는 것은 단 하나의 것만을 알려줍니다. 즉, 당신이 듣기를 멈췄다는 것입니다. 이것은 원격 실행(remote execution)에 대해서는 아무것도 말해주지 않습니다.
수정된 모델: DEADLINE은 _실패(failed)_가 아니라 _알 수 없음(unknown)_을 의미합니다.
outcome = with_deadline(lambda: call_model(model, prompt), seconds=20)
if outcome.verdict is Verdict.DEADLINE:
# 원격 상태는 알 수 있음: "작업이 수행되지 않았다"고 가정하지 마십시오.
...
이것은 호출에 부작용(side effects)이 있을 때 중요합니다. 행을 쓰거나 메시지를 보내는 도구 호출(tool call)이 이미 완료되었을 수도 있습니다. 눈가림식으로 재시도하면 두 번 작성하게 됩니다.
신화 2: "HTTP 200은 답변을 받았다는 의미다"
200이라는 코드는 전송 계층(transport)에서 작동했다는 것만을 말합니다. 그 안에 있는 텍스트가 사용 가능한지 여부는 말해주지 않습니다.
저는 건강한 상태 코드와 함께 도착하는 세 가지 실패 형태를 목격했습니다:
- 빈 값 (Empty) — 공백 문자만 있거나, 텍스트가 있어야 할 곳에 빈 필드인 경우.
- 잘림 (Truncated) — 제한(cap)에 도달하여 구절 중간에서 끝나는 경우.
- 목표 이탈 (Off-target) — 지침을 무시하는 유창한 텍스트.
첫 번째와 두 번째는 감지하기 쉽습니다. 세 번째는 필수 필드나 스키마 파싱(schema parse)과 같이 직접 작성해야 하는 검사가 필요합니다.
수정된 모델: 성공에는 두 개의 게이트가 있습니다. 전송 계층의 성공, 그리고 콘텐츠의 성공입니다. 당신의 재시도 정책은 이 둘을 모두 확인해야 합니다.
신화 3: "재시도는 무료다"
모든 재시도는 또 다른 호출입니다. 시간 단위 하나를 더 소모합니다. 제한에 도달할 기회를 하나 더 만듭니다.
스스로에게 질문해 보세요. 최악의 시나리오는 무엇인가요? 시도 횟수(attempts)와 마감 시간(deadline)을 곱하면 얼마가 되나요? 만약 그 답이 40분이라면, 당신은 재시도 정책(retry policy)을 가진 것이 아니라 시스템 장애(outage)를 겪고 있는 것입니다.
숫자는 하나가 아닌 두 개로 제한해야 합니다:
- 시도 횟수 (Attempts) — 루프가 아닌 작은 정수여야 합니다.
- 전체 경과 시간 (Total wall clock) — 작업 전체에 할당된 예산입니다.
그리고 THROTTLED 상태를 TRANSPORT 상태와 다르게 취급해야 합니다. 백프레셔(backpressure)는 더 긴 일시 정지 시간을 원합니다. 연결 끊김(dropped connection)은 짧고 불규칙한 지터(jitter)를 원합니다.
오해 4: "프롬프트가 같으면 답변도 같으니 프롬프트로 캐싱할 수 있다"
출력물은 상수(constant)가 아니라 스냅샷(snapshot)입니다. 같은 프롬프트라도 두 번 실행하면 다른 텍스트를 반환할 수 있습니다.
이는 채팅 환경에서는 괜찮지만, 캐시 키(cache key)로 사용하기에는 버그입니다.
수정된 모델: 키는 출력을 형성하는 모든 것의 지문(fingerprint)이어야 합니다.
def fingerprint(model: str, prompt: str, params: dict) -> str:
blob = json.dumps({"m": model, "p": prompt, "params": params}, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
모델 ID, 프롬프트, 그리고 매개변수(parameters) 모두 해시(hash)에 포함되어야 합니다. 모델을 변경하면 캐시는 반드시 실패해야 합니다. 그렇지 않으면 새로운 레이블 아래에서 어제의 답변을 제공하게 됩니다.
오해 5: "스트리밍은 진행 중이라는 뜻이니 타임아웃을 무시해도 된다"
스트리밍은 더 빨리 바이트를 제공할 뿐입니다. 완료된다는 보장을 해주지 않습니다.
멈춘 스트림(stalled stream)은 느린 응답보다 더 나쁩니다. 작업자 슬롯(worker slot)을 점유하고 살아있는 것처럼 보이게 만듭니다. 데이터가 계속 흘러나오기 때문에 전체 타임아웃이 절대 발동되지 않습니다.
두 개의 시계를 사용하세요:
- 유휴 시간 초과 (Idle timeout) — N초 동안 바이트가 없으면 포기합니다.
- 전체 타임아웃 (Total timeout) — 전체 호출에 대한 하드한 상한선입니다.
둘 중 하나만 가지고 있다면, 잘못된 것을 가진 것입니다.
프로브(Probe) 구축하기
여기가 제가 실제로 실행하는 도구(harness)입니다. 의도적으로 제공업체에 구애받지 않도록 설계되었습니다. 클라이언트를 주입하고 자신만의 수치를 유지하세요.
"""probe_call.py - 재시도하기 전에 모델 호출 결과를 분류합니다."""
from __future__ import annotations
...
이 시스템을 단순한 장난감 이상으로 유용하게 만드는 두 가지 요소가 있습니다. 원장(ledger)은 추가 전용(append-only)이므로, 재시작된 작업은 이미 완료된 작업을 건너뛸 수 있습니다. 또한, 판결은 어떤 재시도 결정보다 먼저 기록됩니다.
먼저 실패(kill) 테스트를 실행하세요
좋은 날에 재시도 경로를 테스트하지 마십시오. 나쁜 상황을 강제하십시오.
# 1) force the local-clock path
python probe_call.py --prompt "reply with: ok" --deadline 0.01
...
4단계는 사람들이 건너뛰는 부분입니다. 동일한 지문(fingerprint)을 두 번 실행하면 두 번째 호출이 아닌 '건너뜀(skip)'을 봐야 합니다.
결정 테이블
| Verdict | Retry? | Why | What to log |
|---|
OK | No | You have text | Fingerprint, seconds |
| ... | | | |
DEADLINE 행이 판단이 필요한 부분입니다. 호출이 읽기만 할 때는 동일한 지문을 재시도하는 것이 안전합니다. 하지만 호출이 쓰기를 수행할 때는 그렇지 않습니다.
무료 호스트에서 이를 실행하는 방법
저는 비용이 들지 않는 리소스를 사용하여 탐색 행렬(probe matrix)을 실행하므로, 잘못된 루프가 청구서로 이어질 수 없습니다. 여기서 MonkeyCode가 저에게 도움이 됩니다. 이 서비스는 무료 모델 접근 및 무료 서버 옵션을 제공하며, 이는 위의 실패 테스트를 종단 간(end to end)으로 실행하기에 충분합니다.
고지: 이 글은 MonkeyCode의 제품 홍보 활동의 일환으로 작성되었습니다.
무료 서버는 이러한 신화를 드러내는 환경 그 자체입니다. 느릴 수도 있고, 잠들 수도 있으며, 회수될 수도 있습니다. 이것이 바로 이번 실험을 위한 특징입니다. 이는 지연 시간 기준선(latency baseline)이 아닙니다.
그러니 판결을 읽으십시오. 벽시계 시간(wall-clock) 숫자는 무시하십시오. 그 어떤 것도 신뢰하기 전에 자체 호스트에서 재실행하십시오.
이 하네스(harness)가 증명하지 못하는 것
경계 조건에 대해 정직해야 합니다. 제 경계는 날카롭습니다.
- 모델의 품질을 측정하지 않습니다.
OK는 텍스트가 도착했다는 의미일 뿐, 텍스트가 정확하다는 것을 의미하지는 않습니다.
- 호출을 Idempotent하게 만들지 않습니다. 원장(ledger)은 시도 횟수를 기록할 뿐입니다. 중복 쓰기(double writes)를 방지하는 것은 오직 사용자의 코드만이 할 수 있습니다.
- 자르기 확인(truncation check)은 휴리스틱(heuristic)일 뿐입니다. 세 가지 접미사 패턴은 데모이며, 파서가 아닙니다.
- 사용자의 속도 제한(rate limits)을 모델링하지 않습니다. 제가 출력하는 특정 시도 횟수는 사용자 계정에 대한 추측일 뿐입니다.
- 스레드는 취소 기능이 아닙니다.
DEADLINE 분기는 스레드를 포기할 뿐, 원격 작업(remote work)을 종료시키지는 않습니다.
마지막 내용은 이 글 전체를 한 문장으로 요약한 것입니다. 기다리는 것을 멈출 수 있습니다. 질문했던 것을 취소할 수는 없습니다.
이것을 건너뛰어야 하는 팀
일부 팀은 이 패턴을 채택해서는 안 됩니다. 저는 미리 말씀드리는 것이 낫다고 생각합니다.
- 엄격한 지연 시간 서비스 수준 목표(SLO, Service Level Objectives). 무료 호스팅과 대기하는 상자는 지연 시간 주장을 무의미하게 만듭니다.
- 엄격한 데이터 거주지(data residency). 선택하는 모든 호스팅 및 모델의 약관을 읽으십시오. 제 말만 믿지 마십시오.
- 높은 팬아웃 파이프라인(fan-out pipelines). 호출당 스레드에 원장 추가가 더해지면 금방 복잡해집니다. 실제 큐를 사용하십시오.
- 돈이 걸린 모든 것. 중복 쓰기가 실제 비용을 발생시킨다면, 먼저 API 계층에서 Idempotency Key를 구축해야 합니다.
반드시 기억해야 할 단 하나의 질문
재시도(retry)를 추가하기 전에, 스스로에게 물어보십시오. 이 호출이 이미 실행되었다면, 무엇이 깨지겠습니까?
답할 수 없다면, 재시도가 해결책이 아닙니다. 누락된 원장(ledger)이 문제입니다. 프로브(probe)로 시작하고, 종료 테스트(kill tests)를 실행하며, 스스로의 판결이 정책을 형성하도록 하십시오.
이러한 종료 테스트를 수행할 비용 제로 장소를 원한다면, MonkeyCode의 무료 모델과 무료 서버가 이 역할을 할 것입니다.