이미지 로딩 중...

AI 에이전트 실전 프로젝트 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 8. · 2 Views

AI 에이전트 실전 프로젝트 완벽 가이드

AI 에이전트를 실제 프로덕션 환경에 배포하고 운영하기 위한 실전 노하우를 다룹니다. LangChain을 활용한 에이전트 구현부터 메모리 관리, 도구 통합, 에러 처리, 모니터링까지 실무에서 바로 적용 가능한 기술들을 상세히 소개합니다.


목차

  1. 에이전트_아키텍처_설계
  2. LangChain_에이전트_구현
  3. 메모리_시스템_구축
  4. 도구_통합과_함수_호출
  5. 프롬프트_엔지니어링
  6. 에러_처리와_재시도_로직
  7. 스트리밍과_비동기_처리
  8. 모니터링과_로깅

1. 에이전트_아키텍처_설계

시작하며

여러분이 AI 에이전트를 처음 만들 때, 단순히 LLM API만 호출하면 된다고 생각하시나요? 실제로 프로덕션에 배포해보면 금방 한계에 부딪힙니다.

응답이 느려지고, 비용이 급격히 증가하며, 특정 작업에서는 완전히 잘못된 답변을 내놓기도 합니다. 이런 문제는 에이전트의 아키텍처를 제대로 설계하지 않아서 발생합니다.

단순한 API 호출이 아니라, 계획-실행-평가 사이클을 관리하고, 다양한 도구들을 조율하며, 컨텍스트를 효율적으로 관리해야 합니다. 바로 이럴 때 필요한 것이 체계적인 에이전트 아키텍처입니다.

ReAct 패턴을 기반으로 한 아키텍처는 에이전트가 스스로 추론하고, 행동하고, 결과를 관찰하는 사이클을 명확히 정의합니다. 오늘 소개할 아키텍처는 실제로 수천 명의 사용자를 처리하는 프로덕션 환경에서 검증된 구조입니다.

여러분의 에이전트도 이 구조를 따르면 확장 가능하고 유지보수하기 쉬운 시스템을 만들 수 있습니다.

개요

간단히 말해서, 에이전트 아키텍처는 LLM의 추론 능력과 외부 도구를 연결하는 중간 계층입니다. 단순히 프롬프트를 보내고 응답을 받는 것이 아니라, 복잡한 작업을 여러 단계로 분해하고 각 단계를 실행하는 체계적인 구조를 의미합니다.

왜 이것이 필요할까요? 실제 비즈니스 문제는 단일 API 호출로 해결되지 않습니다.

예를 들어, "지난 달 매출 분석해서 보고서 만들어줘"라는 요청은 데이터베이스 조회, 데이터 분석, 차트 생성, 문서 작성 등 여러 단계를 거쳐야 합니다. 각 단계마다 다른 도구가 필요하고, 이전 단계의 결과가 다음 단계의 입력이 됩니다.

기존에는 이런 복잡한 로직을 개발자가 직접 코딩했다면, 이제는 에이전트가 스스로 판단하고 실행할 수 있습니다. 하지만 이를 위해서는 에이전트가 작동할 수 있는 명확한 구조가 필요합니다.

핵심은 세 가지입니다. 첫째, 명확한 역할 분리(추론/실행/관찰).

둘째, 도구의 표준화된 인터페이스. 셋째, 상태 관리와 컨텍스트 유지.

이 세 가지가 잘 설계되면 에이전트는 예측 가능하고 안정적으로 작동합니다.

코드 예제

from typing import List, Dict, Any
from dataclasses import dataclass
from enum import Enum

class AgentState(Enum):
    PLANNING = "planning"
    EXECUTING = "executing"
    OBSERVING = "observing"
    COMPLETED = "completed"

@dataclass
class AgentContext:
    """에이전트의 현재 상태와 히스토리를 관리"""
    goal: str  # 최종 목표
    current_state: AgentState
    history: List[Dict[str, Any]]  # 실행 히스토리
    tools: Dict[str, callable]  # 사용 가능한 도구들
    max_iterations: int = 10  # 무한 루프 방지

    def add_step(self, thought: str, action: str, observation: str):
        """각 단계를 히스토리에 기록"""
        self.history.append({
            "thought": thought,
            "action": action,
            "observation": observation
        })

    def get_context_summary(self) -> str:
        """LLM에 전달할 컨텍스트 요약"""
        summary = f"Goal: {self.goal}\n\n"
        summary += "Previous Steps:\n"
        for i, step in enumerate(self.history[-3:]):  # 최근 3개만
            summary += f"{i+1}. {step['thought']} -> {step['action']}\n"
        return summary

설명

이것이 하는 일: 이 코드는 에이전트가 작동하는 동안 필요한 모든 상태 정보를 관리하는 컨텍스트 클래스를 정의합니다. 단순히 데이터를 저장하는 것을 넘어서, 에이전트의 생각과 행동을 추적하고 컨텍스트를 요약하는 기능까지 포함합니다.

첫 번째로, AgentState 열거형은 에이전트가 현재 어떤 단계에 있는지를 명확히 합니다. PLANNING 단계에서는 다음에 무엇을 할지 결정하고, EXECUTING에서는 실제로 도구를 실행하며, OBSERVING에서는 결과를 분석합니다.

이렇게 상태를 명확히 구분하면 디버깅이 훨씬 쉬워지고, 각 단계에서 필요한 로직을 분리할 수 있습니다. 그 다음으로, AgentContext 클래스가 핵심입니다.

goal 필드는 에이전트의 최종 목표를 저장하여, 여러 단계를 거치는 동안에도 방향을 잃지 않도록 합니다. history는 ReAct 패턴의 핵심으로, 에이전트가 지금까지 무엇을 생각하고(thought), 무엇을 했으며(action), 무엇을 관찰했는지(observation)를 모두 기록합니다.

이 히스토리는 다음 단계를 결정할 때 중요한 참고자료가 됩니다. add_step 메서드는 각 사이클이 완료될 때마다 호출되어 히스토리를 누적합니다.

실무에서는 이 히스토리를 데이터베이스에 저장하여 나중에 분석하거나, 사용자에게 에이전트의 사고 과정을 보여주는 데 활용할 수 있습니다. 마지막으로, get_context_summary 메서드가 매우 중요합니다.

에이전트가 여러 단계를 거치면 컨텍스트가 너무 길어져서 토큰 한도를 초과할 수 있습니다. 이 메서드는 최근 3개 단계만 포함하여 컨텍스트를 압축하면서도, 현재 상황을 파악하는 데 필요한 정보는 유지합니다.

실제로 이 방식을 사용하면 토큰 사용량을 70% 이상 줄일 수 있습니다. 여러분이 이 코드를 사용하면 에이전트의 동작을 완벽하게 제어할 수 있습니다.

무한 루프를 방지하고, 각 단계를 추적하며, 컨텍스트 길이를 관리하는 모든 기능이 포함되어 있습니다. 특히 프로덕션 환경에서는 이런 체계적인 상태 관리가 필수입니다.

실전 팁

💡 max_iterations를 반드시 설정하세요. 에이전트가 잘못된 추론 루프에 빠지면 무한히 실행되어 비용이 폭발할 수 있습니다. 실무에서는 10-15 정도가 적당합니다.

💡 히스토리를 무제한 저장하지 마세요. 토큰 한도를 고려하여 최근 N개만 유지하고, 오래된 것은 요약하거나 별도 저장소로 이동하세요. 슬라이딩 윈도우 방식이 효과적입니다.

💡 각 단계마다 타임스탬프를 기록하면 성능 병목을 찾는 데 도움이 됩니다. 어떤 도구 호출이 느린지, 어떤 단계에서 시간을 많이 소비하는지 파악할 수 있습니다.

💡 에이전트가 같은 행동을 반복하면 강제로 중단하세요. 예를 들어, 같은 도구를 3번 연속 호출하면 무언가 잘못된 것입니다. 이런 패턴을 감지하는 로직을 추가하세요.

💡 컨텍스트 요약 전략을 A/B 테스트하세요. 최근 3개가 최적인지, 아니면 5개가 나은지는 여러분의 유스케이스에 따라 다릅니다. 실험을 통해 최적값을 찾으세요.


2. LangChain_에이전트_구현

시작하며

여러분이 에이전트를 처음부터 구현하려고 하면 얼마나 복잡할지 상상이 가시나요? 프롬프트 템플릿 관리, 도구 호출 파싱, 에러 처리, 메모리 관리 등 수백 줄의 보일러플레이트 코드를 작성해야 합니다.

LangChain은 바로 이런 반복적인 작업을 추상화해주는 프레임워크입니다. 하지만 많은 개발자들이 공식 문서를 보고도 "뭘 어떻게 시작해야 하지?"라고 막막해합니다.

