Skip to main content
Summary

이제 모델을 직접 학습시키지 않고도 만들 수 있다

앞에서 우리는 작은 GPT를 직접 만들었다. 문자 단위 Bigram 모델에서 시작해 Self-Attention, Multi-Head Attention, Transformer Block을 차례로 쌓았고, 마침내 Mini GPT가 글자를 하나씩 이어 쓰는 장면까지 확인했다. 그 과정은 조금 고되었지만 아주 중요한 감각을 남겼다. LLM은 마법 상자가 아니라, 입력 문맥을 보고 다음 토큰의 확률을 계산하는 모델이라는 감각이다.

이제 방향을 바꿔 보자. 실제 제품을 만들 때마다 거대한 모델을 처음부터 학습시킬 수는 없다. GPU 비용, 데이터 수집, 학습 안정화, 평가, 안전성 검토까지 생각하면 대부분의 팀에게는 현실적인 선택이 아니다. 대신 우리는 이미 학습된 상용 LLM을 API로 호출해 제품 기능에 붙일 수 있다.

LLM API를 사용한다는 것은 이런 뜻이다. 내 애플리케이션이 사용자 입력을 받고, 그 입력을 API 요청으로 포장해 모델 서버에 보낸다. 모델 서버는 다음 토큰 예측을 반복해 답변을 만들고, 완성된 텍스트를 다시 내 애플리케이션에 돌려준다. 사용자는 채팅창에서 답을 보는 것처럼 느끼지만, 개발자 입장에서는 HTTP 요청과 JSON 응답을 주고받는 일이다.

OpenAI의 텍스트 생성 문서도 프롬프트나 메시지를 입력으로 보내 텍스트를 생성하고, 메시지 타입과 JSON 같은 출력 형식을 함께 다룬다1. 구현 방식은 서비스마다 조금씩 다르지만 핵심 구조는 비슷하다. 모델명을 고르고, 입력 메시지를 보내고, 생성 옵션을 설정하고, 응답 객체에서 생성된 텍스트를 꺼낸다.

이 장의 목표는 거창하지 않다. 상용 LLM API를 호출해 텍스트 생성, 요약, 대화 기능을 구현하는 것이다. 다만 우리는 앞에서 원리를 배웠기 때문에, 단순히 코드를 복사하지 않는다. API 호출 뒤에서 어떤 일이 벌어지는지 계속 연결해 볼 것이다.

LLM API 시작하기

API 호출은 원격 추론이다

LLM API를 처음 보면 '질문을 보내면 답이 온다'는 단순한 구조처럼 보인다. 하지만 내부에서는 우리가 Mini GPT에서 보았던 과정이 훨씬 큰 규모로 일어난다. 입력 문장이 토큰으로 쪼개지고, 토큰 ID가 임베딩으로 바뀌고, Transformer 층을 지나 다음 토큰의 확률 분포가 계산된다. 그중 하나의 토큰이 선택되고, 새로 선택된 토큰이 다시 문맥 뒤에 붙는다. 이 과정을 멈출 때까지 반복하면 답변이 된다.

Hugging Face의 텍스트 생성 문서도 LLM이 초기 프롬프트와 이미 생성된 출력 토큰을 조건으로 다음 토큰을 생성하며, 미리 정한 길이에 도달하거나 EOS, 즉 문장 종료 토큰에 도달하면 생성을 멈춘다고 설명한다2. 우리가 API에서 max_tokens, temperature, stop 같은 옵션을 지정하는 이유도 바로 여기에 있다. 이 옵션들은 모델의 지식을 바꾸는 스위치가 아니라, 다음 토큰을 얼마나 길게, 얼마나 다양하게, 어디에서 멈추게 할지 조절하는 손잡이다.

비유하자면 API는 주방을 빌리는 일과 비슷하다. 우리는 직접 밭을 갈고 밀을 재배하고 오븐을 만들지 않는다. 대신 이미 준비된 주방에 주문서를 보낸다. 주문서가 프롬프트이고, 어떤 요리사가 요리할지 정하는 것이 모델명이며, 맵기와 양을 정하는 것이 생성 옵션이다. 결과물은 접시에 담긴 답변이다.

중요한 점은, API를 쓴다고 해서 원리가 사라지는 것이 아니라는 사실이다. 오히려 원리를 알수록 API를 더 잘 쓴다. 출력이 너무 짧으면 max_tokens를 확인하고, 답이 매번 달라지면 temperature를 조절하며, 긴 대화에서 앞 내용을 잊는 것처럼 보이면 컨텍스트 윈도우와 메시지 길이를 의심할 수 있다.

준비: SDK 설치와 API 키 관리

