Skip to content
Published on

LLM 애플리케이션 개발 가이드: 프로토타입에서 프로덕션까지

Authors

목차

  1. LLM 애플리케이션 개발 개요
  2. 프롬프트 엔지니어링 기초
  3. LLM API와 SDK
  4. 검색 증강 생성(RAG)
  5. 툴 사용과 함수 호출
  6. 스트리밍과 비동기 패턴
  7. 평가와 테스트
  8. 비용 최적화
  9. 프로덕션 배포
  10. 관측성과 모니터링

1. LLM 애플리케이션 개발 개요

1.1 LLM 애플리케이션이란?

LLM 애플리케이션은 자연어 처리, 콘텐츠 생성, 정보 추론, 또는 행동 수행에 대형 언어 모델을 핵심 구성 요소로 활용하는 모든 소프트웨어 시스템입니다. 모든 동작이 명시적으로 프로그래밍된 전통적인 소프트웨어와 달리, LLM 애플리케이션은 로직의 상당 부분을 사전 학습된 모델에 위임합니다.

일반적인 LLM 애플리케이션 카테고리:

카테고리예시핵심 과제
챗봇 및 어시스턴트고객 지원, 개인 비서컨텍스트 관리, 어조 일관성
문서 Q&A계약서 검토, 내부 검색검색 정확도, 할루시네이션
코드 생성자동완성, PR 리뷰, 테스트 작성정확성, 보안
콘텐츠 생성마케팅 카피, 요약품질 관리, 브랜드 보이스
데이터 추출양식 파싱, 구조화된 출력스키마 준수, 견고성
자율 에이전트리서치 에이전트, 작업 자동화신뢰성, 비용 제어

1.2 개발 스택

현대적인 LLM 애플리케이션은 일반적으로 다음과 같은 레이어로 구성됩니다:

┌─────────────────────────────────────────┐
│           사용자 인터페이스              │
  (Web, Mobile, API, Slack, CLI)├─────────────────────────────────────────┤
│           애플리케이션 로직              │
  (오케스트레이션, 비즈니스 규칙)├─────────────────────────────────────────┤
LLM 오케스트레이션 레이어         │
  (LangChain, LlamaIndex, 원시 SDK)├─────────────────────────────────────────┤
LLM 제공자                    │
  (OpenAI, Anthropic, Google, 로컬)├─────────────────────────────────────────┤
│           지원 서비스                   │
  (벡터 DB, 캐시, 검색,)└─────────────────────────────────────────┘

1.3 핵심 원칙

1. 단순하게 시작하고, 필요할 때만 복잡성을 추가하세요. 잘 만든 프롬프트를 사용하는 직접적인 API 호출이 복잡한 오케스트레이션 프레임워크보다 성능이 좋은 경우가 많습니다. 확실한 사용 사례가 증명된 후에 추상화 계층을 추가하세요.

2. 프롬프트를 코드처럼 다루세요. 프롬프트를 버전 관리하고, 테스트를 작성하고, 변경 사항을 신중하게 추적하세요. 프롬프트 회귀는 코드 회귀만큼 치명적입니다.

3. 출시 전에 평가하세요. LLM 출력은 비결정적입니다. 체계적인 평가 없이는 변경 사항이 품질을 개선했는지 저하시켰는지 알 수 없습니다.

4. 실패를 고려하여 설계하세요. LLM은 할루시네이션을 일으키고, 타임아웃이 발생하며, 예상치 못한 형식을 반환합니다. 처음부터 재시도 로직, 폴백, 검증을 구축하세요.


2. 프롬프트 엔지니어링 기초

2.1 프롬프트의 구조

프로덕션 프롬프트는 네 가지 선택적 섹션으로 구성됩니다:

[시스템 지침]
당신은 Acme Corp의 친절한 고객 지원 에이전트입니다.
사용자가 쓰는 언어로 응답하세요.
항상 예의 바르고 간결하게. 경쟁사는 절대 언급하지 마세요.