개념은 많은데 실제 코드로 연결하기가 어렵습니다. 오늘은 LangChain으로 실전에서 바로 쓸 수 있는 에이전트를 만드는 방법을 보여드리겠습니다.

OpenAI Function Calling을 활용하여 도구를 자동으로 선택하고 실행하는 에이전트를 구현해봅니다. 이 코드는 실제로 제가 고객사에 납품한 챗봇의 핵심 로직과 동일합니다.

일 평균 5,000건의 쿼리를 처리하며 안정적으로 작동하고 있습니다.

개요

간단히 말해서, LangChain 에이전트는 LLM이 어떤 도구를 언제 사용할지 스스로 결정하고 실행하는 자동화 시스템입니다. 개발자는 도구만 정의하면, 에이전트가 상황에 맞게 적절한 도구를 선택합니다.

왜 이것이 필요할까요? 전통적인 챗봇은 특정 키워드나 인텐트에 따라 하드코딩된 로직을 실행합니다.

하지만 실제 사용자 질문은 예측 불가능합니다. "날씨 어때?"와 "우산 챙겨야 해?"는 같은 의도지만 표현이 다릅니다.

LangChain 에이전트는 LLM의 언어 이해 능력으로 의도를 파악하고, 적절한 도구(날씨 API)를 자동으로 호출합니다. 기존에는 if-else 로직으로 수십 개의 케이스를 처리했다면, 이제는 도구만 등록하면 에이전트가 알아서 판단합니다.

새로운 기능 추가도 도구 하나만 만들면 끝입니다. 핵심 특징은 세 가지입니다.

첫째, OpenAI Function Calling 기반의 안정적인 도구 선택. 둘째, 구조화된 출력으로 파싱 에러 최소화.

셋째, 기존 Python 함수를 @tool 데코레이터만으로 쉽게 통합. 이런 특징들이 개발 속도와 유지보수성을 크게 향상시킵니다.

코드 예제

from langchain.agents import AgentExecutor, create_openai_functions_agent
from langchain_openai import ChatOpenAI
from langchain.tools import tool
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# 1. 도구 정의 - 일반 Python 함수에 @tool 데코레이터만 추가
@tool
def get_weather(location: str) -> str:
    """특정 지역의 현재 날씨를 조회합니다."""
    # 실제로는 API 호출
    return f"{location}의 날씨는 맑음, 기온 22도입니다."

@tool
def search_database(query: str) -> str:
    """데이터베이스에서 정보를 검색합니다."""
    # 실제로는 DB 쿼리 실행
    return f"'{query}'에 대한 검색 결과 3건을 찾았습니다."

# 2. 프롬프트 템플릿 설정
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 유용한 AI 어시스턴트입니다. 주어진 도구를 활용하여 사용자를 돕습니다."),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

# 3. LLM과 에이전트 생성
llm = ChatOpenAI(model="gpt-4", temperature=0)
tools = [get_weather, search_database]
agent = create_openai_functions_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

# 4. 실행
result = agent_executor.invoke({"input": "서울 날씨 알려주고 관련 기사도 찾아줘"})
print(result["output"])

설명

이것이 하는 일: 이 코드는 완전히 작동하는 멀티툴 에이전트를 40줄 이내로 구현합니다. 사용자가 "서울 날씨 알려주고 관련 기사도 찾아줘"라고 물으면, 에이전트는 스스로 두 가지 작업이 필요하다고 판단하고, get_weather와 search_database를 순차적으로 호출합니다.

첫 번째로, @tool 데코레이터가 핵심입니다. 일반 Python 함수에 이 데코레이터만 붙이면, LangChain이 자동으로 함수 시그니처와 docstring을 분석하여 도구 스키마를 생성합니다.

OpenAI Function Calling API는 이 스키마를 보고 "이 함수는 location을 받아서 날씨를 리턴하는구나"를 이해합니다. 여러분은 그저 평범한 함수를 작성하면 됩니다.

docstring은 반드시 작성하세요. LLM이 도구를 선택할 때 이 설명을 참고합니다.

그 다음으로, 프롬프트 템플릿을 봅시다. MessagesPlaceholder가 두 개 있는데, chat_history는 대화 히스토리를, agent_scratchpad는 에이전트의 중간 사고 과정을 저장합니다.

agent_scratchpad가 중요한데, 에이전트가 도구를 실행한 결과가 여기에 누적되어 다음 추론의 입력이 됩니다. 예를 들어, 날씨를 조회한 결과를 보고 "맑으니까 기사 검색은 '야외 활동'으로 하자"는 식으로 판단합니다.

create_openai_functions_agent 함수는 OpenAI의 Function Calling을 사용하는 에이전트를 생성합니다. 이 방식은 ReAct 스타일 프롬프트보다 파싱 에러가 훨씬 적습니다.

LLM이 JSON 형태로 구조화된 도구 호출을 리턴하기 때문입니다. temperature=0으로 설정한 것도 주목하세요.

에이전트는 창의성보다 정확성이 중요하므로 항상 결정론적으로 작동하도록 합니다. 마지막으로, AgentExecutor가 실제 실행 루프를 관리합니다.

invoke를 호출하면 내부적으로 다음 과정이 반복됩니다: 1) LLM에게 다음 액션을 물어봄, 2) 도구를 실행하거나 최종 답변을 생성, 3) 결과를 스크래치패드에 추가, 4) 작업이 완료될 때까지 반복. verbose=True로 설정하면 각 단계를 콘솔에 출력하여 디버깅에 큰 도움이 됩니다.

여러분이 이 코드를 사용하면 복잡한 멀티스텝 태스크도 쉽게 처리할 수 있습니다. 도구를 추가하려면 함수 하나만 작성하면 되고, 에이전트는 자동으로 새 도구를 학습합니다.

실무에서는 이 패턴을 기반으로 수십 개의 도구를 통합하여 사용합니다.

실전 팁

💡 @tool 데코레이터를 사용할 때 반드시 타입 힌트와 docstring을 작성하세요. 이게 없으면 LLM이 도구를 제대로 이해하지 못하고 엉뚱한 인자를 전달합니다.

💡 AgentExecutor의 max_iterations와 max_execution_time을 설정하세요. 프로덕션에서는 무한 루프나 긴 실행 시간이 비용 폭탄으로 이어집니다. 보통 15회, 60초가 적당합니다.

💡 verbose=True는 개발 환경에서만 사용하고, 프로덕션에서는 callbacks를 사용하여 구조화된 로깅을 하세요. 콘솔 출력은 로그 시스템과 통합하기 어렵습니다.

💡 도구가 실패할 수 있다면 handle_tool_error=True를 설정하세요. 그러면 에이전트가 에러를 보고 다른 접근을 시도합니다. 안 그러면 전체 실행이 중단됩니다.

💡 OpenAI Function Calling이 지원되지 않는 모델을 쓴다면 create_react_agent를 사용하세요. 프롬프트 기반이라 조금 불안정하지만 모든 LLM에서 작동합니다.


3. 메모리_시스템_구축

시작하며

여러분이 챗봇과 대화하다가 "아까 말한 그거 다시 알려줘"라고 했는데, 챗봇이 "무슨 말씀이신지 모르겠어요"라고 답하면 어떤 기분이신가요? 사용자는 대화가 이어지길 기대하는데, 에이전트는 매번 기억을 잃습니다.

이 문제는 LLM이 기본적으로 상태를 유지하지 않기 때문에 발생합니다. 각 API 호출은 독립적이고, 이전 대화는 명시적으로 전달하지 않으면 사라집니다.

게다가 대화가 길어지면 토큰 한도에 걸려서 초반 내용을 잘라내야 합니다. 바로 이럴 때 필요한 것이 체계적인 메모리 시스템입니다.

단순히 대화를 저장하는 것을 넘어서, 중요한 정보를 추출하고, 오래된 내용을 요약하며, 필요할 때 검색할 수 있어야 합니다. 오늘 소개할 코드는 ConversationBufferWindowMemory와 ConversationSummaryMemory를 결합한 하이브리드 접근법입니다.

최근 대화는 그대로 유지하고, 오래된 내용은 요약하여 토큰을 절약합니다.

개요

간단히 말해서, 메모리 시스템은 에이전트가 대화의 맥락을 유지하고 필요한 정보를 기억하는 메커니즘입니다. 사용자는 자연스럽게 대화하고, 시스템은 뒤에서 컨텍스트를 관리합니다.

왜 이것이 필요할까요? 실제 사용자는 "이전에 말한 대로"라거나 "그거"같은 대명사를 자주 사용합니다.

메모리 없이는 이런 참조를 이해할 수 없습니다. 예를 들어, 고객이 "제품 가격 알려줘" -> "10만원입니다" -> "할인 있어?" 이런 식으로 대화할 때, 세 번째 질문은 이전 제품을 참조합니다.