이 장에서는 Python을 기준으로 설명한다. 다른 언어를 쓰더라도 구조는 거의 같다. JavaScript, Go, Java에서도 결국 모델명, 메시지, 옵션을 담은 요청을 보내고 응답을 파싱한다.

먼저 새 프로젝트 폴더를 만들고 가상 환경을 준비하자.

mkdir llm-api-start
cd llm-api-start
python -m venv .venv

macOS나 Linux라면 다음 명령으로 가상 환경을 활성화한다.

source .venv/bin/activate

Windows PowerShell에서는 다음과 같이 실행한다.

.venvScriptsActivate.ps1

이제 필요한 패키지를 설치한다.

pip install openai python-dotenv pandas

openai는 OpenAI API를 편하게 호출하기 위한 Python SDK다. python-dotenv는 .env 파일에 저장한 환경 변수를 읽을 때 사용한다. pandas는 여러 문서나 질문을 표 형태로 처리할 때 사용할 것이다.

API 키는 절대 코드에 직접 적지 않는 것이 기본이다. 다음처럼 프로젝트 루트에 .env 파일을 만든다.

OPENAI_API_KEY=여기에_발급받은_API_키를_넣으세요

그리고 .gitignore 파일에 .env를 추가한다.

.env
.venv/
__pycache__/

이 한 줄을 빼먹으면 API 키가 GitHub에 올라갈 수 있다. API 키는 집 열쇠와 같다. 앱을 실행하려고 잠깐 코드에 적어 두는 순간 편하긴 하지만, 그 코드가 공유 저장소에 올라가면 누군가 내 비용으로 API를 호출할 수 있다. 실무에서는 키를 환경 변수, 시크릿 매니저, 클라우드 런타임 설정에 보관한다. 프론트엔드 JavaScript 코드에 직접 넣는 것은 피해야 한다. 브라우저에 내려간 키는 사용자가 개발자 도구에서 볼 수 있기 때문이다.

API 키가 제대로 읽히는지 간단히 확인해 보자.

# check_env.py
import os
from dotenv import load_dotenv

load_dotenv()

api_key = os.getenv('OPENAI_API_KEY')

if not api_key:
    raise RuntimeError('OPENAI_API_KEY가 설정되어 있지 않습니다.')

print('API 키가 설정되어 있습니다.')
print(api_key[:8] + '...')

실행한다.

python check_env.py

앞부분 몇 글자만 출력되면 성공이다. 전체 키를 출력하는 습관은 들이지 않는 편이 좋다.

첫 번째 텍스트 생성 요청

이제 첫 API 호출을 만들어 보자. basic_generation.py 파일을 만든다.

# basic_generation.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI()

response = client.chat.completions.create(
    model='gpt-4o-mini',
    messages=[
        {
            'role': 'system',
            'content': '너는 친절한 한국어 AI 튜터다. 어려운 개념을 쉬운 비유로 설명한다.'
        },
        {
            'role': 'user',
            'content': 'LLM API가 무엇인지 초등학생도 이해할 수 있게 한 문단으로 설명해 줘.'
        }
    ],
    max_tokens=300,
    temperature=0.7,
)

answer = response.choices[0].message.content
print(answer)

실행한다.

python basic_generation.py

여기서 중요한 부분은 네 가지다. client = OpenAI()는 API를 호출할 클라이언트를 만든다. SDK는 기본적으로 OPENAI_API_KEY 환경 변수를 찾아 인증에 사용한다. model은 어떤 모델을 사용할지 정한다. messages는 모델에게 전달할 대화 내용이다. max_tokens는 출력으로 생성할 토큰 수의 상한이고, temperature는 다음 토큰을 고를 때 얼마나 다양하게 샘플링할지 조절한다.

응답 객체에서 실제 답변은 보통 response.choices[0].message.content에 들어 있다. choices라는 이름이 낯설 수 있지만, 모델이 여러 후보 답변을 만들 수 있는 구조를 생각하면 이해하기 쉽다. 여기서는 후보를 하나만 요청했으므로 첫 번째 항목을 꺼낸다.

일부 최신 모델이나 엔드포인트에서는 출력 토큰 제한 파라미터 이름이 max_tokens, max_completion_tokens, max_output_tokens처럼 다를 수 있다. 이 책의 코드는 메시지 기반 호출 흐름을 이해하기 위한 예제다. 실제 프로젝트에서는 사용 중인 SDK와 모델의 최신 문서를 확인해 파라미터 이름을 맞추면 된다. 개념적으로는 모두 '최대 출력 길이'를 정한다.

messages는 모델에게 건네는 대화 기록이다

messages는 단순한 문자열 하나보다 더 구조적이다. 보통 다음 세 역할을 사용한다.

역할의미예
system모델의 역할, 말투, 제약을 지정한다너는 친절한 한국어 튜터다
user사용자의 질문이나 작업 지시를 담는다이 문서를 세 문장으로 요약해 줘
assistant이전 모델 답변을 대화 기록으로 포함한다물론입니다. 요약하면...

