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

코드에서 모델 호출하기

docs.scrimba.com

지난 장에서는 메시지 목록을 작성했습니다. 이 장에서는 그 목록을 Python에서 실제 모델로 보내고 답변을 다시 받습니다. 그 왕복은 이 모델들을 사용하여 구축하는 가장 작은 완전한 루프이며, 작성할 거의 모든 기능이 그 위에 앉아 있습니다.

프롬프트를 입력하면, 코드가 어딘가로 보내고, 텍스트가 돌아옵니다. 모델이 프로그램에 살고 있는 것처럼 느껴집니다. 그렇지 않으며, 실제로 어디서 실행되는지 보면 비용, 속도, 그리고 만날 특이한 점들을 설명합니다.

그러면 물리적으로 무슨 일이 일어나고 있는지 상상해 봅시다. 모델은 공급자의 서버에 앉아 있는 엄청난 양의 매개변수 집합이며, 절대 볼 수 없는 하드웨어에 있습니다. 호출할 때, 코드는 메시지를 담은 일반적인 웹 요청을 인터넷을 통해 보냅니다.

공급자는 자신의 머신에서 예측 루프를 실행하고 생성된 토큰을 다시 보냅니다. 모델 호출은 다른 누군가의 컴퓨터에서 잠깐의 시간을 빌리는 것입니다. 이 프레이밍은 많은 것을 설명합니다: 지연은 모델이 자신의 끝에서 한 번에 하나의 토큰을 생성하기 때문이고, 비용은 그들이 그 계산을 청구하기 때문이며, 전체는 네트워크 호출이며 그것이 의미하는 모든 것입니다.

예제는 공식 OpenAI 라이브러리를 사용합니다. 다른 공급자는 세부 사항에서 다르지만, 형태는 거의 모든 곳에서 동일합니다: 메시지 목록을 보내고, 메시지를 받습니다. 그 동일성은 같은 코드가 호스팅하거나 다른 곳에서 임차하는 오픈 모델을 가리킬 수 있다는 보험이며, 종종 기본 URL의 변경만으로 가능합니다 (개방형 및 폐쇄형 모델이 그 선택을 다룹니다).

라이브러리 함수를 호출하면, 몇 초가 지나고, 텍스트가 돌아옵니다. 라이브러리가 로컬인 것처럼 느끼게 하지만, 모델에 대해 머신에 있는 것은 아무것도 없습니다.

이해할 동작은 그 함수가 무엇을 감싸고 있는지입니다. 코드가 요청을 JSON으로 패킹하고, 공급자에게 HTTPS 연결을 열고, 그들의 하드웨어가 예측 루프를 실행하고 토큰을 다시 스트리밍하는 동안 기다립니다. SDK(공급자의 소프트웨어 라이브러리, openai 같은)는 하나의 POST 요청에 대한 얇은 편의 계층입니다.

와이어 형식이 아래에 있는 것이 순수 JSON이라는 것을 아는 것은 실제로 보상을 줍니다. 호출이 이상하게 동작할 때, 정확한 요청과 응답을 검사할 수 있습니다. SDK가 아직 노출하지 않은 기능을 원할 때, 필드를 직접 보낼 수 있습니다.

전체가 네트워크 호출이라는 것이 이 장의 나머지 부분에 대한 비용, 지연, 오류 처리를 주도합니다. 예제는 OpenAI를 사용하지만, 요청 형태(메시지 목록 입력, 메시지 출력)는 공급자 전체에 걸쳐 거의 동일합니다.

모델 호출은 함수 호출처럼 보이고 불완전한 인프라에 대한 원격 프로시저처럼 동작합니다. 그 불일치는 프로덕션 문제가 나오는 곳이므로, 처음부터 실제 그림을 유지할 가치가 있습니다.

SDK 아래에서, 모든 호출은 하나의 HTTPS POST입니다: JSON 본문이 올라가고, 공급자가 자신의 하드웨어에서 추론을 실행하며, 토큰이 돌아오며, 선택적으로 열린 연결을 통해 스트리밍됩니다. 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": "당신은 간결한 어시스턴트입니다. 한 문장으로 답하세요."},
        {"role": "user", "content": "하늘은 왜 파란가요?"},
    ],
)

두 가지가 필요합니다: model, 실행하려는 모델의 이름, 그리고 messages, 지난 장의 역할 태그가 지정된 메시지 목록. 나머지는 모두 기본값입니다.