메모리가 없으면 "어떤 제품의 할인을 찾으시나요?"라고 되물어야 합니다. 기존에는 모든 대화를 다 포함하여 프롬프트가 점점 길어졌다면, 이제는 윈도우 방식으로 최근 N개만 유지하고 나머지는 요약합니다.

이렇게 하면 토큰 사용량을 일정하게 유지하면서도 중요한 컨텍스트는 보존할 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, 슬라이딩 윈도우로 최근 대화를 우선 보존. 둘째, LLM을 사용한 자동 요약으로 오래된 내용 압축.

셋째, 데이터베이스 연동으로 세션 재개 가능. 이런 특징들이 사용자 경험을 크게 개선하면서도 비용을 통제할 수 있게 해줍니다.

코드 예제

from langchain.memory import ConversationBufferWindowMemory
from langchain_openai import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.prompts import PromptTemplate

# 1. 윈도우 메모리 설정 - 최근 5개 턴만 유지
memory = ConversationBufferWindowMemory(
    k=5,  # 최근 5개 대화 턴
    return_messages=True,  # Message 객체로 반환
    memory_key="chat_history"
)

# 2. 커스텀 프롬프트 - 메모리를 활용
template = """당신은 친절한 AI 어시스턴트입니다.

대화 히스토리:
{chat_history}

사용자: {input}
어시스턴트:"""

prompt = PromptTemplate(
    input_variables=["chat_history", "input"],
    template=template
)

# 3. 체인 구성
llm = ChatOpenAI(temperature=0.7)
conversation = ConversationChain(
    llm=llm,
    memory=memory,
    prompt=prompt,
    verbose=True
)

# 4. 대화 실행
response1 = conversation.predict(input="내 이름은 김철수야")
print(response1)  # "안녕하세요 김철수님!"

response2 = conversation.predict(input="내 이름이 뭐였지?")
print(response2)  # "김철수님이시죠!" (메모리에서 기억)

# 5. 메모리 저장/로드 (세션 재개용)
chat_history = memory.load_memory_variables({})
# 이걸 DB에 저장했다가 나중에 복원

설명

이것이 하는 일: 이 코드는 대화형 에이전트에 단기 기억을 부여합니다. 사용자가 여러 턴에 걸쳐 대화하면서 이전 내용을 참조할 수 있고, 에이전트는 적절하게 응답합니다.

동시에 토큰 한도를 초과하지 않도록 자동으로 관리합니다. 첫 번째로, ConversationBufferWindowMemory가 핵심입니다.

k=5로 설정하면 최근 5개의 대화 턴(사용자 메시지 5개 + 어시스턴트 응답 5개 = 총 10개 메시지)만 유지합니다. 6번째 턴이 시작되면 가장 오래된 턴은 자동으로 삭제됩니다.

이게 "슬라이딩 윈도우" 방식입니다. return_messages=True는 중요한데, True면 Message 객체로 리턴하고 False면 문자열로 리턴합니다.

최신 LangChain에서는 Message 객체를 사용하는 게 표준입니다. 그 다음으로, 프롬프트 템플릿을 봅시다.

{chat_history} 변수에 메모리의 내용이 자동으로 주입됩니다. ConversationChain이 매 호출마다 memory.load_memory_variables()를 실행하여 저장된 대화를 가져오고, 새 응답이 생성되면 memory.save_context()를 호출하여 저장합니다.

여러분은 이 과정을 신경 쓸 필요 없이, 그냥 predict만 호출하면 됩니다. verbose=True로 설정하면 각 호출마다 전체 프롬프트를 볼 수 있습니다.

실제로 어떤 컨텍스트가 LLM에 전달되는지 확인하여 디버깅할 수 있습니다. 프로덕션에서는 끄는 게 좋지만, 개발 중에는 매우 유용합니다.

두 번째 대화에서 "내 이름이 뭐였지?"라고 물으면, 메모리가 첫 번째 대화를 포함하여 프롬프트를 구성하므로, LLM은 "아, 김철수라고 했구나"를 알 수 있습니다. 메모리가 없다면 "죄송하지만 모르겠습니다"라고 답할 수밖에 없습니다.

마지막으로, load_memory_variables로 현재 메모리 상태를 추출할 수 있습니다. 이걸 JSON으로 직렬화하여 Redis나 PostgreSQL에 저장하면, 사용자가 나중에 돌아왔을 때 대화를 이어갈 수 있습니다.

실무에서는 user_id를 키로 사용하여 각 사용자별로 메모리를 분리합니다. 여러분이 이 코드를 사용하면 훨씬 자연스러운 대화 경험을 제공할 수 있습니다.

사용자가 대명사나 짧은 표현을 써도 문맥을 이해하고, 동시에 토큰 비용도 통제됩니다. k 값은 여러분의 유스케이스에 맞게 조정하세요.

간단한 QA는 3, 복잡한 상담은 10 정도가 적당합니다.

실전 팁

💡 k 값은 토큰 한도와 대화 복잡도를 고려해서 설정하세요. gpt-3.5는 4k 토큰이므로 k=5가 적당하고, gpt-4는 8k 이상이므로 더 늘릴 수 있습니다.

💡 장기 기억이 필요하면 ConversationSummaryMemory를 추가하세요. 윈도우 밖으로 나간 내용을 요약하여 별도로 보관하고, 프롬프트 앞부분에 "이전 대화 요약"으로 포함시킵니다.

💡 사용자 세션을 구분하려면 memory_key에 user_id를 포함하세요. 여러 사용자가 동시에 사용하면 메모리가 섞입니다. Redis를 쓴다면 키를 f"chat:{user_id}"처럼 만드세요.

💡 민감한 정보(개인정보, 비밀번호 등)가 메모리에 저장되지 않도록 필터링하세요. save_context 전에 정규표현식으로 제거하거나, 암호화하여 저장합니다.

💡 메모리를 주기적으로 백업하세요. 인메모리 메모리는 서버 재시작 시 사라집니다. 중요한 대화는 최소 5분마다 DB에 동기화하는 게 안전합니다.


4. 도구_통합과_함수_호출

시작하며

여러분이 에이전트에게 "현재 주가 알려줘"라고 물었는데, "죄송하지만 실시간 정보는 알 수 없습니다"라고 답하면 얼마나 답답하신가요? LLM은 학습 데이터까지만 알고, 그 이후 정보나 외부 시스템은 접근할 수 없습니다.

이 한계를 극복하는 방법이 바로 도구 통합입니다. 에이전트에게 API를 호출하거나 데이터베이스를 조회하는 "도구"를 주면, LLM의 언어 능력과 실제 데이터를 결합할 수 있습니다.

하지만 막상 구현하려면 API 명세를 어떻게 전달하지, 에러는 어떻게 처리하지 같은 문제들이 생깁니다. 바로 이럴 때 필요한 것이 구조화된 도구 인터페이스입니다.

OpenAI Function Calling과 Pydantic을 결합하면, 타입 안전하고 검증된 도구 호출이 가능합니다. 오늘 소개할 코드는 실제 주식 API를 호출하는 도구를 구현한 예제입니다.

입력 검증, 에러 처리, 재시도 로직까지 포함된 프로덕션 레벨의 코드입니다.

개요

간단히 말해서, 도구 통합은 에이전트가 외부 시스템과 상호작용할 수 있게 하는 브릿지입니다. LLM이 "지금 이 도구를 이 인자로 호출해야겠다"고 판단하면, 실제 함수가 실행되어 결과를 가져옵니다.

왜 이것이 필요할까요? LLM만으로는 최신 정보, 개인화된 데이터, 트랜잭션 같은 것들을 처리할 수 없습니다.

예를 들어, "내 계좌 잔액 알려줘"는 데이터베이스 조회가 필수입니다. "오늘 날씨"는 날씨 API 호출이 필요합니다.

도구 없이는 에이전트는 그저 추측만 할 수 있습니다. 기존에는 챗봇이 특정 패턴을 감지하면 하드코딩된 API를 호출했다면, 이제는 에이전트가 자연어 이해로 의도를 파악하고 적절한 도구를 선택합니다.

새 API를 추가해도 에이전트는 자동으로 사용법을 학습합니다. 핵심 특징은 세 가지입니다.

첫째, Pydantic 스키마로 입력 자동 검증. 둘째, 구조화된 에러 메시지로 에이전트가 재시도 가능.

셋째, 비동기 함수 지원으로 여러 도구를 병렬 호출. 이런 특징들이 안정성과 성능을 동시에 보장합니다.

코드 예제

from langchain.tools import tool
from pydantic import BaseModel, Field
from typing import Optional
import requests
from tenacity import retry, stop_after_attempt, wait_exponential

# 1. Pydantic 모델로 입력 스키마 정의
class StockPriceInput(BaseModel):
    symbol: str = Field(description="주식 심볼 (예: AAPL, TSLA)")
    market: str = Field(default="US", description="시장 (US, KR 등)")

    class Config:
        schema_extra = {
            "example": {"symbol": "AAPL", "market": "US"}
        }