system 메시지는 무대의 규칙을 정한다. 모델이 어떤 역할을 해야 하는지, 어떤 톤으로 말해야 하는지, 어떤 제약을 지켜야 하는지를 넣는다. user 메시지는 실제 요청이다. assistant 메시지는 이전 답변을 다시 보내 대화 흐름을 이어 가는 데 사용한다.

예를 들어 사용자가 이렇게 묻는다고 해 보자.

내 이름은 지민이야.

모델이 이렇게 답했다.

반가워요, 지민님. 무엇을 도와드릴까요?

다음 사용자가 묻는다.

내 이름이 뭐라고 했지?

모델이 이름을 기억하려면 이전 대화가 요청에 포함되어야 한다. LLM API는 보통 서버 쪽에서 모든 대화를 자동으로 영원히 기억하지 않는다. 매 요청마다 필요한 대화 기록을 messages에 담아 보내야 한다.

다음은 아주 작은 터미널 챗봇이다.

# terminal_chat.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI()

messages = [
    {
        'role': 'system',
        'content': '너는 간결하고 친절한 한국어 도우미다. 답변은 가능하면 5문장 이내로 한다.'
    }
]

while True:
    user_input = input('You: ')

    if user_input.lower() in ['quit', 'exit', 'q']:
        print('Assistant: 다음에 또 만나요!')
        break

    messages.append({'role': 'user', 'content': user_input})

    response = client.chat.completions.create(
        model='gpt-4o-mini',
        messages=messages,
        max_tokens=500,
        temperature=0.7,
    )

    assistant_reply = response.choices[0].message.content
    print('Assistant:', assistant_reply)

    messages.append({'role': 'assistant', 'content': assistant_reply})

이 코드는 대화를 할 때마다 messages 리스트에 사용자의 말과 모델의 답변을 추가한다. 그러면 다음 요청에서 이전 맥락을 함께 보낼 수 있다.

하지만 조심할 점이 있다. 대화 기록이 길어질수록 입력 토큰 수가 늘어난다. 비용도 늘어나고, 컨텍스트 윈도우도 차지한다. 실무에서는 모든 대화를 무한정 붙이지 않는다. 최근 N개의 메시지만 유지하거나, 오래된 대화는 요약해서 별도로 저장하거나, 중요한 정보만 구조화해 보관한다.

간단한 제한을 넣으면 다음과 같다.

def trim_messages(messages, max_history_messages=12):
    system_messages = [m for m in messages if m['role'] == 'system']
    non_system_messages = [m for m in messages if m['role'] != 'system']
    return system_messages + non_system_messages[-max_history_messages:]

API 호출 직전에 messages = trim_messages(messages)처럼 사용하면 최근 대화만 유지할 수 있다.

요약기 만들기

LLM API로 가장 먼저 만들기 좋은 기능은 요약기다. 요약은 입력 문서를 읽고 핵심을 짧게 정리하는 작업이다. 블로그 글, 회의록, 고객 문의, 리서치 메모, 긴 이메일에 바로 적용할 수 있다.

다음 함수를 보자.

# summarizer.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI()

SYSTEM_PROMPT = '''
너는 정확하고 간결한 요약 전문가다.
원문에 없는 사실을 추가하지 않는다.
중요한 수치, 일정, 이름이 있으면 보존한다.
답변은 한국어로 작성한다.
'''.strip()


def summarize(text, max_sentences=3):
    user_prompt = f'''
다음 글을 {max_sentences}문장으로 요약해 줘.

원문:
{text}
'''.strip()

    response = client.chat.completions.create(
        model='gpt-4o-mini',
        messages=[
            {'role': 'system', 'content': SYSTEM_PROMPT},
            {'role': 'user', 'content': user_prompt},
        ],
        max_tokens=400,
        temperature=0.2,
    )

    return response.choices[0].message.content


if __name__ == '__main__':
    text = '''
    오늘 제품 회의에서는 온보딩 화면 개선안이 논의되었다. 신규 사용자가 첫 화면에서
    무엇을 해야 하는지 명확히 알지 못한다는 피드백이 많았고, 이에 따라 첫 3단계 안내를
    추가하기로 했다. 디자인 시안은 금요일까지 공유하고, 다음 주 화요일에 A/B 테스트
    설정을 마무리하기로 했다. 고객지원팀은 최근 문의 중 27%가 초기 설정과 관련되어
    있다고 보고했다.
    '''

    print(summarize(text, max_sentences=3))

