사용자 액션을 실수로 중복시키지 않고 LLM API를 위한 재시도(Retry)를 설정하는 방법
요약
LLM API 호출이 포함된 워크플로에서 부수 효과(side effects)로 인한 중복 실행 문제를 방지하는 재시도 설계 전략을 다룹니다. 단순 HTTP 재시도가 아닌, 작업의 멱등성을 고려하여 사용자 의도가 아닌 기술적 전송 단계만을 재시도해야 함을 강조합니다.
핵심 포인트
- LLM 호출이 이메일 전송, 결제 등 부수 효과를 동반할 때 단순 재시도는 위험함
- 작업이 멱등성을 갖지 않는 한 사용자 의도를 다시 재생하지 말 것
- 읽기 전용 작업과 실행형 작업을 구분하여 재시도 로직을 설계해야 함
- 사용자 액션당 고유한 operation ID를 부여하여 추적 및 제어
재시도(Retry)는 LLM 호출이 어떤 동작을 수행하도록 허용되기 전까지는 단순해 보입니다.
일반적인 읽기 전용 (read-only) API 요청의 경우, 재시도는 보통 지루할 정도로 간단합니다:
if (status === 429 || status >= 500) {
retryWithBackoff();
}
하지만 LLM API는 종종 순수하게 읽기 전용이 아닌 워크플로 (workflow) 내부에 위치합니다.
실패한 LLM 호출은 다음과 같은 과정의 일부일 수 있습니다:
- 이메일 전송
- 지원 티켓 (support ticket) 생성
- CRM 레코드 업데이트
- 도구 (tool) 호출
- 데이터베이스 (database) 쓰기
- 크레딧 차징
- 문서 생성
- 다른 자동화 단계 트리거
이러한 경우, 맹목적으로 재시도를 하면 하나의 사용자 액션이 현실 세계에서 두 개, 세 개, 혹은 네 개의 액션으로 변할 수 있습니다.
그것이 제가 가장 피하려고 노력하는 버그입니다.
실수
제가 LLM API를 위해 처음 사용했던 재시도 설정은 기본적으로 일반적인 HTTP API에서 복사한 것이었습니다:
async function callWithRetry(fn: () => Promise<Response>) {
let lastError: unknown;
...
이 방식은 일부 LLM 호출에는 잘 작동합니다.
예를 들어:
- 이 단락 요약하기
- 이 티켓 분류하기
- 이 제목 다시 쓰기
- 이 텍스트에서 엔티티 (entities) 추출하기
첫 번째 시도가 실패하더라도, 재시도를 하는 것이 아마 괜찮을 것입니다.
하지만 LLM 호출이 부수 효과 (side effects)가 있는 워크플로의 일부가 되면 위험해집니다.
다음과 같은 에이전트 (agent) 단계를 상상해 보세요:
- 고객 메시지 읽기
- 환불이 필요한지 결정
issueRefund호출- 답장 초안 작성
- 이메일 전송
만약 3단계 이후에 요청이 타임아웃 (timeout) 된다면, 당신의 재시도 코드는 전체 과정을 다시 실행할 수도 있습니다.
이제 당신은 두 번의 환불을 처리하게 될 수도 있습니다.
이것은 모델 품질의 문제가 아닙니다.
이것은 재시도 설계 (retry design)의 문제입니다.
나의 규칙: 사용자 의도가 아닌 전송 (transport)을 재시도하라
제가 현재 사용하는 주요 원칙은 다음과 같습니다:
기술적 실패는 재시도하되, 작업이 멱등성 (idempotent)을 갖지 않는 한 사용자 의도 (user intent)를 다시 재생하지 마라.
사용자는
type LlmOperationKind =
| "read_only_generation"
| "structured_extraction"
...
그다음 저는 이들을 다르게 취급합니다.
function canRetryOperation(kind: LlmOperationKind) {
switch (kind) {
case "read_only_generation":
...
이것이 핵심적인 구분점입니다.
모델의 계획 (planning) 단계를 재시도하는 것은 보통 괜찮습니다.
하지만 실제로 이메일을 보내거나, 카드를 결제하거나, 외부 시스템을 업데이트하는 단계를 재시도하는 것은 해당 단계가 멱등성 (idempotent)을 갖지 않는 한 괜찮지 않습니다.
사용자 액션당 하나의 operation ID 사용하기
사용자가 트리거한 모든 워크플로 (workflow)에는 operation ID가 부여됩니다.
type LlmOperation = {
operationId: string;
userId: string;
...
해당 operationId는 요청을 따라 다음과 같은 곳들을 통과합니다:
- LLM 호출 로그 (call logs)
- 재시도 시도 (retry attempts)
- 도구 호출 (tool calls)
- 데이터베이스 쓰기 (database writes)
- 외부 API 호출 (external API calls)
- 사용자에게 보이는 상태 업데이트 (user-visible status updates)
재시도는 새로운 연산 (operation)이 아닙니다.
그것은 동일한 연산 내에서의 또 다른 시도입니다.
type LlmAttemptLog = {
operationId: string;
attempt: number;
...
이렇게 하면 디버깅이 훨씬 쉬워집니다.
관련 없는 네 개의 LLM 실패를 보는 대신, 다음과 같이 볼 수 있습니다:
하나의 사용자 액션이 하나의 연산을 생성했고, 이것이 네 번의 시도를 수행했다.
장애 발생 시 이 차이는 매우 중요합니다.
도구 호출을 멱등하게 만들기
LLM이 도구 (tools)를 호출할 수 있다면, 해당 도구들은 멱등성 키 (idempotency keys)를 가져야 합니다.
예를 들어, 다음과 같은 일이 발생하게 두지 마세요:
await issueRefund({
customerId,
amount
...
다음 방식을 권장합니다:
await issueRefund({
customerId,
amount,
...
외부 액션도 마찬가지입니다:
await sendEmail({
to,
subject,
...
정확한 키는 액션에 따라 다르지만, 아이디어는 동일합니다:
시스템이 재시도하더라도, 외부 액션은 최대 한 번만 실행되어야 한다.
타임아웃 (timeout)은 모호하기 때문에 이 점이 특히 중요합니다.
타임아웃이 항상 "아무 일도 일어나지 않음"을 의미하지는 않습니다.
그것은 종종 다음과 같은 의미입니다:
클라이언트는 기다리는 것을 중단했지만, 제공자(provider)나 도구는 여전히 작동 중일 수 있다.
이러한 모호함 때문에 중복 액션이 발생합니다.
계획과 실행 분리하기
에이전트(agents)의 경우, 저는 워크플로(workflow)를 두 단계로 분리하려고 노력합니다.
- LLM이 무엇이 일어나야 하는지 결정합니다.
- 애플리케이션 코드(Application code)가 액션(action)을 실행합니다.
LLM은 계획(planning) 단계를 재시도(retry)할 수 있습니다.
애플리케이션이 실행(execution)을 제어합니다.
예시:
type PlannedAction =
| {
type: "send_email";
...
LLM은 계획을 반환합니다.
그 후 애플리케이션이 이를 검증하고 실행합니다.
async function executePlannedAction(
action: PlannedAction,
operationId: string
...
이렇게 하면 재시도 로직(retry logic)이 되돌릴 수 없는 부수 효과(irreversible side effects)로부터 분리됩니다.
일시적인 오류(transient error)로 인해 LLM 계획이 실패하면, 계획 단계를 재시도할 수 있습니다.
실행이 시작되면, 전체 워크플로를 자유롭게 재시도 가능한 것으로 취급하지 않습니다.
알려진 일시적 실패만 재시도하기
저는 모든 LLM 실패를 재시도하지 않습니다.
저는 보통 다음과 같은 경우에 재시도합니다:
- 일시적인 속도 제한 (rate limits)
- 제공자(provider)의 5xx 오류
- 네트워크 타임아웃 (network timeouts)
- 연결 재설정 (connection resets)
- 유용한 출력이 나오기 전에 중단된 스트림 (stream)
- 때때로 잘못된 형식의 구조화된 출력 (malformed structured output)
저는 보통 다음과 같은 경우에는 재시도하지 않습니다:
- 인증 오류 (auth errors)
- 할당량 소진 (quota exhaustion)
- 컨텍스트 길이 오류 (context length errors)
- 콘텐츠 거부 (content refusals)
- 잘못된 요청 형태 (invalid request shape)
- 완료된 도구 실행 (completed tool execution)
- 사용자가 취소한 작업 (user-cancelled operations)
간단한 분류기(classifier)가 도움이 됩니다.
type LlmErrorCategory =
| "rate_limited"
| "provider_unavailable"
...
그러면 재시도 로직이 더 명시적이 됩니다.
function shouldRetryLlmCall(params: {
operationKind: LlmOperationKind;
error: LlmErrorCategory;
...
이것은 화려하지 않습니다.
하지만 많은 비용이 드는 불필요한 상황을 방지해 줍니다.
스트리밍 재시도는 다른 규칙이 필요합니다
스트리밍 응답(Streaming responses)은 특별한 경우입니다.
토큰이 도착하기 전에 스트림이 실패하면, 재시도하는 것이 보통 괜찮습니다.
사용자가 이미 답변의 절반을 본 후에 스트림이 실패한다면, 처음부터 다시 재시도하는 것은 이상할 수 있습니다.
사용자는 다음과 같은 상황을 겪을 수 있습니다:
- 부분적인 답변
- 갑작스러운 초기화
- 다른 답변
- 중복된 콘텐츠
채팅 UI의 경우, 저는 콘텐츠가 수신되었는지 여부를 추적합니다.
type StreamAttempt = {
operationId: string;
attempt: number;
...
그러면 동작은 다음과 같습니다:
- 토큰을 받지 못한 경우: 자동으로 재시도 (retry)
- 일부 토큰을 받은 경우: 불완전한 상태 (incomplete state) 표시
- 구조화된 출력 (structured output)이 예상되는 경우: 폐기 또는 복구
- 도구 호출 (tool call)이 포함된 경우: 중단 후 확인 요청
사용자에게 보이는 채팅의 경우, 저는 다음과 같이 표시하는 것을 선호합니다:
응답이 중단되었습니다. 계속하시겠습니까?
조용히 재시도하여 두 번째 답변을 생성하는 것보다 이 방식이 낫습니다.
백오프 (Backoff)는 여전히 중요합니다
재시도가 안전하다는 것을 알게 된 후에도, 저는 여전히 일반적인 백오프 (backoff)를 사용합니다.
function retryDelayMs(attempt: number) {
const base = Math.min(1000 * 2 ** attempt, 10000);
const jitter = Math.floor(Math.random() * 500);
...
또한 제공 가능한 경우 프로바이더 (provider)의 재시도 힌트 (retry hints)를 준수합니다.
function getRetryDelay(error: {
retryAfterMs?: number;
}, attempt: number) {
...
중요한 점은 백오프 (backoff)가 전략의 전부가 아니라는 것입니다.
백오프 (backoff)는 다음 질문에 답합니다:
재시도하기 전에 얼마나 기다려야 하는가?
하지만 다음 질문에는 답하지 못합니다:
재시도가 안전한가?
두 번째 질문이야말로 대부분의 LLM 워크플로 (workflow) 버그가 발생하는 지점입니다.
필요할 때 사용자가 재시도 상태를 볼 수 있게 하세요
백그라운드 작업 (background jobs)의 경우, 재시도는 보이지 않을 수 있습니다.
사용자 대상 워크플로 (user-facing workflows)의 경우, 저는 상태 (state)를 노출하는 것을 선호합니다.
상태 예시:
type OperationStatus =
| "queued"
| "running"
...
작업을 자동으로 재시도하는 것이 안전하다면, UI는 다음과 같이 말할 수 있습니다:
아직 작업 중입니다. 일시적인 모델 오류로 인해 재시도 중입니다.
만약 작업이 중복된 부수 효과 (side effect)를 일으킬 수 있다면, UI는 다음과 같이 물어야 합니다:
이메일이 이미 전송되었을 수 있습니다. 다시 시도하기 전에 상태를 확인하시겠습니까?
이 방식이 덜 매끄럽게 느껴질 수 있지만, 실수로 이메일을 두 번 보내는 것보다는 훨씬 낫습니다.
재시도를 하나의 워크플로 (workflow)의 일부로 로그를 남기세요
최악의 디버깅 경험 중 하나는 재시도 기록이 서로 관련 없는 로그로 보이는 것입니다.
저는 로그가 하나의 작업 (operation) 아래에 그룹화되기를 원합니다.
{
"operation_id": "op_8f23",
"user_id": "user_123",
...
부수 효과 (side effects)의 경우, 멱등성 키 (idempotency key)도 함께 로그로 남깁니다.
{
"operation_id": "op_8f23",
"action": "send_email",
...
이렇게 하면 두려운 질문에 대해 명확한 답을 얻을 수 있습니다:
사용자 액션이 한 번 발생했나요, 여러 번 발생했나요, 아니면 전혀 발생하지 않았나요?
나의 재시도(Retry) 설정
제가 현재 사용하는 설정은 기본적으로 다음과 같습니다:
- 사용자 액션당 하나의 작업 ID (operation ID)를 생성합니다.
- LLM 작업 유형 (LLM operation type)을 분류합니다.
- 오류를 분류합니다.
- 알려진 일시적 실패 (transient failures)에 대해서만 재시도합니다.
- 멱등성 (idempotent)이 보장되지 않는 한 외부 부작용 (external side effects)에 대해서는 재시도하지 않습니다.
- 도구 (tools) 및 외부 API를 위해 멱등성 키 (idempotency keys)를 사용합니다.
- LLM 계획 (planning)과 애플리케이션 실행 (execution)을 분리합니다.
- 스트리밍되는 부분 출력 (streaming partial output)을 특수 상태로 취급합니다.
- 동일한 작업 아래에서 모든 시도 (attempts)를 로그로 남깁니다.
- 시스템이 액션이 이미 발생했는지 여부를 알 수 없을 때는 확인을 요청합니다.
코드상에서 재시도 래퍼 (retry wrapper)는 일반적인 HTTP 헬퍼라기보다는 작업 인지형 실행기 (operation-aware executor)에 더 가깝습니다.
async function runLlmAttempt<T>(params: {
operationId: string;
operationKind: LlmOperationKind;
...
이는 단순한 재시도 헬퍼보다 더 많은 코드가 필요합니다.
하지만 이를 통해 위험한 작업을 해롭지 않은 API 읽기 작업처럼 취급하는 실수로부터 저를 구해줄 수 있었습니다.
마지막 생각
재시도가 자동으로 신뢰성을 보장하는 것은 아닙니다.
LLM 애플리케이션의 경우, 작업이 읽기 전용 (read-only)이고, 일시적 (transient)이며, 반복해도 안전할 때 재시도는 신뢰성을 향상시킬 수 있습니다.
하지만 모델이 외부 세계를 변화시키는 워크플로 (workflow)의 일부일 때는 심각한 버그를 유발할 수 있습니다.
제가 이제 스스로에게 던지는 질문은 단지 다음과 같은 것이 아닙니다:
LLM 호출이 실패했는가?
그것은 다음과 같습니다:
이 사용자 액션으로 인해 이미 어떤 일이 일어났는가?
아무 일도 일어나지 않았다면, 재시도하세요.
무언가 일어났을 가능성이 있다면, 확인하거나, 중복을 제거 (dedupe)하거나, 확인을 요청하세요.
이 한 가지 차이점이 LLM 재시도 로직을 훨씬 덜 두렵게 만듭니다.
멀티 모델 워크플로 (multi-model workflows)의 경우, 재시도, 폴백 (fallback), 라우팅 (routing)을 중앙 집중화하는 것도 이 로직을 일관되게 유지하는 데 도움이 됩니다. 저는 이러한 설정을 위해 TokenBay를 사용합니다. API 인터페이스를 익숙하게 유지하면서도 모델/제공자 (provider) 선택을 한 곳에서 더 쉽게 관리할 수 있기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기