[컨텍스트 / 검색된 문서]
주문 #12345, 2026-03-10 접수. 상태: 발송됨.
운송장 번호: 1Z999AA10123456784

[예시 (few-shot)]
사용자: 제 주문은 어디 있나요?
어시스턴트: 주문 #9999935일에 발송되어 배송 중입니다.
예상 배달일: 312.

[사용자 메시지]
지난주에 주문했는데 아직 받지 못했어요.

2.2 시스템 지침 모범 사례

다음 특성을 갖춘 시스템 지침을 작성하세요:

  • 역할 특화: 모델이 누구이고 목적이 무엇인지 정확히 정의.
  • 제약 명시: 모델이 해야 할 것과 하지 말아야 할 것을 명시.
  • 형식 지정: 출력 형식이 중요할 때 명확히 기술.
  • 어조 정의: 격식 수준, 언어, 길이 기대치 지정.
SYSTEM_PROMPT = """당신은 핀테크 회사의 시니어 Python 코드 리뷰어입니다.

책임:
- 정확성, 보안 취약점, 성능 문제를 검토
- 코드 예시를 포함한 구체적인 개선 사항 제안
- GDPR/CCPA를 위반하는 PII 처리 식별

출력 형식:
- 한 문장의 전반적인 평가로 시작
- 심각도와 함께 문제 목록: [CRITICAL], [WARNING], [SUGGESTION]
- 변경이 필요한 경우 수정된 코드 블록으로 마무리

새로운 기능을 생성하지 않습니다. 주어진 것만 검토합니다."""

2.3 Few-Shot 프롬프팅

Few-shot 예시는 모델에게 기대하는 입력-출력 패턴을 보여줍니다. 다음 상황에서 특히 효과적입니다:

  • 커스텀 출력 형식
  • 도메인 특화 어조나 용어
  • 비표준 레이블을 사용하는 분류
FEW_SHOT_EXAMPLES = """
아래 회의록에서 액션 아이템을 추출하세요.
JSON 배열로 출력하세요.

회의록: John이 금요일까지 배포 가이드를 업데이트할 예정입니다.
Sarah는 이사회 회의 전에 Q1 예산을 검토해야 합니다.
액션 아이템: [
  {"owner": "John", "task": "배포 가이드 업데이트", "due": "금요일"},
  {"owner": "Sarah", "task": "Q1 예산 검토", "due": "이사회 회의 전"}
]

회의록: API 팀이 이번 스프린트에 속도 제한을 추가하기로 합의했습니다.
문서 업데이트는 담당자가 지정되지 않았습니다.
액션 아이템: [
  {"owner": "API team", "task": "속도 제한 추가", "due": "이번 스프린트"},
  {"owner": null, "task": "문서 업데이트", "due": null}
]

회의록: {meeting_text}
액션 아이템:"""

2.4 Chain-of-Thought (CoT)

복잡한 추론 작업의 경우, 최종 답변을 제시하기 전에 사고 과정을 보여달라고 요청하세요.

COT_PROMPT = """다음 문제를 단계별로 풀어보세요.
각 단계에서 추론 과정을 보여준 다음 최종 답변을 제시하세요.

문제: 고객의 크레딧이 500달러입니다. 320달러짜리 주문을 하고,
80달러짜리 상품 하나를 반품했습니다. 남은 크레딧은 얼마인가요?

단계별로 생각해봅시다:"""

Zero-shot CoT 트리거: 예시 없이 프롬프트 끝에 "단계별로 생각해봅시다."를 추가하면 됩니다. 이 단순한 추가만으로 많은 모델에서 다단계 추론이 크게 향상됩니다.

2.5 구조화된 출력

JSON 모드나 스키마 제약을 사용하여 파싱 가능한 출력을 강제하세요.

from openai import OpenAI
import json

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    response_format={"type": "json_object"},
    messages=[
        {"role": "system", "content": "엔티티를 추출하세요. 유효한 JSON만 출력하세요."},
        {"role": "user", "content": "Apple이 2024년 9월 9일 쿠퍼티노에서 iPhone 16을 발표했습니다."}
    ]
)