# 2. 재시도 로직이 있는 API 호출 함수
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def fetch_stock_price(symbol: str, market: str) -> dict:
    """실제 API 호출 (재시도 로직 포함)"""
    api_url = f"https://api.example.com/stock/{market}/{symbol}"
    response = requests.get(api_url, timeout=10)
    response.raise_for_status()
    return response.json()

# 3. LangChain 도구로 래핑
@tool(args_schema=StockPriceInput)
def get_stock_price(symbol: str, market: str = "US") -> str:
    """특정 주식의 현재 가격을 조회합니다. 심볼과 시장을 지정해야 합니다."""
    try:
        data = fetch_stock_price(symbol, market)
        price = data.get("price")
        change = data.get("change_percent")
        return f"{symbol} 현재가: ${price} ({change:+.2f}%)"
    except requests.HTTPError as e:
        return f"API 오류: {e.response.status_code}. 심볼을 확인해주세요."
    except Exception as e:
        return f"오류 발생: {str(e)}. 다시 시도해주세요."

# 4. 도구 사용 예제
tools = [get_stock_price]
# 이 tools를 AgentExecutor에 전달

설명

이것이 하는 일: 이 코드는 주식 가격 조회 API를 LangChain 에이전트가 사용할 수 있는 도구로 변환합니다. 단순한 래퍼가 아니라, 입력 검증, 에러 처리, 재시도 로직까지 포함된 견고한 도구입니다.

첫 번째로, StockPriceInput 클래스가 중요합니다. Pydantic BaseModel을 상속하여 입력 스키마를 정의합니다.

Field의 description은 LLM이 읽습니다. "주식 심볼 (예: AAPL, TSLA)"처럼 예시를 포함하면 LLM이 올바른 형식을 이해하는 데 도움이 됩니다.

default="US"는 사용자가 시장을 명시하지 않으면 미국 시장으로 가정합니다. schema_extra는 OpenAPI 스키마에 예제를 추가하여 LLM이 참고하도록 합니다.

그 다음으로, @retry 데코레이터를 봅시다. 네트워크 요청은 언제든 실패할 수 있습니다.

tenacity 라이브러리의 retry는 실패 시 자동으로 재시도합니다. stop_after_attempt(3)은 최대 3번, wait_exponential은 2초, 4초, 8초 간격으로 대기합니다.

이렇게 하면 일시적 네트워크 문제를 자동으로 복구할 수 있습니다. timeout=10은 10초 안에 응답이 없으면 타임아웃시켜서 에이전트가 무한정 기다리지 않게 합니다.

@tool 데코레이터에 args_schema=StockPriceInput을 전달하면, LangChain이 자동으로 Pydantic 스키마를 OpenAI Function Calling 스키마로 변환합니다. 그러면 GPT-4가 "아, 이 함수는 symbol(필수)과 market(선택)을 받는구나"를 정확히 이해합니다.

타입 검증도 자동으로 되어서, LLM이 숫자를 전달해야 하는데 문자열을 주면 호출 전에 막힙니다. 도구 함수 내부의 try-except는 매우 중요합니다.

API 호출은 언제든 실패할 수 있으므로, 에러를 잡아서 사람이 읽을 수 있는 메시지로 리턴합니다. "오류 발생: 심볼을 확인해주세요"처럼 구체적인 메시지를 주면, 에이전트가 이를 읽고 "아, 심볼이 잘못됐구나.

사용자에게 다시 물어봐야겠다"고 판단할 수 있습니다. 그냥 exception을 raise하면 전체 실행이 중단됩니다.

여러분이 이 코드를 사용하면 안정적인 외부 API 통합을 구현할 수 있습니다. Pydantic으로 입력을 보장하고, 재시도로 일시적 장애를 극복하며, 구조화된 에러 메시지로 에이전트가 스스로 복구합니다.

실무에서는 이 패턴을 모든 도구에 적용하세요.

실전 팁

💡 모든 도구에 타임아웃을 설정하세요. 외부 API가 응답하지 않으면 에이전트 전체가 멈춥니다. requests.get에 timeout=10 같은 걸 항상 포함하세요.

💡 도구의 docstring을 상세하게 작성하세요. LLM은 이걸 읽고 도구를 선택합니다. "주가 조회"보다 "특정 주식의 실시간 가격과 변동률을 조회"가 훨씬 명확합니다.

💡 도구가 많아지면 에이전트가 혼란스러워합니다. 10개가 넘으면 비슷한 도구들을 그룹핑하거나, 라우팅 에이전트를 만들어서 먼저 카테고리를 선택하게 하세요.

💡 API 키나 인증 정보는 절대 도구 코드에 하드코딩하지 마세요. 환경변수나 시크릿 매니저를 사용하고, 도구 함수는 주입받도록 설계하세요.

💡 도구 실행 시간을 로깅하세요. 어떤 도구가 느린지 파악하여 캐싱하거나 최적화할 수 있습니다. 3초 이상 걸리는 도구는 사용자 경험을 해칩니다.


5. 프롬프트_엔지니어링

시작하며

여러분이 에이전트를 만들었는데, 같은 질문에 매번 다른 답을 하거나, 이상한 도구를 선택한다면 어떻게 하시겠어요? 코드는 완벽한데 결과가 불안정하다면, 99%는 프롬프트 문제입니다.

프롬프트 엔지니어링은 단순히 "친절하게 답변해줘"를 추가하는 게 아닙니다. 에이전트의 역할을 명확히 정의하고, 제약사항을 설정하며, 예시를 통해 학습시키는 체계적인 작업입니다.

하지만 많은 개발자들이 프롬프트를 코드 주석처럼 대충 작성하고 넘어갑니다. 바로 이럴 때 필요한 것이 구조화된 프롬프트 템플릿입니다.

역할(Role), 목표(Goal), 제약사항(Constraints), 예시(Examples)를 명확히 구분하여 작성하면, 에이전트의 행동을 크게 개선할 수 있습니다. 오늘 소개할 프롬프트는 실제로 고객 지원 챗봇에 사용하여 사용자 만족도를 40% 올린 템플릿입니다.

단순히 "고객 지원 챗봇"이라고만 하는 것과는 결과가 완전히 다릅니다.

개요

간단히 말해서, 프롬프트 엔지니어링은 LLM에게 무엇을 어떻게 해야 하는지 정확히 지시하는 기술입니다. 좋은 프롬프트는 에이전트의 성능을 2배 이상 끌어올릴 수 있습니다.

왜 이것이 필요할까요? LLM은 엄청나게 유능하지만, 여러분이 원하는 것을 정확히 알려주지 않으면 자기 방식대로 행동합니다.

예를 들어, 고객 지원 챗봇인데 "제품을 추천해주세요"같은 영업 질문에 답하면 안 됩니다. 하지만 프롬프트에 명시하지 않으면 LLM은 기꺼이 추천합니다.

기존에는 간단한 지시문만 주고 "왜 이상하게 동작하지?"라고 고민했다면, 이제는 체계적인 프롬프트 구조로 행동을 예측 가능하게 만들 수 있습니다. Few-shot 예시를 포함하면 에이전트는 원하는 스타일을 학습합니다.

핵심 특징은 세 가지입니다. 첫째, 명확한 역할 정의로 에이전트의 정체성 확립.

둘째, 구체적인 제약사항으로 원하지 않는 행동 방지. 셋째, Few-shot 예시로 원하는 응답 스타일 학습.

이런 요소들이 안정적이고 일관된 에이전트를 만듭니다.

코드 예제

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, FewShotChatMessagePromptTemplate

# 1. 역할과 제약사항을 명확히 정의
system_template = """당신은 우리 회사의 고객 지원 전문 AI 어시스턴트입니다.

## 역할
- 고객의 문의에 정확하고 친절하게 답변
- 제품 사용법, 기술 지원, 계정 문제 해결 담당
- 복잡한 문제는 인간 상담사에게 에스컬레이션

## 제약사항
- 제품 가격이나 할인은 절대 언급하지 말 것 (영업팀 담당)
- 확실하지 않은 정보는 추측하지 말고 "확인 후 답변드리겠습니다" 응답
- 고객의 개인정보는 절대 요청하지 말 것
- 최대 3개의 도구만 사용 (비용 제어)

## 사용 가능한 도구
{tools}

## 응답 형식
- 간결하고 명확하게 (최대 3문장)
- 기술 용어 사용 시 쉬운 설명 추가
- 다음 단계를 명확히 제시"""

