Skip to content
This page has been auto-translated and may contain errors.View in English

코드에서 모델 호출하기

docs.scrimba.com

지난 장에서는 메시지 목록을 만들었습니다. 이 장에서는 그 목록을 Python으로 실제 모델에 보내서 답을 받아옵니다. 이 왕복 과정이 이 모델들로 무언가를 만드는 가장 작은 완전한 순환 구조이며, 여러분이 작성할 거의 모든 기능이 이 위에 세워집니다.

프롬프트를 입력하면 코드가 어딘가로 보내고, 텍스트가 돌아옵니다. 마치 모델이 여러분의 프로그램 안에 있는 것처럼 느껴지지만 실제로는 그렇지 않습니다. 모델이 실제로 어디에서 실행되는지 알면 비용, 속도, 그리고 여러분이 겪게 될 여러 특이한 현상들이 설명됩니다.

실제로 물리적으로 무슨 일이 벌어지는지 그려봅시다. 모델은 프로바이더의 서버, 여러분이 절대 볼 수 없는 하드웨어 위에 놓인 거대한 파라미터 집합입니다. 모델을 호출하면 여러분의 코드는 메시지를 담은 평범한 웹 요청을 인터넷을 통해 보냅니다.

프로바이더는 자신들의 머신에서 예측 루프를 실행하고 생성된 토큰을 여러분에게 돌려보냅니다. 모델 호출이란 남의 컴퓨터를 잠깐 빌려 쓰는 것입니다. 이렇게 생각하면 많은 것이 이해됩니다. 지연 시간은 모델이 상대편에서 토큰을 하나씩 생성하는 데 걸리는 시간이고, 비용은 그 연산에 대해 프로바이더가 부과하는 요금이며, 이 전체가 네트워크 호출이라는 사실이 함축하는 모든 것을 그대로 안고 갑니다.

예제는 공식 OpenAI 라이브러리를 사용합니다. 다른 프로바이더는 세부사항이 다르지만, 형태는 거의 어디서나 같습니다. 메시지 목록을 보내면 메시지가 하나 돌아옵니다. 이런 동일한 형태 덕분에 특정 프로바이더에 종속되지 않을 수 있습니다. 같은 코드로 여러분이 직접 호스팅하거나 다른 곳에서 대여한 오픈 모델을 가리키게 할 수 있고, 대개는 base URL만 바꾸면 됩니다(오픈 모델과 클로즈드 모델에서 이 선택을 다룹니다).

라이브러리 함수를 호출하고, 몇 초가 지나면 텍스트가 돌아옵니다. 라이브러리가 이 과정을 로컬처럼 느껴지게 만들지만, 모델과 관련된 어떤 것도 여러분의 머신에는 없습니다.

이해해야 할 핵심은 그 함수가 실제로 감싸고 있는 것이 무엇인지입니다. 여러분의 코드는 요청을 JSON으로 패키징하고, 프로바이더로 HTTPS 연결을 열고, 그들의 하드웨어가 예측 루프를 실행하며 토큰을 스트리밍해 돌려줄 때까지 기다립니다. SDK(프로바이더의 소프트웨어 라이브러리, 예를 들어 openai)는 하나의 POST 요청을 감싸는 얇은 편의 계층일 뿐입니다.

그 아래에 있는 전송 형식이 평범한 JSON이라는 사실을 알아두면 실전에서 도움이 됩니다. 호출이 이상하게 동작할 때 정확한 요청과 응답을 직접 살펴볼 수 있습니다. SDK가 아직 노출하지 않은 기능을 쓰고 싶을 때는 필드를 직접 보내면 됩니다.

전체가 네트워크 호출이라는 사실이 이 장의 나머지 부분에서 다룰 비용, 지연 시간, 오류 처리를 좌우합니다. 예제는 OpenAI를 사용하지만, 요청의 형태, 즉 메시지 목록이 들어가고 메시지가 나오는 구조는 프로바이더마다 거의 동일합니다.

모델 호출은 함수 호출처럼 보이지만 불안정한 인프라 위에서 이루어지는 원격 프로시저처럼 동작합니다. 이 불일치에서 프로덕션 문제가 생겨나므로, 처음부터 실제 그림을 제대로 잡아두는 것이 좋습니다.

SDK 아래에서는 모든 호출이 하나의 HTTPS POST입니다. JSON 본문이 올라가고, 프로바이더가 자신들의 하드웨어에서 추론을 실행하고, 토큰이 돌아옵니다. 원한다면 열려 있는 연결을 통해 스트리밍으로 받을 수도 있습니다. SDK(프로바이더의 클라이언트 라이브러리)는 인증, 재시도, 타입 지정, 스트리밍 파싱을 대가로 여러분이 통제할 수 없는 변화 위에 추상화 계층 하나를 더 얹습니다. SDK를 블랙박스가 아니라 속이 들여다보이는 래퍼로 다루세요.

이 장의 나머지를 이끌어가는 결론은 두 가지입니다. 첫째, 이것은 토큰 단위로 요금이 부과되는 네트워크 호출이므로 비용, 지연 시간, 멱등성, 실패 처리는 나중에 덧붙이는 것이 아니라 설계 단계에서 고려할 문제입니다. 둘째, SDK와 그 필드 이름들은 여러분이 의존하는 것들 중 가장 자주 바뀌는 부분이므로, 오래 가는 방법은 프로바이더 고유의 부분을 여러분만의 경계 뒤에 격리하는 것입니다. 예제는 OpenAI를 사용합니다. 요청의 형태는 거의 보편적이지만, 그 주변을 감싸는 래퍼는 정확히 여러분의 코드베이스 전체에 퍼져서는 안 될 부분입니다.

요청

pip install openai로 라이브러리를 설치하고, 클라이언트를 만들고, 호출하세요. 클라이언트는 환경 변수에서 API 키를 읽어오므로 키가 코드에 등장하지 않습니다.

python
from openai import OpenAI

client = OpenAI()  # 환경 변수에서 OPENAI_API_KEY를 읽어옵니다

MODEL = "gpt-4o-mini"  # 모델을 바꿀 때 고치는 단 한 줄

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "You are a concise assistant. Answer in one sentence."},
        {"role": "user", "content": "Why is the sky blue?"},
    ],
)

필수 항목은 두 가지입니다. 실행하고 싶은 모델의 이름인 model, 그리고 지난 장에서 다룬 역할이 붙은 메시지 목록인 messages입니다. 나머지는 모두 기본값이 있습니다.