요약에서는 temperature를 낮게 두는 편이 좋다. 창의적인 표현보다 원문 충실성이 중요하기 때문이다. system 메시지에는 '원문에 없는 사실을 추가하지 않는다'는 제약을 넣었다. 이것이 환각을 완전히 막지는 못하지만, 모델의 답변 방향을 정하는 데 도움이 된다.

프롬프트를 함수로 감싸면 같은 호출을 여러 곳에서 재사용할 수 있다. 서비스 코드에서 매번 긴 API 호출 코드를 쓰는 대신 summarize(text)처럼 의미 있는 함수로 감싸 두면 유지보수가 쉬워진다.

생성 옵션 실험하기

이제 API 옵션이 출력에 어떤 영향을 주는지 실험해 보자. 앞에서 배운 원리를 떠올리면 이해가 쉽다. 모델은 매 단계마다 다음 토큰의 확률 분포를 계산한다. 생성 옵션은 이 확률 분포에서 어떤 토큰을 고를지, 언제 멈출지에 관여한다.

max_tokens는 답변 길이의 상한이다. 너무 작으면 답변이 중간에서 끊길 수 있다. 너무 크게 잡으면 비용이 늘고, 필요 이상으로 장황한 답변이 나올 수 있다.

temperature는 다양성의 정도다. 낮으면 높은 확률의 토큰을 더 자주 고르므로 답변이 안정적이고 예측 가능해진다. 높으면 확률이 낮은 후보도 선택될 가능성이 커져 더 다양한 표현이 나온다. 사실 요약, 정보 추출, 코드 생성처럼 정확성이 중요한 작업은 낮은 값을 자주 쓴다. 브레인스토밍, 카피 문구, 이야기 생성처럼 다양성이 중요한 작업은 조금 높일 수 있다.

stop은 특정 문자열이 나오면 생성을 멈추게 하는 종료 조건이다. 예를 들어 모델이 ---를 출력하면 거기서 멈추도록 할 수 있다.

간단히 비교해 보자.

# temperature_test.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI()

prompt = '비 오는 월요일 아침을 주제로 짧은 카페 홍보 문구를 하나 써 줘.'

for temperature in [0.1, 0.7, 1.2]:
    response = client.chat.completions.create(
        model='gpt-4o-mini',
        messages=[{'role': 'user', 'content': prompt}],
        max_tokens=120,
        temperature=temperature,
    )

    print('n--- temperature =', temperature, '---')
    print(response.choices[0].message.content)

실행해 보면 낮은 temperature에서는 비교적 평범하고 안정적인 문장이 나오고, 높은 temperature에서는 표현이 더 튀거나 예상 밖의 단어가 섞일 수 있다. 어느 쪽이 좋다고 단정할 수는 없다. 작업에 맞는 값이 있을 뿐이다.

실무에서는 다음 기준으로 시작하면 무난하다.

작업추천 경향
요약, 분류, 정보 추출낮은 temperature
일반 질의응답, 설명중간 temperature
광고 문구, 아이디어 발산, 창작중간 이상 temperature

다만 temperature를 높인다고 모델이 더 똑똑해지는 것은 아니다. 더 과감하게 고를 뿐이다. 모르는 것을 자신 있게 꾸며 낼 가능성도 함께 커질 수 있다.

고정 프롬프트와 변수 프롬프트

제품 기능을 만들다 보면 프롬프트에는 고정된 부분과 매번 바뀌는 부분이 있다. 예를 들어 '문서를 요약하라'는 지시는 고정되어 있고, 실제 문서 내용은 매번 바뀐다. 이 둘을 분리하면 코드가 깔끔해진다.

# llm_utils.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI()

DEFAULT_MODEL = 'gpt-4o-mini'


def llm_chat(system_prompt, user_prompt, model=DEFAULT_MODEL, max_tokens=500, temperature=0.3):
    response = client.chat.completions.create(
        model=model,
        messages=[
            {'role': 'system', 'content': system_prompt},
            {'role': 'user', 'content': user_prompt},
        ],
        max_tokens=max_tokens,
        temperature=temperature,
    )
    return response.choices[0].message.content


def summarize_document(text):
    system_prompt = '너는 비즈니스 문서를 정확하게 요약하는 전문가다. 추측하지 말고 원문에 근거해 답한다.'
    user_prompt = f'''
다음 문서를 요약해 줘.

요구사항:
- 핵심 내용을 3문장으로 정리한다.
- 일정, 수치, 담당자가 있으면 포함한다.
- 마지막에 다음 행동을 한 문장으로 제안한다.

문서:
{text}
'''.strip()
    return llm_chat(system_prompt, user_prompt, max_tokens=500, temperature=0.2)


def rewrite_tone(text, tone):
    system_prompt = '너는 문장을 자연스럽게 다듬는 한국어 편집자다.'
    user_prompt = f'''
다음 문장을 {tone} 말투로 바꿔 줘.
의미는 바꾸지 말고 표현만 조정해.

문장:
{text}
'''.strip()
    return llm_chat(system_prompt, user_prompt, max_tokens=300, temperature=0.5)

