Study/LangChain

5. LangChain 에이전트 구축 가이드 - Tool 연동과 에이전트 패턴

bluebamus 2026. 3. 3.

05_LangChain_에이전트_구축_가이드_new.md
0.08MB
05_LangChain_에이전트_구축_가이드.ipynb
0.11MB

 

* 본 문서는 LangGraph를 사용하지 않는 순수 LangChain 기반 에이전트 구축 방법을 다룬다.

1. 에이전트(Agent) 개요

   1.1. 에이전트란?

      - 에이전트는 LLM이 스스로 판단하여 어떤 도구(Tool)를 어떤 순서로 사용할지 결정하는 시스템이다. 일반 체인(Chain)과 달리, 실행 흐름이 고정되지 않고 LLM의 추론에 따라 동적으로 결정된다.

 

      - 기존 체인(Chain)은 개발자가 정의한 순서대로 작업을 수행한다. 예를 들어 "프롬프트 구성 -> LLM 호출 -> 출력 파싱"이라는 고정된 파이프라인을 따른다. 반면, 에이전트는 사용자의 질문을 분석하여 어떤 도구를 호출할지, 몇 번 호출할지, 어떤 순서로 호출할지를 LLM이 자율적으로 결정한다. 이는 마치 사람이 문제를 해결할 때 상황에 따라 다른 도구를 선택하는 것과 유사하다.

[체인 (Chain)]
사용자 입력 → 프롬프트 → LLM → 출력  (고정된 흐름)

[에이전트 (Agent)]
사용자 입력 → LLM 판단 → Tool A 실행 → 결과 확인 → LLM 판단 → Tool B 실행 → 최종 답변
                ↑                                      ↑
                └──── LLM이 동적으로 결정 ──────────────┘


      1.1.1. 에이전트의 핵심 동작 원리: 관찰-사고-행동 루프

         - 에이전트는 내부적으로 반복적인 루프를 실행한다. 이 루프는 다음 단계로 구성된다:

[에이전트 실행 루프 상세]

1. 사용자 질문 수신
   ↓
2. LLM이 질문 분석 (사고/Thought)
   ↓
3. 도구 호출 필요 여부 판단
   ├── 도구 필요 없음 → 바로 최종 답변 생성
   └── 도구 필요 → 적절한 도구 선택 및 파라미터 결정
                    ↓
4. 도구 실행 (행동/Action)
   ↓
5. 도구 결과 수신 (관찰/Observation)
   ↓
6. LLM이 결과 평가
   ├── 추가 도구 호출 필요 → 3번으로 돌아감
   └── 충분한 정보 확보 → 최종 답변 생성

 

         - 이 루프는 LLM이 "충분한 정보를 확보했다"고 판단할 때까지 반복된다. `max_iterations` 파라미터로 최대 반복 횟수를 제한하여 무한 루프를 방지한다.

 

      1.1.2. 체인(Chain)과 에이전트(Agent)의 상세 비교

기준 체인 (Chain) 에이전트 (Agent)
실행 흐름 개발자가 미리 정의한 고정 순서 LLM이 실시간으로 결정하는 동적 순서
도구 사용 체인 내에 고정 배치 LLM이 필요에 따라 선택적으로 호출
유연성 낮음 (정해진 경로만 실행) 높음 (상황에 따라 다른 경로 선택 가능)
예측 가능성 높음 (항상 동일한 실행 경로) 낮음 (입력에 따라 실행 경로 변동)
디버깅 쉬움 (고정된 단계별 추적 가능) 어려움 (동적 경로 추적 필요)
비용 예측 가능 (고정된 LLM 호출 수) 변동적 (도구 호출 횟수에 따라 증가)
적합한 상황 정형화된 단일 목적 작업 복합 질의, 다중 도구 활용, 동적 판단이 필요한 경우


         - 실무 권장: 단순한 RAG 질의응답은 체인으로 충분하다. 에이전트는 "여러 도구를 조합해야 하는 복합 작업", "사용자 질문에 따라 다른 처리가 필요한 경우", "도구 호출 결과에 따라 추가 작업이 필요한 경우"에 사용한다. 에이전트는 LLM 호출 횟수가 증가하므로 비용과 지연 시간이 체인보다 높다는 점을 고려해야 한다.

 

   1.2. 에이전트 구성 요소

구성 요소 역할 예시 상세 설명
LLM 추론과 의사결정 ChatOpenAI(model="gpt-4o") 에이전트의 두뇌 역할을 하며, 사용자 입력을 분석하고 어떤 도구를 사용할지 판단한다. Function Calling을 지원하는 모델이 필요하다.
Tools 실행 가능한 도구 검색, 계산, API 호출 등 에이전트가 실제로 활용할 수 있는 기능의 집합. 각 도구는 이름, 설명, 입력 스키마를 가진다.
Prompt 에이전트 행동 지침 시스템 프롬프트, 도구 사용법 LLM에게 역할, 규칙, 도구 사용 방식 등을 알려주는 지시문이다.
Agent Executor 에이전트 실행 루프 AgentExecutor 관찰 → 사고 → 행동의 루프를 관리하며, 도구 호출과 그 결과를 LLM에 전달한다.
Memory 대화 이력 관리 ConversationBufferMemory 이전 대화 내용을 저장하여 멀티턴(다회차) 대화를 가능하게 만든다.


      1.2.1. 구성 요소 간 관계

[에이전트 아키텍처]

┌─────────────────────────────────────────────────┐
│                 AgentExecutor                   │
│  ┌──────────┐    ┌───────────┐    ┌───────────┐ │
│  │  Memory  │───→│   Agent   │───→│   Tools   │ │
│  │(대화이력)│    │ (LLM +    │    │(도구 목록)│ │
│  │          │    │  Prompt)  │    │           │ │
│  └──────────┘    └───────────┘    └───────────┘ │
│       ↑               │                  │      │
│       │               ↓                  ↓      │
│       └─────── 결과 저장 ←──── 도구 실행 결과   │
└─────────────────────────────────────────────────┘


         - Agent: LLM과 Prompt를 조합하여 "어떤 도구를 호출할지" 결정하는 핵심 로직이다.
         - AgentExecutor: Agent의 결정을 받아 실제 도구를 실행하고, 결과를 다시 Agent에 전달하는 실행 관리자이다.
         - Memory: 매 대화 턴마다 입력과 출력을 저장하여, 이전 대화를 참조할 수 있게 한다.

 

      1.2.2. Function Calling이란?

         - 에이전트가 도구를 호출하는 핵심 메커니즘이 Function Calling이다. Function Calling은 LLM이 텍스트 응답 대신 "어떤 함수를 어떤 인자로 호출해야 하는지"를 구조화된 JSON 형태로 출력하는 기능이다.

[Function Calling 동작 흐름]

사용자: "연봉 5000만원의 소득세는?"
          ↓
LLM 응답 (텍스트가 아닌 구조화된 호출 지시):
{
  "function": "calculate_income_tax",
  "arguments": {"annual_income": 50000000}
}
          ↓
시스템이 실제 함수 실행:
calculate_income_tax(annual_income=50000000)
          ↓
함수 결과를 LLM에 전달:
"연소득 50,000,000원의 예상 소득세: 5,820,000원"
          ↓
LLM이 최종 답변 생성:
"연봉 5,000만원 기준 예상 소득세는 약 582만원입니다."

 

         - OpenAI의 GPT-4o, GPT-4o-mini 등 최신 모델은 Function Calling(도구 호출)을 기본 지원하며, 이를 통해 에이전트가 안정적으로 도구를 호출할 수 있다. Function Calling을 지원하지 않는 모델(예: 일부 오픈소스 LLM)은 프롬프트 기반 에이전트(ReAct 등)를 사용해야 한다.

 

         - 실무 권장: Function Calling을 지원하는 모델(GPT-4o, GPT-4o-mini, Claude 3.5 Sonnet 등)을 사용하면 도구 호출의 안정성이 크게 향상된다. 프롬프트 기반 방식은 모델이 출력 형식을 지키지 않아 파싱 에러가 발생할 수 있으므로, 가능하면 Function Calling 방식을 우선 사용한다.

 

2. 도구(Tool) 정의 (단계 1: 에이전트의 능력 정의)

   - 도구(Tool)는 에이전트가 실행할 수 있는 기능 단위이다. 에이전트의 능력은 곧 도구의 종류와 품질에 의해 결정된다. LangChain에서 도구는 세 가지 핵심 속성을 가진다:

속성 설명 중요도
name 도구의 고유한 이름으로, LLM이 해당 도구를 식별할 때 사용된다. LLM이 도구를 선택하고 호출할 때 필수적인 식별자
description 도구의 기능과 용도를 설명하며, LLM이 어떤 상황에서 이 도구를 사용할지 판단하는 기준이 된다. 도구 선택 판단의 핵심 정보
args_schema 도구가 입력으로 받는 파라미터의 구조를 정의하여, LLM이 올바른 형식으로 인자를 전달하도록 돕는다. 함수 호출의 정확도를 보장하는 필수 스키마

 

   - 실무 권장: `description`은 에이전트 성능에 가장 큰 영향을 미치는 요소이다. 도구가 "무엇을 하는지"뿐 아니라 "언제 사용해야 하는지"를 명확히 기술해야 한다. 예를 들어 "세법 검색"보다 "세법 관련 정보를 검색합니다. 소득세, 법인세, 부가가치세 등의 질문에 사용하세요."가 더 효과적이다.

 

   2.1. @tool 데코레이터로 도구 정의

      - 가장 간결한 도구 정의 방식이다. 기존 Python 함수에 `@tool` 데코레이터를 추가하면 즉시 LangChain 도구로 변환된다. 함수의 docstring이 도구의 `description`으로 사용되며, 함수의 타입 힌트가 `args_schema`로 자동 변환된다.

from langchain_core.tools import tool

@tool
def search_tax_law(query: str) -> str:
    """세법 관련 정보를 검색합니다. 소득세, 법인세, 부가가치세 등의 질문에 사용하세요."""
    # retriever.invoke()를 호출하여 벡터 DB에서 관련 문서를 검색한다.
    # query: 사용자가 입력한 검색 질의 문자열
    # 반환값: Document 객체의 리스트 (각 Document는 page_content와 metadata를 가짐)
    docs = retriever.invoke(query)
    # 검색된 문서들의 본문(page_content)을 줄바꿈으로 연결하여 하나의 문자열로 반환
    return "\n".join([doc.page_content for doc in docs])