data = json.loads(response.choices[0].message.content)
# {"company": "Apple", "product": "iPhone 16", "location": "쿠퍼티노", "date": "2024-09-09"}

Pydantic과 OpenAI SDK의 구조화된 출력 기능:

from pydantic import BaseModel
from openai import OpenAI

class NewsEvent(BaseModel):
    company: str
    product: str
    location: str
    date: str

client = OpenAI()
response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "이벤트 세부 사항을 추출하세요."},
        {"role": "user", "content": "Apple이 2024년 9월 9일 쿠퍼티노에서 iPhone 16을 발표했습니다."}
    ],
    response_format=NewsEvent,
)
event = response.choices[0].message.parsed
print(event.company)  # Apple

3. LLM API와 SDK

3.1 OpenAI SDK

from openai import OpenAI

client = OpenAI(api_key="sk-...")  # 또는 OPENAI_API_KEY 환경 변수 설정

# 기본 채팅 완성
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "당신은 도움이 되는 어시스턴트입니다."},
        {"role": "user", "content": "트랜스포머 아키텍처를 3문장으로 요약해줘."}
    ],
    temperature=0.7,
    max_tokens=200,
)

print(response.choices[0].message.content)
print(f"사용된 토큰: {response.usage.total_tokens}")

3.2 Anthropic SDK

import anthropic

client = anthropic.Anthropic(api_key="sk-ant-...")

message = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    system="당신은 도움이 되는 어시스턴트입니다.",
    messages=[
        {"role": "user", "content": "트랜스포머의 어텐션 메커니즘을 설명해줘."}
    ]
)

print(message.content[0].text)
print(f"입력 토큰: {message.usage.input_tokens}")
print(f"출력 토큰: {message.usage.output_tokens}")

3.3 LiteLLM으로 통합 인터페이스 구축

LiteLLM은 100개 이상의 LLM 제공자에 단일 인터페이스를 제공합니다:

from litellm import completion

# OpenAI
response = completion(
    model="gpt-4o",
    messages=[{"role": "user", "content": "안녕하세요"}]
)

# Anthropic (동일한 인터페이스)
response = completion(
    model="anthropic/claude-opus-4-5",
    messages=[{"role": "user", "content": "안녕하세요"}]
)

# 로컬 Ollama 모델 (동일한 인터페이스)
response = completion(
    model="ollama/llama3",
    messages=[{"role": "user", "content": "안녕하세요"}]
)

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

3.4 대화 히스토리 관리

class ConversationManager:
    def __init__(self, system_prompt: str, max_history: int = 20):
        self.system_prompt = system_prompt
        self.max_history = max_history
        self.history: list[dict] = []
        self.client = OpenAI()

    def chat(self, user_message: str) -> str:
        self.history.append({"role": "user", "content": user_message})

        # 컨텍스트 윈도우 초과 방지를 위해 히스토리 트리밍
        if len(self.history) > self.max_history:
            self.history = self.history[-self.max_history:]

        response = self.client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": self.system_prompt},
                *self.history
            ]
        )

        assistant_message = response.choices[0].message.content
        self.history.append({"role": "assistant", "content": assistant_message})
        return assistant_message

4. 검색 증강 생성(RAG)

4.1 왜 RAG인가?

LLM에는 RAG가 해결하는 두 가지 근본적인 한계가 있습니다:

  1. 지식 컷오프: 모델은 학습 데이터에 있던 것만 알고 있습니다.
  2. 컨텍스트 윈도우 한계: 모델은 모든 문서를 한 번에 "알" 수 없습니다.

RAG는 추론 시점에 관련 정보를 검색하여 프롬프트에 주입함으로써 두 가지 모두를 해결합니다.

사용자 질의
[질의 임베딩] ──► [벡터 검색] ──► 상위 K개 관련 청크
            [증강된 프롬프트 구성]
            시스템: 도움이 되는 어시스턴트입니다.
            컨텍스트: {검색된 청크}
            사용자: {원래 질의}
               [LLM이 답변 생성]