이 구조의 장점은 명확하다. API 호출 방식이 바뀌면 llm_chat만 수정하면 된다. 요약 프롬프트를 개선하고 싶으면 summarize_document만 손보면 된다. 같은 모델을 여러 기능에서 재사용할 수 있고, 나중에 로그 기록이나 재시도 로직을 넣기도 쉽다.

LLM을 데이터 처리 함수처럼 감싸고 DataFrame에 적용하는 패턴은 교육 자료와 실습에서도 자주 쓰인다. 예를 들어 고정 프롬프트를 함수화하고, 변수 입력을 바꿔 여러 행에 적용하는 방식은 Python과 pandas를 활용한 LLM 텍스트 생성 워크플로의 기본 형태로 소개된다3.

여러 문서를 반복 처리하기

실제 서비스에서는 문서 하나만 요약하지 않는다. 고객 문의 100개를 분류하거나, 리뷰 1,000개를 요약하거나, 상품 설명 수백 개를 다듬어야 할 수 있다. 이때는 pandas와 함께 처리 흐름을 만들면 편하다.

다음 예시는 여러 고객 문의를 요약하는 코드다.

# batch_summary.py
import time
import pandas as pd
from llm_utils import summarize_document

rows = [
    {
        'ticket_id': 1,
        'text': '결제는 완료됐는데 주문 내역에 상품이 보이지 않습니다. 영수증은 이메일로 받았습니다.'
    },
    {
        'ticket_id': 2,
        'text': '배송지가 잘못 입력되었습니다. 아직 출고 전이면 주소를 변경하고 싶습니다.'
    },
    {
        'ticket_id': 3,
        'text': '구독을 해지했는데 이번 달 요금이 다시 청구되었습니다. 환불이 가능한지 알고 싶습니다.'
    },
]

df = pd.DataFrame(rows)

summaries = []

for text in df['text']:
    summary = summarize_document(text)
    summaries.append(summary)
    time.sleep(0.3)  # 짧은 간격을 두어 과도한 연속 요청을 피한다.

df['summary'] = summaries
print(df[['ticket_id', 'summary']])

이 코드는 가장 단순한 순차 처리 방식이다. 데이터가 적을 때는 충분하다. 데이터가 많아지면 병렬 처리, 큐, 백그라운드 작업, 공급자의 Batch API, 재시도 정책 등을 검토해야 한다. 하지만 처음에는 순차 처리로 정확한 결과와 비용을 확인하는 편이 안전하다.

반복 처리에서 특히 중요한 것은 중간 결과 저장이다. 1,000개 문서를 처리하다가 700번째에서 네트워크 오류가 나면 처음부터 다시 돌리고 싶지 않을 것이다. 실무에서는 각 행의 처리 상태를 저장하고, 이미 처리된 항목은 건너뛰도록 만든다.

for idx, row in df.iterrows():
    if pd.notna(row.get('summary')):
        continue

    try:
        df.at[idx, 'summary'] = summarize_document(row['text'])
        df.to_csv('summaries.csv', index=False)
    except Exception as e:
        df.at[idx, 'error'] = str(e)
        df.to_csv('summaries.csv', index=False)

이런 코드는 멋있어 보이지는 않지만, 실제 운영에서는 매우 중요하다. LLM 기능은 모델 품질만으로 완성되지 않는다. 실패했을 때 어디서 다시 시작할 수 있는지가 제품의 안정성을 결정한다.

모델 선택은 품질만의 문제가 아니다

처음에는 가장 강력한 모델을 쓰고 싶어진다. 어려운 문제를 더 잘 풀고, 복잡한 지시도 더 잘 따르며, 긴 문맥을 더 안정적으로 처리하기 때문이다. 하지만 제품에서는 모델 선택이 곧 비용, 속도, 사용자 경험의 문제로 이어진다.

모델을 고를 때는 다음 요소를 함께 봐야 한다.

기준확인할 질문
정확도와 추론 품질이 작업에 복잡한 추론이 필요한가?
입력 토큰 비용사용자가 긴 문서나 대화 기록을 자주 보내는가?
출력 토큰 비용모델이 긴 답변을 자주 생성하는가?
응답 지연시간사용자가 실시간으로 기다리는 화면인가?
컨텍스트 길이한 번에 얼마나 긴 문서를 넣어야 하는가?
지원 기능구조화 출력, 도구 호출, 이미지 입력 등이 필요한가?