모델 이름이 단일 상수에 있음을 주목하세요. 더 나은, 더 저렴한 모델이 몇 개월마다 나오는 분야에서는 의도적입니다. **모델 이름**을 한 곳에 유지하여 모델 교환이 한 줄 변경인지 확인하세요. 이것은 실제 변동성 규칙입니다: 변동성 있는 사양을 찾을 수 있는 곳에 유지하고, 코드 전체에 분산시키지 마세요.

Juno요청 호출에는 modelmessages만 필요합니다. 모델 이름은 단일 상수에 속합니다. 몇 개월마다 더 저렴하거나 더 나은 것이 나타나기 때문에, 파일 전체에 하드코딩하면 교환이 사냥이 됩니다. 저는 모델이 이름이 바뀌었는데 9개 파일에 붙여넣은 후 느리게 배웠습니다.

두 필드가 필요합니다, 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": "당신은 간결한 어시스턴트입니다."},
            {"role": "user", "content": "하늘은 왜 파란가요?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # SDK가 객체로 파싱하는 것과 동일한 형태

실제 코드에서 SDK를 가져올 것입니다: 인증, 재시도, 타이핑을 처리합니다. 하지만 본문이 순수 dict라는 것을 보면 나머지는 구체적입니다. URL과 필드 이름은 OpenAI의 것이고, 정확한 엔드포인트와 키는 공급자마다 다르지만, 동작(JSON 입력, JSON 출력)은 모든 곳에서 동일합니다.

Juno요청 필수 필드는 modelmessages이며, 다른 것은 없습니다. SDK create 호출은 아래에 하나의 JSON POST이므로, 뭔가 잘못되었을 때 와이어로 떨어져서 리터럴 본문을 검사할 수 있습니다. 모델 이름을 상수에 유지하세요: 변동성이 있는 부분이고, 한 곳에 원합니다.

필수 필드는 modelmessages입니다. 흥미로운 질문은 그들이 무엇인지가 아니라 코드에서 어디에 있는지입니다, model이 요청에서 가장 변동성이 있는 값이고 SDK가 그 주변에서 가장 변동성이 있는 의존성이기 때문입니다.

create 호출은 JSON POST로 직렬화됩니다. 그것이 중요한 이유는 요청이 SDK가 수행하는 마법이 아니라 당신이 소유한 데이터이기 때문입니다. 본문을 빌드하고, 로그하고, 실행 전체에서 diff하거나, 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)

그 자리에서, 기능당, 사용자당 비용은 추측이 아니라 쿼리가 되고, 조용히 크기가 3배로 늘어난 달아나는 프롬프트는 놀라운 청구 대신 메트릭으로 나타납니다. 비대칭성을 보세요: 입력 토큰은 저렴하고 병렬로 처리할 수 있고, 출력 토큰은 비싼, 직렬 부분이며 LLM 작동 방식에서 다룹니다, 그래서 긴 회신을 방출하는 기능은 프롬프트 크기가 시사하는 것보다 더 많이 비싸고 지연됩니다. 속성 이름은 OpenAI의 것이고 공급자마다 이동하므로, 앱 전체에 response.usage.prompt_tokens을 분산시키지 말고 자신의 경계 뒤에서 수를 로그하세요.

Juno응답 읽기"stop"이 아닌 모든 finish_reason을 실패한 호출로 취급하세요: "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" 절단을 트리거하므로, 여유를 남겨두세요. 추출과 분류를 위해, 매번 낮은 온도를 찾으세요.

두 매개변수는 대부분의 무게를 전달합니다: 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에서도 완벽하게 결정론적이지 않으므로, 정확한 일치 캐싱이나 바이트 동일 테스트를 위에 구축하지 마세요, 하지만 구조화된 작업을 위해 원하는 바닥입니다. 다양성이 제품인 표면에 더 높은 온도를 보류하고, 그 분산을 평가가 여러 실행 전체에서 측정하는 것으로 취급하세요, 한 번의 눈 검사가 아닙니다.

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" 절단은 스트리밍할 때 사라지지 않습니다, 나중에만 주목합니다, 그리고 어셈블된 텍스트를 신뢰하기 전에 확인합니다.
  • 스트림은 중간에 끊길 수 있습니다: 연결이 40의 10 토큰 후에 떨어지므로, 반쪽짜리 회신이 남으므로, 스트리밍된 호출은 자신의 반-실패 처리가 필요하고 깨끗하게 재시도하기 어렵습니다.
  • 스트리밍된 응답은 보통 usage를 생략합니다, 포함할 플래그를 전달하거나 비용 추적을 위해 어셈블된 텍스트를 직접 토큰화하세요.

SSE 프레이밍과 청크 스키마는 공급자 특정이므로, 나머지와 동일한 경계 뒤에 스트림 파싱을 유지하고, 호출자가 어셈블된 결과를 보게 하고, 원본 청크는 보지 마세요.