4.2 문서 수집 파이프라인

from langchain.document_loaders import PyPDFLoader, DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# 1. 문서 로드
loader = DirectoryLoader("./docs", glob="**/*.pdf", loader_cls=PyPDFLoader)
documents = loader.load()

# 2. 청크로 분할
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", " ", ""]
)
chunks = splitter.split_documents(documents)

# 3. 임베딩 및 저장
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)
print(f"{len(documents)}개 문서에서 {len(chunks)}개 청크 인덱싱 완료")

4.3 검색과 생성

from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# 기존 벡터스토어 로드
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=OpenAIEmbeddings(model="text-embedding-3-small")
)

# 검색기 생성
retriever = vectorstore.as_retriever(
    search_type="mmr",          # 다양성을 위한 Maximal Marginal Relevance
    search_kwargs={"k": 5, "fetch_k": 20}
)

# 커스텀 프롬프트
QA_PROMPT = PromptTemplate(
    template="""다음 컨텍스트를 사용하여 질문에 답하세요.
컨텍스트에 답이 없으면 "해당 정보가 없습니다."라고 말하세요.
정보를 만들어내지 마세요.

컨텍스트:
{context}

질문: {question}

답변:""",
    input_variables=["context", "question"]
)

# 체인
llm = ChatOpenAI(model="gpt-4o", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    chain_type_kwargs={"prompt": QA_PROMPT},
    return_source_documents=True
)

result = qa_chain.invoke({"query": "환불 정책은 무엇인가요?"})
print(result["result"])
for doc in result["source_documents"]:
    print(f"출처: {doc.metadata['source']}, 페이지 {doc.metadata.get('page', 'N/A')}")

4.4 검색 품질 향상

하이브리드 검색은 밀집(시맨틱)과 희소(키워드) 검색을 결합합니다:

from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever

# 밀집 검색기 (시맨틱)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 희소 검색기 (BM25 키워드)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5

# 동일 가중치로 앙상블
ensemble_retriever = EnsembleRetriever(
    retrievers=[dense_retriever, bm25_retriever],
    weights=[0.6, 0.4]
)

초기 검색 후 크로스 인코더를 사용한 리랭킹:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, docs: list, top_k: int = 3) -> list:
    pairs = [(query, doc.page_content) for doc in docs]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
    return [doc for _, doc in ranked[:top_k]]

5. 툴 사용과 함수 호출

5.1 툴 정의

툴은 LLM이 외부 API를 호출하거나, 데이터베이스를 검색하거나, 코드를 실행할 수 있게 합니다:

import json
import requests
from openai import OpenAI

client = OpenAI()

# 툴 정의
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "도시의 현재 날씨를 가져옵니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "도시명, 예: '서울'"
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "온도 단위"
                    }
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_web",
            "description": "최신 정보를 위해 웹을 검색합니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "검색 쿼리"}
                },
                "required": ["query"]
            }
        }
    }
]

5.2 툴 호출 처리

def get_weather(city: str, unit: str = "celsius") -> dict:
    # 실제 구현에서는 날씨 API를 호출
    return {"city": city, "temp": 18, "unit": unit, "condition": "맑음"}

def search_web(query: str) -> str:
    # 실제 구현에서는 검색 API를 호출
    return f"검색 결과: {query}"

TOOL_MAP = {
    "get_weather": get_weather,
    "search_web": search_web,
}

def run_agent(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
            tool_choice="auto"
        )

        message = response.choices[0].message

        # 툴 호출 없음 → 최종 답변
        if not message.tool_calls:
            return message.content

        # 히스토리에 어시스턴트 응답 추가
        messages.append(message)

        # 각 툴 호출 실행
        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)

            result = TOOL_MAP[func_name](**func_args)

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": json.dumps(result)
            })

answer = run_agent("도쿄 날씨가 어떤가요? 우산을 가져가야 할까요?")
print(answer)