@tool
def calculate_income_tax(annual_income: int) -> str:
    """연간 근로소득을 입력받아 소득세를 계산합니다. annual_income은 원 단위입니다."""
    # 2024년 기준 소득세 과세표준 구간별 세율표
    # 각 튜플: (구간 상한금액, 해당 구간 세율)
    # 예: 1,400만원 이하 구간은 6% 세율 적용
    brackets = [
        (14_000_000, 0.06),      # 1,400만원 이하: 6%
        (50_000_000, 0.15),      # 1,400만원 초과 ~ 5,000만원 이하: 15%
        (88_000_000, 0.24),      # 5,000만원 초과 ~ 8,800만원 이하: 24%
        (150_000_000, 0.35),     # 8,800만원 초과 ~ 1.5억원 이하: 35%
        (300_000_000, 0.38),     # 1.5억원 초과 ~ 3억원 이하: 38%
        (500_000_000, 0.40),     # 3억원 초과 ~ 5억원 이하: 40%
        (1_000_000_000, 0.42),   # 5억원 초과 ~ 10억원 이하: 42%
        (float('inf'), 0.45)     # 10억원 초과: 45%
    ]

    # 누진세 계산: 각 구간별로 해당 구간에 속하는 금액에 세율을 곱하여 합산
    tax = 0
    prev_limit = 0  # 이전 구간의 상한금액 (현재 구간의 시작점)
    for limit, rate in brackets:
        if annual_income <= limit:
            # 현재 구간 내에 소득이 포함되면 남은 금액에 세율 적용 후 종료
            tax += (annual_income - prev_limit) * rate
            break
        else:
            # 현재 구간을 초과하면 구간 전체에 세율 적용 후 다음 구간으로
            tax += (limit - prev_limit) * rate
            prev_limit = limit

    return f"연소득 {annual_income:,}원의 예상 소득세: {int(tax):,}원"

@tool
def get_current_date() -> str:
    """현재 날짜를 반환합니다."""
    from datetime import datetime
    # strftime으로 한국어 형식의 날짜 문자열을 반환
    return datetime.now().strftime("%Y년 %m월 %d일")

 

      - 파라미터 정의 기준: `@tool` 데코레이터를 사용할 때 함수의 타입 힌트(예: `query: str`, `annual_income: int`)는 필수이다. LangChain이 타입 힌트를 기반으로 입력 스키마를 자동 생성하며, 타입 힌트가 없으면 에이전트가 올바른 형식으로 인자를 전달하지 못할 수 있다.

 

      2.1.1. @tool 데코레이터의 추가 옵션

from langchain_core.tools import tool

# return_direct=True: 도구 결과를 LLM에 다시 전달하지 않고 사용자에게 직접 반환
# 단순 조회성 도구에서 LLM의 추가 가공이 필요 없을 때 사용
@tool(return_direct=True)
def get_tax_rate_table() -> str:
    """현재 소득세율표를 반환합니다."""
    return """
    과세표준          | 세율
    1,400만원 이하     | 6%
    5,000만원 이하     | 15%
    8,800만원 이하     | 24%
    1.5억원 이하       | 35%
    """

# name과 description을 명시적으로 지정 (함수명, docstring을 대체)
@tool(name="tax_law_search", description="세법 조항을 검색합니다. 법률 조문이나 규정을 찾을 때 사용하세요.")
def search(query: str) -> str:
    """이 docstring 대신 위의 description이 사용된다."""
    docs = retriever.invoke(query)
    return "\n".join([doc.page_content for doc in docs])


         - 실무 권장: `return_direct=True`는 도구 결과를 LLM이 추가 해석하거나 다른 도구와 조합할 필요가 없는 경우에만 사용한다. 예를 들어 단순 표 조회, 고정된 안내 문구 반환 등에 적합하다. 복합 질의에서는 LLM이 여러 도구 결과를 종합해야 하므로 `return_direct=False`(기본값)를 유지한다.

   

   2.2. Tool 클래스로 정의

      - `BaseTool` 클래스를 상속하여 도구를 정의하는 방식이다. `@tool` 데코레이터보다 코드가 길지만, 초기화 로직, 상태 관리, 비동기 처리를 세밀하게 제어할 수 있어 복잡한 도구에 적합하다.

from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
# 참고: langchain_core.pydantic_v1은 deprecated. LangChain v0.3+에서는 pydantic v2 직접 사용

# 입력 파라미터 스키마를 Pydantic 모델로 명시적으로 정의
# 에이전트가 각 파라미터의 타입, 기본값, 설명을 정확히 파악할 수 있다
class TaxSearchInput(BaseModel):
    query: str = Field(description="세법 관련 검색 질의")          # 필수 파라미터
    year: int = Field(default=2024, description="적용 연도")      # 선택 파라미터 (기본값 2024)

class TaxSearchTool(BaseTool):
    # name: 에이전트가 이 도구를 참조할 때 사용하는 고유 식별자
    name: str = "tax_search"
    # description: 에이전트가 이 도구를 선택할지 판단하는 핵심 기준
    description: str = "세법 관련 정보를 검색합니다. 연도를 지정할 수 있습니다."
    # args_schema: 입력 파라미터의 타입, 기본값, 설명을 정의하는 Pydantic 모델
    args_schema: type[BaseModel] = TaxSearchInput

    def _run(self, query: str, year: int = 2024) -> str:
        """동기 실행 메서드. AgentExecutor가 도구를 실행할 때 호출된다."""
        # 연도와 질의를 조합하여 검색 쿼리 구성
        docs = retriever.invoke(f"{year}년 {query}")
        return "\n".join([doc.page_content for doc in docs])

    async def _arun(self, query: str, year: int = 2024) -> str:
        """비동기 실행 메서드. 비동기 환경(예: FastAPI)에서 호출된다."""
        # ainvoke는 invoke의 비동기 버전으로, 이벤트 루프를 블로킹하지 않는다
        docs = await retriever.ainvoke(f"{year}년 {query}")
        return "\n".join([doc.page_content for doc in docs])


      - 파라미터 정의 기준: `args_schema`에 정의하는 Pydantic 모델의 `Field(description=...)`은 에이전트가 각 파라미터의 의미를 이해하는 데 결정적인 역할을 한다. description이 없으면 에이전트가 파라미터 용도를 정확히 파악하지 못해 잘못된 값을 전달할 수 있다.

 

      2.2.1. BaseTool 사용이 적합한 경우

상황 이유
초기화 시 외부 리소스 연결 __init__에서 DB 연결, API 클라이언트 생성 등 작업을 수행해 도구 실행 시마다 재초기화 비용을 줄이고 일관된 연결을 유지할 수 있다.
도구 내부에 상태 유지 호출 횟수 추적, 캐시 관리 등의 상태를 인스턴스 변수에 저장하여, 동일 세션/에이전트 실행 동안 누적 정보에 기반한 동작을 수행할 수 있다. 
동기/비동기 동작을 각각 최적화 _run과 _arun을 독립적으로 구현해 CPU 바운드/IO 바운드 작업 특성에 맞춰 동기·비동기 처리를 나누고, 필요 시 한쪽만 지원하도록 설계할 수 있다.
검증 로직 추가 입력 값 범위 검증, 권한 확인 등의 공통 검증 로직을 도구 클래스 내부에 두어, LLM이 잘못된 인자를 보내도 안전하게 방어하고 에러를 제어된 형태로 반환할 수 있다. 
여러 메서드를 조합하는 복잡한 도구 내부 헬퍼 메서드들을 정의해 외부 API 호출, 응답 파싱, 후처리 등을 단계적으로 나누어 재사용 가능하고 테스트하기 쉬운 복합 도구를 만들 수 있다. 


      2.2.2. StructuredTool.from_function() (함수 기반 도구 생성)

         - `@tool` 데코레이터와 `BaseTool` 클래스의 중간 방식으로, 기존 함수를 도구로 변환할 때 유용하다. 특히 이미 작성된 유틸리티 함수를 에이전트 도구로 재사용할 때 적합하다. 함수 자체를 수정하지 않고도 도구로 변환할 수 있어, 기존 코드베이스와의 호환성이 좋다.

from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field

# 1. 기존 함수를 그대로 도구로 변환
# 함수 자체에는 변경이 필요 없다 (데코레이터 추가 불필요)
def search_tax_law(query: str, year: int = 2024) -> str:
    """세법 조항을 검색합니다."""
    return f"{year}년 세법에서 '{query}' 관련 조항을 검색한 결과입니다."

# StructuredTool.from_function()으로 기존 함수를 도구 객체로 변환
search_tool = StructuredTool.from_function(
    func=search_tax_law,                         # 변환할 함수 (수정 불필요)
    name="tax_law_search",                       # 도구 이름 (에이전트가 참조)
    description="세법 조항을 연도별로 검색합니다",  # 에이전트가 도구 선택 시 참고하는 설명
)

# 2. Pydantic 스키마로 입력 파라미터를 명시적으로 정의
# args_schema를 지정하면 함수의 타입 힌트보다 우선 적용된다
class TaxCalculatorInput(BaseModel):
    income: int = Field(description="연간 소득 (원)")
    deductions: int = Field(default=0, description="공제액 (원)")

def calculate_tax(income: int, deductions: int = 0) -> str:
    """소득세를 계산합니다."""
    taxable = income - deductions    # 과세표준 = 소득 - 공제액
    tax = int(taxable * 0.15)        # 간이 세율 15% 적용 (예시용)
    return f"과세표준: {taxable:,}원, 예상 세액: {tax:,}원"

tax_calc_tool = StructuredTool.from_function(
    func=calculate_tax,
    name="tax_calculator",
    description="소득과 공제액을 기반으로 소득세를 계산합니다",
    args_schema=TaxCalculatorInput,       # Pydantic 스키마로 입력 파라미터 명시
)

# 3. 비동기 함수도 지원
# 비동기 함수는 func 대신 coroutine 파라미터로 전달한다
async def async_search(query: str) -> str:
    """비동기 세법 검색"""
    return f"'{query}' 비동기 검색 결과"

async_tool = StructuredTool.from_function(
    coroutine=async_search,               # 비동기 함수는 coroutine 파라미터 사용
    name="async_tax_search",
    description="비동기로 세법을 검색합니다",
)

# 도구 테스트: invoke 메서드로 딕셔너리 형태의 입력을 전달하여 실행
print(search_tool.invoke({"query": "근로소득세", "year": 2024}))
print(tax_calc_tool.invoke({"income": 50000000, "deductions": 5000000}))


         - 도구 생성 방식 비교:

방식 적합한 경우 특징 코드 복잡도
@tool 데코레이터 새 도구를 빠르게 정의 가장 간결하며, 함수 위에 데코레이터만 추가하면 되고 타입 힌트로 입력 스키마를 유추한다. 낮음
StructuredTool.from_function() 기존 함수를 도구로 변환 기존 함수를 거의 수정하지 않고 감쌀 수 있고, 별도 스키마를 명시적으로 정의할 수 있다. 중간
BaseTool 클래스 복잡한 초기화 및 상태 관리가 필요한 경우 __init__, _run, _arun 등을 직접 정의해 초기화·상태·동기/비동기 동작을 가장 세밀하게 제어할 수 있다. 높음

 

         - 실무 권장: 프로토타입 단계에서는 `@tool` 데코레이터로 빠르게 도구를 정의하고, 기존 유틸리티 함수를 재사용해야 할 때는 `StructuredTool.from_function()`을, 프로덕션에서 복잡한 상태 관리가 필요하면 `BaseTool` 클래스를 사용한다.

 

   2.3. RAG를 도구로 래핑

      - RAG 체인을 에이전트의 도구로 래핑하면, 에이전트가 필요할 때만 문서 검색을 수행할 수 있다. 이는 에이전트에 "지식 검색 능력"을 부여하는 핵심 패턴이다.

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