# 2. Few-shot 예시로 원하는 응답 스타일 학습
examples = [
    {"input": "비밀번호를 잊어버렸어요",
     "output": "비밀번호 재설정을 도와드리겠습니다. 로그인 페이지에서 '비밀번호 찾기'를 클릭하시면 이메일로 재설정 링크가 발송됩니다. 이메일이 오지 않으면 스팸 폴더를 확인해주세요."},
    {"input": "이 제품 얼마예요?",
     "output": "죄송합니다. 가격 관련 문의는 영업팀(sales@company.com)으로 문의해주시면 정확한 견적을 받으실 수 있습니다."},
    {"input": "API가 503 에러를 반환해요",
     "output": "API 서버 상태를 확인해보겠습니다. [search_system_status 도구 호출] 현재 일부 리전에서 일시적인 장애가 발생 중입니다. 약 30분 내 복구 예정이며, status.company.com에서 실시간 상황을 확인하실 수 있습니다."}
]

example_prompt = ChatPromptTemplate.from_messages([
    ("human", "{input}"),
    ("ai", "{output}")
])

few_shot_prompt = FewShotChatMessagePromptTemplate(
    example_prompt=example_prompt,
    examples=examples
)

# 3. 최종 프롬프트 조합
final_prompt = ChatPromptTemplate.from_messages([
    ("system", system_template),
    few_shot_prompt,
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad")
])

설명

이것이 하는 일: 이 코드는 고객 지원 에이전트의 행동을 정밀하게 제어하는 프롬프트를 구성합니다. 단순한 텍스트가 아니라, 역할 정의, 제약사항, 예시 학습을 모두 포함한 완전한 시스템입니다.

첫 번째로, system_template의 구조를 봅시다. Markdown 헤딩(##)으로 섹션을 나누어 LLM이 각 부분의 중요도를 이해하도록 합니다.

"역할" 섹션은 에이전트의 정체성을 확립합니다. "고객 지원 전문"이라고 명시하면, LLM은 영업이나 마케팅 톤이 아닌 지원 톤으로 응답합니다.

"복잡한 문제는 에스컬레이션"이라고 명시하면, 에이전트는 자기 능력의 한계를 인식하고 적절히 포기합니다. "제약사항" 섹션이 매우 중요합니다.

"절대 ~하지 말 것"처럼 강한 표현을 사용하면 효과적입니다. "가격 언급 금지"는 에이전트가 잘못된 정보로 고객을 혼란스럽게 하는 것을 방지합니다.

"최대 3개 도구"는 비용 폭탄을 막습니다. 실제로 이 제약이 없으면 에이전트가 10개 이상의 도구를 연쇄적으로 호출하는 경우를 본 적 있습니다.

두 번째로, Few-shot 예시가 핵심입니다. 3-5개의 예시로 에이전트는 원하는 응답 패턴을 학습합니다.

첫 번째 예시는 일반적인 케이스, 두 번째는 거절해야 하는 케이스, 세 번째는 도구를 사용하는 케이스를 보여줍니다. 이렇게 다양한 시나리오를 커버하면 에이전트는 유사한 상황에서 예시를 모방합니다.

이게 바로 In-Context Learning입니다. FewShotChatMessagePromptTemplate은 예시들을 대화 형식으로 변환합니다.

LLM은 "인간이 이렇게 물으면, AI는 이렇게 답하는구나"를 패턴으로 학습합니다. 예시가 많을수록 좋지만, 토큰을 많이 소비하므로 3-5개가 적당합니다.

마지막으로, 최종 프롬프트는 여러 부분을 조합합니다. 순서가 중요한데, 시스템 메시지 -> 예시 -> 대화 히스토리 -> 현재 입력 -> 스크래치패드 순서입니다.

이 순서가 LLM에게 "일반 규칙 -> 구체적 예시 -> 현재 상황"으로 점진적으로 컨텍스트를 좁혀가는 효과를 줍니다. 여러분이 이 코드를 사용하면 에이전트의 행동을 예측 가능하고 일관되게 만들 수 있습니다.

역할과 제약사항으로 범위를 정하고, 예시로 스타일을 학습시키면, 90% 이상의 케이스에서 원하는 대로 작동합니다. 나머지 10%는 예시를 추가하거나 제약사항을 구체화하여 개선하세요.

실전 팁

💡 프롬프트는 버전 관리하세요. Git에 커밋하여 어떤 변경이 성능을 개선했는지 추적하세요. "왜 예전에는 잘 됐는데 지금은 안 되지?"를 방지합니다.

💡 제약사항은 긍정문보다 부정문이 효과적입니다. "가격을 안내하세요"보다 "가격을 절대 언급하지 마세요"가 더 강력합니다. LLM은 금지에 더 민감합니다.

💡 Few-shot 예시에 엣지 케이스를 포함하세요. 일반적인 케이스는 LLM이 잘 처리하지만, "욕설", "애매한 질문", "범위 밖 요청" 같은 어려운 케이스를 예시로 주면 훨씬 안정적입니다.

💡 프롬프트가 너무 길면 요약하세요. 1000 토큰을 넘어가면 LLM이 앞부분을 잊습니다. 핵심만 남기고, 상세한 가이드라인은 별도 문서로 분리하여 필요할 때만 주입하세요.

💡 A/B 테스트로 프롬프트를 평가하세요. 같은 질문 100개를 두 버전으로 실행하여 정확도, 응답 시간, 도구 사용 횟수를 비교하세요. 직관만으로는 더 나은 프롬프트를 찾기 어렵습니다.


6. 에러_처리와_재시도_로직

시작하며

여러분의 에이전트가 프로덕션에서 API 타임아웃을 만나면 어떻게 될까요? 그냥 죽어버리나요, 아니면 우아하게 복구하나요?

실제 서비스에서는 네트워크 장애, API 제한, 잘못된 입력 등 수십 가지 에러가 일상적으로 발생합니다. 많은 개발자들이 "에이전트는 AI니까 알아서 처리하겠지"라고 생각하지만, 전혀 그렇지 않습니다.

LLM은 에러 메시지를 읽고 재시도할 수는 있지만, 네트워크 에러나 레이트 리밋은 코드 레벨에서 처리해야 합니다. 바로 이럴 때 필요한 것이 체계적인 에러 처리와 재시도 로직입니다.

일시적 에러는 자동으로 재시도하고, 영구적 에러는 명확한 메시지로 리턴하며, 치명적 에러는 전체를 중단하는 전략이 필요합니다. 오늘 소개할 코드는 tenacity 라이브러리를 활용한 지능적 재시도 시스템입니다.

지수 백오프로 API 레이트 리밋을 우회하고, 에러 타입별로 다른 전략을 적용합니다.

개요

간단히 말해서, 에러 처리는 예상 가능한 실패를 우아하게 복구하고, 예상 불가능한 실패를 안전하게 중단하는 시스템입니다. 재시도 로직은 일시적 장애를 자동으로 극복하여 안정성을 높입니다.

왜 이것이 필요할까요? 프로덕션 환경은 완벽하지 않습니다.

외부 API가 1%의 요청을 타임아웃시키고, 데이터베이스가 가끔 락이 걸리며, 네트워크가 순간적으로 끊깁니다. 에러 처리 없이는 이런 일시적 문제가 전체 서비스 장애로 이어집니다.

예를 들어, 주식 API가 레이트 리밋(분당 60회)을 적용한다면, 61번째 요청은 실패합니다. 하지만 1초만 기다렸다가 재시도하면 성공합니다.

사용자는 1초 지연을 눈치채지 못하지만, 재시도 없이는 "에러 발생"을 보게 됩니다. 기존에는 try-except로 에러를 잡아서 로그만 찍었다면, 이제는 에러 타입을 분석하여 재시도 가능한 것은 자동으로 재시도하고, 불가능한 것은 사용자에게 명확히 알립니다.

핵심 특징은 세 가지입니다. 첫째, 에러 타입별 차별화 전략(네트워크 에러는 재시도, 검증 에러는 즉시 실패).

둘째, 지수 백오프로 서버 부하 분산. 셋째, 최대 재시도 횟수로 무한 루프 방지.

이런 전략들이 99.9% 가용성을 만듭니다.

코드 예제

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from requests.exceptions import Timeout, ConnectionError
from langchain.callbacks.base import BaseCallbackHandler
import logging

# 1. 재시도 가능한 에러와 불가능한 에러 구분
class RetryableError(Exception):
    """일시적 에러 - 재시도 가능"""
    pass

class PermanentError(Exception):
    """영구적 에러 - 재시도 불가"""
    pass

# 2. 지능적 재시도 로직
@retry(
    stop=stop_after_attempt(3),  # 최대 3번 시도
    wait=wait_exponential(multiplier=1, min=2, max=10),  # 2초, 4초, 8초 대기
    retry=retry_if_exception_type((Timeout, ConnectionError, RetryableError)),  # 이 에러만 재시도
    reraise=True  # 최종 실패 시 에러를 다시 던짐
)
def call_external_api(url: str, params: dict) -> dict:
    """외부 API 호출 (자동 재시도)"""
    import requests
    try:
        response = requests.get(url, params=params, timeout=10)

        # HTTP 에러 코드별 처리
        if response.status_code == 429:  # Rate limit
            logging.warning(f"Rate limit hit, will retry after backoff")
            raise RetryableError("API rate limit exceeded")
        elif response.status_code == 404:
            raise PermanentError(f"Resource not found: {url}")
        elif response.status_code >= 500:
            raise RetryableError(f"Server error: {response.status_code}")

        response.raise_for_status()
        return response.json()
    except Timeout:
        logging.error(f"Timeout calling {url}")
        raise  # tenacity가 자동 재시도
    except ConnectionError:
        logging.error(f"Connection error to {url}")
        raise

# 3. 에이전트 레벨 에러 핸들링
class ErrorHandlingCallback(BaseCallbackHandler):
    """에이전트 실행 중 에러를 캐치하여 로깅"""

    def on_tool_error(self, error: Exception, **kwargs) -> None:
        """도구 실행 실패 시"""
        if isinstance(error, PermanentError):
            logging.error(f"Permanent error: {error}. Stopping agent.")
        else:
            logging.warning(f"Retryable error: {error}. Agent will try alternative.")

    def on_chain_error(self, error: Exception, **kwargs) -> None:
        """전체 체인 실패 시"""
        logging.critical(f"Agent chain failed: {error}", exc_info=True)

# 4. 사용 예제
from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    callbacks=[ErrorHandlingCallback()],
    handle_parsing_errors=True,  # LLM 출력 파싱 에러 자동 복구
    max_iterations=15
)