모델 이름이 하나의 상수에 담겨 있는 것을 눈여겨보세요. 몇 달에 한 번씩 더 좋고 더 저렴한 모델이 등장하는 분야이기 때문에 이렇게 해두는 것입니다. **모델 이름**을 한 곳에만 두면 모델을 바꿀 때 한 줄만 고치면 됩니다. 이것이 변화에 대응하는 규칙을 실제로 적용한 예입니다. 자주 바뀌는 구체적인 값들은 코드 곳곳에 흩어놓지 말고 찾기 쉬운 한 곳에 모아두세요.

Juno요청 호출에 필요한 것은 modelmessages 두 가지뿐입니다. 모델 이름은 하나의 상수에 담아두세요. 더 저렴하거나 더 좋은 모델이 몇 달에 한 번씩 나오는데, 그럴 때마다 코드를 뒤지고 싶지는 않을 테니까요. 저도 이걸 힘들게 배웠습니다. 모델 이름이 바뀌었을 때 아홉 개 파일에 그대로 박혀 있는 걸 발견하고서요.

필수 필드는 modelmessages 두 가지이며, 나머지는 모두 기본값이 있습니다. 모델 이름을 상수로 두는 것은 단순한 스타일의 문제가 아닙니다. 더 저렴하거나 더 좋은 모델이 종종 등장하기 때문에, 여러 파일에 하드코딩해두면 모델을 바꾸는 일이 이곳저곳을 뒤지는 작업으로 변해버립니다. 그러니 이 값 하나만큼은 한 곳에 모아두는 것이 중요합니다.

이제 한 단계 아래를 살펴봅시다. create 호출은 JSON 본문으로 직렬화되어 POST 요청으로 전송됩니다. 풀어서 말하면, **전송 형식**은 프로바이더가 실제로 받는 요청 그 자체입니다.

python
import httpx, os