5.3 병렬 툴 호출

GPT-4o와 Claude 3 이상은 병렬 툴 호출을 지원하여, 독립적인 작업의 레이턴시를 크게 줄입니다:

# 모델이 여러 툴을 동시에 호출할 수 있습니다
# 위의 루프가 이미 이를 처리합니다 — message.tool_calls는 리스트입니다
# 날씨와 검색을 단일 모델 턴에서 모두 호출 가능

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "user", "content": "도쿄와 파리의 날씨를 비교하고, 여행 팁도 검색해줘."}
    ],
    tools=tools,
    parallel_tool_calls=True  # GPT-4o에서 기본값 True
)

# 응답에 3개의 툴 호출이 동시에 있을 수 있습니다: weather(도쿄), weather(파리), search(여행 팁)

6. 스트리밍과 비동기 패턴

6.1 응답 스트리밍

스트리밍은 텍스트가 생성되는 즉시 표시되므로 사용자가 느끼는 레이턴시를 크게 개선합니다:

from openai import OpenAI

client = OpenAI()

# 동기 스트리밍
with client.chat.completions.stream(
    model="gpt-4o",
    messages=[{"role": "user", "content": "로봇에 관한 짧은 이야기를 써줘."}]
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

6.2 FastAPI로 비동기 스트리밍

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI

app = FastAPI()
client = AsyncOpenAI()

@app.post("/chat")
async def chat(body: dict):
    async def generate():
        async with client.chat.completions.stream(
            model="gpt-4o",
            messages=body["messages"]
        ) as stream:
            async for text in stream.text_stream:
                yield f"data: {text}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

6.3 비동기 배치 처리

많은 항목을 처리할 때 비동기 동시성은 처리량을 크게 향상시킵니다:

import asyncio
from openai import AsyncOpenAI

client = AsyncOpenAI()

async def classify_one(text: str, semaphore: asyncio.Semaphore) -> str:
    async with semaphore:
        response = await client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": "POSITIVE, NEGATIVE, 또는 NEUTRAL로 분류하세요."},
                {"role": "user", "content": text}
            ],
            max_tokens=10
        )
        return response.choices[0].message.content.strip()

async def classify_batch(texts: list[str], max_concurrent: int = 20) -> list[str]:
    semaphore = asyncio.Semaphore(max_concurrent)
    tasks = [classify_one(text, semaphore) for text in texts]
    return await asyncio.gather(*tasks)

# 사용 예시
texts = ["이 제품 정말 좋아요!", "끔찍한 경험이었습니다.", "그냥 그랬어요."] * 100
results = asyncio.run(classify_batch(texts))

7. 평가와 테스트

7.1 LLM 평가가 어려운 이유

전통적인 소프트웨어 테스트는 결정론적 단언을 사용합니다:

assert add(2, 3) == 5  # 항상 통과 또는 실패

LLM 출력은 비결정적이고 다음이 필요합니다:

  • 시맨틱 동등성 검사 (문자열 동일성이 아님)
  • 루브릭 기반 채점
  • 참조 없는 품질 평가
  • 통계적 샘플링 (한 번 실행으로는 충분하지 않음)

7.2 LLM-as-Judge

유능한 LLM을 사용하여 다른 LLM의 출력을 평가합니다:

from openai import OpenAI

client = OpenAI()

JUDGE_PROMPT = """AI 어시스턴트의 응답을 평가하고 있습니다.
다음 기준으로 응답을 평가하세요 (각 1-5점):
- 정확성: 정보가 맞나요?
- 유용성: 질문을 완전히 다루고 있나요?
- 간결성: 적절히 간결한가요?

질문: {question}
응답: {response}
참조 답변: {reference}

JSON으로 출력: {{"accuracy": X, "helpfulness": X, "conciseness": X, "reasoning": "..."}}"""