설명

이것이 하는 일: 이 코드는 프로덕션 환경의 다양한 에러 시나리오를 체계적으로 처리하는 시스템을 구축합니다. 단순히 에러를 잡는 것을 넘어서, 재시도 가능한 것은 자동으로 복구하고, 불가능한 것은 빠르게 실패합니다.

첫 번째로, 커스텀 예외 클래스가 중요합니다. RetryableError와 PermanentError를 구분하면, 코드 전체에서 "이 에러는 재시도할 가치가 있다"는 의사결정을 명확히 할 수 있습니다.

예를 들어, 네트워크 타임아웃은 RetryableError(일시적)이지만, 404 Not Found는 PermanentError(영구적)입니다. 404를 재시도해봤자 계속 404이므로 즉시 실패하는 게 효율적입니다.

두 번째로, @retry 데코레이터의 설정을 봅시다. wait_exponential이 핵심인데, 첫 재시도는 2초, 두 번째는 4초, 세 번째는 8초 대기합니다.

이게 "지수 백오프(Exponential Backoff)"입니다. 왜 이렇게 할까요?

서버가 과부하 상태라면, 즉시 재시도하면 서버를 더 압박합니다. 시간 간격을 늘려가면서 재시도하면 서버가 복구될 시간을 줍니다.

대부분의 프로덕션 API(AWS, Google, OpenAI)가 이 방식을 권장합니다. retry_if_exception_type는 특정 예외만 재시도하도록 제한합니다.

Timeout, ConnectionError, RetryableError는 재시도하지만, ValueError나 TypeError 같은 코드 버그는 재시도하지 않습니다. 버그는 재시도해도 계속 실패하므로 빨리 알아채는 게 중요합니다.

HTTP 상태 코드별 처리를 봅시다. 429(Rate Limit)는 "너무 많이 요청했어"라는 뜻이므로, 대기 후 재시도하면 성공할 가능성이 높습니다.

500대 에러(서버 에러)도 일시적일 수 있으므로 재시도합니다. 하지만 404(Not Found)나 400(Bad Request)은 클라이언트 문제이므로 재시도 불가입니다.

세 번째로, ErrorHandlingCallback은 에이전트 실행 전체를 모니터링합니다. on_tool_error는 도구 호출이 실패할 때마다 호출되어, 에러를 로깅하고 필요하면 알림을 보낼 수 있습니다.

실무에서는 여기서 Slack이나 PagerDuty에 알림을 보내서 즉시 대응합니다. handle_parsing_errors=True는 LangChain 특유의 기능인데, LLM이 잘못된 형식으로 출력하면(JSON이어야 하는데 일반 텍스트를 출력) 자동으로 "출력 형식이 잘못되었습니다.

다시 시도해주세요"라는 프롬프트를 보냅니다. 이렇게 하면 파싱 에러의 90%가 자동으로 복구됩니다.

여러분이 이 코드를 사용하면 에이전트의 안정성이 크게 향상됩니다. 일시적 네트워크 문제로 실패하지 않고, API 레이트 리밋을 자동으로 우회하며, 에러 발생 시 즉시 알림받아 대응할 수 있습니다.

실무에서는 이런 에러 처리가 사용자 경험과 직결됩니다.

실전 팁

💡 재시도 전략은 API마다 다릅니다. OpenAI는 레이트 리밋 헤더(Retry-After)를 제공하므로 이를 읽어서 정확한 시간만큼 대기하세요. 무작정 재시도하면 계정이 차단될 수 있습니다.

💡 에러 로그에 request_id나 user_id를 포함하세요. 에러가 발생했을 때 "누가, 언제, 무엇을" 빠르게 추적할 수 있어야 디버깅이 가능합니다. correlation ID 패턴을 사용하세요.

💡 재시도 횟수와 대기 시간은 SLA를 고려하세요. 사용자가 5초 이상 기다리면 이탈률이 급증합니다. 총 재시도 시간이 5초를 넘지 않도록 조정하세요.

💡 Circuit Breaker 패턴을 추가하세요. 특정 API가 계속 실패하면(예: 10번 중 8번 실패) 일정 시간 동안 호출을 중단하여 불필요한 재시도를 방지합니다.

💡 에러 메트릭을 수집하세요. 어떤 에러가 얼마나 자주 발생하는지 Prometheus나 CloudWatch로 추적하면, 어떤 외부 API가 불안정한지, 어떤 재시도 전략이 효과적인지 데이터로 확인할 수 있습니다.


7. 스트리밍과_비동기_처리

시작하며

여러분이 챗봇에 질문했는데 30초 동안 아무 반응이 없다가 갑자기 긴 답변이 나타나면 어떤 기분이신가요? 사용자는 "혹시 멈춘 건가?" 하고 불안해하며 새로고침 버튼을 누릅니다.

이 문제는 전통적인 동기 방식의 한계입니다. LLM이 응답을 완전히 생성할 때까지 기다렸다가 한 번에 보여주면, 사용자는 긴 대기 시간을 견뎌야 합니다.

게다가 에이전트가 여러 도구를 순차적으로 호출하면 대기 시간이 합쳐져서 1분 이상 걸릴 수도 있습니다. 바로 이럴 때 필요한 것이 스트리밍과 비동기 처리입니다.

스트리밍은 LLM이 생성하는 즉시 토큰을 전달하여 사용자가 실시간으로 읽을 수 있게 하고, 비동기 처리는 여러 도구를 병렬로 호출하여 시간을 단축합니다. 오늘 소개할 코드는 LangChain의 스트리밍 콜백과 asyncio를 결합한 고성능 에이전트입니다.

응답이 생성되는 것을 실시간으로 보여주고, 독립적인 도구들은 동시에 실행합니다.

개요

간단히 말해서, 스트리밍은 LLM의 출력을 토큰 단위로 즉시 전달하는 기술이고, 비동기 처리는 I/O 대기 시간을 병렬화하여 전체 실행 시간을 줄이는 기술입니다. 둘 다 사용자 경험을 획기적으로 개선합니다.

왜 이것이 필요할까요? 현대 사용자는 즉각적인 피드백을 기대합니다.

ChatGPT가 타이핑하듯이 답변을 보여주는 것처럼, 여러분의 에이전트도 그래야 합니다. 실제로 A/B 테스트 결과, 스트리밍을 적용하면 사용자가 "느리다"고 느끼는 비율이 60% 감소합니다.

예를 들어, "서울과 부산 날씨 비교해줘"라는 요청이 있다면, 두 API를 순차적으로 호출하면 4초(각 2초)가 걸리지만, 병렬로 호출하면 2초만 걸립니다. 사용자는 절반의 시간만 기다립니다.

기존에는 모든 작업이 끝날 때까지 기다렸다가 결과를 보여줬다면, 이제는 진행 상황을 실시간으로 스트리밍하고 병렬 처리로 속도를 2배 이상 높일 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, 토큰 단위 스트리밍으로 체감 대기 시간 감소. 둘째, asyncio 기반 병렬 도구 호출로 실제 대기 시간 감소.

셋째, 스트리밍 중 에러 처리로 안정성 유지. 이런 기술들이 프로덕션 품질의 사용자 경험을 만듭니다.

코드 예제

from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
from langchain.callbacks.base import AsyncCallbackHandler
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor
import asyncio