@tool
def rag_search(question: str) -> str:
    """문서를 검색하여 질문에 답변합니다. 세법, 회계, 세무 관련 질문에 사용하세요."""
    # RAG 프롬프트 구성: 검색된 문서(context)와 사용자 질문(input)을 조합
    prompt = ChatPromptTemplate.from_template(
        "컨텍스트를 기반으로 답하세요.\n컨텍스트: {context}\n질문: {input}\n답변:"
    )
    # create_stuff_documents_chain: 검색된 문서를 하나의 프롬프트에 "stuff"(채워넣기)하는 체인
    # 검색된 모든 문서를 {context} 자리에 연결하여 삽입한다
    combine_chain = create_stuff_documents_chain(llm, prompt)
    # create_retrieval_chain: retriever + combine_chain을 연결하는 RAG 체인
    # 질문 → 검색 → 문서 결합 → LLM 생성의 전체 흐름을 관리한다
    rag_chain = create_retrieval_chain(retriever, combine_chain)
    result = rag_chain.invoke({"input": question})
    # result["answer"]: LLM이 생성한 최종 답변 문자열
    return result["answer"]


   2.3.1. RAG 도구 래핑 시 고려사항

고려사항 설명 권장
도구 description LLM이 RAG 도구를 언제 사용해야 하는지 명확히 판단할 수 있도록 구체적인 사용 상황을 기술해야 한다. "세법, 회계, 세무 관련 질문에만 사용하세요"처럼 도메인과 상황을 명확히 구체화
검색 결과 수 (k) retriever가 반환하는 문서 개수가 최종 답변 품질과 토큰 사용량에 직접 영향을 준다. k=3~5 권장 (컨텍스트 길이 초과 방지, 품질 유지)
출처 포함 답변의 신뢰성을 높이고 LLM이 검증할 수 있도록 문서 출처 정보를 포함해야 한다. result["context"]의 metadata.source를 함께 반환하거나 요약
응답 길이 도구 결과가 너무 길면 에이전트가 후속 판단에 어려움을 겪거나 토큰 제한에 걸린다. 핵심 내용만 200~300자 정도로 요약하여 반환


   2.4. 외부 API 도구

      - 에이전트에 외부 API를 도구로 제공하면, 실시간 정보 조회, 이메일 발송, 데이터베이스 쿼리 등 LLM 단독으로는 불가능한 작업을 수행할 수 있다.

@tool
def search_web(query: str) -> str:
    """웹에서 최신 정보를 검색합니다. 최신 뉴스, 실시간 정보가 필요할 때 사용하세요."""
    import requests
    # SerpAPI를 사용한 웹 검색 예시
    # api_key: SerpAPI에서 발급받은 API 키
    # engine: 검색 엔진 종류 (google, bing 등)
    params = {"q": query, "api_key": "...", "engine": "google"}
    response = requests.get("https://serpapi.com/search", params=params)
    # organic_results: 자연 검색 결과 리스트 (광고 제외)
    results = response.json().get("organic_results", [])
    # 상위 3개 결과의 snippet(요약)만 추출하여 반환
    return "\n".join([r.get("snippet", "") for r in results[:3]])

@tool
def send_email(to: str, subject: str, body: str) -> str:
    """이메일을 발송합니다."""
    # 실제 이메일 발송 로직 (예: SMTP, SendGrid API 등)
    return f"{to}에게 '{subject}' 제목의 이메일을 발송했습니다."

 

      - 실무 권장: 외부 API를 도구로 연결할 때는 반드시 에러 처리와 타임아웃을 설정해야 한다. API 장애가 에이전트 전체의 응답 실패로 이어질 수 있으므로, `try-except`로 에러를 잡고 에이전트에게 "이 도구가 실패했으므로 다른 방법을 시도하세요"라는 메시지를 반환하는 것이 좋다. 또한 이메일 발송, 데이터 수정 등 부작용(side effect)이 있는 도구는 반드시 사용자 확인(Human-in-the-loop)을 추가하는 것을 권장한다.

 

      2.4.1. 외부 API 도구의 안전한 에러 처리 패턴

@tool
def search_web_safe(query: str) -> str:
    """웹에서 최신 정보를 검색합니다. 최신 뉴스, 실시간 정보가 필요할 때 사용하세요."""
    import requests
    try:
        params = {"q": query, "api_key": "...", "engine": "google"}
        # timeout=10: 10초 이내에 응답이 없으면 예외 발생
        response = requests.get("https://serpapi.com/search", params=params, timeout=10)
        response.raise_for_status()  # HTTP 에러 코드(4xx, 5xx)가 있으면 예외 발생
        results = response.json().get("organic_results", [])
        return "\n".join([r.get("snippet", "") for r in results[:3]])
    except requests.exceptions.Timeout:
        # 타임아웃 발생 시 에이전트에게 상황을 알려 다른 도구를 시도하게 유도
        return "웹 검색 시간이 초과되었습니다. 다른 도구를 사용해주세요."
    except requests.exceptions.RequestException as e:
        # 네트워크 오류, HTTP 에러 등 모든 요청 관련 예외를 포괄 처리
        return f"웹 검색 중 오류가 발생했습니다: {str(e)}. 다른 도구를 사용해주세요."


   2.5. 도구 모음 구성

      - 에이전트에 등록할 도구들을 리스트로 구성한다. 이 리스트가 에이전트의 "능력 범위"를 결정한다.

# 에이전트가 사용할 도구 리스트 구성
# 리스트에 포함된 도구만 에이전트가 호출할 수 있다
tools = [
    search_tax_law,        # 세법 검색 도구
    calculate_income_tax,  # 소득세 계산 도구
    get_current_date,      # 현재 날짜 반환 도구
    rag_search,            # RAG 기반 문서 검색 도구
]


      2.5.1. 도구 모음 구성 시 주의사항

주의사항 설명
도구 수 제한 LLM이 한 번에 처리할 수 있는 도구 수가 제한되어 있어, 10개 이하로 유지해야 정확한 도구 선택이 가능하다.
도구 이름 충돌 동일한 name을 가진 도구가 있으면 LangChain에서 충돌 에러가 발생하므로, 모든 도구에 고유한 이름을 부여해야 한다.
설명 차별화 기능이 비슷한 도구들끼리 description에서 사용 시나리오와 조건을 명확히 구분해야 LLM이 적절히 선택한다.
불필요한 도구 제거 실제로 사용하지 않는 도구는 에이전트의 tools 리스트에서 제거하여 LLM의 선택 과부하와 토큰 낭비를 방지한다.


         - 실무 권장: 도구 수가 많아지면 에이전트의 도구 선택 정확도가 떨어진다. 도메인별로 에이전트를 분리(세법 에이전트, 노동법 에이전트 등)하여 각 에이전트가 소수의 관련 도구만 보유하도록 설계하는 것이 효과적이다. 도구가 10개를 넘어가면 "라우터 + 전문 에이전트" 패턴(8.2절)을 검토한다.

 

3. 에이전트 생성 (단계 2: 에이전트 초기화)

   - LangChain은 여러 유형의 에이전트 생성 함수를 제공한다. 각 유형은 도구 호출 방식, 지원 모델, 기능이 다르므로 사용 환경에 맞는 유형을 선택해야 한다.

 

   3.1. OpenAI Functions Agent

      - 주의: `create_openai_functions_agent`는 deprecated 상태이며, `create_openai_tools_agent`(3.2절)로 대체를 권장한다. OpenAI Tools Agent는 병렬 도구 호출을 지원하며, 최신 OpenAI API의 tools 파라미터를 사용한다. 아래는 레거시 호환성을 위한 참고 코드이다.