실용적인 기본 전략은 '가벼운 모델부터 시작하기'다. 짧은 요약, 문장 변환, 단순 분류, 키워드 추출처럼 비교적 쉬운 작업은 경량 모델로도 충분한 경우가 많다. 반면 복잡한 법률 문서 분석, 긴 코드 리뷰, 다단계 추론, 중요한 의사결정 보조에는 더 강력한 모델을 검토한다.

더 좋은 전략은 작업별로 모델을 나누는 것이다. 예를 들어 고객지원 챗봇에서 질문 의도 분류는 경량 모델로 처리하고, 실제 답변 생성은 중간급 모델로 처리할 수 있다. 해결이 어려운 질문만 더 강한 모델로 보내는 라우팅도 가능하다.

이때 반드시 측정해야 한다. '왠지 큰 모델이 좋을 것 같다'가 아니라, 같은 테스트셋에서 정확도, 평균 응답 시간, 평균 비용을 비교해야 한다. 모델 선택은 감이 아니라 데이터로 결정하는 편이 좋다.

스트리밍으로 기다리는 느낌 줄이기

LLM 응답은 즉시 완성되지 않는다. 모델은 토큰을 하나씩 생성한다. 사용자가 긴 답변을 요청하면 전체 답변이 완성될 때까지 몇 초 이상 걸릴 수 있다. 이때 스트리밍을 사용하면 부분 결과를 바로 화면에 보여 줄 수 있다.

스트리밍은 답변이 완성된 뒤 한 번에 받는 대신, 생성되는 조각을 순서대로 받는 방식이다. 채팅 서비스에서 글자가 실시간으로 나타나는 것처럼 보이는 효과가 여기서 나온다. Hugging Face의 Text Generation Inference도 OpenAI 호환 메시지 API와 토큰 스트리밍을 지원하며, 운영 환경에서 스트리밍과 배치 처리 같은 기능이 중요한 요소로 다뤄진다4.

Python에서는 다음처럼 작성할 수 있다.

# streaming_chat.py
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI()

stream = client.chat.completions.create(
    model='gpt-4o-mini',
    messages=[
        {'role': 'system', 'content': '너는 쉽고 재미있게 설명하는 과학 선생님이다.'},
        {'role': 'user', 'content': '블랙홀을 중학생도 이해할 수 있게 설명해 줘.'},
    ],
    max_tokens=700,
    temperature=0.7,
    stream=True,
)

full_text = []

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

print('nn--- 전체 응답 저장 완료 ---')
answer = ''.join(full_text)

스트리밍은 모델을 더 빠르게 만드는 기술은 아니다. 전체 생성 시간이 극적으로 줄어드는 것은 아니다. 하지만 사용자가 첫 글자를 보는 시간은 짧아진다. 체감 속도가 좋아진다. 제품에서는 이 차이가 크다. 5초 동안 빈 화면을 보는 것과 0.7초 뒤부터 답변이 흘러나오는 것은 전혀 다른 경험이다.

웹 서비스에서는 보통 Server-Sent Events나 WebSocket을 사용해 서버에서 프론트엔드로 조각을 전달한다. 처음에는 터미널에서 스트리밍을 확인하고, 나중에 웹 UI와 연결하면 된다.

오류 처리와 재시도는 선택이 아니다

로컬에서 한두 번 API를 호출할 때는 모든 것이 잘 되는 것처럼 보인다. 하지만 실제 서비스에서는 네트워크가 끊기고, 타임아웃이 발생하고, 사용량 제한에 걸리고, 공급자 서버가 일시적으로 불안정해질 수 있다. LLM API도 결국 외부 서비스다. 실패를 전제로 코드를 짜야 한다.

대표적인 실패 상황은 다음과 같다.

  • 네트워크 연결 오류가 발생한다.

  • 요청 시간이 너무 길어져 타임아웃이 난다.

  • 짧은 시간에 너무 많은 요청을 보내 rate limit에 걸린다.

  • 입력이 너무 길어 컨텍스트 한도를 초과한다.

  • 서버 측 일시 오류로 5xx 응답이 돌아온다.

가장 기본적인 대응은 예외 처리와 재시도다. 다만 재시도는 무작정 반복하면 안 된다. 실패하자마자 바로 다시 보내면 서버에 더 큰 부담을 줄 수 있다. 보통은 지수 백오프를 사용한다. 첫 실패 후 1초, 다음 실패 후 2초, 그다음 4초처럼 대기 시간을 늘리는 방식이다. 여기에 약간의 무작위 지연을 더하면 여러 요청이 동시에 재시도되는 것을 줄일 수 있다.

# safe_call.py
import random
import time
from dotenv import load_dotenv
from openai import OpenAI
from openai import APIConnectionError, APITimeoutError, APIStatusError, RateLimitError

load_dotenv()
client = OpenAI(timeout=30, max_retries=0)