def evaluate(question: str, response: str, reference: str) -> dict:
    import json
    result = client.chat.completions.create(
        model="gpt-4o",
        response_format={"type": "json_object"},
        messages=[{
            "role": "user",
            "content": JUDGE_PROMPT.format(
                question=question,
                response=response,
                reference=reference
            )
        }]
    )
    return json.loads(result.choices[0].message.content)

7.3 평가 프레임워크

DeepEval은 포괄적인 LLM 평가 메트릭을 제공합니다:

from deepeval import evaluate
from deepeval.metrics import (
    AnswerRelevancyMetric,
    FaithfulnessMetric,
    ContextualRecallMetric,
)
from deepeval.test_case import LLMTestCase

test_case = LLMTestCase(
    input="프랑스의 수도는 어디인가요?",
    actual_output="프랑스의 수도는 파리입니다.",
    expected_output="파리",
    retrieval_context=["프랑스는 서유럽의 나라입니다. 수도는 파리입니다."]
)

metrics = [
    AnswerRelevancyMetric(threshold=0.8),
    FaithfulnessMetric(threshold=0.9),
    ContextualRecallMetric(threshold=0.8),
]

evaluate([test_case], metrics)

7.4 Promptfoo로 회귀 테스트

Promptfoo를 사용하면 YAML로 테스트 케이스를 정의하고 모델 버전 간에 실행할 수 있습니다:

# promptfooconfig.yaml
prompts:
  - '다음 텍스트를 2문장으로 요약하세요: {{text}}'

providers:
  - openai:gpt-4o
  - openai:gpt-4o-mini

tests:
  - vars:
      text: '에펠탑은 1889년 만국박람회를 위해 건설되었습니다...'
    assert:
      - type: llm-rubric
        value: '요약에는 1889년과 만국박람회가 언급되어야 합니다'
      - type: javascript
        value: "output.split('.').length <= 3" # 최대 3문장

8. 비용 최적화

8.1 토큰 계산과 예산 책정

import tiktoken

def count_tokens(text: str, model: str = "gpt-4o") -> int:
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

def estimate_cost(
    input_tokens: int,
    output_tokens: int,
    model: str = "gpt-4o"
) -> float:
    # 100만 토큰당 가격 (2026년 3월 기준 대략적인 수치)
    PRICING = {
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
        "claude-opus-4-5": {"input": 15.00, "output": 75.00},
        "claude-haiku-3-5": {"input": 0.80, "output": 4.00},
    }
    p = PRICING.get(model, {"input": 5.0, "output": 15.0})
    return (input_tokens * p["input"] + output_tokens * p["output"]) / 1_000_000

8.2 프롬프트 캐싱

Anthropic과 OpenAI 모두 반복되는 시스템 프롬프트나 대용량 컨텍스트에 대한 프롬프트 캐싱을 제공합니다:

# Anthropic 프롬프트 캐싱
import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": very_long_system_prompt,  # 예: 50K 토큰 정책 문서
            "cache_control": {"type": "ephemeral"}  # 이 접두사 캐싱
        }
    ],
    messages=[{"role": "user", "content": user_question}]
)
# 첫 번째 호출: 전체 가격. 이후 호출: 캐시된 토큰 약 90% 할인.

8.3 모델 라우팅

처리 가능한 가장 저렴한 모델로 작업을 라우팅합니다:

def route_to_model(task: str, complexity: str) -> str:
    """작업 복잡도에 따라 적절한 모델로 라우팅."""
    if complexity == "simple":
        return "gpt-4o-mini"          # 단순 분류, 추출
    elif complexity == "medium":
        return "gpt-4o"               # 요약, Q&A
    else:
        return "claude-opus-4-5"      # 복잡한 추론, 코드 리뷰

# 예시: 라우팅 전 복잡도 분류
def smart_complete(messages: list, task_description: str) -> str:
    from openai import OpenAI
    client = OpenAI()

    # 저렴한 분류 단계
    complexity = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"이 작업의 복잡도를 'simple', 'medium', 'complex' 중 하나로 평가해줘: {task_description}"
        }],
        max_tokens=5
    ).choices[0].message.content.strip().lower()

    model = route_to_model(task_description, complexity)

    return client.chat.completions.create(
        model=model,
        messages=messages
    ).choices[0].message.content