from langchain.agents import create_openai_functions_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# 에이전트 프롬프트 구성
# system: 에이전트의 역할, 규칙, 도구 사용 지침을 정의
# MessagesPlaceholder("chat_history"): 이전 대화 이력이 삽입되는 위치 (optional=True로 없어도 동작)
# human: 사용자 입력이 삽입되는 위치
# MessagesPlaceholder("agent_scratchpad"): 에이전트의 중간 사고 과정(도구 호출/결과)이 기록되는 위치
agent_prompt = ChatPromptTemplate.from_messages([
    ("system", """당신은 세법 전문 AI 어시스턴트입니다.
사용자의 질문에 답하기 위해 적절한 도구를 사용하세요.

규칙:
1. 세법 관련 질문은 반드시 tax_search 도구를 사용하세요
2. 세금 계산이 필요하면 calculate_income_tax 도구를 사용하세요
3. 도구의 결과를 바탕으로 정확하게 답변하세요
4. 불확실한 경우 추측하지 마세요"""),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

# 에이전트 생성: LLM + 도구 + 프롬프트를 조합하여 Agent 객체 생성
agent = create_openai_functions_agent(
    llm=ChatOpenAI(model="gpt-4o", temperature=0),  # temperature=0: 일관된 도구 호출을 위해 결정적 출력
    tools=tools,                                       # 사용할 도구 리스트
    prompt=agent_prompt,                               # 에이전트 행동 지침 프롬프트
)

# AgentExecutor: 에이전트의 관찰-사고-행동 루프를 관리하는 실행기
agent_executor = AgentExecutor(
    agent=agent,                          # 위에서 생성한 Agent 객체
    tools=tools,                          # 실행할 도구 리스트 (agent와 동일해야 함)
    verbose=True,                         # True: 실행 과정(도구 호출, 결과 등)을 콘솔에 출력
    max_iterations=5,                     # 최대 반복 횟수 (무한 루프 방지)
    return_intermediate_steps=True,       # True: 중간 단계(도구 호출 기록)를 결과에 포함
    handle_parsing_errors=True,           # True: LLM 출력 파싱 실패 시 에러 메시지를 에이전트에 전달하여 재시도
)

 

      - 파라미터 정의 기준: `temperature=0`은 에이전트에서 매우 중요하다. 에이전트는 도구 호출 여부와 파라미터를 정확히 결정해야 하므로, 창의적 출력보다 일관된 결정이 필요하다. temperature가 높으면 동일한 질문에 대해 다른 도구를 호출하거나 잘못된 파라미터를 전달할 수 있다.

 

      3.1.1. agent_scratchpad의 역할

         - `agent_scratchpad`은 에이전트의 중간 작업 기록이 저장되는 공간이다. 에이전트가 도구를 호출할 때마다 "호출한 도구", "전달한 인자", "받은 결과"가 이 공간에 기록되어 LLM이 다음 행동을 결정할 때 참고한다.

[agent_scratchpad 예시]

# 1차 호출
AI: calculate_income_tax(annual_income=50000000)
도구 결과: "연소득 50,000,000원의 예상 소득세: 5,820,000원"

# 2차 호출 (1차 결과를 보고 추가 도구 호출이 필요한 경우)
AI: search_tax_law(query="소득세 공제 항목")
도구 결과: "근로소득공제, 인적공제, 신용카드 공제..."

# LLM이 모든 결과를 종합하여 최종 답변 생성


   3.2. OpenAI Tools Agent (권장)

      - OpenAI의 최신 Tools API를 사용하는 에이전트로, 병렬 도구 호출(Parallel Function Calling)을 지원한다. 예를 들어 "소득세 설명과 연봉 5000만원 세금 계산"이라는 질문에 대해, 검색 도구와 계산 도구를 동시에 호출할 수 있어 응답 속도가 빨라진다.

from langchain.agents import create_openai_tools_agent

# OpenAI의 parallel function calling 지원 (권장 방식)
# 프롬프트는 3.1절의 agent_prompt를 동일하게 사용할 수 있다
agent = create_openai_tools_agent(
    llm=ChatOpenAI(model="gpt-4o", temperature=0),  # Function Calling을 지원하는 OpenAI 모델
    tools=tools,                                       # 사용할 도구 리스트
    prompt=agent_prompt,                               # 에이전트 행동 지침 프롬프트
)

# AgentExecutor로 실행기 생성
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True   # 실행 과정 출력
)


      3.2.1. 병렬 도구 호출의 동작 방식

[순차 호출 (OpenAI Functions Agent)]
질문: "소득세 설명하고, 연봉 5000만원 세금도 계산해줘"
  → search_tax_law("소득세") 실행 → 결과 수신
  → calculate_income_tax(50000000) 실행 → 결과 수신
  → 최종 답변 생성
  총 소요: 도구1 시간 + 도구2 시간 + 답변 생성 시간

[병렬 호출 (OpenAI Tools Agent)]
질문: "소득세 설명하고, 연봉 5000만원 세금도 계산해줘"
  → search_tax_law("소득세") + calculate_income_tax(50000000) 동시 실행 → 결과 수신
  → 최종 답변 생성
  총 소요: max(도구1 시간, 도구2 시간) + 답변 생성 시간


         - 실무 권장: OpenAI 모델을 사용한다면 `create_openai_tools_agent`를 기본 선택으로 사용한다. 기존 `create_openai_functions_agent` 코드는 프롬프트 구조가 동일하므로, 함수 이름만 교체하면 마이그레이션된다.

 

   3.3. ReAct Agent

      ReAct(Reasoning + Acting)는 LLM이 "사고(Thought) → 행동(Action) → 관찰(Observation)"을 반복하는 추론 패턴이다. Function Calling이 아닌 프롬프트 기반으로 동작하므로, Function Calling을 지원하지 않는 모든 LLM에서 사용할 수 있다.

from langchain.agents import create_react_agent
from langchain import hub

# LangChain Hub에서 검증된 ReAct 프롬프트를 가져온다
# hwchase17/react: LangChain 창시자(Harrison Chase)가 작성한 표준 ReAct 프롬프트
# 이 프롬프트에는 도구 사용법, 사고-행동-관찰 형식 등이 정의되어 있다
react_prompt = hub.pull("hwchase17/react")

agent = create_react_agent(
    llm=ChatOpenAI(model="gpt-4o", temperature=0),  # 모든 LLM 사용 가능
    tools=tools,
    prompt=react_prompt,  # ReAct 형식의 프롬프트
)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=10,         # ReAct는 반복이 많을 수 있으므로 여유 있게 설정
    handle_parsing_errors=True, # 프롬프트 기반이므로 파싱 에러가 발생할 수 있음
)


      3.3.1. ReAct 프롬프트의 동작 형식

         - ReAct 에이전트는 LLM의 출력을 특정 텍스트 형식으로 파싱한다:

Thought: 사용자가 연봉 5000만원의 소득세를 물어보고 있다. 계산 도구를 사용해야 한다.
Action: calculate_income_tax
Action Input: {"annual_income": 50000000}
Observation: 연소득 50,000,000원의 예상 소득세: 5,820,000원
Thought: 계산 결과를 받았으니 사용자에게 답변할 수 있다.
Final Answer: 연봉 5,000만원 기준 예상 소득세는 약 582만원입니다.

 

         - 실무 권장: ReAct는 모든 LLM에서 사용 가능하다는 장점이 있지만, 프롬프트 기반이므로 LLM이 형식을 지키지 않으면 파싱 에러가 발생한다. GPT-4o 등 고성능 모델에서는 안정적이지만, 소형 모델에서는 형식 오류가 잦을 수 있다. `handle_parsing_errors=True`를 반드시 설정하고, 파싱 에러 발생 시 에이전트가 재시도하도록 해야 한다.

 

   3.4. Structured Chat Agent (복합 입력 지원)

      - 복잡한 입력 스키마(여러 파라미터, 중첩 구조)를 가진 도구를 프롬프트 기반으로 사용할 때 적합하다. ReAct와 마찬가지로 Function Calling 없이도 동작한다.

from langchain.agents import create_structured_chat_agent
from langchain import hub

# LangChain Hub에서 Structured Chat 프롬프트를 가져온다
# 이 프롬프트는 도구의 입력 스키마를 JSON 형식으로 제시하여
# LLM이 복합 입력을 정확히 생성하도록 안내한다
structured_prompt = hub.pull("hwchase17/structured-chat-agent")

agent = create_structured_chat_agent(
    llm=ChatOpenAI(model="gpt-4o", temperature=0),
    tools=tools,
    prompt=structured_prompt,
)

 

      - 실무 권장: Structured Chat Agent는 Function Calling을 지원하지 않는 모델에서 복합 입력 도구를 사용해야 할 때 선택한다. OpenAI 모델을 사용한다면 OpenAI Tools Agent가 더 안정적이므로 우선 사용한다.

 

   3.5. 에이전트 유형 비교

에이전트 유형 도구 호출 방식 병렬 도구 호출 복합 입력 적합 모델 상태 추천 상황
OpenAI Functions OpenAI Function Calling API X (순차) O GPT-4o, GPT-4-turbo 계열 Deprecated (이전 방식) 기존 레거시 코드 유지보수
OpenAI Tools OpenAI Tools API (개선된 FC) O (병렬 호출 가능) O GPT-4o, GPT-4o-mini 계열 현재 권장 OpenAI 모델 사용 시 최적, 병렬 처리 필요
ReAct 프롬프트로 "Thought → Action → Observation" 반복 X (순차적 추론) X (단일 도구 중심) 모든 LLM (Llama3, Mistral 등) 사용 가능 오픈소스 LLM, 추론 과정 투명성 필요
Structured Chat XML 태그 기반 구조화 프롬프트 X (순차) O (복합 입력 처리) 모든 LLM 사용 가능 복합 입력 + OpenAI 외 모델 조합


      3.5.1. 에이전트 유형 선택 가이드

[에이전트 유형 선택 흐름]

OpenAI 모델(GPT-4o 등) 사용?
├── 예 → create_openai_tools_agent (권장)
└── 아니오 → 복합 입력 도구가 있는가?
             ├── 예 → create_structured_chat_agent
             └── 아니오 → create_react_agent


4. 에이전트 실행과 대화 (단계 3: 에이전트 활용)

   4.1. 기본 실행

      - AgentExecutor의 `invoke` 메서드로 에이전트를 실행한다. 입력은 딕셔너리 형태로 전달하며, `input` 키에 사용자 질문을 담는다.

# 단일 질의 실행
# invoke()는 에이전트의 전체 실행 루프를 한 번 수행하고 결과를 반환한다
result = agent_executor.invoke({"input": "연봉 5000만원일 때 소득세는 얼마인가요?"})

# result는 딕셔너리: {"input": "...", "output": "...", "intermediate_steps": [...]}
# result["output"]: LLM이 생성한 최종 답변 문자열
print(result["output"])

# verbose=True 설정 시 콘솔에 출력되는 실행 과정 예시:
# > Entering new AgentExecutor chain...
# Invoking: `calculate_income_tax` with `{'annual_income': 50000000}`
# 연소득 50,000,000원의 예상 소득세: 5,820,000원
# > Finished chain.


      4.1.1. invoke() 반환값의 구조

타입 설명
input str 사용자가 입력한 원본 질문 문자열로, 에이전트의 입력값이다.
output str 에이전트가 모든 도구 호출과 추론을 거쳐 최종적으로 생성한 답변 문자열이다.
intermediate_steps list[tuple[str, str]] 중간 도구 호출 기록으로 (tool_name, tool_output) 형태의 리스트. return_intermediate_steps=True 설정 시 반환된다.
chat_history list[BaseMessage] Memory가 활성화된 경우 이전 대화 이력을 저장한 메시지 리스트로, 컨텍스트 유지에 사용된다.


   4.2. 중간 단계 확인

      - `return_intermediate_steps=True`로 설정하면 에이전트가 어떤 도구를 어떤 순서로 호출했는지 추적할 수 있다. 디버깅과 에이전트 동작 검증에 필수적이다.

result = agent_executor.invoke({"input": "근로소득세 계산 방법을 알려주고, 연봉 7000만원일 때 세금도 계산해줘"})

# intermediate_steps: [(AgentAction, observation), ...] 형태의 리스트
# AgentAction: 호출한 도구와 입력 파라미터 정보
# observation: 도구 실행 결과 (문자열)
for step in result.get("intermediate_steps", []):
    action, observation = step
    print(f"도구: {action.tool}")         # 호출한 도구 이름
    print(f"입력: {action.tool_input}")   # 도구에 전달한 파라미터
    print(f"결과: {observation[:100]}...")  # 도구 실행 결과 (앞 100자만 출력)
    print("---")


      - 실무 권장: 개발 및 테스트 단계에서는 `verbose=True`와 `return_intermediate_steps=True`를 모두 활성화하여 에이전트의 동작을 상세히 모니터링한다. 프로덕션에서는 `verbose=False`로 전환하되, `return_intermediate_steps=True`는 유지하여 로그 기록이나 감사(audit)에 활용할 수 있도록 한다.

 

   4.3. 스트리밍

      - `stream` 메서드를 사용하면 에이전트의 실행 과정을 실시간으로 수신할 수 있다. 웹 애플리케이션에서 사용자에게 즉각적인 피드백을 제공할 때 유용하다.