# SDK가 내부적으로 보내는 것과 똑같은 요청을 직접 보내보기
resp = httpx.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
    json={
        "model": "gpt-4o-mini",
        "messages": [
            {"role": "system", "content": "You are a concise assistant."},
            {"role": "user", "content": "Why is the sky blue?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # SDK가 객체로 파싱하는 것과 동일한 형태

실제 코드에서는 SDK를 사용하게 될 것입니다. 인증, 재시도, 타입 지정을 대신 처리해주니까요. 하지만 요청 본문이 평범한 딕셔너리라는 것을 직접 보면 나머지도 구체적으로 다가옵니다. 여기 나온 URL과 필드 이름은 OpenAI의 것이며, 정확한 엔드포인트와 키는 프로바이더마다 다르지만, JSON을 보내고 JSON을 받는다는 동작 방식 자체는 어디서나 같습니다.

Juno요청 필수 필드는 modelmessages뿐, 그 이상은 없습니다. SDK의 create 호출은 내부적으로 하나의 JSON POST일 뿐이므로, 뭔가 이상해 보일 때는 전송 계층으로 내려가서 실제 본문을 살펴볼 수 있습니다. 모델 이름은 상수에 담아두세요. 자주 바뀌는 부분이니 한 곳에 모아두는 게 좋습니다.

필수 필드는 modelmessages입니다. 흥미로운 질문은 이 필드들이 무엇인가가 아니라 여러분의 코드에서 어디에 위치하느냐입니다. model은 요청에서 가장 자주 바뀌는 값이고, SDK는 그 주변에서 가장 자주 바뀌는 의존성이기 때문입니다.

create 호출은 JSON POST로 직렬화됩니다. 이것이 중요한 이유는 요청이 SDK가 부리는 마법이 아니라 여러분이 소유한 데이터라는 뜻이기 때문입니다. 본문을 직접 만들고, 로그로 남기고, 실행마다 비교하고, SDK가 새 프로바이더 기능을 아직 지원하지 않을 때는 직접 보낼 수도 있습니다. **전송 형식**을 아는 것이 바로 추상화가 숨기고 있는 호출을 디버깅할 수 있게 해주는 힘입니다.

프로덕션에서 좋은 습관은 모든 호출을 여러분만의 좁은 함수 하나로 통과시키는 것입니다.

python
def complete(messages, *, model=DEFAULT_MODEL, **params):
    # 병목 지점 하나: 프로바이더를 바꾸거나, 로깅을 추가하거나, 재시도를 바꾸는 것은 여기서
    return client.chat.completions.create(model=model, messages=messages, **params)

이 병목 지점 덕분에 모델을 바꾸는 일이 한 줄짜리 수정이 되고, 프로바이더 고유의 변화가 모든 호출부로 퍼지지 않고 한곳에 갇히게 됩니다. 여기 나온 엔드포인트 경로와 필드 이름은 OpenAI의 것입니다. 다른 프로바이더는 경로와 일부 키의 이름을 다르게 짜므로, 이런 경계를 두는 가치는 이름이 바뀌어도 파일 하나만 손보면 되고 코드베이스 전체를 건드릴 필요가 없다는 데 있습니다. 모델 버전도 고정해두세요. "latest"가 여러분도 모르는 사이에 슬그머니 바뀌는 것은 LLM은 어떻게 동작하는가에서 다룬 것과 같은 회귀 위험이며, 이제는 여러분의 요청 본문에서 벌어지고 있는 일입니다.

Juno요청 필수 항목은 modelmessages뿐이며, 둘 다 여러분이 통제하는 경계 뒤에 있는 것이 좋습니다. 모든 호출을 하나의 함수로 통과시키세요. 그곳이 모델 교체를 한 줄로 만드는 지점이고, 로깅이 들어가는 지점이며, 프로바이더의 변화가 호출부까지 새어나가지 않고 멈추는 지점입니다. 이왕이면 모델 버전도 고정하세요. "latest"가 여러분 모르게 바뀌는 것은 여러분이 배포하지도, 알아채지도 못하는 회귀입니다.

응답 읽기

응답은 어떤 구조로 감싸여서 돌아옵니다. 원하는 텍스트는 하나의 경로로 들어가 있지만, 나머지 구조도 알아둘 만합니다. 무슨 일이 있었는지 알려주기 때문입니다.

python
choice = response.choices[0]

print(choice.message.content)   # 응답 텍스트
print(choice.finish_reason)     # "stop"  -> 모델이 스스로 끝냈다는 뜻
print(response.usage)           # Usage(prompt_tokens=24, completion_tokens=18, total_tokens=42)

여기서 중요한 것은 세 가지입니다. message.content가 텍스트이며, choices[0] 안에 들어 있는 것은 API가 여러 개의 선택지를 돌려줄 수 있기 때문입니다. 다만 여러분은 거의 늘 하나만 요청하고 첫 번째를 읽습니다.

finish_reason은 모델이 왜 멈췄는지 알려줍니다. "stop"은 생각을 다 끝냈다는 뜻이고, "length"는 토큰 한도에 걸려서 답이 중간에 끊겼다는 뜻입니다. 이 필드를 확인하는 것이 눈으로 훑어보는 대신 코드로 잘린 응답을 감지하는 방법입니다.

그리고 usage는 이 호출이 소모한 토큰 수를 보내는 프롬프트와 생성된 응답으로 나눠서 보여줍니다. usage 객체가 바로 항목별로 나뉜 청구서입니다. 모든 호출은 토큰 단위로 정확히 얼마가 들었는지 알려주며, 이것이 여러분이 만들어가면서 지켜봐야 할 숫자입니다.

Juno응답 읽기 응답 텍스트는 response.choices[0].message.content에 있지만, 옆에 있는 두 필드도 중요합니다. finish_reason은 왜 멈췄는지 알려줍니다. "stop"은 완료, "length"는 토큰 한도에 걸려 잘렸다는 뜻입니다. 그리고 response.usage는 토큰으로 표시된 청구서로, 프롬프트와 완성분이 나뉘어 있어서 호출 비용을 추측할 필요가 없습니다.

세 필드가 신호를 담고 있습니다. choices[0].message.content는 텍스트입니다. finish_reason은 모델이 왜 멈췄는지 알려줍니다. "stop"은 깨끗하게 끝난 것이고, "length"는 토큰 한도에 걸려서 잘렸다는 뜻이므로, 이 값을 확인하는 것이 절반짜리 답을 그대로 내보내지 않고 코드로 잘린 응답을 잡아내는 방법입니다. 그리고 usage는 프롬프트와 완성분으로 나뉜 토큰 수입니다.

여기서 효과를 보는 방법은 usage를 실제 돈으로 환산하는 것입니다. usage 객체는 호출별 청구 데이터이므로, 바로 그 자리에서 비용을 계산할 수 있습니다.

python
# 프로바이더 가격은 토큰당으로 책정됩니다. 요금이 바뀔 수 있으니 현재 가격을 확인하세요
PROMPT_RATE = 0.15 / 1_000_000      # 프롬프트 토큰 당 달러
COMPLETION_RATE = 0.60 / 1_000_000  # 완성 토큰 당 달러

u = response.usage
cost = u.prompt_tokens * PROMPT_RATE + u.completion_tokens * COMPLETION_RATE
print(f"{u.total_tokens} tokens, ${cost:.6f}")

프롬프트와 완성분은 대개 가격이 다르게 책정되고, 완성 토큰이 더 비싼 경우가 많으므로, 최적화할 때는 이 구분이 중요합니다. 호출마다 비용을 로그로 남기면 월별 청구서가 알려주기 전에 어떤 기능이 비싼지 미리 알 수 있습니다. 여기 나온 필드 이름은 OpenAI의 것입니다. 다른 프로바이더도 같은 정보를 조금 다른 키 이름으로 노출하므로, 정확한 속성명보다는 전체적인 형태를 기억해두세요.

Juno응답 읽기 세 필드를 읽으세요. 텍스트는 content, 코드로 "length" 잘림을 잡으려면 finish_reason, 토큰 수는 usage입니다. usage는 바로 그 자리에서 달러로 환산하세요. 프롬프트와 완성분의 가격이 다르니 따로 계산하고 호출마다 로그를 남기세요. 그러면 청구서보다 먼저 어떤 기능이 비싼지 알아챌 수 있습니다.

응답에는 실제로 활용해야 할 세 필드가 있습니다. content, finish_reason, usage입니다. 개발자들이 무심코 지나쳤다가 나중에 후회하는 것이 finish_reason입니다. "length" 값은 모델이 max_tokens에 걸렸고 출력이 잘렸다는 뜻이며, 종종 형식이 깨진 JSON이나 미완성 문장으로 나타나서 이후 파서가 이를 처리하다가 오류를 냅니다. "stop"이 아닌 값은 모두 실패한 호출로 취급해서 처리하고, content가 완전하다고 함부로 가정하지 마세요.

usage 객체는 유닛 이코노믹스를 위한 계측 데이터이므로, 출력하고 잊어버릴 게 아니라 관측 체계에 연결해두어야 합니다. 프롬프트와 완성 토큰은 가격이 따로 책정되고 대개 완성 토큰이 더 비싸므로, 기능별로 태그를 붙여서 호출마다 둘 다 로그로 남기세요.

python
u = response.usage
log.info("llm_call", feature="invoice_extract", model=MODEL,
         prompt_tokens=u.prompt_tokens, completion_tokens=u.completion_tokens,
         finish_reason=response.choices[0].finish_reason)

이렇게 해두면 기능별, 사용자별 비용이 추측이 아니라 조회 가능한 질문이 되고, 조용히 세 배로 커진 프롬프트가 갑작스러운 청구서가 아니라 지표로 드러납니다. 비대칭성에 주의하세요. 입력 토큰은 처리하기 저렴하고 병렬적이지만, 출력 토큰은 LLM은 어떻게 동작하는가에서 다룬 것처럼 비싸고 순차적인 부분이므로, 긴 답을 내놓는 기능은 프롬프트 크기가 시사하는 것보다 비용과 지연이 더 큽니다. 여기 나온 속성 이름은 OpenAI의 것이며 프로바이더마다 다르므로, response.usage.prompt_tokens를 앱 곳곳에 흩뿌리지 말고 여러분만의 경계 뒤에서 개수를 로그로 남기세요.

Juno응답 읽기finish_reason"stop"이 아니면 모두 실패한 호출로 취급하세요. "length" 잘림은 파서에 깨진 JSON을 넘겨줘서 나중에 그 버그를 쫓아다니게 만듭니다. usage를 기능별로 태그를 붙여 로그에 연결하고 프롬프트와 완성분을 나누세요. 그러면 기능별 비용이 조회 가능해지고, 부풀어 오르는 프롬프트가 청구서의 충격이 아니라 지표로 보입니다. 출력 토큰이 비싼 순차 부분이라, 수다스러운 기능은 프롬프트가 시사하는 것보다 더 큰 비용을 냅니다.

알아둘 만한 파라미터

modelmessages 외에도 자주 등장하는 선택적 설정 두 가지가 있습니다.

temperatureLLM은 어떻게 동작하는가에서 다룬 무작위성 다이얼을 구체화한 것입니다. 낮은 값(0에 가까운 값)은 모델을 상위 후보에 가깝게 유지시켜서 집중되고 반복 가능한 결과를 냅니다. 추출이나 분류 작업에 원하는 특성입니다. 높은 값(0.7에서 1 사이)은 모델이 확률이 낮은 토큰까지 시도하게 해서 더 다채롭고 창의적이지만 방향을 벗어나기도 쉬운 결과를 냅니다. 여러분이 조정하는 것은 모델이 얼마나 대담하게 샘플링하느냐일 뿐, 그 이상은 아닙니다.

max_tokens는 응답이 늘어날 수 있는 최대 토큰 수를 제한합니다. 답이 끝없이 길어지는 것과 청구서가 끝없이 늘어나는 것을 막아줍니다. **max_tokens**를 너무 낮게 잡으면 모델이 중간에 끊깁니다. 이것이 바로 앞 절에서 본 "length" 종료 이유이므로, 여유를 넉넉히 남겨두세요.

python
response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    temperature=0.2,  # 집중되고 일관된 답변
    max_tokens=300,   # 응답 길이 제한
)
Juno알아둘 만한 파라미터temperature는 모델이 얼마나 대담하게 샘플링하느냐를 조정합니다. 낮으면 집중되고 반복 가능한 답, 높으면 다채롭고 창의적인 답이 나옵니다. max_tokens는 청구서를 보호하려고 응답 길이를 제한하는데, 너무 낮게 잡으면 "length" 잘림이 발생하니 여유를 남겨두세요. 추출이나 분류 작업에는 언제나 낮은 temperature를 쓰세요.

대부분의 무게를 지탱하는 파라미터는 temperaturemax_tokens 두 가지입니다. 작동 방식은 LLM은 어떻게 동작하는가에서 다뤘습니다. 여기서 핵심은 기본값을 그대로 믿는 대신 작업에 맞는 값을 고르는 것입니다.

**temperature**를 전역 설정이 아니라 작업별 설정으로 생각하세요. 작업이 필요로 하는 것에 따라 골라야 합니다.

python
TEMPERATURE_BY_TASK = {
    "extraction": 0.0,      # 정답이 하나뿐이니 매번 그대로 원함
    "classification": 0.0,  # 레이블 하나를 고르는 것, 창의성이 필요 없음
    "summary": 0.3,         # 대체로 충실하되 표현에는 약간의 자유
    "brainstorm": 0.9,      # 다양성 자체가 목적
}

나중에 파싱할 결과, 구조화된 데이터, 카테고리, 문서에서 뽑아낸 숫자 같은 것에는 0으로 두고 무작위성에 대한 고민을 멈추세요. 다양성이 가치인 초안 작성이나 아이디어 발상에는 높게 잡으세요.

max_tokens는 목표치가 아니라 상한선입니다. 지출과 폭주하는 응답을 제한하지만, 작업에 필요한 것보다 낮게 잡으면 앞 절에서 본 "length" 잘림이 발생하는데, 이는 청구서가 약간 커지는 것보다 더 나쁩니다. 가장 길게 나올 정답에 여유를 더해서 크기를 잡으세요. 이 두 필드 이름은 프로바이더 간에 대체로 안정적이지만, 일부는 max_tokens의 이름을 다르게 쓰므로 프로바이더를 바꿀 때는 키 이름을 확인하세요.

Juno알아둘 만한 파라미터temperature는 전역으로 한 번 정하지 말고 작업별로 설정하세요. 이후 파싱할 결과는 0으로, 다양성이 목적인 초안 작성에는 높게. max_tokens는 안전 상한선이니 실제로 가장 긴 답에 여유를 더해서 크기를 잡으세요. 너무 빡빡하게 잡으면 약간의 비용 절약과 잘린 응답을 맞바꾸게 됩니다. SDK가 제공하는 기본값을 그대로 믿지 마세요.

temperaturemax_tokens는 다이얼처럼 보이지만 실제로는 비용과 신뢰성이 걸린 정책적 결정처럼 작동합니다. 여기서 깊이 들어가야 할 부분은 이 둘이 여러분이 만드는 다른 모든 것과 어떻게 상호작용하는가입니다.

**temperature**를 신뢰성 계약의 일부로 다루세요. 파싱하거나 저장하는 모든 출력에는 0으로 실행해서 LLM은 어떻게 동작하는가에서 다룬 샘플링에 의한 변동성을 프로바이더가 허용하는 최소치로 줄이세요. 0에서도 완벽하게 결정적이지는 않으므로, 그 위에 정확 일치 캐싱이나 바이트 단위로 동일한 테스트를 만들지는 마세요. 하지만 구조화된 작업에는 이것이 원하는 최저 기준입니다. 더 높은 temperature는 다양성 자체가 상품인 영역에 남겨두고, 그 변동성은 한 번 훑어보는 게 아니라 여러 실행에 걸쳐 평가 체계로 측정할 대상으로 다루세요.

max_tokens는 단순한 안전망이 아니라 지연 시간과 비용을 조절하는 레버입니다. 출력 토큰은 생성 과정 중 순차적이고 비싼 부분이므로, 길이를 제한하면 최악의 비용과 최악의 대기 시간을 동시에 묶어둘 수 있습니다.

여기서 함정은 감으로 크기를 잡는 것입니다. 너무 빡빡하면 정상적인 답이 파서에 깨진 결과를 넘겨주는 "length" 실패로 잘리고, 너무 느슈하면 뭔가가 멈추기 전까지 이상 동작하는 루프가 비용과 지연을 치솟게 만듭니다. 작업별로 가장 긴 정답에 여유를 더해서 크기를 잡고, 한도에 걸렸을 때는 로그를 남겨서 계속 잘리는 기능이 눈에 보이게 하세요. 두 파라미터 이름 모두 OpenAI의 것이며 일부 프로바이더는 이름을 다르게 쓰므로, 모든 호출부가 아니라 경계 함수 뒤에서 설정하세요.

Juno알아둘 만한 파라미터temperature는 신뢰성 계약입니다. 파싱할 것에는 0, 변동성 자체가 상품인 곳에만 더 높게, 그리고 0이라고 해서 바이트 단위로 결정적인 게 아니니 그 위에 정확 일치 캐시를 절대 얹지 마세요. max_tokens는 최악의 비용과 지연을 묶어두는 것이니, 실제 가장 긴 정답에 여유를 더해서 크기를 잡고 한도에 걸렸을 때 로그를 남기세요. 계속 잘리는 기능은 파라미터 뒤에 숨은 버그니까요. 둘 다 모든 호출부가 아니라 경계 뒤에 설정하세요.

스트리밍

기본적으로는 전체 답변이 생성을 마칠 때까지 기다렸다가 한꺼번에 받습니다. 모델이 토큰을 하나씩 생성하기 때문에 긴 답변은 아무것도 없는 화면을 바라보며 오래 기다리는 것을 의미합니다.

**스트리밍**은 각 토큰이 생성되는 즉시 여러분에게 보내주므로, 텍스트가 곧바로 나타나서 실시간으로 채워집니다. 채팅 앱에서 답변이 타이핑되듯 나타나는 것과 같은 방식입니다.

python
stream = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    stream=True,
)

for chunk in stream:
    piece = chunk.choices[0].delta.content
    if piece:
        print(piece, end="", flush=True)

전체 생성 시간은 어느 쪽이든 동일합니다. 모델이 더 빨라지는 것이 아닙니다. 스트리밍은 단지 토큰을 언제 보게 되느냐만 바꿀 뿐이며, 끝날 때까지 붙잡아두지 않고 생성되는 즉시 보여줍니다.

이를 다루는 방법은 청크를 순회하면서 텍스트의 각 delta를 출력하는 것입니다. 대가로 코드가 조금 늘어나지만 대기 경험은 훨씨 좋아집니다. 스트리밍은 토큰을 언제 보는지를 바꿀 뿐, 얼마나 빨리 도착하는지를 바꾸지는 않습니다.

Juno스트리밍 모델은 토큰을 하나씩 생성하므로, 기본적으로는 긴 답변일수록 조용히 오래 기다리게 됩니다. 스트리밍은 각 토큰이 도착하는 즉시 전달하므로 답변이 실시간으로 채워지고, 이 덕분에 채팅 앱이 반응이 빠른 것처럼 느껴집니다. 전체 시간은 같지만, 청크를 순회하며 각 delta를 출력함으로써 토큰을 더 일찍 볼 수 있을 뿐입니다.

스트리밍은 모델이 토큰을 생성하는 즉시 전달하므로, 사용자는 전체 응답을 기다리지 않고 즉시 텍스트를 봅니다. 전체 생성 시간은 변하지 않습니다. 줄어드는 것은 첫 토큰까지의 시간이며, 이것이 "빠르게 느껴진다"는 말의 대부분을 차지합니다.

제대로 된 버전은 간단한 예제 루프가 빠뜨린 두 가지를 처리합니다. 텍스트가 없는 청크, 그리고 화면에 표시하는 동안 전체 응답을 누적하는 것입니다.

python
stream = client.chat.completions.create(model=MODEL, messages=messages, stream=True)

full = []
for chunk in stream:
    delta = chunk.choices[0].delta
    piece = delta.content
    if piece is None:          # 역할만 담은 청크와 마지막 청크는 텍스트가 없습니다
        continue
    full.append(piece)
    print(piece, end="", flush=True)

reply = "".join(full)          # 기록이나 저장을 위한 완전한 메시지

delta를 확인하는 것이 중요합니다. 첫 청크는 흔히 역할만 담고 텍스트는 없으며, **마지막 청크**는 finish_reason을 담고 내용은 비어 있을 수 있습니다. None을 건너뛰지 않으면 결과에 "None"이 그대로 출력될 수 있습니다.

full에 누적해두면 전체 메시지를 기록에 추가할 수 있습니다. 스트리밍은 텍스트를 보여주기만 하고 저장까지 해주지는 않기 때문입니다. 알아둘 만한 트레이드오프 하나는, 스트리밍된 호출은 기본적으로 usage 블록을 돌려주지 않으므로 토큰 수가 필요하면 명시적으로 요청하거나 직접 세어야 한다는 점입니다. 스트리밍은 모든 프로바이더에 존재하지만, 청크의 형태, delta, content, finish_reason이 어디에 들어가는지는 프로바이더마다 다릅니다.

Juno스트리밍 스트리밍은 첫 토큰까지의 시간을 줄이는 것이지, 전체 시간을 줄이는 게 아닙니다. 빈 delta를 방어하세요. 첫 청크는 대개 역할만 담고, 마지막 청크는 텍스트 없이 finish_reason만 담으니, None을 건너뛰지 않으면 응답에 "None"이 찍힙니다. 기록을 위해서는 조각들을 직접 누적하고, 스트리밍된 호출은 요청하지 않으면 usage가 빠진다는 점을 기억하세요.

스트리밍은 더 나은 지연 시간 특성을 얻는 대가로 더 단순한 응답을 포기합니다. 첫 토큰까지의 시간을 줄이지만, 하나의 깔끔한 응답 객체를 포기하고 청크가 도착하는 대로 상태를 조립하는 부담을 지게 됩니다. 사람이 지켜보는 어떤 것에는 값어치가 있지만, 전체를 파싱하는 백엔드 호출에는 거의 쓸모가 없습니다.

프로덕션에서 문제를 일으키는 세부사항은 청크의 경계에 있습니다. 첫 청크는 대개 역할만 담고, **마지막 청크**는 내용 없이 finish_reason을 담으며, 조립된 메시지를 건네주는 것이 없으니 텍스트는 직접 누적해야 합니다.

python
full, finish = [], None
for chunk in stream:
    choice = chunk.choices[0]
    if choice.delta.content:
        full.append(choice.delta.content)
    if choice.finish_reason:        # 마지막 청크에 도착합니다
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # 잘림은 스트림 중간에도 여전히 발생합니다
    handle_truncated(reply)

프로덕션에서 겪게 되는 현실 세 가지입니다.

  • finish_reason은 여전히 스트림 끝에 도착합니다. 그러니 "length" 잘림은 스트리밍한다고 사라지지 않으며, 다만 그 사실을 나중에 알게 될 뿐이므로 조립된 텍스트를 신뢰하기 전에 반드시 확인해야 합니다.
  • 스트림은 중간에 끊길 수 있습니다. 마흔 개 중 열 개 토큰만 받은 상태에서 연결이 끊어져 부분적인 응답만 남을 수 있으므로, 스트리밍된 호출은 자체적인 부분 실패 처리가 필요하고 깔끔하게 재시도하기가 애매합니다.
  • 스트리밍된 응답은 보통 usage를 생략합니다. 그러니 이를 포함하도록 플래그를 넘기거나, 비용 추적을 위해 조립된 텍스트를 직접 토큰화해야 합니다.

SSE 프레이밍과 청크 스키마는 프로바이더 고유의 것이므로, 스트림 파싱을 나머지와 같은 경계 뒤에 두고, 호출하는 쪽에는 원시 청크가 아니라 조립된 결과만 보이도록 하세요.

Juno스트리밍 스트리밍은 첫 토큰까지의 시간을 얻는 대신 깔끔한 응답 객체를 잃습니다. 그러니 사람이 지켜보는 곳에서는 쓰고 백엔드 파싱에는 쓰지 마세요. 마지막 청크는 여전히 finish_reason을 담으니, "length" 잘림은 스트림 중간에도 실제로 일어나므로 텍스트를 신뢰하기 전에 확인하세요. 그리고 스트림은 마흔 개 중 열 개만 받은 채로 끊길 수 있으니, 부분 응답을 처리하고 프로바이더 고유의 청크 파싱은 경계 뒤에 두세요.

모든 호출은 독립적입니다

이것이 이 장에서 가장 중요한 동작이며, 컨텍스트 윈도우 강의에서 곧바로 이어지는 내용입니다. API는 **무상태(stateless)**입니다. 각 요청은 완전히 독립적으로 존재합니다. 프로바이더의 서버는 여러분의 이전 호출을 기억하지 못합니다. 여러분이 보낸 메시지만을 가지고 정확히 예측을 수행하고, 결과를 반환한 뒤에는 다음번을 위해 이 교환에 대해 아무것도 남기지 않습니다.

그러니 대화는 모델이나 API 어디에도 저장되지 않습니다. 대화는 여러분의 코드 안에 존재합니다. 두 번째 턴이 첫 번째 턴에서 무슨 일이 있었는지 알아야 한다면, 여러분이 직접 누적되는 메시지 목록을 유지하고 그 전체를 다시 보내야 합니다. 주고받는 채팅이란 매번 점점 커지는 이력을 다시 보냄으로써 여러분이 만들어내는 환상입니다.

이것이 또한 긴 대화가 시간이 지날수록 메시지당 비용이 더 커지는 이유입니다. 매 호출마다 이전의 모든 턴을 입력 토큰으로 다시 보내므로, 사용자의 새 질문이 짧아도 프롬프트는 계속 커집니다.

python
history = [
    {"role": "system", "content": "You are a friendly travel assistant. Keep answers short."},
]

def chat(user_text):
    history.append({"role": "user", "content": user_text})

    # 이력 전체가 매번 올라갑니다. 서버는 아무것도 기억하지 않습니다
    response = client.chat.completions.create(model=MODEL, messages=history)
    reply = response.choices[0].message.content

    history.append({"role": "assistant", "content": reply})  # 다음 턴을 위해 응답을 보관
    return reply

chat("I have a weekend in Lisbon. What should I see?")
chat("Which of those is best for kids?")  # history가 첫 번째 턴을 함께 실어 나르기 때문에 가능합니다

두 번째 질문이 말이 되는 것은 오직 첫 번째 질문과 그 답이 여전히 history에 남아 함께 전송되기 때문입니다. 여러분이 바로 그 기억입니다. 매번 독립적인 호출을 하기 전에 전체 컨텍스트를 조립하고 다시 보낸다는 이 하나의 개념이 대화의 밑바탕이 되며, 나중에는 도구 사용, 검색, 에이전트의 밑바탕도 됩니다. 이들은 모두 각각의 독립적인 호출 앞에 그 메시지 목록에 무엇을 담을지 결정하는 문제를 변형한 것일 뿐입니다.

Juno모든 호출은 독립적입니다 API는 무상태입니다. 각 요청은 여러분이 보낸 메시지만을 가지고 정확히 실행되고, 서버는 응답하는 순간 그 교환을 잊습니다. 대화는 모델이 아니라 여러분의 코드 안에 존재하므로, 커져가는 메시지 목록을 유지하며 매 턴마다 전체를 다시 보내야 합니다. 그래서 새 질문이 짧아도 매 호출마다 이전 턴 전체를 입력 토큰으로 다시 보내기 때문에 긴 대화일수록 비용이 커집니다.

API는 **무상태**입니다. 서버는 여러분이 보낸 메시지만을 가지고 정확히 실행되고 이후에는 아무것도 남기지 않습니다. 대화는 여러분의 코드 안에 존재하며, 연속성을 흉내내려고 매 턴마다 전체 이력을 다시 보냅니다. 이것이 새 질문이 짧아도 긴 대화일수록 메시지당 비용이 더 커지는 이유입니다.

이 증가는 무시할 수 있는 문제가 아니라 관리해야 하는 문제입니다. 이력은 결국 컨텍스트 윈도우를 초과하게 되기 때문입니다. 첫 번째 대응은 슬라이딩 윈도우입니다. 시스템 메시지와 가장 최근 턴들만 유지하는 방식입니다.

python
def trim(history, keep_recent=10):
    system = history[:1]               # 시스템 메시지는 항상 유지
    recent = history[1:][-keep_recent:]  # 가장 최신 턴만
    return system + recent

이 방법은 저렴하고 비용을 한정하지만, 오래된 턴을 조용히 버립니다. 그래서 모델은 사용자가 여전히 기억하고 있으리라 기대하는 대화 초반의 사실들을 잊어버립니다. 이것이 문제가 될 때 다음 단계는 버려진 턴을 통째로 삭제하는 대신 짧은 요약 메시지 하나로 압축하는 것입니다. 어느 것을 고를지는 기능에 따라 다릅니다. 원래 문제를 다시 참조해야 하는 고객 지원 봇에는 요약이 필요하고, 간단한 질의응답이라면 윈도우 방식으로 충분합니다. 무상태성 자체는 프로바이더 간에 보편적이며, 그 주변의 메시지 형식만 다를 뿐입니다.

Juno모든 호출은 독립적입니다 무상태라는 것은 서버가 매 응답 후 잊어버린다는 뜻이므로, 전체 이력을 다시 보내야 하고 그만큼 커지는 비용을 감당해야 합니다. 시스템 메시지와 최근 턴으로 이루어진 슬라이딩 윈도우로 다듬으면 비용을 한정할 수 있지만, 사용자가 여전히 기억하리라 기대하는 초반의 사실들을 조용히 버린다는 점을 알아야 합니다. 그게 문제가 되면 버려진 턴을 삭제하지 말고 요약하세요.

API는 **무상태**입니다. 대화를 소유하고 매 호출마다 다시 보내는 것은 여러분입니다. 무작정 계속 덧붙여서 전부를 보내는 소박한 방식은 규모가 커지면 두 가지 방식으로 실패합니다. 결국 컨텍스트 윈도우를 초과하게 되고, 입력이 끝없이 커지면서 매 턴이 이전 턴보다 더 비싸고 더 느려집니다.

그러므로 이력 관리는 예산 편성 문제이며, 각기 다른 방식으로 실패하는 표준 전략이 세 가지 있습니다.

  • 슬라이딩 윈도우는 가장 최근의 N개 턴을 유지합니다. 저렴하고 한정되어 있지만, 사용자가 여전히 참조하는 초반의 사실들을 조용히 버립니다.
  • 요약은 오래된 턴을 진행형 요약으로 압축합니다. 토큰당 더 많은 이력을 담을 수 있지만 디테일을 잃고, 자체 오류를 이후 모든 턴에 굳혀버릴 수 있습니다.
  • **검색(retrieval)**은 턴을 외부에 저장하고 관련된 것만 끌어옵니다. 긴 이력까지 확장 가능하지만, 인덱스가 올바른 조각을 돌려주는지에 의존하고 조회 단계가 추가됩니다.

사용 사례에 따라 골라서 계측해두세요. 어떤 전략이든 결국은 언젠가 잘못된 컨텍스트를 드러내기 때문입니다.

python
def build_messages(system, history, user_msg, budget_tokens=6000):
    msgs = [system]
    running = count_tokens(system) + count_tokens(user_msg)
    for turn in reversed(history):           # 최신 것부터
        t = count_tokens(turn)
        if running + t > budget_tokens:
            break                             # 오래된 턴은 예산에서 떨어져 나갑니다
        msgs.insert(1, turn)
        running += t
    msgs.append(user_msg)
    return msgs

턴 개수가 아니라 토큰 개수로 예산을 잡으세요. 턴은 크기가 크게 달라서, 턴 개수 기반의 윈도우는 누군가 문서를 하나 붙여넣는 순간 컨텍스트 예산을 넘겨버립니다. 전략 자체는 프로바이더에 독립적이지만, 개수를 셀 때 쓰는 토크나이저는 그렇지 않으므로 반드시 해당 모델 고유의 토크나이저로 세세요. 이것은 프롬프팅RAG가 다룬 것과 같은 컨텍스트 구성 작업이며, 이제는 대화 자체에 적용된 것입니다.

Juno모든 호출은 독립적입니다 무상태라는 것은 이력 관리가 여러분의 예산 편성 문제라는 뜻이며, "무작정 덧붙이기"는 윈도우를 초과시키고 매 턴의 비용을 부풀립니다. 사용 사례에 따라 전략을 고르세요. 슬라이딩 윈도우는 저렴하지만 초반 사실을 버리고, 요약은 더 많이 담지만 자체 오류를 굳히고, 검색은 확장 가능하지만 인덱스에 의존합니다. 턴 개수가 아니라 토큰 개수로 예산을 잡으세요. 문서 하나를 붙여넣는 순간 턴 개수 기반 윈도우는 즉시 무너집니다.

문제가 생겼을 때

모델 호출은 남의 서버로 보내는 네트워크 호출이므로, 네트워크 호출이 실패하는 모든 방식으로 실패할 수 있습니다. 처음부터 이를 염두에 두고 설계하세요.

  • 오류와 요청 제한(rate limit). 프로바이더는 일정 시간 안에 보낼 수 있는 요청 수를 제한합니다. 이 한도에 걸리면 호출은 rate-limit 오류로 실패합니다. 표준적인 대응은 기다렸다가 재시도하는 것이며, 실패할 때마다 대기 시간을 늘려가는 방식을 **백오프(backoff)**라고 부릅니다. 이렇게 해야 바쁜 서비스를 계속 두드리지 않게 됩니다.
  • 타임아웃. 호출이 멈춰버릴 수 있습니다. 하나의 느린 요청이 앱 전체를 멈춰 세우지 않도록 타임아웃을 설정하세요.
  • 키는 서버에만 두세요. API 키는 여러분의 돈을 쓰는 비밀번호입니다. API 키를 절대 브라우저 코드에 넣지 마세요. 누구나 개발자 도구를 열어 읽을 수 있습니다. 브라우저는 여러분의 백엔드와 대화하고, 그 키를 쥐고 있는 백엔드가 모델과 대화합니다.
  • 어떤 답도 틀릴 수 있다고 가정하세요. 성공한 호출조차 환각이나 형식이 깨진 출력을 돌려줄 수 있으므로, 다음 장들은 그 결과를 여러분의 코드가 믿을 수 있는 것으로 만드는 방법을 다룹니다.
python
def ask_model(messages):
    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            timeout=20,  # 20초 뒤 포기
        )
        return response.choices[0].message.content
    except Exception as err:
        print("Model call failed:", err)
        return "Sorry, something went wrong. Please try again."