Juno스트리밍 스트리밍은 첫 번째 토큰까지의 시간을 사고 깨끗한 응답 객체 비용을 들어, 사람이 보고 있고 백엔드 파싱을 건너뛸 곳에서 사용하세요. 최종 청크는 여전히 `finish_reason`을 전달하므로, `"length"` 절단은 중간-스트림에서 실제이며, 텍스트를 신뢰하기 전에 확인하세요. 그리고 스트림은 40의 10 토큰 후에 죽을 수 있으므로, 반쪽짜리 회신을 처리하고 공급자 특정 청크 파싱을 경계 뒤에 유지하세요.

모든 호출은 독립적입니다

이것이 장에서 가장 중요한 동작이며, 컨텍스트 윈도우 수업에서 직접 따릅니다. API는 **상태 비저장**입니다: 각 요청은 완전히 독립적입니다. 공급자의 서버는 이전 호출을 기억하지 않습니다. 정확히 보낸 메시지에 대해 예측을 실행하고, 결과를 반환하며, 다음 시간을 위해 교환에 대해 아무것도 유지하지 않습니다.

그래서 대화는 어디에든 모델이나 API에 저장되지 않습니다. 코드에 살아 있습니다. 2번 차례가 1번 차례에서 무슨 일이 일어났는지 알기를 원하면, 메시지의 실행 목록을 직접 유지하고 매번 전체를 다시 보냅니다. 주고받기 채팅은 매 호출마다 계속 커지는 기록을 다시 보내는 것으로 생성하는 환상입니다.

그것도 왜 긴 대화가 시간이 지나면서 메시지당 더 많은 비용을 드는지입니다: 각 호출은 모든 이전 차례를 입력 토큰으로 다시 보내므로, 프롬프트는 사용자의 새 질문이 짧을 때도 계속 커집니다.

python
history = [
    {"role": "system", "content": "당신은 친근한 여행 어시스턴트입니다. 답변을 짧게 유지하세요."},
]

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("저는 리스본에서 주말이 있습니다. 뭘 봐야 하나요?")
chat("그 중 아이들에게 가장 좋은 것은?")  # 기록이 1번 차례를 전달하기 때문에만 작동합니다

두 번째 질문은 첫 번째 질문과 그 답변이 여전히 history에 있고 함께 보내지기 때문에만 의미가 있습니다. 당신은 메모리입니다. 이 한 가지 아이디어, 매 독립 호출 이전에 전체 컨텍스트를 어셈블하고 다시 보낸다는 것이 대화의 기본이며, 나중에 이것은 도구 사용, 검색, 에이전트의 기본이기도 합니다. 그들은 모두 각 독립 호출 이전에 메시지 목록에 무엇을 들어갈지 결정하는 변형입니다.

Juno모든 호출은 독립적입니다 API는 상태 비저장입니다: 각 요청은 정확히 보낸 메시지에서 실행되고, 서버는 회신 후 교환을 잊습니다. 대화는 코드에 살아 있고, 연속성을 속이려고 모든 기록을 매번 다시 보내므로, 긴 채팅은 새 질문이 짧을 때도 메시지당 더 많이 비싸집니다.

API는 **상태 비저장**입니다: 서버는 정확히 보낸 메시지에서 실행되고 이후 아무것도 유지하지 않습니다. 대화는 코드에 살아 있고, 매 차례마다 전체 기록을 다시 보내 연속성을 가짜로 만들어서, 긴 채팅이 새 질문이 짧을 때도 메시지당 더 많이 비싸집니다.

그 성장은 무시할 수 없는 문제입니다, 왜냐하면 기록은 결국 컨텍스트 윈도우를 초과하기 때문입니다. 첫 번째 동작은 슬라이딩 윈도우입니다: 시스템 메시지 더하기 가장 최근 차례를 유지합니다.

python
def trim(history, keep_recent=10):
    system = history[:1]               # 항상 시스템 메시지를 유지하세요
    recent = history[1:][-keep_recent:]  # 최신 차례만
    return system + recent

이는 저렴하고 비용을 제한하지만, 조용히 이전 차례를 떨어뜨리므로, 모델은 사용자가 여전히 알기를 기대하는 대화 초반의 사실을 잊습니다. 그것이 중요할 때, 다음 스텝은 떨어진 차례를 완전히 삭제하는 대신 한 짧은 메시지로 요약하는 것입니다. 어떤 것을 선택하는지는 기능에 따라 다릅니다: 원래 문제를 참조하는 지원 봇은 요약이 필요하고, 빠른 Q와 A는 윈도우에 괜찮습니다. 상태 비저장성 자체는 공급자 전체에서 보편적입니다; 그 주변의 메시지 형식만 다릅니다.