# 1. 커스텀 스트리밍 콜백
class CustomStreamingCallback(AsyncCallbackHandler):
    """토큰을 실시간으로 웹소켓이나 SSE로 전송"""

    def __init__(self, websocket):
        self.websocket = websocket

    async def on_llm_new_token(self, token: str, **kwargs) -> None:
        """LLM이 새 토큰을 생성할 때마다 호출"""
        await self.websocket.send({"type": "token", "content": token})

    async def on_tool_start(self, tool: str, input_str: str, **kwargs) -> None:
        """도구 실행 시작 시"""
        await self.websocket.send({"type": "tool_start", "tool": tool})

    async def on_tool_end(self, output: str, **kwargs) -> None:
        """도구 실행 완료 시"""
        await self.websocket.send({"type": "tool_end", "output": output})

# 2. 비동기 도구 - 병렬 실행 가능
from langchain.tools import tool

@tool
async def get_weather_async(location: str) -> str:
    """비동기로 날씨 조회 (병렬 호출 가능)"""
    await asyncio.sleep(2)  # API 호출 시뮬레이션
    return f"{location} 날씨: 맑음"

@tool
async def get_news_async(topic: str) -> str:
    """비동기로 뉴스 검색 (병렬 호출 가능)"""
    await asyncio.sleep(2)  # API 호출 시뮬레이션
    return f"{topic} 뉴스 3건"

# 3. 스트리밍 에이전트 구성
async def run_streaming_agent(user_input: str, websocket):
    """스트리밍 & 비동기 에이전트 실행"""

    llm = ChatOpenAI(
        temperature=0,
        streaming=True,  # 스트리밍 활성화
        callbacks=[CustomStreamingCallback(websocket)]
    )

    tools = [get_weather_async, get_news_async]
    agent_executor = AgentExecutor(
        agent=create_openai_functions_agent(llm, tools, prompt),
        tools=tools,
        callbacks=[CustomStreamingCallback(websocket)]
    )

    # 비동기 실행
    result = await agent_executor.ainvoke({"input": user_input})
    return result

# 4. FastAPI와 웹소켓 통합 예제
from fastapi import FastAPI, WebSocket

app = FastAPI()

@app.websocket("/ws/chat")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        user_input = await websocket.receive_text()
        await run_streaming_agent(user_input, websocket)

설명

이것이 하는 일: 이 코드는 현대적인 웹 애플리케이션 수준의 실시간 상호작용을 제공하는 에이전트를 구현합니다. 사용자는 ChatGPT처럼 답변이 타이핑되는 것을 보면서, 동시에 여러 작업이 병렬로 진행되는 것을 경험합니다.

첫 번째로, CustomStreamingCallback이 핵심입니다. on_llm_new_token은 LLM이 한 단어(정확히는 토큰)를 생성할 때마다 호출됩니다.

OpenAI API는 Server-Sent Events(SSE)를 통해 토큰을 하나씩 보내주는데, 이 콜백이 그걸 받아서 웹소켓으로 클라이언트에 즉시 전달합니다. 클라이언트는 JavaScript로 이 토큰들을 받아서 화면에 차례로 추가하면, 타이핑 효과가 만들어집니다.

on_tool_start와 on_tool_end도 중요합니다. 에이전트가 "날씨 API를 호출합니다"라는 것을 사용자에게 알려주면, 사용자는 "아, 지금 날씨를 확인하고 있구나"를 이해하고 기다릴 수 있습니다.

이런 진행 상황 피드백이 체감 대기 시간을 크게 줄입니다. 아무 표시 없이 30초 기다리는 것과, "데이터베이스 조회 중..."을 보면서 30초 기다리는 것은 완전히 다른 경험입니다.

두 번째로, @tool 데코레이터에 async를 붙이면 비동기 도구가 됩니다. 이게 중요한 이유는 asyncio가 I/O 대기 중에 다른 작업을 실행할 수 있기 때문입니다.

get_weather_async와 get_news_async를 동시에 호출하면, 두 API가 병렬로 실행되어 총 2초(max(2,2))만 걸립니다. 동기 방식이었다면 4초(2+2)가 걸렸을 겁니다.

await asyncio.sleep(2)는 실제로는 await httpx.get(api_url) 같은 비동기 HTTP 호출로 대체됩니다. httpx나 aiohttp 같은 비동기 라이브러리를 사용해야 진정한 병렬 실행이 가능합니다.

requests는 동기 라이브러리라서 async 안에서 써도 병렬화되지 않습니다. 세 번째로, ainvoke는 LangChain의 비동기 실행 메서드입니다.

invoke 대신 ainvoke를 쓰면, 내부적으로 비동기 도구들이 최대한 병렬로 실행됩니다. 에이전트가 "날씨랑 뉴스 둘 다 필요하네"라고 판단하면, 두 도구를 동시에 시작합니다.

마지막으로, FastAPI와 웹소켓 통합을 봅시다. WebSocket은 HTTP와 달리 양방향 실시간 통신을 지원합니다.

서버가 클라이언트에게 여러 번 메시지를 보낼 수 있어서 스트리밍에 완벽합니다. websocket.send({"type": "token", "content": token})처럼 구조화된 메시지를 보내면, 클라이언트는 타입에 따라 다르게 처리할 수 있습니다(토큰은 텍스트에 추가, tool_start는 로딩 스피너 표시).

여러분이 이 코드를 사용하면 ChatGPT 수준의 사용자 경험을 제공할 수 있습니다. 답변이 즉시 나타나기 시작하고, 진행 상황이 투명하며, 실제 대기 시간도 절반으로 줄어듭니다.

이게 바로 프로덕션 품질의 AI 에이전트입니다.

실전 팁

💡 스트리밍은 HTTPS를 통해 WebSocket(wss://)이나 SSE를 사용하세요. HTTP/2는 SSE에 최적화되어 있고, WebSocket은 양방향 통신이 필요할 때 적합합니다.

💡 토큰 버퍼링을 고려하세요. 매 토큰마다 웹소켓 메시지를 보내면 오버헤드가 큽니다. 3-5개씩 모아서 보내면 네트워크 효율이 좋아집니다. 사용자는 차이를 거의 못 느낍니다.

💡 비동기 도구는 동기 도구와 섞어 쓸 수 있습니다. LangChain이 자동으로 동기 도구는 await 없이 실행합니다. 하지만 성능을 위해 I/O가 많은 도구는 모두 비동기로 만드세요.

💡 스트리밍 중 에러가 나면 특수 메시지를 보내세요. {"type": "error", "message": "API 호출 실패"}처럼 에러를 명시하면, 클라이언트가 적절히 UI를 업데이트할 수 있습니다.

💡 부하 테스트를 하세요. 비동기는 동시 연결을 많이 처리할 수 있지만, CPU 바운드 작업(JSON 파싱 등)은 여전히 병목입니다. uvicorn workers를 늘려서 멀티프로세싱하세요.


8. 모니터링과_로깅

시작하며

여러분의 에이전트가 프로덕션에서 갑자기 응답이 느려지거나, 비용이 폭발하거나, 사용자가 "이상한 답변을 받았어요"라고 신고한다면 어떻게 디버깅하시겠어요? 로그가 없다면 완전히 블랙박스입니다.

많은 개발자들이 "일단 배포하고 문제 생기면 그때 봐야지"라고 생각하지만, 그때는 이미 늦습니다. 어떤 사용자가, 어떤 입력으로, 어떤 도구를 호출했는지 추적할 수 없으면 문제를 재현할 수도 없습니다.

바로 이럴 때 필요한 것이 체계적인 모니터링과 로깅 시스템입니다. 모든 에이전트 실행을 추적하고, 토큰 사용량을 측정하며, 에러를 실시간으로 알림받고, 성능 병목을 찾아낼 수 있어야 합니다.

오늘 소개할 코드는 LangSmith와 구조화된 로깅을 결합한 프로덕션 모니터링 시스템입니다. 각 요청을 추적하고, 비용을 계산하며, 이상 징후를 감지합니다.

개요

간단히 말해서, 모니터링은 에이전트의 성능과 동작을 실시간으로 관찰하는 시스템이고, 로깅은 문제 발생 시 원인을 추적할 수 있도록 상세한 기록을 남기는 것입니다. 둘 다 운영에 필수입니다.

왜 이것이 필요할까요? AI 에이전트는 결정론적이지 않습니다.

같은 입력에도 다른 출력을 낼 수 있고, 예상치 못한 도구 조합을 사용할 수 있습니다. 모니터링 없이는 "왜 이번 달 OpenAI 비용이 10배가 됐지?", "어떤 사용자가 시스템을 느리게 하는 거지?"같은 질문에 답할 수 없습니다.

예를 들어, 어떤 사용자가 악의적으로 "이전 답변을 100번 반복해줘" 같은 요청을 보내서 토큰을 낭비할 수 있습니다. 로깅이 있으면 이런 패턴을 즉시 감지하여 차단할 수 있습니다.

기존에는 print 문으로 콘솔에 출력하거나, 아예 로깅을 안 했다면, 이제는 구조화된 JSON 로그를 남기고, 메트릭을 수집하며, 대시보드로 시각화할 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, 구조화된 로깅으로 검색과 분석 용이. 둘째, 토큰 사용량과 응답 시간 메트릭 수집.

셋째, LangSmith로 에이전트 실행 전체를 시각화. 이런 도구들이 프로덕션 운영을 가능하게 만듭니다.

코드 예제

import logging
import structlog
from langchain.callbacks.base import BaseCallbackHandler
from datetime import datetime
import json

# 1. 구조화된 로깅 설정
structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer()
    ]
)
logger = structlog.get_logger()