Juno문제가 생겼을 때 모델 호출은 남의 서버로 보내는 네트워크 호출이니 요청 제한, 타임아웃, 그리고 평범한 실패를 예상하고, 모든 호출을 재시도-백오프가 포함된 오류 처리로 감싸세요. API 키는 서버에만 두고 절대 브라우저에 두지 마세요. 실제 돈을 쓰는 키이고 개발자 도구로 읽힐 수 있으니까요. 그리고 성공한 응답조차 틀릴 수 있다고 여기세요. 다음 장들이 이를 다루는 법을 도와줄 것입니다.

모델 호출은 네트워크 호출이 실패하는 방식으로 실패합니다. 요청 제한, 타임아웃, 일시적인 서버 오류입니다. 핵심은 실제 버그를 숨겨버리는 맨몸의 except로 모든 걸 감싸는 대신, 구체적인 실패를 잡아내고 재시도할 가치가 있는 것만 재시도하는 것입니다.

타입이 지정된 예외를 잡고 **백오프**를 적용하세요. 실패할 때마다 더 오래 기다림으로써 바쁜 서비스를 계속 두드리지 않게 됩니다.

python
import time
from openai import RateLimitError, APITimeoutError, APIError

def ask_model(messages, retries=3):
    for attempt in range(retries):
        try:
            r = client.chat.completions.create(model=MODEL, messages=messages, timeout=20)
            return r.choices[0].message.content
        except (RateLimitError, APITimeoutError, APIError) as err:
            if attempt == retries - 1:
                raise
            wait = 2 ** attempt          # 1초, 2초, 4초: 지수적 백오프
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