Juno모든 호출은 독립적입니다 상태 비저장은 서버가 매 회신 후 잊기 때문에, 전체 기록을 다시 보내고 커질 때 비용을 지불합니다. 최근 차례와 시스템 메시지의 슬라이딩 윈도우로 비용을 제한하세요, 하지만 조용히 사용자가 여전히 기대하는 초반의 사실을 떨어뜨립니다. 그것이 물릴 때, 삭제하는 대신 떨어진 차례를 요약하세요.

API는 **상태 비저장**입니다: 당신은 대화를 소유하고 매 호출마다 다시 보냅니다. 순진한 버전, 계속 추가하고 로트를 보냅니다, 규모 시 두 가지 방법으로 실패합니다: 결국 컨텍스트 윈도우를 초과하고, 입력이 제한 없이 커져서 매 차례를 더 비싸고 느리게 만듭니다.

그래서 기록 관리는 세 가지 표준 전략을 가진 예산 문제입니다, 각각 자신의 방식으로 실패합니다:

  • 슬라이딩 윈도우는 가장 최근의 N 차례를 유지합니다: 저렴하고 제한되지만, 사용자가 여전히 참조하는 초반 사실을 조용히 떨어뜨립니다.
  • 요약은 오래된 차례를 실행 요약으로 압축합니다: 토큰당 더 많은 기록을 유지하지만, 세부 사항을 잃고 이후 매 차례에 자신의 오류를 구워질 수 있습니다.
  • 검색은 외부에 차례를 저장하고 관련된 것만 끌어옵니다: 긴 기록으로 규모를 조정하지만, 인덱스가 올바른 조각을 반환하는 데 달려 있고 조회를 추가합니다.

사용 사례별로 선택하고 계측하세요, 왜냐하면 각각은 결국 잘못된 컨텍스트를 표면할 것입니다.

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모든 호출은 독립적입니다 상태 비저장은 기록 관리가 당신의 예산 문제라는 뜻이고, "계속 추가"는 윈도우를 초과하고 매 차례의 비용을 부풀립니다. 사용 사례별로 전략을 선택하세요: 슬라이딩 윈도우는 저렴하지만 초반 사실을 떨어뜨리고, 요약은 더 많이 유지하지만 자신의 오류를 구우며, 검색은 규모를 조정하지만 인덱스를 의존합니다. 차례 수가 아니라 토큰 수로 예산을 정하세요, 한 붙인 문서가 차례-수 윈도우를 즉시 폭발시킵니다.

뭔가 잘못되었을 때

모델 호출은 다른 누군가의 서버로의 네트워크 호출이므로, 네트워크 호출이 실패하는 모든 방법으로 실패합니다. 처음부터 이를 위해 설계하세요.

  • 오류와 속도 제한. 공급자는 윈도우에 보낼 수 있는 요청 수를 제한합니다. 상한선을 치면 호출이 속도 제한 오류로 실패합니다. 표준 응답은 기다리고 재시도하는 것이고, 각 실패 후 대기를 길게 합니다 (이것을 **백오프**라고 부릅니다) 그래서 바쁜 서비스를 망치지 않습니다.
  • 타임아웃. 호출이 매달릴 수 있습니다. 타임아웃을 설정하여 하나의 느린 요청이 앱을 동결하지 않도록 하세요.
  • 키는 서버에 남아 있습니다. 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 "죄송합니다, 뭔가 잘못되었습니다. 다시 시도하세요."
Juno뭔가 잘못되었을 때 모델 호출은 다른 누군가의 서버로의 네트워크 호출이므로, 속도 제한, 타임아웃, 평문 실패를 기대하고, 재시도-백오프로 모든 호출을 감싸세요. API 키를 서버에 유지하세요, 절대 브라우저에서, 실제 돈을 쓰고 개발자 도구가 읽기 때문입니다. 성공한 회신도 잘못될 수 있다고 취급하세요, 다음 장이 처리하는 것을 도움니다.

모델 호출은 네트워크 호출이 실패하는 방식으로 실패합니다: 속도 제한, 타임아웃, 일시적 서버 오류. 동작은 특정 실패를 캐치하고 재시도할 가치가 있는 것을 적용하는 것입니다, 모든 것을 감싸는 것이 아니라 실제 버그를 숨기는 것입니다.

타입된 예외를 캐치하고 **백오프**를 적용하세요, 바쁜 서비스를 멈출 필요가 없을 때마다 더 길게 기다리세요:

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          # 1s, 2s, 4s: 지수 백오프
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