# stream()은 에이전트의 실행 과정을 청크(chunk) 단위로 실시간 반환하는 제너레이터
for chunk in agent_executor.stream({"input": "소득세에 대해 설명해주세요"}):
    # chunk는 딕셔너리: "output" 또는 "intermediate_step" 키를 가짐
    if "output" in chunk:
        # 최종 답변 텍스트 (마지막 청크)
        print(chunk["output"], end="")
    elif "intermediate_step" in chunk:
        # 중간 도구 호출 정보 (도구 실행 중에 수신)
        action, observation = chunk["intermediate_step"]
        print(f"\n[도구 사용: {action.tool}]\n")


      4.3.1. 스트리밍과 일반 호출 비교

방식 메서드 반환 형태 적합한 상황
일반 호출 invoke() 전체 결과를 한 번에 딕셔너리로 반환 백엔드 처리, 배치 작업, 간단한 API 서버
스트리밍 stream() Generator로 청크 단위 실시간 반환 웹 UI 실시간 출력, 채팅 인터페이스
비동기 호출 ainvoke() 비동기 전체 결과를 한 번에 반환 FastAPI, asyncio 기반 서버
비동기 스트리밍 astream() 비동기 Generator로 청크 반환 비동기 웹 UI, 실시간 대화 시스템

 

5. 메모리(Memory) 통합 (단계 4: 대화 이력 관리)

   - 에이전트에 메모리를 추가하면 이전 대화 내용을 기억하여 멀티턴(multi-turn) 대화가 가능해진다. 메모리가 없으면 에이전트는 매 질문을 독립적으로 처리하므로, "방금 말한 내용", "아까 계산한 결과" 등 이전 대화를 참조하는 질문에 답할 수 없다.

[메모리 없는 에이전트]
사용자: "근로소득세란 무엇인가요?"    → 에이전트: "근로소득세는..."
사용자: "방금 설명한 내용을 요약해줘" → 에이전트: "무슨 내용을 말씀하시는지 알 수 없습니다."

[메모리 있는 에이전트]
사용자: "근로소득세란 무엇인가요?"    → 에이전트: "근로소득세는..."
사용자: "방금 설명한 내용을 요약해줘" → 에이전트: "앞서 설명드린 근로소득세를 요약하면..."


   5.1. ConversationBufferMemory

      - 전체 대화 이력을 원본 그대로 저장한다. 가장 단순하지만, 대화가 길어지면 토큰 사용량이 급격히 증가한다.

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(
    memory_key="chat_history",    # 프롬프트에서 참조할 변수명 (MessagesPlaceholder와 일치해야 함)
    return_messages=True,         # True: 메시지 객체(HumanMessage, AIMessage) 리스트로 반환
                                  # False: 단일 문자열로 반환 (에이전트에서는 True 권장)
    output_key="output"           # AgentExecutor의 출력 키 (에이전트 사용 시 "output"으로 설정)
)

# 에이전트 실행기에 메모리 연결
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    memory=memory,                # 메모리 객체를 연결
    verbose=True
)

# 대화 예시: 메모리가 이전 대화를 자동으로 저장하고 다음 호출에 전달
agent_executor.invoke({"input": "근로소득세란 무엇인가요?"})
# → 에이전트가 답변하고, 질문+답변이 memory에 자동 저장됨
agent_executor.invoke({"input": "방금 설명한 내용을 요약해주세요"})
# → memory에서 이전 대화를 가져와 프롬프트의 chat_history에 삽입됨

 

      - 파라미터 정의 기준: `memory_key`는 프롬프트의 `MessagesPlaceholder(variable_name="chat_history")`와 정확히 일치해야 한다. 불일치하면 대화 이력이 프롬프트에 삽입되지 않아 메모리가 동작하지 않는다.

 

   5.2. ConversationBufferWindowMemory

      - 최근 N개의 대화만 유지하고 오래된 대화는 삭제한다. 토큰 사용량을 일정 수준으로 제한할 수 있다.

from langchain.memory import ConversationBufferWindowMemory

memory = ConversationBufferWindowMemory(
    memory_key="chat_history",
    return_messages=True,
    k=5  # 최근 5개 대화 턴(질문+답변 쌍)만 유지. 6번째 대화가 들어오면 가장 오래된 것을 삭제
)

 

      - 파라미터 정의 기준: `k`는 "대화 턴" 수를 의미한다. 하나의 턴은 사용자 질문 + 에이전트 답변 한 쌍이다. k=5이면 최근 5번의 질문-답변 쌍(총 10개 메시지)이 유지된다. k 값이 너무 작으면 문맥이 유실되고, 너무 크면 토큰 비용이 증가한다.

 

   5.3. ConversationSummaryMemory

      - 대화를 LLM으로 요약하여 저장한다. 대화가 아무리 길어져도 요약된 형태로 압축되므로 토큰 사용량이 일정 수준으로 유지된다. 다만 요약 과정에서 세부 정보가 손실될 수 있다.

from langchain.memory import ConversationSummaryMemory

memory = ConversationSummaryMemory(
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0),  # 요약에 사용할 LLM (비용 절감을 위해 소형 모델 사용)
    memory_key="chat_history",
    return_messages=True,
)

# 대화가 길어져도 요약으로 압축되어 토큰 절약
# 예: 20턴의 대화 → "사용자가 근로소득세에 대해 질문했고, 에이전트가 세율표와 계산 방법을 설명했음"

 

      - 실무 권장: 요약에 사용하는 LLM은 메인 에이전트보다 저렴한 모델(gpt-4o-mini 등)을 사용하여 비용을 절감한다. 요약 모델의 품질이 낮으면 중요한 정보가 누락될 수 있으므로, 너무 저품질의 모델은 피한다.

 

   5.4. ConversationSummaryBufferMemory

      - 최근 대화는 원본 그대로 유지하고, 오래된 대화는 요약으로 압축하는 하이브리드 방식이다. Buffer와 Summary의 장점을 결합하여 가장 실용적인 메모리 유형으로 평가된다.

from langchain.memory import ConversationSummaryBufferMemory

memory = ConversationSummaryBufferMemory(
    llm=ChatOpenAI(model="gpt-4o-mini"),   # 요약에 사용할 LLM
    memory_key="chat_history",
    return_messages=True,
    max_token_limit=1000  # 이 토큰 수를 초과하면 오래된 대화부터 요약으로 압축
)

 

      - 파라미터 정의 기준: `max_token_limit`은 원본으로 유지할 대화의 최대 토큰 수이다. 이 한도를 초과하면 가장 오래된 대화가 요약으로 변환된다. 값이 작으면 자주 요약이 발생하고(정보 손실 증가), 크면 토큰 사용량이 증가한다. 일반적으로 1000~2000 토큰이 적절하다.

 

   5.5. 메모리 유형 비교

메모리 유형 저장 방식 토큰 사용량 정보 손실 추가 LLM 호출 적합 상황
Buffer 전체 대화 기록을 순차 저장 대화 길이에 비례하여 지속 증가 없음 없음 짧은 대화 (5턴 이내), 토큰 제한 여유 시
BufferWindow 최근 k개 턴만 유지 (슬라이딩 윈도우) 고정 크기 (k 메시지에 비례) 있음 (오래된 대화 삭제) 없음 중간 길이 대화, 최근 맥락만 필요
Summary 매번 전체 대화를 요약해서 저장 요약 길이에 따라 일정 수준 있음 (세부사항 압축) 매 턴 요약 생성 매우 긴 대화, 전체 맥락 요약 필요
SummaryBuffer 최근 k개 + 요약 (하이브리드) 중간 (버퍼 + 요약) 부분적 (최근 제외 오래된 내용) 버퍼 한도 초과 시에만 일반적인 채팅 에이전트에 권장


      5.5.1. 메모리 유형 선택 가이드

[메모리 유형 선택 흐름]

대화가 5턴 이내로 짧은가?
├── 예 → ConversationBufferMemory (단순, 정보 손실 없음)
└── 아니오 → 세부 정보 보존이 중요한가?
             ├── 예 → ConversationBufferWindowMemory (최근 대화 원본 유지)
             └── 아니오 → 비용 제한이 있는가?
                          ├── 예 → ConversationSummaryMemory (최소 토큰 사용)
                          └── 아니오 → ConversationSummaryBufferMemory (권장, 균형 잡힌 선택)


         - 실무 권장: 대부분의 프로덕션 환경에서는 `ConversationSummaryBufferMemory`를 권장한다. max_token_limit=1000~2000으로 설정하면 최근 대화는 원본으로, 오래된 대화는 요약으로 관리하여 비용과 품질의 균형을 맞출 수 있다.

 

6. RAG + 에이전트 통합 (단계 5: RAG 에이전트 구축)

   - RAG 체인을 에이전트의 도구로 통합하면, 에이전트가 문서 검색 능력과 다른 도구(계산, API 호출 등)를 조합하여 복합적인 질문에 답할 수 있다. 이것이 "RAG 에이전트"의 핵심 패턴이다.

[단순 RAG 체인]
질문 → 문서 검색 → LLM 생성 → 답변
(항상 문서 검색을 수행, 계산이나 외부 API 호출 불가)

[RAG 에이전트]
질문 → LLM 판단 → 문서 검색 필요? → RAG 도구 호출
                 → 계산 필요?     → 계산 도구 호출
                 → 외부 정보 필요? → 웹 검색 도구 호출
                 → 결과 종합 → 최종 답변
(LLM이 필요에 따라 적절한 도구를 선택적으로 호출)


   6.1. RAG를 도구로 통합하는 패턴

      - RAG 체인을 재사용 가능한 도구로 변환하는 빌더 클래스 패턴이다. 동일한 구조로 여러 도메인의 RAG 도구를 생성할 수 있다.

from langchain_core.tools import tool
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

# RAG 체인을 에이전트 도구로 변환하는 빌더 클래스
class RAGToolBuilder:
    """RAG 체인을 에이전트 도구로 변환하는 빌더.
    동일한 구조로 여러 도메인(세법, 노동법, 회계 등)의 RAG 도구를 생성할 수 있다."""

    def __init__(self, retriever, llm):
        # retriever: 벡터 DB에서 문서를 검색하는 검색기 객체
        # llm: 검색된 문서를 기반으로 답변을 생성하는 LLM
        self.retriever = retriever
        self.llm = llm

    def build_tool(self, name: str, description: str, prompt_template: str):
        """지정된 이름, 설명, 프롬프트로 RAG 도구를 생성하여 반환한다."""
        # 클로저에서 사용할 변수를 로컬로 바인딩
        retriever = self.retriever
        llm = self.llm

        # RAG 프롬프트 구성: {context}에 검색된 문서, {input}에 질문이 삽입됨
        prompt = ChatPromptTemplate.from_template(prompt_template)
        # create_stuff_documents_chain: 검색된 문서들을 하나의 프롬프트에 삽입하는 체인
        combine_chain = create_stuff_documents_chain(llm, prompt)
        # create_retrieval_chain: retriever → combine_chain을 연결하는 RAG 체인
        rag_chain = create_retrieval_chain(retriever, combine_chain)

        # @tool 데코레이터로 RAG 체인을 에이전트 도구로 래핑
        @tool(name=name, description=description)
        def rag_tool(question: str) -> str:
            result = rag_chain.invoke({"input": question})
            # 출처 정보를 함께 반환하여 답변의 신뢰성을 높인다
            sources = [doc.metadata.get("source", "unknown") for doc in result["context"]]
            return f"답변: {result['answer']}\n출처: {', '.join(set(sources))}"

        return rag_tool