예외 타입이 무엇을 해야 할지 알려줍니다. RateLimitErrorAPITimeoutError는 일시적이므로 재시도할 가치가 있지만, 잘못된 요청(형식이 깨진 메시지, 알 수 없는 모델)은 여러분의 버그이므로 재시도하지 말고 크게 실패해야 합니다. 시도마다 대기 시간을 두 배로 늘리면 이미 힘들어하는 서비스에 더 부담을 얹지 않게 됩니다. SDK가 이미 일부 실패를 대신 재시도해주므로, 여러분만의 계층을 추가하기 전에 기본 동작을 확인하세요.

키는 서버 쪽에 두고, 성공한 응답도 틀릴 수 있다고 여기세요. 이는 다음 장들이 다룰 내용입니다. 여기 나온 예외 클래스 이름은 OpenAI의 것입니다. 다른 SDK는 자신만의 예외를 발생시키므로 이를 여러분의 경계에서 매핑하세요.

Juno문제가 생겼을 때 맨몸의 except가 아니라 타입이 지정된 예외를 잡으세요. RateLimitErrorAPITimeoutError는 일시적이므로 재시도할 가치가 있고, 잘못된 요청은 여러분의 버그이니 크게 실패해야 합니다. 대기 시간을 두 배씩 늘려가며 지수적으로 백오프해서 힘들어하는 서비스를 계속 두드리지 마세요. 여러분만의 계층을 추가하기 전에 SDK가 이미 재시도하고 있는지 확인하고, 키는 서버 쪽에 두세요.