9. 프로덕션 배포

9.1 FastAPI 백엔드

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from openai import AsyncOpenAI
import asyncio

app = FastAPI(title="LLM API")
client = AsyncOpenAI()

class ChatRequest(BaseModel):
    messages: list[dict]
    model: str = "gpt-4o"
    temperature: float = 0.7
    max_tokens: int = 1000

class ChatResponse(BaseModel):
    content: str
    usage: dict

@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    try:
        response = await client.chat.completions.create(
            model=request.model,
            messages=request.messages,
            temperature=request.temperature,
            max_tokens=request.max_tokens,
        )
        return ChatResponse(
            content=response.choices[0].message.content,
            usage={
                "input_tokens": response.usage.prompt_tokens,
                "output_tokens": response.usage.completion_tokens
            }
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

9.2 속도 제한과 재시도

import asyncio
import random
from functools import wraps

def with_retry(max_attempts: int = 3, base_delay: float = 1.0):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    # 지터가 있는 지수 백오프
                    delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
                    await asyncio.sleep(delay)
        return wrapper
    return decorator

@with_retry(max_attempts=3)
async def robust_completion(messages: list) -> str:
    client = AsyncOpenAI()
    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=messages
    )
    return response.choices[0].message.content

9.3 Redis로 캐싱

import hashlib
import json
import redis.asyncio as redis

redis_client = redis.from_url("redis://localhost:6379")
CACHE_TTL = 3600  # 1시간

def cache_key(messages: list, model: str) -> str:
    payload = json.dumps({"messages": messages, "model": model}, sort_keys=True)
    return f"llm:{hashlib.md5(payload.encode()).hexdigest()}"

async def cached_completion(messages: list, model: str = "gpt-4o") -> str:
    key = cache_key(messages, model)

    # 캐시 확인
    cached = await redis_client.get(key)
    if cached:
        return cached.decode()

    # 생성
    from openai import AsyncOpenAI
    client = AsyncOpenAI()
    response = await client.chat.completions.create(model=model, messages=messages)
    result = response.choices[0].message.content

    # TTL과 함께 저장
    await redis_client.setex(key, CACHE_TTL, result)
    return result

10. 관측성과 모니터링

10.1 추적해야 할 핵심 메트릭

메트릭중요한 이유경고 임계값
레이턴시 (p50, p95, p99)사용자 경험스트리밍에서 p95 > 5초
토큰 사용량비용예산 편차 > 20%
오류율신뢰성요청의 > 1%
캐시 히트율비용 효율성< 30% (조사 필요)
평가 점수품질기준치에서 > 5% 하락

10.2 LangSmith 추적

import os
from langchain_openai import ChatOpenAI
from langchain.callbacks.tracers import LangChainTracer

os.environ["LANGCHAIN_API_KEY"] = "ls__..."
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = "my-llm-app"

# 모든 LangChain 호출이 자동으로 추적됨
llm = ChatOpenAI(model="gpt-4o")
response = llm.invoke("RAG란 무엇인가요?")
# LangSmith UI에서 전체 추적(프롬프트, 응답, 레이턴시, 토큰) 확인 가능

10.3 커스텀 로깅

import time
import logging
from dataclasses import dataclass, field, asdict

logger = logging.getLogger(__name__)

@dataclass
class LLMCallLog:
    model: str
    input_tokens: int
    output_tokens: int
    latency_ms: float
    success: bool
    error: str = ""
    metadata: dict = field(default_factory=dict)