# 사용 예시
builder = RAGToolBuilder(retriever=retriever, llm=llm)

tax_rag_tool = builder.build_tool(
    name="tax_rag_search",
    description="세법 문서를 검색하여 답변합니다. 소득세, 법인세 등 세법 관련 질문에 사용하세요.",
    prompt_template="컨텍스트를 기반으로 정확히 답하세요.\n컨텍스트: {context}\n질문: {input}\n답변:"
)


   6.2. 다중 RAG 소스 에이전트

      - 여러 도메인의 벡터 DB를 각각 별도의 도구로 연결하면, 에이전트가 질문 내용에 따라 적절한 도메인의 문서를 검색할 수 있다. 이는 단일 RAG에 모든 문서를 넣는 것보다 검색 정밀도가 높다.

# 도메인별로 별도의 벡터 DB와 검색기를 구성
# 각 검색기는 해당 도메인의 문서만 포함하므로 검색 노이즈가 감소한다
tax_retriever = tax_db.as_retriever(search_kwargs={"k": 4})          # 세법 문서 검색
labor_retriever = labor_db.as_retriever(search_kwargs={"k": 4})      # 노동법 문서 검색
accounting_retriever = accounting_db.as_retriever(search_kwargs={"k": 4})  # 회계 문서 검색

# 도메인별 RAG 도구 생성
builder = RAGToolBuilder(retriever=tax_retriever, llm=llm)
tax_tool = builder.build_tool(
    name="search_tax_law",
    description="세법(소득세법, 법인세법, 부가가치세법 등) 관련 질문에 사용",
    prompt_template="세법 컨텍스트: {context}\n질문: {input}\n법적 근거를 포함하여 답변:"
)

builder2 = RAGToolBuilder(retriever=labor_retriever, llm=llm)
labor_tool = builder2.build_tool(
    name="search_labor_law",
    description="근로기준법, 노동법 관련 질문에 사용",
    prompt_template="노동법 컨텍스트: {context}\n질문: {input}\n답변:"
)

builder3 = RAGToolBuilder(retriever=accounting_retriever, llm=llm)
accounting_tool = builder3.build_tool(
    name="search_accounting",
    description="회계기준, 재무제표, 회계처리 관련 질문에 사용",
    prompt_template="회계 컨텍스트: {context}\n질문: {input}\n답변:"
)

# 에이전트가 질문 내용에 따라 적절한 도구를 선택
# 예: "소득세 관련 질문" → search_tax_law 도구 선택
#     "퇴직금 계산 질문" → search_labor_law 도구 선택
tools = [tax_tool, labor_tool, accounting_tool, calculate_income_tax]

 

      - 실무 권장: 다중 RAG 소스 에이전트에서 각 도구의 `description`을 명확히 차별화하는 것이 핵심이다. description이 모호하면 에이전트가 잘못된 도구를 선택할 수 있다. 예를 들어 "법률 검색"보다 "소득세법, 법인세법, 부가가치세법 등 세법 관련 질문에 사용"이 더 효과적이다.

 

   6.3. 완전한 RAG 에이전트 예시

      - 환경 설정부터 에이전트 실행까지 전체 코드를 포함하는 통합 예시이다.

from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.memory import ConversationSummaryBufferMemory

# 환경 변수 로딩 (.env 파일에서 OPENAI_API_KEY 등을 읽어옴)
load_dotenv()

# 1. LLM 초기화
# temperature=0: 에이전트의 도구 선택과 답변 생성에 일관성을 부여
llm = ChatOpenAI(model="gpt-4o", temperature=0)

# 2. 벡터 DB 로딩 (사전에 인덱싱된 Chroma DB를 로드)
embedding = OpenAIEmbeddings(model="text-embedding-3-large")
db = Chroma(persist_directory="./chroma_db", embedding_function=embedding)
# as_retriever(): 벡터 DB를 LangChain Retriever 인터페이스로 변환
# search_kwargs={"k": 4}: 상위 4개의 유사 문서를 반환
retriever = db.as_retriever(search_kwargs={"k": 4})

# 3. RAG 도구 생성
@tool
def search_documents(query: str) -> str:
    """문서를 검색합니다. 세법, 규정, 법률 관련 질문에 사용하세요."""
    docs = retriever.invoke(query)
    # 각 문서의 출처(source)와 내용을 구조화하여 반환
    # 에이전트가 출처를 확인하고 답변에 인용할 수 있도록 한다
    return "\n---\n".join([
        f"[출처: {d.metadata.get('source', 'unknown')}]\n{d.page_content}"
        for d in docs
    ])

@tool
def calculate_tax(income: int) -> str:
    """소득세를 계산합니다. income은 연간 총소득(원)입니다."""
    # 누진공제 방식의 간이 세율표
    # (상한금액, 세율, 누진공제액) 형태
    # 산출세액 = 과세표준 × 세율 - 누진공제액
    brackets = [
        (14_000_000, 0.06, 0),
        (50_000_000, 0.15, 1_260_000),
        (88_000_000, 0.24, 5_760_000),
        (150_000_000, 0.35, 15_000_000),
    ]
    for limit, rate, deduction in brackets:
        if income <= limit:
            tax = income * rate - deduction
            return f"과세표준: {income:,}원\n세율: {rate*100}%\n산출세액: {int(tax):,}원"
    return "과세표준이 범위를 초과합니다."

tools = [search_documents, calculate_tax]

# 4. 에이전트 프롬프트
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 전문 세무 상담 AI입니다. 도구를 사용하여 정확한 정보를 제공하세요."),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

# 5. 에이전트 생성 (OpenAI Tools Agent 사용)
agent = create_openai_tools_agent(llm, tools, prompt)

# 6. 메모리 설정 (SummaryBufferMemory로 비용과 품질 균형)
memory = ConversationSummaryBufferMemory(
    llm=ChatOpenAI(model="gpt-4o-mini"),  # 요약용 LLM (비용 절감을 위해 소형 모델)
    memory_key="chat_history",
    return_messages=True,
    max_token_limit=2000  # 2000 토큰 초과 시 오래된 대화 요약
)

# 7. 실행기 생성
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    memory=memory,
    verbose=True,              # 실행 과정 콘솔 출력
    max_iterations=5,          # 최대 도구 호출 반복 횟수
    handle_parsing_errors=True, # LLM 출력 파싱 에러 시 자동 재시도
)

# 8. 실행
# 에이전트가 질문을 분석하여 필요한 도구를 자동으로 선택하고 실행한다
# 이 질문에서는 search_documents(검색)와 calculate_tax(계산) 도구가 모두 호출될 수 있다
result = agent_executor.invoke({
    "input": "근로소득세에 대해 설명해주고, 연봉 6000만원일 때 세금도 계산해줘"
})
print(result["output"])

 

      - 실무 권장: 완전한 RAG 에이전트를 구성할 때 가장 중요한 것은 (1) 도구의 description을 명확히 작성하는 것, (2) 프롬프트에서 에이전트의 역할과 규칙을 구체적으로 정의하는 것, (3) max_iterations를 적절히 설정하여 무한 루프를 방지하는 것이다. 이 세 가지를 우선 확인한 후 성능을 튜닝한다.

7. 에이전트 실행 제어 (단계 6: 안전과 제어)

   - 에이전트는 LLM의 판단에 따라 동적으로 실행되므로, 예상치 못한 동작(무한 루프, 과도한 API 호출, 잘못된 도구 사용)이 발생할 수 있다. 실행 제어는 이러한 위험을 방지하고 안전한 에이전트 운영을 보장하는 핵심 메커니즘이다.

 

   7.1. 실행 제한

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    max_iterations=5,                   # 최대 도구 호출 횟수 (이 횟수를 초과하면 루프 종료)
    max_execution_time=30,              # 최대 실행 시간 (초). 이 시간을 초과하면 강제 종료
    early_stopping_method="generate",   # 제한 도달 시 동작: "generate"=지금까지의 정보로 답변 생성
                                        # "force"=즉시 종료하고 에러 반환
    handle_parsing_errors=True,         # LLM 출력 파싱 실패 시 에러 메시지를 에이전트에 전달하여 재시도
)


      - 파라미터 정의 기준: `max_iterations`는 일반적으로 3~10으로 설정한다. 단순 질의는 1~2회의 도구 호출로 충분하고, 복합 질의도 5회 이내에 해결되는 경우가 대부분이다. `early_stopping_method="generate"`를 사용하면 제한에 도달해도 LLM이 수집된 정보로 답변을 시도하므로, 사용자에게 빈 응답을 반환하지 않는다.

      7.1.1. 실행 제한 파라미터 상세

파라미터 타입 기본값 설명
max_iterations int 15 에이전트가 도구 호출과 추론을 반복할 수 있는 최대 횟수. 무한 루프 방지용.
max_execution_time float None 전체 에이전트 실행 시간을 초 단위로 제한. 타임아웃 제어용.
early_stopping_method str "force" 반복/시간 제한 도달 시 "generate"(답변 생성) 또는 "force"(강제 중단) 중 선택.
handle_parsing_errors bool / str / Callable FALSE LLM 출력 파싱 실패 시 True(재시도), "Check"(에러 메시지 생성), 또는 커스텀 핸들러로 처리.


         - 실무 권장: 프로덕션 환경에서는 `max_iterations=5`, `max_execution_time=30`, `early_stopping_method="generate"`를 기본 설정으로 사용한다. 특히 `max_execution_time`은 사용자 응답 시간의 상한선을 보장하므로 반드시 설정해야 한다.

 

   7.2. 도구 실행 전 확인 (Human-in-the-loop)

      - 이메일 발송, 데이터 수정, 결제 처리 등 부작용이 있는 도구는 실행 전에 사용자 확인을 받는 것이 안전하다.

from langchain.tools import HumanInputRun

# 사람의 확인이 필요한 도구 (HumanInputRun은 사용자 입력을 받는 기본 도구)
human_approval = HumanInputRun()

@tool
def execute_with_approval(action: str) -> str:
    """사용자 확인이 필요한 작업을 실행합니다."""
    # input()으로 사용자에게 승인을 요청 (CLI 환경)
    # 웹 환경에서는 API 콜백이나 WebSocket으로 대체해야 한다
    approval = input(f"다음 작업을 실행하시겠습니까? [{action}] (y/n): ")
    if approval.lower() == 'y':
        return f"'{action}' 작업이 실행되었습니다."
    return "사용자가 작업을 취소했습니다."

 

      - 실무 권장: Human-in-the-loop는 부작용이 있는 도구(이메일 발송, 데이터 삭제, 외부 시스템 변경 등)에 반드시 적용해야 한다. 프로덕션 웹 환경에서는 `input()` 대신 API 콜백이나 비동기 승인 시스템을 구현한다.

 

   7.3. 에러 처리

      - 도구 실행 중 에러가 발생했을 때 에이전트가 적절히 대응할 수 있도록 에러 처리를 구성한다. `ToolException`을 사용하면 에러 메시지가 에이전트에 전달되어 다른 도구를 시도하거나 사용자에게 안내할 수 있다.