실패 처리는 이것이 더 이상 함수 호출이 아니라 분산 시스템을 다루는 작업이 되는 지점입니다. 호출은 알려진 방식으로 실패합니다. 요청 제한, 타임아웃, 일시적인 5xx 오류입니다. 위험한 부분은 재시도 자체가 아니라, 재시도가 무엇을 대가로 치르는지 생각하지 않고 재시도하는 것입니다.

여기서 함정은 **멱등성(idempotency)**입니다(어떤 작업을 두 번 실행하는 것이 한 번 실행하는 것과 같은 효과를 내는지 여부). 타임아웃 후의 소박한 재시도는 이중 청구로 이어질 수 있습니다. 첫 요청이 이미 완료되어 프로바이더 쪽에서 토큰이 생성되었는데 응답이 여러분에게 도달하지 못했다면, 재시도는 두 번째 전체 생성 비용을 그대로 지불하게 됩니다. 조회성 호출이라면 낭비된 돈일 뿐이지만, 부수 효과가 있는 작업이라면 중복된 행위가 됩니다. 프로바이더가 멱등성 키를 지원한다면, 이를 보내서 재시도가 같은 요청으로 인식되게 하세요. 지원하지 않는다면, 그 작업을 감싸는 로직을 반복해도 안전하게 만드세요.