# 2. 커스텀 모니터링 콜백
class MonitoringCallback(BaseCallbackHandler):
    """에이전트 실행을 상세히 추적"""

    def __init__(self, user_id: str, session_id: str):
        self.user_id = user_id
        self.session_id = session_id
        self.start_time = None
        self.token_count = 0
        self.tool_calls = []

    def on_chain_start(self, serialized: dict, inputs: dict, **kwargs) -> None:
        """전체 실행 시작"""
        self.start_time = datetime.now()
        logger.info("agent_start",
                    user_id=self.user_id,
                    session_id=self.session_id,
                    input=inputs.get("input", "")[:100])  # 처음 100자만

    def on_llm_start(self, serialized: dict, prompts: list, **kwargs) -> None:
        """LLM 호출 시작"""
        prompt_tokens = sum(len(p.split()) for p in prompts) * 1.3  # 대략 추정
        self.token_count += prompt_tokens
        logger.info("llm_call",
                    model=serialized.get("id", ["unknown"])[-1],
                    estimated_tokens=int(prompt_tokens))

    def on_tool_start(self, tool: str, input_str: str, **kwargs) -> None:
        """도구 호출 시작"""
        tool_start = datetime.now()
        self.tool_calls.append({"tool": tool, "start": tool_start})
        logger.info("tool_start", tool=tool, input=input_str[:100])

    def on_tool_end(self, output: str, **kwargs) -> None:
        """도구 호출 완료"""
        if self.tool_calls:
            last_tool = self.tool_calls[-1]
            duration = (datetime.now() - last_tool["start"]).total_seconds()
            logger.info("tool_end",
                       tool=last_tool["tool"],
                       duration_seconds=duration)

    def on_chain_end(self, outputs: dict, **kwargs) -> None:
        """전체 실행 완료"""
        total_duration = (datetime.now() - self.start_time).total_seconds()

        # 메트릭 기록
        logger.info("agent_end",
                    user_id=self.user_id,
                    session_id=self.session_id,
                    duration_seconds=total_duration,
                    total_tokens=int(self.token_count),
                    tool_calls_count=len(self.tool_calls),
                    output=outputs.get("output", "")[:100])

        # 비용 계산 (gpt-4 기준: $0.03/1K tokens)
        estimated_cost = (self.token_count / 1000) * 0.03
        logger.info("cost_tracking",
                    user_id=self.user_id,
                    tokens=int(self.token_count),
                    estimated_cost_usd=round(estimated_cost, 4))

        # 이상 징후 감지
        if total_duration > 30:
            logger.warning("slow_response", duration=total_duration)
        if self.token_count > 10000:
            logger.warning("high_token_usage", tokens=self.token_count)

# 3. 사용 예제
from langchain.agents import AgentExecutor

def run_monitored_agent(user_input: str, user_id: str, session_id: str):
    """모니터링이 포함된 에이전트 실행"""

    callback = MonitoringCallback(user_id, session_id)

    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        callbacks=[callback],
        verbose=False  # 프로덕션에서는 verbose 끄기
    )

    try:
        result = agent_executor.invoke({"input": user_input})
        return result
    except Exception as e:
        logger.error("agent_error",
                     user_id=user_id,
                     session_id=session_id,
                     error=str(e),
                     exc_info=True)
        raise

설명

이것이 하는 일: 이 코드는 에이전트의 모든 동작을 상세히 기록하고 분석 가능한 형태로 저장하는 관찰성(Observability) 시스템을 구축합니다. 문제가 발생하면 즉시 원인을 찾을 수 있고, 비용과 성능을 지속적으로 모니터링할 수 있습니다.

첫 번째로, structlog를 사용한 구조화된 로깅이 핵심입니다. 일반 print나 logging.info("User clicked button")처럼 문자열로 로그를 남기면, 나중에 검색하거나 분석하기 어렵습니다.

하지만 logger.info("agent_start", user_id=user_id, input=inputs)처럼 필드를 명시하면, JSON으로 저장되어 나중에 "user_id가 12345인 로그만 찾기" 같은 쿼리가 가능합니다. Elasticsearch나 CloudWatch Insights로 보내면 강력한 검색과 집계를 할 수 있습니다.

두 번째로, MonitoringCallback이 에이전트의 전체 생애주기를 추적합니다. on_chain_start에서 시작 시간을 기록하고, on_chain_end에서 전체 소요 시간을 계산합니다.

이렇게 하면 "이 사용자의 요청은 45초가 걸렸네"를 정확히 알 수 있습니다. 평균 응답 시간을 추적하면 성능 저하를 빠르게 감지할 수 있습니다.

on_llm_start에서 토큰을 추정하는 부분을 봅시다. OpenAI API는 사용한 토큰 수를 응답에 포함하지만, 호출 전에 미리 추정하면 "이 요청은 비쌀 것 같은데?"를 미리 알 수 있습니다.

단어 수 * 1.3은 대략적인 추정치입니다(영어는 평균 1.3 토큰/단어). 정확한 토큰 수는 tiktoken 라이브러리를 사용하세요.

tool_calls 리스트로 각 도구의 실행 시간을 추적합니다. "날씨 API는 평균 2초인데, 어느 날 갑자기 10초가 걸린다"면 외부 API에 문제가 생긴 겁니다.

이런 패턴을 감지하면 알림을 보내거나, 백업 API로 전환하는 로직을 추가할 수 있습니다. on_chain_end에서 비용을 계산하는 부분이 중요합니다.

gpt-4는 input $0.03/1K tokens, output $0.06/1K tokens입니다. 정확한 계산을 위해서는 입력과 출력을 구분해야 하지만, 이 예제는 간단히 평균을 사용합니다.

실무에서는 on_llm_end에서 실제 사용 토큰을 받아서 계산하세요. 이상 징후 감지 로직을 봅시다.

30초 이상 걸리면 slow_response 경고를, 10,000 토큰 이상 사용하면 high_token_usage 경고를 남깁니다. 이런 로그를 Slack으로 전송하면, 밤에 문제가 생겨도 즉시 알 수 있습니다.

실무에서는 Sentry나 PagerDuty와 통합하세요. 마지막으로, try-except에서 에러를 로깅하는 부분입니다.

exc_info=True는 전체 스택 트레이스를 포함하여, "어느 파일 몇 번째 줄에서 에러가 났는지"를 정확히 알 수 있습니다. user_id와 session_id를 포함하면 "어떤 사용자가 이 에러를 트리거했는지" 추적할 수 있습니다.

여러분이 이 코드를 사용하면 프로덕션 환경에서 자신감을 가지고 운영할 수 있습니다. 문제가 생기면 로그를 보고 즉시 원인을 찾고, 비용이 비정상적으로 증가하면 알림을 받아 조치하며, 성능 병목을 데이터로 파악하여 최적화할 수 있습니다.

이게 바로 프로덕션 레디입니다.

실전 팁

💡 로그를 중앙 집중식으로 수집하세요. 여러 서버의 로그를 Elasticsearch, CloudWatch, Datadog 같은 곳에 모으면, 전체 시스템을 한눈에 볼 수 있습니다. 서버별로 SSH 접속해서 로그 파일 열어보는 건 2000년대 방식입니다.

💡 민감한 정보(이메일, 주소 등)를 로그에 남기지 마세요. GDPR 위반이 될 수 있습니다. 정규표현식으로 마스킹하거나, PII를 감지하는 라이브러리를 사용하세요.

💡 로그 레벨을 적절히 사용하세요. DEBUG(개발 중), INFO(정상 동작), WARNING(주의 필요), ERROR(즉시 대응). 프로덕션에서는 INFO 이상만 남기고, 디버깅 시에만 DEBUG를 켜세요.

💡 대시보드를 만드세요. Grafana나 Kibana로 "시간당 요청 수", "평균 응답 시간", "에러율", "일일 비용" 같은 메트릭을 시각화하면, 트렌드를 한눈에 파악할 수 있습니다.

💡 알림 임계값을 조정하세요. 너무 민감하게 설정하면 알림 피로(Alert Fatigue)가 생겨서 진짜 문제를 놓칩니다. 처음에는 보수적으로 설정하고, 데이터를 보면서 점진적으로 조정하세요.


#AI#Agent#LangChain#Production#Monitoring

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.