from langchain_core.tools import ToolException

@tool
def risky_tool(query: str) -> str:
    """위험할 수 있는 작업"""
    try:
        result = some_operation(query)
        return result
    except Exception as e:
        # ToolException을 발생시키면 에러 메시지가 에이전트에 전달된다
        # 에이전트는 이 메시지를 읽고 다른 도구를 시도하거나 사용자에게 안내할 수 있다
        raise ToolException(f"도구 실행 실패: {str(e)}. 다른 방법을 시도하세요.")

# handle_tool_error=True: ToolException 발생 시 에러 메시지를 에이전트에 전달
# False(기본값)이면 에이전트 실행 전체가 중단된다
risky_tool.handle_tool_error = True

# handle_tool_error에 callable을 전달하여 커스텀 에러 처리도 가능
# 에러 메시지를 가공하거나 로깅을 추가할 수 있다
def custom_error_handler(error: ToolException) -> str:
    # 에러 로깅, 알림 등 추가 처리를 여기에 구현
    return f"도구 오류 발생: {error.args[0]}. 관리자에게 문의하세요."

risky_tool.handle_tool_error = custom_error_handler


      7.3.1. 에러 처리 방식 비교

방식 설정 동작 적합한 상황
에러 전파 handle_tool_error=False (기본값) 도구 에러 발생 시 에이전트 전체 실행을 즉시 중단하고 예외를 상위로 throw 치명적 시스템 에러, 반드시 실패 처리해야 하는 핵심 도구
자동 전달 handle_tool_error=True 도구 에러 메시지를 문자열로 에이전트에 전달하여 LLM이 상황을 인지하고 재시도/대처 가능 일반적인 네트워크/API/입력 에러, 회복 가능 상황
커스텀 처리 handle_tool_error=your_custom_function 에러 객체를 받아 커스텀 로직(로깅, 사용자 메시지 변환 등) 후 문자열로 반환 에러 로깅 + 사용자 친화적 메시지, 슬랙/이메일 알림 연동


   7.4. LLM에 도구 직접 바인딩 (bind_tools)

      - 에이전트 없이 LLM에 직접 도구를 바인딩하여 도구 호출을 유도할 수 있다. AgentExecutor의 자동 실행 루프 없이 도구 호출을 세밀하게 제어하고 싶을 때 사용한다.

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0)

# bind_tools(): LLM에 도구 정보를 바인딩
# 바인딩 후 LLM은 텍스트 응답 외에 도구 호출 지시를 생성할 수 있다
llm_with_tools = llm.bind_tools(tools)

# invoke() 호출 시 LLM이 도구 호출 필요 여부를 자동 판단
response = llm_with_tools.invoke("연봉 5000만원일 때 소득세는?")

# response.tool_calls: LLM이 호출을 지시한 도구 목록 (리스트)
# 도구 호출이 없으면 빈 리스트
if response.tool_calls:
    for tool_call in response.tool_calls:
        print(f"도구: {tool_call['name']}")   # 호출할 도구 이름
        print(f"인자: {tool_call['args']}")   # 전달할 파라미터
        # 주의: bind_tools는 도구를 자동 실행하지 않는다
        # 실제 도구 실행은 개발자가 직접 코드로 처리해야 한다
else:
    # 도구 호출 없이 텍스트로 직접 답변한 경우
    print(f"답변: {response.content}")

# tool_choice로 특정 도구를 강제 호출할 수 있다
# LLM의 판단과 무관하게 반드시 지정된 도구를 호출하도록 강제
llm_forced = llm.bind_tools(tools, tool_choice="calculate_income_tax")


      7.4.1. bind_tools vs AgentExecutor 비교

기준 bind_tools AgentExecutor
도구 실행 개발자가 llm.invoke(tools_call_result) 등으로 직접 도구를 실행하고 결과를 처리 LLM의 도구 호출 결과를 자동으로 받아 실행하고, 출력을 다시 LLM에 전달
반복 루프 없음 — 1회성 LLM 호출로 종료 자동으로 "관찰 → 사고 → 행동" 루프를 반복 (max_iterations까지)
제어 수준 높음 — 모든 단계(도구 선택 → 호출 → 결과 처리)를 수동으로 커스터마이징 가능 중간 — max_iterations, early_stopping 등 파라미터로 제어
적합한 상황 단일 도구 호출, 복잡한 커스텀 로직, 미세한 제어 필요 시 복합 질의, 다중 도구 자동 조합, ReAct 같은 표준 에이전트 패턴


   - 실무 권장: `bind_tools`는 에이전트의 자동 실행 루프 없이 도구 호출을 제어하고 싶을 때 유용하다. 예를 들어 "도구 호출 결과를 커스텀 후처리한 후 다시 LLM에 전달"하는 복잡한 흐름을 구현할 때 적합하다. 일반적인 에이전트 구축에서는 AgentExecutor를 사용하고, 특수한 커스텀 로직이 필요할 때만 bind_tools를 고려한다.

 

8. 에이전트 패턴과 아키텍처

   - 에이전트 시스템의 규모와 복잡도가 증가하면, 단일 에이전트로는 모든 요구를 처리하기 어렵다. 이 섹션에서는 실무에서 자주 사용되는 에이전트 아키텍처 패턴을 다룬다.

 

   8.1. 단일 목적 에이전트

      - 하나의 도메인에 특화된 에이전트로, 관련 도구만 보유한다. 도구 수가 적어 LLM의 도구 선택 정확도가 높고, 프롬프트를 도메인에 맞게 최적화할 수 있다.

# 세무 상담 전용 에이전트: 세법 관련 도구만 보유
tax_agent = create_openai_tools_agent(
    llm=ChatOpenAI(model="gpt-4o"),
    tools=[search_tax_law, calculate_income_tax],  # 세법 관련 도구만 포함
    prompt=tax_prompt  # 세무 전문가 역할의 프롬프트
)
[단일 목적 에이전트 아키텍처]

┌─────────────────────────────┐
│       세무 상담 에이전트    │
│  ┌─────────┐ ┌────────────┐ │
│  │세법 검색│ │ 소득세 계산│ │
│  └─────────┘ └────────────┘ │
└─────────────────────────────┘

장점: 도구 수가 적어 선택 정확도 높음, 프롬프트 최적화 용이
단점: 도메인 외 질문에 답변 불가

 

      - 실무 권장: 단일 목적 에이전트는 가장 안정적인 패턴이다. 에이전트 구축을 시작할 때는 이 패턴으로 시작하여 동작을 검증한 후, 필요에 따라 라우터 패턴으로 확장하는 것을 권장한다.

 

   8.2. 라우터 에이전트 (질의 분류 + 전문 체인)

      - 사용자 질의를 먼저 분류한 뒤, 분류 결과에 따라 적절한 전문 체인으로 라우팅하는 패턴이다. 에이전트가 직접 모든 도구를 선택하는 것이 아니라, 분류기가 먼저 도메인을 결정하고 해당 도메인의 전문 체인이 처리하므로 정확도가 높다.

from langchain_core.runnables import RunnableBranch, RunnableLambda
from langchain_core.output_parsers import StrOutputParser

# 1단계: 질의 분류기 - LLM이 사용자 질문의 도메인을 판단
classifier_prompt = ChatPromptTemplate.from_template(
    "다음 질문을 분류하세요: tax, labor, general\n질문: {input}\n분류:"
)
# 분류기 체인: 프롬프트 → LLM → 출력 파싱 (텍스트로 "tax", "labor", "general" 중 하나 반환)
classifier = classifier_prompt | llm | StrOutputParser()

# 2단계: 도메인별 전문 체인 정의
# 각 체인은 해당 도메인에 특화된 프롬프트와 도구를 사용
tax_chain = tax_prompt | llm | StrOutputParser()        # 세법 전문 체인
labor_chain = labor_prompt | llm | StrOutputParser()     # 노동법 전문 체인
general_chain = general_prompt | llm | StrOutputParser()  # 일반 질문 체인

# 3단계: RunnableBranch로 분류 결과에 따라 적절한 체인으로 라우팅
# 조건이 참인 첫 번째 브랜치가 실행되고, 모든 조건이 거짓이면 마지막 인자(기본 체인)가 실행
router_chain = RunnableBranch(
    (lambda x: "tax" in classifier.invoke(x), tax_chain),      # tax로 분류 → 세법 체인
    (lambda x: "labor" in classifier.invoke(x), labor_chain),  # labor로 분류 → 노동법 체인
    general_chain  # 기본: 일반 질문 체인
)
[라우터 에이전트 아키텍처]

                    사용자 질문
                        ↓
                   ┌──────────┐
                   │  분류기  │
                   │ (LLM)    │
                   └──────────┘
                   ↙    ↓    ↘
             tax     labor    general
              ↓        ↓        ↓
         ┌─────────┐ ┌────────┐ ┌─────────┐
         │세법 체인│ │ 노동법 │ │일반 체인 │
         │         │ │  체인  │ │         │
         └─────────┘ └────────┘ └─────────┘

 

      - 실무 권장: 라우터 패턴은 도메인이 3개 이상이고, 각 도메인의 도구와 프롬프트가 크게 다를 때 효과적이다. 분류기의 정확도가 전체 시스템 품질에 직결되므로, 분류기 프롬프트를 충분히 테스트하고 few-shot 예시를 추가하여 정확도를 높여야 한다.

 

   8.3. 순차 에이전트 (파이프라인 에이전트)

      - 여러 단계의 작업을 순서대로 수행하는 에이전트 패턴이다. 각 단계의 출력이 다음 단계의 입력이 되는 파이프라인 구조로, 복잡한 분석이나 보고서 생성 작업에 적합하다.

# 단계적으로 작업을 수행하는 에이전트
# 각 도구의 description에 "N단계"를 명시하여 에이전트가 순서를 파악하도록 유도
@tool
def step1_gather_info(query: str) -> str:
    """1단계: 관련 정보 수집. 주제에 대한 문서를 검색하여 원본 데이터를 수집합니다."""
    docs = retriever.invoke(query)
    # retriever.invoke()는 Document 리스트를 반환하므로 문자열로 변환
    return "\n\n".join([doc.page_content for doc in docs])

@tool
def step2_analyze(info: str) -> str:
    """2단계: 수집된 정보 분석. 1단계에서 수집한 데이터를 분석하여 핵심 내용을 추출합니다."""
    response = llm.invoke(f"다음 정보를 분석하세요:\n{info}")
    return response.content