python
import random, time
from openai import RateLimitError, APITimeoutError, APIError

RETRYABLE = (RateLimitError, APITimeoutError, APIError)

def complete(messages, *, retries=3, **params):
    for attempt in range(retries):
        try:
            return client.chat.completions.create(messages=messages, model=MODEL, **params)
        except RETRYABLE as err:
            if attempt == retries - 1:
                raise
            time.sleep((2 ** attempt) + random.random())  # 백오프 + 지터

프로덕션에서 겪게 되는 현실 세 가지가 더 있습니다.

  • 지터(jitter), 각 대기 시간에 더해지는 무작위 값은, 여러 클라이언트가 동시에 재시도해서 요청 제한을 다시 치솟게 만드는 것(선더링 허드 문제)을 막아줍니다.
  • 연결 재사용은 부하가 있을 때 중요합니다. 클라이언트를 하나 만들어 공유하면 매 호출마다 새로운 TLS 핸드셰이크를 치르는 대신 연결이 풀링되며, 느린 순차 루프가 아니라 독립적인 호출을 동시에 실행할 수 있습니다.
  • 재시도하기 전에 분류하세요. RateLimitError는 일시적이지만, 형식이 잘못된 요청 오류는 여러분의 버그이며 이를 재시도하는 것은 결코 성공하지 못할 호출에 시간과 돈을 낭비하는 것입니다.