def safe_chat_completion(messages, model='gpt-4o-mini', max_tokens=500, temperature=0.3, max_retries=4):
    for attempt in range(max_retries):
        try:
            return client.chat.completions.create(
                model=model,
                messages=messages,
                max_tokens=max_tokens,
                temperature=temperature,
            )

        except (RateLimitError, APITimeoutError, APIConnectionError) as e:
            if attempt == max_retries - 1:
                raise

            sleep_seconds = min((2 ** attempt) + random.random(), 20)
            print(f'일시 오류 발생: {type(e).__name__}. {sleep_seconds:.1f}초 후 재시도합니다.')
            time.sleep(sleep_seconds)

        except APIStatusError as e:
            if 500 <= e.status_code < 600 and attempt < max_retries - 1:
                sleep_seconds = min((2 ** attempt) + random.random(), 20)
                print(f'서버 오류 {e.status_code}. {sleep_seconds:.1f}초 후 재시도합니다.')
                time.sleep(sleep_seconds)
                continue
            raise

이 코드는 완벽한 운영 코드라기보다 기본 골격이다. 실제 서비스에서는 요청 ID, 사용자 ID, 기능 이름, 입력 토큰 수, 출력 토큰 수, 지연시간, 오류 코드를 함께 기록해야 한다.

요청 로그를 남겨야 개선할 수 있다

LLM 기능을 만들 때 흔한 실수는 '답이 나오는 것'에서 멈추는 것이다. 하지만 제품은 출시 후에 좋아진다. 어떤 질문에서 실패했는지, 어떤 프롬프트가 비용을 많이 쓰는지, 어떤 모델이 느린지 알아야 개선할 수 있다. 이를 위해 로그가 필요하다.

간단한 JSONL 로그를 남겨 보자. JSONL은 한 줄에 JSON 객체 하나를 저장하는 형식이다. 나중에 pandas로 읽기 쉽다.

# logging_utils.py
import json
import time
from pathlib import Path

LOG_PATH = Path('logs/llm_calls.jsonl')
LOG_PATH.parent.mkdir(exist_ok=True)


def append_llm_log(event):
    with LOG_PATH.open('a', encoding='utf-8') as f:
        f.write(json.dumps(event, ensure_ascii=False) + 'n')


def now_ms():
    return int(time.time() * 1000)

API 호출을 감싸는 함수에서 로그를 남긴다.

# logged_call.py
from logging_utils import append_llm_log, now_ms
from safe_call import safe_chat_completion


def logged_chat(messages, feature_name, model='gpt-4o-mini'):
    started = now_ms()

    try:
        response = safe_chat_completion(messages=messages, model=model)
        latency_ms = now_ms() - started

        usage = getattr(response, 'usage', None)

        append_llm_log({
            'timestamp_ms': started,
            'feature': feature_name,
            'model': model,
            'status': 'ok',
            'latency_ms': latency_ms,
            'prompt_tokens': getattr(usage, 'prompt_tokens', None) if usage else None,
            'completion_tokens': getattr(usage, 'completion_tokens', None) if usage else None,
            'total_tokens': getattr(usage, 'total_tokens', None) if usage else None,
        })

        return response.choices[0].message.content

    except Exception as e:
        latency_ms = now_ms() - started

        append_llm_log({
            'timestamp_ms': started,
            'feature': feature_name,
            'model': model,
            'status': 'error',
            'latency_ms': latency_ms,
            'error_type': type(e).__name__,
            'error_message': str(e)[:500],
        })

        raise

로그를 남길 때는 개인정보와 민감정보를 조심해야 한다. 사용자의 원문 전체를 그대로 저장하면 나중에 보안 문제가 될 수 있다. 특히 고객 문의, 의료 정보, 계약서, 내부 문서처럼 민감한 데이터는 원문을 저장하지 않거나, 필요한 경우 마스킹하고 접근 권한을 제한해야 한다. 문제 분석을 위해 일부 샘플이 필요하다면 사용자 동의, 보관 기간, 삭제 정책까지 함께 설계해야 한다.

작은 대화형 요약 서비스의 뼈대

지금까지의 조각을 합쳐 아주 작은 요약 서비스를 상상해 보자. 사용자가 긴 글을 붙여 넣으면, 우리는 다음 흐름으로 처리한다.

사용자 입력을 받는다. 입력 길이를 검사한다. 너무 길면 나누거나 사용자에게 줄여 달라고 안내한다. 시스템 메시지와 사용자 메시지를 만든다. 안전한 API 호출 함수로 요청한다. 응답 텍스트를 사용자에게 보여 준다. 동시에 모델명, 지연시간, 토큰 사용량, 오류 여부를 로그로 남긴다.

코드로 쓰면 다음과 같은 모양이 된다.

# mini_summary_app.py
from logged_call import logged_chat