@tool
def step3_generate_report(analysis: str) -> str:
    """3단계: 분석 결과를 보고서로 작성. 2단계의 분석 내용을 정리된 보고서로 작성합니다."""
    response = llm.invoke(f"다음 분석을 보고서로 작성하세요:\n{analysis}")
    return response.content

pipeline_tools = [step1_gather_info, step2_analyze, step3_generate_report]
[순차 에이전트 아키텍처]

질문 → [1단계: 정보 수집] → [2단계: 분석] → [3단계: 보고서 생성] → 최종 답변
         (문서 검색)        (핵심 추출)      (보고서 작성)

 

      - 실무 권장: 순차 에이전트에서 각 단계의 도구 description에 단계 번호와 역할을 명확히 기술해야 에이전트가 올바른 순서로 도구를 호출한다. 프롬프트에도 "1단계부터 순서대로 작업을 수행하세요"와 같은 지시를 추가하면 더 안정적이다. 단, 에이전트가 반드시 정해진 순서를 지킬 것이라는 보장은 없으므로, 엄격한 순서 제어가 필요하면 에이전트 대신 체인으로 구현하거나 LangGraph를 사용하는 것이 적합하다.

 

9. 에이전트 구축 단계 요약

단계 목표 핵심 활동 결과물 관련 절
1. 도구 정의 에이전트의 기본 능력(도구) 정의 @tool 데코레이터, BaseTool 상속, StructuredTool.from_function() 중 선택 실행 가능한 Tool 객체 리스트 2절 도구 정의
2. 에이전트 생성 LLM + 도구를 결합한 에이전트 초기화 create_openai_tools_agent() 또는 create_react_agent() 사용 Runnable 형태의 Agent 객체 3절 에이전트 생성
3. 에이전트 활용 실제 질의 처리와 응답 생성 AgentExecutor.invoke(), stream(), ainvoke() 등으로 실행 최종 응답 ({"input": ..., "output": ...}) 4절 실행 방법
4. 메모리 통합 멀티턴 대화 상태 유지 ConversationSummaryBufferMemory 또는 ConversationBufferWindowMemory 바인딩 대화 이력을 기억하는 에이전트 5절 메모리
5. RAG 통합 외부 지식 기반 강화 RAG 체인(create_retrieval_chain)을 @tool로 래핑, 다중 RAG 소스 라우팅 세법/회계 등 도메인별 RAG 에이전트 6절 RAG 도구
6. 실행 제어 안전하고 효율적인 실행 보장 max_iterations=10, handle_parsing_errors=True, Human-in-the-loop 제어된 안전한 에이전트 시스템 7절 제어 및 디버깅


   9.1. 에이전트 구축 체크리스트

항목 확인 사항
도구 description 각 도구의 description이 구체적이고 사용 시나리오가 명확히 기술되어 LLM이 적절히 선택할 수 있는가?
도구 수 에이전트에 등록된 도구가 10개 이하인가? (초과 시 도구 그룹화 또는 라우팅 에이전트 분리 검토 필요)
temperature LLM의 temperature=0으로 설정되어 도구 선택과 판단이 일관되게 유지되는가?
max_iterations 무한 루프 방지를 위해 max_iterations=10~15 정도로 적절히 제한되었는가?
max_execution_time 응답 지연 방지를 위해 max_execution_time=60초 등 상한선이 설정되었는가?
handle_parsing_errors LLM 출력 파싱 실패 시 handle_parsing_errors=True 또는 커스텀 핸들러로 재시도/대처가 가능한가?
에러 처리 외부 API/DB 호출 도구에 try-except와 raise ToolException()이 적용되어 에이전트에 안전하게 전달되는가?
Human-in-the-loop 파일 생성/금전 거래 등 부작용 도구에 사용자 승인 절차(예: input())가 구현되었는가?
메모리 멀티턴 대화 지원 시 ConversationSummaryBufferMemory(max_token_limit=1000) 등 적절한 메모리가 바인딩되었는가?
테스트 verbose=True로 에이전트의 생각 과정, 도구 선택, 실행 로그를 확인하여 올바른 동작을 검증했는가?


10. 실무 프로젝트: 세무 상담 AI 에이전트

   - 본 문서에서 학습한 에이전트 기능을 모두 활용하여 세무 상담 AI 에이전트를 구축한다. 문서 검색(RAG), 세금 계산, 일정 안내 등 3가지 도구를 가진 에이전트이다.

   - 시나리오 및 요구 명세

항목 요구사항
사용자 세무 지식이 없는 일반인 — 전문 용어 최소화, 쉬운 설명, 단계별 안내
도구 (1) 세법 문서 검색 (RAG), (2) 소득세 계산기, (3) 세무 신고 일정 안내 및 알림
대화 방식 멀티턴 대화 지원 — 이전 대화 맥락 참조 (ConversationSummaryBufferMemory 사용)
안전 장치 max_iterations=8, handle_parsing_errors=True, 모든 도구에 ToolException 에러 처리
응답 형식 세법 근거(출처) 포함 답변, 계산 결과는 명확한 표 형식으로 제시


   - 전체 구현

# ============================================================
# 세무 상담 AI 에이전트 - 전체 구현
# ============================================================
import os
from dotenv import load_dotenv
load_dotenv()

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

# ── 도구 1: 세법 문서 검색 ──
@tool
def search_tax_law(query: str) -> str:
    """세법 관련 질문에 답하기 위해 세법 문서를 검색합니다.
    소득세, 법인세, 부가가치세, 공제, 세율 등의 질문에 사용하세요."""
    # 실제 환경에서는 벡터 DB에서 검색
    # 여기서는 학습용 샘플 데이터 사용
    tax_knowledge = {
        "소득세율": "과세표준 1,400만원 이하: 6%, 5,000만원 이하: 15%, 8,800만원 이하: 24%",
        "연말정산": "근로소득자는 매년 2월에 연말정산을 통해 소득세를 정산합니다.",
        "부양가족공제": "기본공제: 1인당 150만원. 배우자, 직계존비속, 형제자매 등이 대상.",
        "의료비공제": "총급여 3%를 초과하는 의료비에 대해 15% 세액공제 (한도: 700만원)",
    }
    # 키워드 매칭으로 관련 정보 검색
    results = []
    for key, value in tax_knowledge.items():
        if any(k in query for k in key):
            results.append(f"[{key}] {value}")
    return "\n".join(results) if results else "관련 세법 정보를 찾을 수 없습니다."

# ── 도구 2: 소득세 계산 ──
@tool
def calculate_income_tax(annual_income: int) -> str:
    """연간 총소득(원)을 입력받아 소득세를 계산합니다.
    예시: calculate_income_tax(50000000) → 연소득 5천만원의 세금 계산"""
    # 2024년 기준 소득세 누진세율표
    brackets = [
        (14_000_000, 0.06, 0),
        (50_000_000, 0.15, 1_260_000),
        (88_000_000, 0.24, 5_760_000),
        (150_000_000, 0.35, 15_440_000),
        (300_000_000, 0.38, 19_940_000),
        (500_000_000, 0.40, 25_940_000),
        (1_000_000_000, 0.42, 35_940_000),
        (float('inf'), 0.45, 65_940_000),
    ]
    for upper, rate, deduction in brackets:
        if annual_income <= upper:
            tax = annual_income * rate - deduction
            effective_rate = (tax / annual_income * 100) if annual_income > 0 else 0
            return (
                f"연소득: {annual_income:,}원\n"
                f"적용세율: {rate*100:.0f}%\n"
                f"산출세액: {int(tax):,}원\n"
                f"실효세율: {effective_rate:.1f}%"
            )
    return "계산 오류"

# ── 도구 3: 세무 일정 안내 ──
@tool
def get_tax_schedule(month: int) -> str:
    """특정 월의 세무 일정을 안내합니다. month는 1~12 사이 정수입니다."""
    schedules = {
        1: "1/25: 부가가치세 확정신고 (7~12월분)",
        2: "2/28: 근로소득 연말정산 완료",
        3: "3/31: 법인세 확정신고",
        5: "5/31: 종합소득세 확정신고",
        7: "7/25: 부가가치세 확정신고 (1~6월분)",
    }
    return schedules.get(month, f"{month}월에는 주요 세무 일정이 없습니다.")

# ── 에이전트 생성 ──
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [search_tax_law, calculate_income_tax, get_tax_schedule]

# 프롬프트: 세무 전문가 역할 + 대화 이력 지원
prompt = ChatPromptTemplate.from_messages([
    ("system",
     "당신은 친절한 세무 상담 AI입니다. "
     "세법 검색, 세금 계산, 일정 안내 도구를 활용하여 답변하세요. "
     "반드시 도구를 사용한 후 답변하고, 추측하지 마세요."),
    MessagesPlaceholder(variable_name="chat_history"),  # 대화 이력
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),  # 에이전트 중간 단계
])

agent = create_openai_tools_agent(llm, tools, prompt)

# AgentExecutor: 에이전트 실행 환경 (안전 장치 포함)
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,               # 도구 호출 과정 출력 (디버깅용)
    max_iterations=8,           # 무한 루프 방지
    handle_parsing_errors=True, # 파싱 에러 자동 복구
)

# ── 실행 테스트 ──
chat_history = []  # 대화 이력 저장

test_conversations = [
    "연봉 5천만원이면 소득세가 얼마인가요?",
    "부양가족 공제는 어떻게 받나요?",
    "5월에 해야 할 세무 일정이 있나요?",
]

for question in test_conversations:
    result = executor.invoke({
        "input": question,
        "chat_history": chat_history,
    })
    print(f"\nQ: {question}")
    print(f"A: {result['output']}")
    # 대화 이력에 추가 (멀티턴 지원)
    from langchain_core.messages import HumanMessage, AIMessage
    chat_history.append(HumanMessage(content=question))
    chat_history.append(AIMessage(content=result['output']))

 

   - 예상 실행 결과 (verbose 출력 요약):

> Entering new AgentExecutor chain...
Invoking: `calculate_income_tax` with `{'annual_income': 50000000}`
연소득: 50,000,000원 / 적용세율: 15% / 산출세액: 6,240,000원 / 실효세율: 12.5%

Q: 연봉 5천만원이면 소득세가 얼마인가요?
A: 연봉 5,000만원 기준으로 소득세를 계산하면 산출세액은 약 624만원이며,
   실효세율은 12.5%입니다. (적용세율: 15%, 누진공제 126만원 적용)

Q: 부양가족 공제는 어떻게 받나요?
A: 부양가족 기본공제는 1인당 150만원입니다. 배우자, 직계존비속(부모, 자녀),
   형제자매 등이 대상이며, 연말정산 시 신청할 수 있습니다.

Q: 5월에 해야 할 세무 일정이 있나요?
A: 5월에는 종합소득세 확정신고가 있습니다. 5월 31일까지 신고해야 합니다.

댓글