예외 클래스와 멱등성 메커니즘은 프로바이더 고유의 것이므로, 이 정책 전체를 경계 함수 뒤에 두고 각 프로바이더의 오류를 여러분만의 재시도 결정으로 한 번에 매핑하세요. 키는 서버 쪽에 두고, 성공한 응답도 검증되기 전까지는 여전히 신뢰할 수 없습니다. 이는 구조화된 출력안전 장이 다루는 내용입니다.

Juno문제가 생겼을 때 재시도가 위험한 부분입니다. 타임아웃 후의 소박한 재시도는 이중 청구를 일으킬 수 있습니다. 첫 호출이 이미 토큰을 생성했지만 여러분이 받지 못했을 수 있기 때문이니, 멱등성 키를 쓰거나 작업을 반복해도 안전하게 만드세요. 백오프에 지터를 더해서 여러 클라이언트가 동시에 재시도해 제한을 다시 치솟게 만들지 않도록 하고, 클라이언트 하나를 풀링해서 재사용하고, 독립적인 호출은 동시에 실행하세요. 먼저 분류하고, 일시적인 오류만 재시도하고 여러분의 잘못된 요청은 크게 실패시키세요. 그리고 정책 전체를 경계 하나 뒤에 두어서 각 프로바이더의 특이한 동작이 한곳에서 재시도 결정으로 매핑되게 하세요.