예외 타입은 무엇을 할지 말합니다: RateLimitErrorAPITimeoutError는 일시적이고 재시도할 가치가 있는 반면, 나쁜 요청 (잘못된 메시지, 미지의 모델)은 버그이고 조용히 재시도하지 말고 크게 실패해야 합니다. 각 시도 후 대기를 두 배로 하면 이미 어려움을 겪는 서비스에 파일 처리하지 않습니다. SDK는 일부 실패를 당신을 위해 재시도하므로, 자신의 계층을 추가하기 전에 기본값을 확인하세요.

키는 서버측을 유지하고, 성공한 회신을 잘못될 수 있다고 취급하세요, 다음 장이 다룹니다. 예외 클래스 이름은 OpenAI의 것입니다; 다른 SDK는 자신의 것을 발생시키므로, 경계에서 맵핑하세요.

Juno뭔가 잘못되었을 때 타입된 예외를 캐치하세요, 벌거벗은 except 아닙니다: RateLimitErrorAPITimeoutError는 일시적이고 재시도할 가치가 있으면, 나쁜 요청은 버그이고 크게 실패해야 합니다. 지수적으로 백오프하세요, 대기를 두 배로 하여 이미 어려움을 겪는 서비스를 멈춥니다. 자신의 계층을 추가하기 전에 SDK가 이미 재시도하는지 확인하고, 키를 서버측 유지하세요.

실패 처리는 함수 호출을 멈추고 분산 시스템 작업이 됩니다. 호출은 알려진 방법으로 실패하고, 속도 제한, 타임아웃, 일시적 5xx 오류, 그리고 안전하지 않은 부분은 재시도가 아니라 재시도가 당신에게 비용을 얼마나 드는지 생각하지 않고 재시도합니다.

함정은 멱등성 (작업을 두 번 실행하는 것이 한 번 실행하는 것과 동일한 효과를 가지는지)입니다. 타임아웃 후 순진한 재시도는 두 배 청구를 할 수 있습니다: 첫 번째 요청은 공급자 쪽에서 완료되고 토큰을 생성했을 수 있고, 응답이 당신에게 도달하지 않았으므로 재시도는 두 번째 전체 생성을 지불합니다. 읽기 스타일 호출의 경우 낭비된 돈이고, 어떤 부작용이 있는 것은 중복된 작업입니다. 공급자가 멱등성 키를 지원하는 경우, 하나를 보내서 재시도가 동일한 요청으로 인식됩니다; 그렇지 않으면, 주변 작업이 반복하기에 안전하도록 합니다.

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())  # 백오프 + 지터

세 가지 더 많은 프로덕션 현실:

  • 지터, 각 대기에 추가되는 무작위 분수는 클라이언트 플릿이 동시에 재시도하는 것을 멈추고 속도 제한을 다시 스파이킹합니다 (천둥 떼 문제).
  • 연결 재사용은 부하에서 중요합니다: 하나의 클라이언트를 만들고 공유하여 연결이 풀됩니다 호출마다 신선한 TLS 악수를 지불하는 대신, 그리고 느린 직렬 루프보다 독립적 호출을 동시에 실행하세요.
  • 재시도하기 전에 분류합니다: RateLimitError는 일시적이고, 잘못된 요청 오류는 버그이며 재시도하는 것이 절대 성공하지 않는 호출에 시간과 돈을 낭비합니다.

예외 클래스와 멱등성 메커니즘은 공급자 특정이므로, 이 전체 정책은 경계 함수 뒤에 속하고, 각 공급자의 오류를 재시도 결정에 한 번 맵핑합니다. 키는 서버측을 유지하고, 성공한 회신은 검증될 때까지 여전히 신뢰되지 않습니다, 구조화된 출력안전 장이 처리합니다.

Juno뭔가 잘못되었을 때 재시도는 위험한 부분입니다: 타임아웃 후 순진한 재시도는 두 배 청구를 할 수 있으므로, 첫 번째 호출은 절대 받지 못한 토큰을 생성했으므로, 멱등성 키를 사용하거나 작업이 반복하기에 안전하도록 합니다. 플릿이 동시에 재시도하고 속도 제한을 다시 스파이킹하지 않도록 지터를 백오프에 추가하고, 풀된 하나의 클라이언트를 재사용하고, 느린 직렬 루프보다 독립적 호출을 동시에 실행하세요. 분류 먼저, 일시적 오류를 재시도하고 버그를 크게 실패하도록 하고, 공급자 특이를 매핑하는 각 정책을 재시도 결정에 매핑하는 한 경계 뒤에 전체 정책을 유지하세요.