async def traced_completion(messages: list, model: str = "gpt-4o", **metadata) -> str:
    from openai import AsyncOpenAI
    client = AsyncOpenAI()

    start = time.perf_counter()
    success = True
    error = ""
    input_tokens = output_tokens = 0

    try:
        response = await client.chat.completions.create(model=model, messages=messages)
        result = response.choices[0].message.content
        input_tokens = response.usage.prompt_tokens
        output_tokens = response.usage.completion_tokens
        return result
    except Exception as e:
        success = False
        error = str(e)
        raise
    finally:
        log = LLMCallLog(
            model=model,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            latency_ms=(time.perf_counter() - start) * 1000,
            success=success,
            error=error,
            metadata=metadata
        )
        logger.info("llm_call", extra=asdict(log))

10.4 가드레일과 안전성

from guardrails import Guard
from guardrails.hub import ToxicLanguage, DetectPII

guard = Guard().use_many(
    ToxicLanguage(threshold=0.5, on_fail="exception"),
    DetectPII(pii_entities=["EMAIL_ADDRESS", "PHONE_NUMBER"], on_fail="fix"),
)

def safe_completion(user_input: str) -> str:
    from openai import OpenAI
    client = OpenAI()

    # 입력 검증
    guard.validate(user_input)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": user_input}]
    )
    output = response.choices[0].message.content

    # 출력 검증 및 수정
    validated = guard.validate(output)
    return validated.validated_output

요약

프로덕션 수준의 LLM 애플리케이션 구축은 여러 차원에서의 숙달을 요구합니다:

영역핵심 교훈
프롬프트 엔지니어링프롬프트를 코드로 다루세요; 버전 관리, 테스트, 반복
RAG하이브리드 검색 + 리랭킹으로 검색 품질 대폭 향상
툴 사용병렬 툴 호출로 다단계 작업의 레이턴시 감소
스트리밍대화형 UX에 필수; FastAPI와 SSE 활용
평가LLM-as-judge + 자동화된 테스트 스위트로 회귀 감지
비용캐싱, 라우팅, 프롬프트 캐싱으로 비용 80%+ 절감 가능
모니터링첫날부터 레이턴시, 토큰, 품질 메트릭 추적

이 분야는 빠르게 진화하지만, 이 기본 원칙들은 어떤 모델이나 프레임워크가 내년에 지배하더라도 변함없이 유용합니다. 단순하게 시작하고, 모든 것을 측정하며, 실제 사용 데이터를 기반으로 반복하세요.

지식 확인 퀴즈

Q1. RAG란 무엇이며, 왜 유용한가요?

RAG는 Retrieval-Augmented Generation(검색 증강 생성)의 약자입니다. LLM이 학습한 적 없는 문서에 대한 질문에 답할 수 있도록, 추론 시점에 관련 텍스트를 검색하여 프롬프트에 주입합니다. 이를 통해 지식 컷오프 문제와 컨텍스트 윈도우 한계를 모두 해결합니다.

Q2. LLM 에이전트에서 병렬 툴 호출의 주요 장점은 무엇인가요?

병렬 툴 호출은 모델이 순차적이 아닌 단일 턴에서 여러 툴을 동시에 호출할 수 있게 합니다. 툴 호출이 서로 독립적인 다단계 작업의 전체 레이턴시를 줄여줍니다.

Q3. 단순 문자열 매칭보다 LLM-as-judge 평가가 선호되는 이유는 무엇인가요?

LLM 출력은 다양한 표현 방식으로 시맨틱하게 동등할 수 있어, 문자열 매칭은 거짓 음성을 생성합니다. LLM 심판은 루브릭을 사용하여 시맨틱 정확성, 유용성, 품질을 평가할 수 있어 결정론적 비교보다 훨씬 정확한 품질 신호를 제공합니다.

Q4. 품질 저하 없이 LLM API 비용을 줄이는 두 가지 기법을 설명하세요.

  1. 프롬프트 캐싱: 반복되는 대용량 접두사(시스템 프롬프트, 참조 문서)를 캐싱하여 전체 가격은 최초 한 번만 부과되게 합니다.
  2. 모델 라우팅: 단순 작업(분류, 추출)은 저렴한 소형 모델로 처리하고, 복잡한 추론 작업에만 비싼 대형 모델을 사용합니다.