SYSTEM_PROMPT = '''
너는 업무 문서를 요약하는 AI 도우미다.
원문에 없는 사실을 만들지 않는다.
답변은 다음 형식으로 작성한다.

요약:
다음 행동:
주의할 점:
'''.strip()


def summarize_for_app(text):
    if len(text) < 20:
        return '요약할 내용이 너무 짧습니다. 조금 더 긴 문서를 입력해 주세요.'

    user_prompt = f'''
다음 문서를 읽고 핵심을 요약해 줘.

문서:
{text}
'''.strip()

    messages = [
        {'role': 'system', 'content': SYSTEM_PROMPT},
        {'role': 'user', 'content': user_prompt},
    ]

    return logged_chat(messages, feature_name='document_summary')


if __name__ == '__main__':
    print('요약할 문서를 입력하세요. 입력을 마치려면 빈 줄에서 Enter를 누르세요.')

    lines = []
    while True:
        line = input()
        if line == '':
            break
        lines.append(line)

    text = 'n'.join(lines)
    print('n--- 요약 결과 ---')
    print(summarize_for_app(text))

아직 웹 화면도 없고, 사용자 인증도 없고, 데이터베이스도 없다. 하지만 핵심은 들어 있다. API 키를 안전하게 읽고, 메시지를 구성하고, 모델을 호출하고, 응답을 꺼내고, 실패와 로그를 처리한다. 많은 AI 기능이 이 골격에서 출발한다.

흔한 실수들

LLM API를 처음 붙일 때 자주 만나는 실수가 있다. 미리 알고 있으면 시간을 많이 아낄 수 있다.

첫째, API 키를 코드에 직접 적는다. 개인 실험에서는 괜찮아 보이지만, 코드가 공유되는 순간 문제가 된다. 키는 환경 변수나 시크릿 관리 도구에 넣어야 한다.

둘째, 모든 대화 기록을 끝없이 보낸다. 처음에는 대화가 잘 이어져 좋아 보이지만, 곧 비용이 커지고 컨텍스트 한도에 부딪힌다. 최근 기록 유지, 요약 메모리, 검색 기반 문맥 주입 같은 전략이 필요하다.

셋째, 출력 길이를 통제하지 않는다. max_tokens를 너무 작게 두면 답이 잘리고, 너무 크게 두면 불필요하게 비용이 늘어난다. 기능별로 적절한 출력 길이를 정해야 한다.

넷째, temperature의 의미를 오해한다. temperature는 지능 조절 버튼이 아니다. 샘플링의 다양성을 조절한다. 정확한 답이 필요한 작업에서 무작정 높이면 품질이 흔들릴 수 있다.

다섯째, 실패 처리를 나중으로 미룬다. 데모에서는 문제가 없어 보여도 서비스에서는 반드시 실패가 발생한다. 타임아웃, rate limit, 재시도, 로그는 초기에 넣는 편이 좋다.

여섯째, 모델 하나로 모든 일을 처리한다. 간단한 분류와 복잡한 추론을 같은 모델에 맡기면 비용 구조가 비효율적일 수 있다. 쉬운 작업은 경량 모델, 어려운 작업은 고성능 모델로 나누는 전략을 검토하자.

API를 쓴다는 것은 제품의 문을 여는 일이다

우리는 앞에서 LLM의 내부를 배웠다. 다음 토큰 예측, 토큰화, 임베딩, Attention, Transformer, 학습과 정렬, 환각까지 살펴보았다. 이 지식은 API 앞에서도 그대로 유효하다. API는 원리를 감추는 장막이 아니라, 원리를 제품으로 연결하는 문이다.

이제 우리는 텍스트 생성 요청을 보낼 수 있다. 요약기를 만들 수 있다. 대화 기록을 유지하는 간단한 챗봇을 만들 수 있다. 여러 문서를 반복 처리할 수 있다. 모델 선택, 스트리밍, 예외 처리, 로그 기록이 왜 중요한지도 알게 되었다.

하지만 아직 중요한 질문이 남아 있다. 같은 모델을 호출해도 어떤 프롬프트를 쓰느냐에 따라 결과가 크게 달라진다. '요약해 줘'라고만 쓰는 것과, 역할, 대상 독자, 출력 형식, 제약 조건, 예시를 함께 주는 것은 전혀 다르다. 다음 장에서는 이 차이를 본격적으로 다룬다. 프롬프트 엔지니어링은 모델을 속이는 기술이 아니라, 모델에게 일을 명확히 맡기는 기술이다.

참고

1Text generation | OpenAI API

2Text generation · Hugging Face

324 Using LLMs in Python for Text Generation

4GitHub - huggingface/text-generation-inference: Large Language Model Text Generation Inference · GitHub

책 목록

처음 만나는 LLM: 원리부터 Mini GPT 구현, RAG 서비스 개발까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기