Study/LangChain

1. LangGraph - LangChain Tool Calling 학습 매뉴얼

bluebamus 2026. 3. 24.

01_LangChain_ToolCalling.md
0.11MB
01_LangChain_ToolCalling_practice.ipynb
0.08MB
01_LangChain_ToolCalling_practice_new.ipynb
0.06MB
01_LangChain_ToolCalling_new.md
0.11MB

 

- 이 문서는 LangGraph 학습을 위한 선행 지식으로서 LangChain의 도구 호출(Tool Calling) 메커니즘을 학습하기 위한 문서이다.
- 도구(Tool)란 무엇인지, LLM이 어떻게 도구를 호출하는지, 그리고 도구 호출 체인을 어떻게 구성하는지를 단계적으로 다룬다.
- LangGraph에서는 도구 호출이 핵심 메커니즘으로 활용되므로, 이 문서에서 다루는 도구 정의 → 도구 호출 → 도구 실행 → ToolMessage → LLM 재전달의 파이프라인을 충분히 이해하는 것이 중요하다.

 

1. 사전 작업

   1.1. Env 환경변수

from dotenv import load_dotenv
load_dotenv(override=True)
# .env
OPENAI_API_KEY=""
ANTHROPIC_API_KEY=""
LANGCHAIN_TRACING_V2=true
LANGCHAIN_ENDPOINT=""
LANGCHAIN_PROJECT=""
LANGCHAIN_API_KEY=""
TAVILY_API_KEY=""
GOOGLE_API_KEY=""
GROQ_API_KEY=""


   1.2. 라이브러리

import re
import os, json

from textwrap import dedent
from pprint import pprint

import warnings
warnings.filterwarnings("ignore")


2. 도구 호출 (Tool Calling)

   - 도구 호출은 LLM이 특정 작업을 수행하기 위해 외부 기능(도구)을 호출하는 메커니즘이다.
   - 이를 통해 LLM은 외부 API 호출, 데이터베이스 검색, 웹 검색 등 자체적으로는 수행할 수 없는 작업을 처리할 수 있다.
   - 도구 호출의 전체 파이프라인은 다음과 같은 5단계로 구성된다:
     1) 도구 정의: 도구의 이름, 설명, 입력 스키마를 정의한다.
     2) 도구 바인딩: bind_tools()로 LLM에 사용 가능한 도구 목록을 알려준다.
     3) 도구 호출 결정: LLM이 사용자 질문을 분석하여 도구 호출이 필요한지 판단하고. 필요하면 tool_calls에 호출 정보를 담는다.
     4) 도구 실행: tool_calls의 정보를 바탕으로 실제 도구를 실행하여 결과를 ToolMessage로 생성한다.
     5) 최종 답변 생성: ToolMessage를 LLM에 재전달하여 사용자에게 제공할 최종 답변을 생성한다.


   - 이 파이프라인은 LangGraph에서 ToolNode, ReAct Agent 등의 핵심 구조로 확장되므로 반드시 숙지해야 한다.


   2.1. 랭체인 내장 도구

      - 이 절에서는 LangChain에서 제공하는 내장 도구인 TavilySearchResults를 예시로 도구 호출의 전체 흐름을 학습한다.
      - Tavily는 검색 엔진 API로, 웹 검색 결과를 LLM에 제공하기 위한 도구이다.

 

      2.1.1. 도구(tool) 정의하기

         - 이 단계는 도구 호출 파이프라인의 첫 번째 단계로, 사용할 도구를 초기화하고 그 속성을 파악하는 단계이다.
         - TavilySearchResults는 LangChain에 내장된 웹 검색 도구로, Tavily API를 사용하여 웹 검색을 수행한다.
         - 모든 도구는 다음 세 가지 핵심 속성을 가진다:
            - name: 도구의 고유 이름 (LLM이 호출 시 이 이름을 사용한다)
            - description: 도구의 설명 (LLM이 어떤 도구를 호출할지 판단할 때 참고한다)
            - args_schema: 도구의 입력 스키마 (LLM이 도구에 전달할 인자의 구조를 정의한다)


         - 이 속성들은 LLM이 적절한 도구를 선택하고 올바른 인자를 전달하는 데 핵심적인 역할을 한다.

 

         2.1.1.1. 기본 사용법

from langchain_community.tools import TavilySearchResults

# [LangChain 내장] TavilySearchResults: Tavily API 기반 웹 검색 도구 클래스
# Tavily 검색 도구 초기화 (최대 2개의 결과 반환)
web_search = TavilySearchResults(max_results=2)

# [LangChain 내장] invoke(): 도구를 실행하는 메서드. 문자열 또는 딕셔너리를 입력으로 받는다.
# 웹 검색 실행
search_results = web_search.invoke("스테이크와 어울리는 와인을 추천해주세요.")

# 검색 결과 출력
for result in search_results:
    print(result)
    print("-" * 100)
{'url': 'https://alcohol.hobby-tech.com/...', 'content': '말벡은 블랙베리와 자두 같은 과일향이 강하면서도 탄닌이 부드러운 레드 와인입니다...'}
----------------------------------------------------------------------------------------------------
{'url': 'https://mashija.com/...', 'content': '음식과 와인 전문가인 피오나 베켓이 스테이크와 함께 몇 가지의 고급 와인을 테이스팅한 후...'}
----------------------------------------------------------------------------------------------------

 

         2.1.1.2. 실무 코드

from langchain_community.tools import TavilySearchResults

# 검색할 쿼리 설정
query = "스테이크와 어울리는 와인을 추천해주세요."

# [LangChain 내장] TavilySearchResults: Tavily API 기반 웹 검색 도구 클래스
# Tavily 검색 도구 초기화 (최대 2개의 결과 반환)
web_search = TavilySearchResults(max_results=2)

# [LangChain 내장] invoke(): 도구를 실행하는 메서드
# 웹 검색 실행
search_results = web_search.invoke(query)

# 검색 결과 출력
for result in search_results:
    print(result)
    print("-" * 100)

# [LangChain 내장] name, description, args_schema: 모든 도구가 가지는 핵심 속성
# 도구 속성 확인
print("자료형: ")
print(type(web_search))
print("-" * 100)

print("name: ")
print(web_search.name)
print("-" * 100)

print("description: ")
pprint(web_search.description)
print("-" * 100)

print("schema: ")
pprint(web_search.args_schema.schema())
print("-" * 100)
{'url': 'https://alcohol.hobby-tech.com/...', 'content': '말벡 (Malbec) – 풍부한 과일 향과 부드러운 탄닌...'}
----------------------------------------------------------------------------------------------------
{'url': 'https://mashija.com/...', 'content': '음식과 와인 전문가인 피오나 베켓(Fiona Becket)이...'}
----------------------------------------------------------------------------------------------------
자료형:
<class 'langchain_community.tools.tavily_search.tool.TavilySearchResults'>
----------------------------------------------------------------------------------------------------
name:
tavily_search_results_json
----------------------------------------------------------------------------------------------------
description:
('A search engine optimized for comprehensive, accurate, and trusted results. '
 'Useful for when you need to answer questions about current events. Input '
 'should be a search query.')
----------------------------------------------------------------------------------------------------
schema:
{'description': 'Input for the Tavily tool.',
 'properties': {'query': {'description': 'search query to look up',
                          'title': 'Query',
                          'type': 'string'}},
 'required': ['query'],
 'title': 'TavilySearchInput',
 'type': 'object'}
----------------------------------------------------------------------------------------------------


      2.1.2. 도구(tool) 호출하기

         - 이 단계는 도구 호출 파이프라인의 두 번째와 세 번째 단계에 해당하며, LLM에 도구를 바인딩하고 LLM이 도구 호출 여부를 결정하는 과정이다.
         - bind_tools() 메서드로 LLM에 도구를 바인딩하면, LLM은 사용자 질문을 분석하여 도구 호출이 필요한지 자동으로 판단한다.
         - 도구 호출이 필요 없는 경우: AIMessage의 content에 텍스트 답변이 들어가고, tool_calls는 빈 리스트이다.
         - 도구 호출이 필요한 경우: AIMessage의 content는 비어있고, tool_calls에 호출할 도구의 정보가 담긴다.
         - tool_calls의 각 항목은 다음 4가지 필드로 구성된 딕셔너리이다:
            - name: 호출할 도구의 이름
            - args: 도구에 전달할 인자 (딕셔너리)
            - id: 도구 호출의 고유 ID (ToolMessage와 매칭하는 데 사용)
            - type: 항상 "tool_call" (호출 유형 식별자)


      - 즉, tool_calls는 `[{'name': str, 'args': dict, 'id': str, 'type': 'tool_call'}, ...]` 형태의 리스트이며, LangChain이 LLM의 응답을 파싱하여 자동 생성한다.
      - 이 구조를 이해하는 것이 LangGraph에서 ToolNode가 AIMessage의 tool_calls를 처리하는 방식을 이해하는 핵심이다.

 

      - 참고: 이 코드는 섹션 2.1.1에서 정의한 `web_search` (TavilySearchResults 인스턴스, max_results=2)를 사용합니다.

 

         2.1.2.1. 기본 사용법

from langchain_openai import ChatOpenAI

# ChatOpenAI 모델 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# [LangChain 내장] bind_tools(): LLM에 사용 가능한 도구 목록을 등록하는 메서드
# web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults(max_results=2)로 생성한 도구 인스턴스
llm_with_tools = llm.bind_tools(tools=[web_search])
(바인딩 자체는 출력이 없으며, llm_with_tools 객체가 생성된다.)

 

         2.1.2.1. 실무 코드 - 도구 호출이 필요 없는 경우

# llm_with_tools는 [사용자 정의 - 위 기본 사용법 참조] llm.bind_tools(tools=[web_search])로 생성한 객체
# 도구 호출이 필요 없는 LLM 호출을 수행
query = "안녕하세요."
ai_msg = llm_with_tools.invoke(query)

# LLM의 전체 출력 결과 출력
pprint(ai_msg)
print("-" * 100)

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
# 메시지 content 속성 (텍스트 출력)
pprint(ai_msg.content)
print("-" * 100)

# [LangChain 내장] .tool_calls: LLM이 호출을 결정한 도구 정보 리스트 (LangChain이 자동 파싱하여 생성)
# 도구 호출이 없으면 빈 리스트 []가 반환된다.
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
print("-" * 100)
AIMessage(content='안녕하세요! 어떻게 도와드릴까요?', ...)
----------------------------------------------------------------------------------------------------
'안녕하세요! 어떻게 도와드릴까요?'
----------------------------------------------------------------------------------------------------
[]
----------------------------------------------------------------------------------------------------


            - 위 결과에서 content에 텍스트 응답이 있고, tool_calls가 빈 리스트인 것을 확인할 수 있다.
            - LLM이 "안녕하세요"라는 인사에 대해 도구 호출 없이 직접 답변을 생성한 것이다.

 

         2.1.2.2. 실무 코드 - 도구 호출이 필요한 경우

# 도구 호출이 필요한 LLM 호출을 수행
query = "스테이크와 어울리는 와인을 추천해주세요."
# llm_with_tools는 [사용자 정의 - 위 기본 사용법 참조] llm.bind_tools(tools=[web_search])로 생성한 객체
ai_msg = llm_with_tools.invoke(query)

# LLM의 전체 출력 결과 출력
pprint(ai_msg)
print("-" * 100)

# [LangChain 내장] .content: 도구 호출 시에는 빈 문자열 ''이 반환된다.
# 메시지 content 속성 (텍스트 출력)
pprint(ai_msg.content)
print("-" * 100)

# [LangChain 내장] .tool_calls: 각 항목은 {'name': str, 'args': dict, 'id': str, 'type': 'tool_call'} 구조의 딕셔너리
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
print("-" * 100)
AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Tdo6lSKNKwk5ajxjcJ2HKlMU', 'function': {'arguments': '{"query":"스테이크와 어울리는 와인 추천"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}], ...})
----------------------------------------------------------------------------------------------------
''
----------------------------------------------------------------------------------------------------
[{'name': 'tavily_search_results_json',
  'args': {'query': '스테이크와 어울리는 와인 추천'},
  'id': 'call_Tdo6lSKNKwk5ajxjcJ2HKlMU',
  'type': 'tool_call'}]
----------------------------------------------------------------------------------------------------


            - content가 빈 문자열이고, tool_calls에 호출 정보가 담겨 있다.
            - LLM은 최신 정보가 필요한 질문임을 판단하고, 웹 검색 도구를 호출하기로 결정한 것이다.

# [LangChain 내장] .tool_calls: LangChain이 LLM 응답을 파싱하여 자동 생성하는 도구 호출 정보 리스트
# tool_calls에서 첫 번째 도구 호출 정보 추출
# tool_call은 딕셔너리로, 구조: {'name': str, 'args': dict, 'id': str, 'type': 'tool_call'}
tool_call = ai_msg.tool_calls[0]
tool_call
{'name': 'tavily_search_results_json',
 'args': {'query': '스테이크와 어울리는 와인 추천'},
 'id': 'call_Tdo6lSKNKwk5ajxjcJ2HKlMU',
 'type': 'tool_call'}


      2.1.3. 도구(tool) 실행하기

         - 이 단계는 도구 호출 파이프라인의 네 번째 단계로, LLM이 결정한 도구 호출 정보를 바탕으로 실제 도구를 실행하는 과정이다.
         - 도구를 실행하는 방법은 3가지가 있다:
            - 방법 1: tool_call의 args를 직접 추출하여 도구의 invoke에 전달 (결과가 원시 데이터로 반환)
            - 방법 2: 도구를 직접 실행한 뒤 결과와 tool_call_id를 사용하여 ToolMessage 객체를 수동 생성
            - 방법 3: tool_call 딕셔너리 자체를 도구의 invoke에 전달 (자동으로 ToolMessage 객체 생성)


         - 방법 3이 가장 간결하고 LangChain의 추상화를 잘 활용하는 방법이다.
         - 여러 개의 도구 호출이 있는 경우 batch() 메서드로 병렬 실행이 가능하다.
         - ToolMessage는 도구 실행 결과를 LLM에 재전달하기 위한 메시지 타입으로, content, tool_call_id, name 속성을 가진다.

         - 참고: 이 코드는 섹션 2.1.1에서 정의한 `web_search` (TavilySearchResults 인스턴스)와 섹션 2.1.2에서 생성한 `tool_call` (ai_msg.tool_calls[0]에서 추출한 딕셔너리, 구조: `{'name': str, 'args': dict, 'id': str, 'type': 'tool_call'}`)을 사용합니다.

 

         2.1.3.1. 기본 사용법

### 방법 1: 직접 도구 호출 처리
# tool_call은 [사용자 정의 - 섹션 2.1.2 참조] ai_msg.tool_calls[0]에서 추출한 딕셔너리
# tool_call["args"]는 LangChain이 자동 생성한 딕셔너리 키로, 도구에 전달할 인자를 담고 있다 (예: {'query': '...'})
# web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults(max_results=2) 인스턴스
# [LangChain 내장] invoke(): args 딕셔너리를 전달하면 원시 데이터(list/dict)가 반환된다.
tool_output = web_search.invoke(tool_call["args"])
# tool_call["name"]은 LangChain이 자동 생성한 딕셔너리 키로, 호출된 도구의 이름 문자열
print(f"{tool_call['name']} 호출 결과:")
print(tool_output)
tavily_search_results_json 호출 결과:
[{'url': 'https://mashija.com/...', 'content': '음식과 와인 전문가인 피오나 베켓(Fiona Becket)이...'}]

 

         2.1.3.2. 실무 코드

### 방법 1: 직접 도구 호출 처리

# 이 방법은 AI 메시지에서 첫 번째 도구 호출을 가져와 직접 처리한다.
# tool_call은 [사용자 정의 - 섹션 2.1.2 참조] ai_msg.tool_calls[0]에서 추출한 딕셔너리
# tool_call["args"]는 LangChain이 자동 생성한 딕셔너리 키로, 도구에 전달할 인자 (예: {'query': '스테이크와 어울리는 와인 추천'})
# web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults(max_results=2) 인스턴스
# [LangChain 내장] invoke(): args 딕셔너리를 전달하면 원시 데이터(list)가 반환된다.
tool_output = web_search.invoke(tool_call["args"])
print(f"{tool_call['name']} 호출 결과:")
print("-" * 100)
print(tool_output)
tavily_search_results_json 호출 결과:
----------------------------------------------------------------------------------------------------
[{'url': 'https://mashija.com/...', 'content': '음식과 와인 전문가인 피오나 베켓(Fiona Becket)이 2007년 디캔터에서 스테이크와 함께 몇 가지의 고급 와인을 테이스팅한 후...'}]

 

### 방법 2: ToolMessage 객체 수동 생성

# 도구 호출 결과를 사용하여 ToolMessage 객체를 생성한다.
# 도구 호출의 ID와 이름을 포함하여 더 구조화된 메시지를 만든다.

from langchain_core.messages import ToolMessage
# [LangChain 내장] ToolMessage: 도구 실행 결과를 담는 메시지 클래스
# tool_output은 방법 1에서 반환된 원시 데이터 (list)
# tool_call["id"]는 LangChain이 자동 생성한 도구 호출 고유 ID (ToolMessage와 AIMessage를 매칭하는 데 사용)
# tool_call["name"]은 호출된 도구의 이름 문자열
tool_message = ToolMessage(
    content=tool_output,
    tool_call_id=tool_call["id"],
    name=tool_call["name"]
)

print(tool_message)
content=[{'url': 'https://mashija.com/...', 'content': '음식과 와인 전문가인 피오나 베켓(Fiona Becket)이...'}] name='tavily_search_results_json' tool_call_id='call_Tdo6lSKNKwk5ajxjcJ2HKlMU'

 

### 방법 3: 도구 직접 호출하여 바로 ToolMessage 객체 생성

# [LangChain 내장] invoke(): tool_call 딕셔너리 전체를 전달하면 자동으로 ToolMessage 객체가 생성된다.
# tool_call은 [사용자 정의 - 섹션 2.1.2 참조] {'name': str, 'args': dict, 'id': str, 'type': 'tool_call'} 구조의 딕셔너리
# 가장 간단하고 직관적인 방법으로, LangChain의 추상화를 활용한다.

tool_message = web_search.invoke(tool_call)

print(tool_message)
content='[{"url": "https://mashija.com/...", "content": "음식과 와인 전문가인 피오나 베켓(Fiona Becket)이..."}]' name='tavily_search_results_json' tool_call_id='call_Tdo6lSKNKwk5ajxjcJ2HKlMU'
# [LangChain 내장] ToolMessage의 주요 속성: tool_call_id, name, content
pprint(tool_message.tool_call_id)   # 도구 호출 ID
pprint(tool_message.name)           # 도구 이름
pprint(tool_message.content)        # 도구 실행 결과
'call_Tdo6lSKNKwk5ajxjcJ2HKlMU'
'tavily_search_results_json'
'[{"url": "https://mashija.com/...", "content": "음식과 와인 전문가인 피오나 베켓..."}]'
# [LangChain 내장] batch(): 여러 tool_call 딕셔너리를 한 번에 실행하여 ToolMessage 리스트를 반환
# ai_msg는 [사용자 정의 - 섹션 2.1.2 참조] llm_with_tools.invoke(query)의 반환값 (AIMessage 객체)
# ai_msg.tool_calls는 LangChain이 자동 생성한 도구 호출 정보 리스트
# batch 실행 - 도구 호출이 여러 개인 경우
tool_messages = web_search.batch(ai_msg.tool_calls)

print(tool_messages)
print("-" * 100)
pprint(tool_messages[0].content)
[ToolMessage(content='[{"url": "https://mashija.com/...", "content": "음식과 와인 전문가인 피오나 베켓..."}]', name='tavily_search_results_json', tool_call_id='call_Tdo6lSKNKwk5ajxjcJ2HKlMU')]
----------------------------------------------------------------------------------------------------
'[{"url": "https://mashija.com/...", ...}]'

 


      2.1.4. ToolMessage를 LLM에 전달하여 답변 생성하기

         - 이 단계는 도구 호출 파이프라인의 마지막 단계로, 도구 실행 결과(ToolMessage)를 LLM에 재전달하여 최종 답변을 생성하는 과정이다.
         - @chain 데코레이터를 사용하여 전체 파이프라인을 하나의 체인으로 구성한다.
         - ChatPromptTemplate의 placeholder를 활용하여 동적으로 메시지를 추가할 수 있다.
         - 전체 흐름은 다음과 같다:
            1) 사용자 입력을 LLM 체인에 전달하여 첫 번째 AIMessage를 받는다 (tool_calls 포함).
            2) tool_calls를 기반으로 도구를 실행하여 ToolMessage 리스트를 생성한다.
            3) 첫 번째 AIMessage와 ToolMessage들을 messages placeholder에 담아 LLM 체인에 다시 전달한다.
            4) LLM이 도구 실행 결과를 바탕으로 최종 텍스트 답변을 생성한다.

        - @chain 데코레이터와 invoke() 호출 관계: `@chain`은 일반 Python 함수를 LangChain의 Runnable 객체로 변환하는 데코레이터이다. 변환 후에는 `.invoke()`로 호출해야 한다. 이때 외부의 `web_search_chain.invoke("질문")`은 **함수 본문을 실행시키는 역할**이고, 함수 내부의 `llm_chain.invoke(...)`는 **함수 로직 내에서 LLM 체인을 호출하는 일반 메서드 호출**이다. "invoke 안에 invoke"가 아니라 역할이 다른 별개의 호출이며, 내부 `llm_chain.invoke()`의 반환값(AIMessage)이 그대로 외부 `web_search_chain.invoke()`의 반환값이 된다.

 

        -  batch() 사용 이유: `web_search.batch(ai_msg.tool_calls)`에서 `ai_msg.tool_calls`는 사용자가 정의한 것이 아니라, LangChain이 LLM 응답을 파싱하여 **자동 생성하는 내장 속성(리스트)**이다. LLM이 질문의 복잡도에 따라 도구를 1개 호출할 수도 있고 2개 이상 호출할 수도 있으므로, 이 리스트의 길이는 런타임에 결정된다. `batch()`는 이 가변 길이 리스트의 각 항목에 대해 `invoke()`를 **병렬로 실행**하는 LangChain 내장 메서드이다. `for` 루프로 순차 실행하는 것보다 코드가 간결하고, 복수 도구 호출 시 실행 시간이 단축되며, 빈 리스트(`[]`)도 안전하게 처리된다. 단, `batch()`는 **동일한 하나의 도구**를 여러 인자로 실행하는 것이며, 여러 종류의 도구(search_menu, search_wine 등)를 구분하여 호출해야 하는 경우에는 섹션 2.4.3처럼 `tool_call["name"]` 기준의 조건 분기가 필요하다.

 

        -  RunnableConfig의 역할: `@chain` 함수의 두 번째 인자 `config: RunnableConfig`는 **실행 환경 설정**을 담는 LangChain 내장 딕셔너리 타입이다. 첫 번째 인자(`user_input`)가 "무엇을 처리할지"를 전달하는 반면, `config`는 "어떻게 실행할지"를 제어한다. 함수 내부에서 `config=config`로 전파하면 모든 하위 `invoke()`, `batch()` 호출이 동일한 설정(추적 태그, 메타데이터, 동시성 제한 등)을 공유한다. `config`는 `invoke()`의 두 번째 인자로 전달하며, 주요 설정값은 다음과 같다:

           - `configurable`: 사용자 정의 설정 딕셔너리 (예: `{"thread_id": "session-001"}` — LangGraph의 MemorySaver가 세션을 식별하는 데 사용)

           - `tags`: 실행 추적/필터링용 태그 리스트 (예: `["production", "wine-query"]`)

           - `metadata`: 실행에 첨부할 메타데이터 딕셔너리 (예: `{"user_id": "user-123"}`)

           - `max_concurrency`: `batch()` 병렬 실행 시 최대 동시 실행 수

           - `callbacks`: 실행 이벤트를 수신하는 콜백 핸들러 리스트

 

         - 참고: 이 코드는 섹션 2.1.1에서 정의한 `web_search` (TavilySearchResults 인스턴스)를 사용합니다. `llm_chain`은 이 섹션 내에서 `prompt | llm_with_tools`로 정의되는 LCEL 체인입니다.

 

         2.1.4.1. 기본 사용법

# [LangChain 내장] @chain 데코레이터: 일반 함수를 Runnable 체인으로 변환하여 invoke(), batch() 등의 메서드를 사용 가능하게 한다.
from langchain_core.runnables import chain

@chain  # 이 데코레이터로 감싸면 search_chain.invoke("질문")으로 호출 가능
def search_chain(user_input: str):
    # llm_chain은 [사용자 정의 - 아래 실무 코드 참조] prompt | llm_with_tools로 구성된 LCEL 체인

    # ── 1단계: LLM 호출 ──
    # llm_chain.invoke()를 호출하면 사용자 질문이 프롬프트를 거쳐 LLM에 전달된다.
    # LLM은 질문을 분석하여 "도구 호출이 필요한가?"를 스스로 판단한다.
    # 도구 호출이 필요하다고 판단하면, 반환되는 AIMessage 객체의 tool_calls 속성에
    # 호출할 도구 정보(이름, 인자 등)가 리스트 형태로 자동 담긴다.
    # 참고: 이 invoke()는 외부의 search_chain.invoke()와 다른 호출이다.
    #   외부 invoke()는 @chain이 만든 Runnable을 "실행시키는" 역할이고,
    #   여기의 invoke()는 함수 내부에서 LLM 체인을 "호출하는" 일반 메서드 호출이다.
    ai_msg = llm_chain.invoke({"user_input": user_input})

    # ── 2단계: 도구 실행 ──
    # ai_msg.tool_calls란?
    #   - 사용자가 별도로 정의한 변수가 아니라, LangChain이 LLM 응답을 파싱하여
    #     AIMessage 객체 안에 자동으로 생성해주는 내장 속성(리스트)이다.
    #   - 이 리스트의 길이는 LLM이 런타임에 결정한다. 질문이 단순하면 1개,
    #     복합적이면 2개 이상, 도구가 불필요하면 0개(빈 리스트)가 된다.
    #   - 예: "스테이크 와인 추천" 질문 시 LLM이 한국어/영어 2개 쿼리로 검색을 결정하면
    #     → [{"args": {"query": "스테이크 와인 추천"}, ...}, {"args": {"query": "steak wine pairing"}, ...}]
    #
    # batch()란?
    #   - LangChain 내장 메서드로, 리스트의 각 항목에 대해 invoke()를 병렬로 실행한다.
    #   - 즉, web_search.batch([tool_call_1, tool_call_2])는 내부적으로
    #     web_search.invoke(tool_call_1)과 web_search.invoke(tool_call_2)를 동시에 실행한다.
    #   - for 루프로 하나씩 순차 실행하는 것보다 코드가 간결하고 속도가 빠르다.
    #   - 빈 리스트([])가 들어오면 아무것도 실행하지 않고 빈 리스트를 반환하므로 안전하다.
    #   - 주의: batch()는 동일한 하나의 도구(web_search)를 여러 인자로 실행하는 것이다.
    #     여러 종류의 도구(search_menu, search_wine 등)를 구분해야 하면 섹션 2.4.3의 조건 분기 필요.
    #
    # web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults 인스턴스
    tool_msgs = web_search.batch(ai_msg.tool_calls)

    # ── 3단계: AIMessage + ToolMessage를 LLM에 재전달 → 최종 답변 ──
    # 이 return 값(AIMessage)이 외부 search_chain.invoke()의 반환값이 된다
    return llm_chain.invoke({"user_input": user_input, "messages": [ai_msg, *tool_msgs]})
(체인 객체가 생성된다. invoke로 호출 시 전체 파이프라인이 실행된다.)


         2.1.4.2. 실무 코드

from datetime import datetime
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig, chain

# 오늘 날짜 설정
today = datetime.today().strftime("%Y-%m-%d")

# [LangChain 내장] ChatPromptTemplate: 대화형 프롬프트 템플릿
# "placeholder"는 동적으로 메시지(AIMessage, ToolMessage 등)를 삽입할 수 있는 슬롯
# 프롬프트 템플릿
prompt = ChatPromptTemplate([
    ("system", f"You are a helpful AI assistant. Today's date is {today}."),
    ("human", "{user_input}"),
    ("placeholder", "{messages}"),  # 동적으로 AIMessage, ToolMessage 삽입
])

# ChatOpenAI 모델 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults(max_results=2) 인스턴스
# LLM에 도구를 바인딩
llm_with_tools = llm.bind_tools(tools=[web_search])

# [사용자 정의] LCEL 체인: prompt와 llm_with_tools를 파이프(|) 연산자로 연결
# LLM 체인 생성
llm_chain = prompt | llm_with_tools

# [LangChain 내장] @chain 데코레이터: 일반 함수를 Runnable 체인으로 변환
# 변환 후 .invoke(), .batch(), .stream() 등 Runnable 인터페이스 사용 가능
# 다른 체인과 파이프(|)로 연결하거나 LangGraph 노드로 등록할 수 있게 된다
# 도구 실행 체인 정의
@chain
def web_search_chain(user_input: str, config: RunnableConfig):
    # ── 함수 인자 설명 ──
    # user_input (첫 번째 인자): "무엇을 처리할지" — 사용자의 질문 텍스트
    #   외부에서 web_search_chain.invoke("질문")으로 전달된 문자열이 여기에 들어온다.
    #
    # config (두 번째 인자): "어떻게 실행할지" — 실행 환경 설정을 담는 딕셔너리
    #   외부에서 invoke()의 두 번째 인자로 전달하면 자동으로 이 파라미터에 매핑된다. 예:
    #   web_search_chain.invoke("질문", config={"tags": ["prod"], "configurable": {"thread_id": "s1"}})
    #   config를 전달하지 않으면 LangChain이 기본값을 자동 생성한다.
    #   함수 내부에서 config=config로 전파하면, 이 함수 안에서 호출되는
    #   llm_chain.invoke()와 web_search.batch() 등 모든 하위 호출이 동일한 설정을 공유한다.
    #   주요 설정: configurable(thread_id 등), tags(추적 태그), metadata(메타데이터),
    #            max_concurrency(batch 동시 실행 수), callbacks(이벤트 핸들러)

    input_ = {"user_input": user_input}

    # ── 1단계: LLM 호출 ──
    # llm_chain은 [사용자 정의 - 바로 위에서 정의] prompt | llm_with_tools LCEL 체인
    # 사용자 질문이 프롬프트를 거쳐 LLM에 전달되고, LLM이 "도구 호출이 필요한가?"를 판단한다.
    # 도구 호출이 필요하면 반환되는 AIMessage의 tool_calls에 호출 정보가 담긴다.
    # 참고: 이 invoke()는 함수 내부에서 LLM 체인을 "호출하는" 일반 메서드 호출이다.
    #   아래 외부의 web_search_chain.invoke()와는 역할이 다르다.
    ai_msg = llm_chain.invoke(input_, config=config)
    print("ai_msg: \n", ai_msg)
    print("-" * 100)

    # ── 2단계: 도구 실행 ──
    #
    # ai_msg.tool_calls란?
    #   사용자가 별도로 정의한 변수가 아니라, LangChain이 LLM 응답을 파싱하여
    #   AIMessage 객체 안에 자동으로 생성해주는 내장 속성이다. 타입은 리스트(list)이다.
    #   이 리스트에는 LLM이 "이 도구를 이런 인자로 호출해달라"고 요청한 정보가 담긴다.
    #
    #   리스트의 길이는 LLM이 런타임에 결정한다:
    #   - 도구가 불필요한 질문("안녕하세요") → 빈 리스트 []
    #   - 단일 검색이 필요한 질문("샴페인 가격") → 1개 항목 [{"args": {"query": "..."}, ...}]
    #   - 복합 질문("스테이크 와인 추천") → 2개 이상 [{"args": {"query": "한국어 쿼리"}, ...}, {"args": {"query": "영어 쿼리"}, ...}]
    #
    # batch()란?
    #   LangChain 내장 메서드로, 리스트를 받아서 각 항목마다 invoke()를 병렬로 실행한다.
    #   예를 들어, ai_msg.tool_calls에 2개 항목이 있으면:
    #     web_search.batch([tool_call_1, tool_call_2]) 는 내부적으로
    #       스레드1: web_search.invoke(tool_call_1) → ToolMessage_1
    #       스레드2: web_search.invoke(tool_call_2) → ToolMessage_2
    #     를 동시에 실행하고, 결과를 [ToolMessage_1, ToolMessage_2] 리스트로 반환한다.
    #
    #   batch()를 사용하는 이유:
    #   - LLM이 도구를 몇 번 호출할지 사전에 알 수 없으므로, 가변 길이 리스트를 처리해야 한다.
    #   - for 루프로 하나씩 순차 실행하면 도구 호출이 2개일 때 2배 시간이 걸리지만,
    #     batch()는 병렬 실행하므로 거의 1배 시간으로 처리된다.
    #   - 빈 리스트([])가 들어오면 아무것도 실행하지 않고 빈 리스트를 안전하게 반환한다.
    #
    #   주의: batch()는 동일한 하나의 도구(web_search)를 여러 인자로 실행하는 것이다.
    #   만약 search_menu, search_wine 등 여러 종류의 도구를 구분하여 호출해야 하는 경우에는
    #   batch()만으로는 불가능하고, 섹션 2.4.3처럼 tool_call["name"] 기준의 조건 분기가 필요하다.
    #
    # web_search는 [사용자 정의 - 섹션 2.1.1 참조] TavilySearchResults 인스턴스
    tool_msgs = web_search.batch(ai_msg.tool_calls, config=config)
    print("tool_msgs: \n", tool_msgs)
    print("-" * 100)

    # ── 3단계: AIMessage + ToolMessage를 LLM에 재전달 → 최종 답변 ──
    # messages 키에 [ai_msg, *tool_msgs]를 전달하면 프롬프트 템플릿의 placeholder에 삽입된다.
    # LLM은 1단계의 AIMessage(도구 호출 요청)와 2단계의 ToolMessage(도구 실행 결과)를 함께 받아
    # 검색 결과를 바탕으로 사용자에게 제공할 최종 텍스트 답변을 생성한다.
    # 이 return 값(AIMessage)이 외부 web_search_chain.invoke()의 최종 반환값이 된다.
    return llm_chain.invoke({**input_, "messages": [ai_msg, *tool_msgs]}, config=config)

# ── 외부 호출 ──
# 이 invoke()는 @chain이 만든 Runnable 객체를 "실행시키는" 역할이다.
# 전달한 문자열 "오늘 모엣샹동..."이 함수의 user_input 파라미터에 들어가고,
# 함수 본문(1단계→2단계→3단계)이 실행된 후, return의 AIMessage가 response에 담긴다.
# config를 전달하지 않았으므로 LangChain이 기본 config를 자동 생성하여 사용한다.
# 체인 실행
response = web_search_chain.invoke("오늘 모엣샹동 샴페인의 가격은 얼마인가요?")

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
# 응답 출력
pprint(response.content)
ai_msg:
 content='' additional_kwargs={'tool_calls': [{'id': 'call_42Ke34rhPU0oXxdENB12sfXy', 'function': {'arguments': '{"query":"모엣샹동 샴페인 가격"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}], ...}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content='[{"url": "...", "content": "모엣 샹동 임페리얼 브뤼..."}]', name='tavily_search_results_json', tool_call_id='call_42Ke34rhPU0oXxdENB12sfXy')]
----------------------------------------------------------------------------------------------------
('모엣 샹동 샴페인의 가격은 다음과 같습니다:\n\n'
 '- 모엣 샹동 임페리얼 브뤼 750ml: 약 79,000원\n'
 '- 모엣 샹동 로제 임페리얼 750ml: 약 89,000원\n'
 '...')

 

         2.1.4.3. 다이어그램 읽는 법

            - 아래 다이어그램에서 AIMessage(C)에서 **두 갈래 화살표**가 나가는데, 이것은 조건에 따라 경로가 나뉘는 분기(if/else)가 **아니다.** 하나의 AIMessage 객체가 **서로 다른 용도로 두 곳에서 사용**되는 것이다.
            - 왜 AIMessage가 두 곳에서 필요한지 이해하려면, LLM의 관점에서 생각해보면 된다:

               - 1단계에서 LLM은 "이 질문에 답하려면 tavily_search를 호출해야 한다"고 판단한다. 이 판단 결과가 AIMessage의 `tool_calls` 속성에 담긴다.

               - 2단계에서 실제로 도구를 실행하여 검색 결과(ToolMessage)를 얻는다.

               - 3단계에서 LLM에게 다시 물어보는데, 이때 LLM은 **"내가 무엇을 요청했는지"(AIMessage)와 "그 결과가 무엇인지"(ToolMessage)를 쌍으로 받아야** 검색 결과를 올바르게 해석하고 최종 답변을 작성할 수 있다.

 

            - 따라서 코드의 `"messages": [ai_msg, *tool_msgs]`는:

               - `ai_msg` — 1단계에서 LLM이 "tavily_search를 호출해달라"고 요청한 기록 (다이어그램의 C→F 화살표)

               - `*tool_msgs` — 2단계에서 도구를 실행한 검색 결과 (다이어그램의 E→F 화살표)

 

            - 이 둘을 **함께** LLM에 재전달하여, LLM이 검색 결과를 바탕으로 최종 텍스트 답변을 생성하는 구조이다.


   2.2. 사용자 정의 도구

      - 이 절에서는 @tool 데코레이터를 사용하여 사용자가 직접 도구를 정의하는 방법을 학습한다.
      - 전체 파이프라인 흐름에서 도구 정의 단계에 해당한다.

 

      2.2.1. 도구(tool) 정의하기

         - @tool 데코레이터를 사용하면 일반 파이썬 함수를 도구로 변환할 수 있다.

         - 함수의 docstring이 도구의 description이 된다. LLM은 이 description을 보고 도구 호출 여부를 판단하므로, 명확하고 구체적인 docstring 작성이 중요하다.

         - @tool로 생성된 도구의 타입은 StructuredTool이다.

 

         2.2.1.1. 기본 사용법

# [LangChain 내장] @tool 데코레이터: 일반 파이썬 함수를 StructuredTool 객체로 변환
from langchain_core.tools import tool

@tool
def search_web(query: str) -> str:
    """Searches the internet for information."""
    # 도구 로직 구현
    return "검색 결과"
(도구 객체가 생성된다. search_web.name, search_web.description으로 속성 확인 가능)

 

         2.2.1.2. 실무 코드

from langchain_community.tools import TavilySearchResults
# [LangChain 내장] @tool 데코레이터: 일반 파이썬 함수를 StructuredTool 객체로 변환
from langchain_core.tools import tool
from typing import List

# [사용자 정의] @tool 데코레이터로 정의한 커스텀 웹 검색 도구
# 내부에서 TavilySearchResults를 사용하되, 결과를 포맷팅하여 반환한다.
@tool
def search_web(query: str) -> str:
    """Searches the internet for information that does not exist in the database or for the latest information."""

    # [LangChain 내장] TavilySearchResults: Tavily API 기반 웹 검색 도구 클래스
    tavily_search = TavilySearchResults(max_results=2)
    # [LangChain 내장] invoke(): 도구를 실행하는 메서드
    docs = tavily_search.invoke(query)

    formatted_docs = "\n---\n".join([
        f'<Document href="{doc["url"]}"/>\n{doc["content"]}\n</Document>'
        for doc in docs
        ])

    if len(formatted_docs) > 0:
        return formatted_docs

    return "관련 정보를 찾을 수 없습니다."
# [LangChain 내장] name, description, args_schema: 모든 도구가 가지는 핵심 속성
# 도구 속성 확인
print("자료형: ")
print(type(search_web))
print("-" * 100)

print("name: ")
print(search_web.name)
print("-" * 100)

print("description: ")
pprint(search_web.description)
print("-" * 100)

print("schema: ")
pprint(search_web.args_schema.schema())
print("-" * 100)
자료형:
<class 'langchain_core.tools.structured.StructuredTool'>
----------------------------------------------------------------------------------------------------
name:
search_web
----------------------------------------------------------------------------------------------------
description:
('Searches the internet for information that does not exist in the database or '
 'for the latest information.')
----------------------------------------------------------------------------------------------------
schema:
{'description': 'Searches the internet for information that does not exist in '
                'the database or for the latest information.',
 'properties': {'query': {'title': 'Query', 'type': 'string'}},
 'required': ['query'],
 'title': 'search_web',
 'type': 'object'}
----------------------------------------------------------------------------------------------------
# search_web은 [사용자 정의 - 바로 위에서 정의] @tool로 생성한 커스텀 웹 검색 도구
# [LangChain 내장] invoke(): 도구를 실행하는 메서드. 문자열 입력 시 원시 데이터 반환.
# 도구 직접 실행 테스트
query = "스테이크와 어울리는 와인을 추천해주세요."
search_result = search_web.invoke(query)
print(search_result)
<Document href="https://alcohol.hobby-tech.com/..."/>
말벡 (Malbec) – 풍부한 과일 향과 부드러운 탄닌
추천 이유: 말벡은 블랙베리와 자두 같은 과일향이 강하면서도 탄닌이 부드러운 레드 와인입니다...
</Document>
---
<Document href="https://mashija.com/..."/>
음식과 와인 전문가인 피오나 베켓(Fiona Becket)이...
</Document>
# llm은 섹션 2.1.2 이후부터 사용 중인 ChatOpenAI(model="gpt-4o-mini") 인스턴스
# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# search_web은 [사용자 정의 - 바로 위에서 정의] @tool로 생성한 커스텀 웹 검색 도구
# LLM에 도구를 바인딩
llm_with_tools = llm.bind_tools(tools=[search_web])

# 도구 호출이 필요한 LLM 호출을 수행
query = "스테이크와 어울리는 와인을 추천해주세요."
ai_msg = llm_with_tools.invoke(query)

# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
[{'name': 'search_web',
  'args': {'query': '스테이크와 어울리는 와인 추천'},
  'id': 'call_TJGiEDG8aKQggYsbGABpFL5n',
  'type': 'tool_call'},
 {'name': 'search_web',
  'args': {'query': 'red wine pairings with steak'},
  'id': 'call_2H9ULFSuuN7IMKYT48Vyh82U',
  'type': 'tool_call'}]


      2.2.2. LLM 도구 호출 성능 비교하기

         - 동일한 도구를 서로 다른 LLM 모델에 바인딩하여 도구 호출 결과를 비교한다.
         - 모델마다 tool_calls의 형식이나 인자 구성이 다를 수 있으며, 이는 도구 호출 성능의 차이로 이어진다.

 

         - 참고: 이 코드는 섹션 2.2.1에서 정의한 `search_web` (@tool 데코레이터로 생성한 커스텀 웹 검색 도구)를 사용합니다.

 

         2.2.2.1. 실무 코드

from langchain_openai import ChatOpenAI
from langchain_groq import ChatGroq

# 기본 LLM
llm_gpt = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_groq = ChatGroq(model="llama-3.3-70b-versatile", temperature=0)

# tools는 search_web을 담은 리스트 [사용자 정의 - 섹션 2.2.1 참조]
# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
tools = [search_web]

gpt_with_tools = llm_gpt.bind_tools(tools)
groq_llama3_with_tools = llm_groq.bind_tools(tools)
# GPT-4o-mini 도구 호출 결과
query = "스테이크와 어울리는 와인을 추천해주세요."
ai_msg = gpt_with_tools.invoke(query)

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
pprint(ai_msg.content)
print("-" * 100)
# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
pprint(ai_msg.tool_calls)
''
----------------------------------------------------------------------------------------------------
[{'name': 'search_web',
  'args': {'query': '스테이크와 어울리는 와인 추천'},
  'id': 'call_bc8K8oBeK9ILWCqZp95E4Dai',
  'type': 'tool_call'},
 {'name': 'search_web',
  'args': {'query': 'steak wine pairing recommendations'},
  'id': 'call_FvLztnuTkjMP09zwJ9YAoRuU',
  'type': 'tool_call'}]


            - GPT-4o-mini는 한국어와 영어 두 가지 쿼리로 도구를 2회 호출한다.

# Groq Llama3 도구 호출 결과
query = "스테이크와 어울리는 와인을 추천해주세요."
ai_msg = groq_llama3_with_tools.invoke(query)

pprint(ai_msg.content)
print("-" * 100)
pprint(ai_msg.tool_calls)
''
----------------------------------------------------------------------------------------------------
[{'name': 'search_web',
  'args': {'query': 'steak wine pairing recommendation'},
  'id': '84s33m56v',
  'type': 'tool_call'}]

 

            - Groq Llama3는 영어 쿼리 하나로만 도구를 1회 호출한다.

            - 모델 간 차이점:

               - GPT-4o-mini: 한국어/영어 쿼리로 2회 호출, 더 긴 호출 ID

               - Llama3: 영어 쿼리로 1회 호출, 짧은 호출 ID

               - 모델에 따라 도구 호출 전략과 결과 형식이 달라질 수 있다.

 

   2.3. Runnable 객체를 도구(tool) 변환

      - 이 절에서는 기존의 Runnable 객체(함수, 체인 등)를 도구로 변환하는 방법을 학습한다.

      - as_tool() 메서드를 사용하여 문자열이나 dict 입력을 받는 Runnable을 도구로 변환할 수 있다.

      - 이를 통해 기존에 구축한 LCEL 체인이나 함수를 도구 호출 파이프라인에 통합할 수 있다.

      2.3.1. Document Loader

         - 이 단계에서는 WikipediaLoader를 Runnable로 감싸고, as_tool()로 도구로 변환한다.

         - RunnableLambda로 일반 함수를 Runnable로 변환한 뒤, as_tool()에 name, description, args_schema를 지정한다.

         - args_schema에는 Pydantic BaseModel을 사용하여 입력 스키마를 정의한다.

 

         2.3.1.1. 기본 사용법

# [LangChain 내장] RunnableLambda: 일반 파이썬 함수를 Runnable 객체로 변환하는 클래스
from langchain_core.runnables import RunnableLambda
from pydantic import BaseModel, Field

# 입력 스키마 정의
class MySchema(BaseModel):
    query: str = Field(..., description="검색 쿼리")

# [LangChain 내장] RunnableLambda(): 함수를 Runnable로 변환
# [LangChain 내장] as_tool(): Runnable 객체를 StructuredTool로 변환하는 메서드
# 함수를 Runnable로 변환 후 도구로 변환
runnable = RunnableLambda(my_function)
my_tool = runnable.as_tool(
    name="my_tool",
    description="도구 설명",
    args_schema=MySchema
)
(StructuredTool 객체가 생성된다.)

 

         2.3.1.2. 실무 코드

from langchain_community.document_loaders import WikipediaLoader
from langchain_core.documents import Document
# [LangChain 내장] RunnableLambda: 일반 파이썬 함수를 Runnable 객체로 변환하는 클래스
from langchain_core.runnables import RunnableLambda
from pydantic import BaseModel, Field
from typing import List

# [사용자 정의] 위키피디아 문서를 검색하는 함수
# WikipediaLoader를 사용하여 위키피디아 문서를 검색하는 함수
def search_wiki(input_data: dict) -> List[Document]:
    """Search Wikipedia documents based on user input (query) and return k documents"""
    query = input_data["query"]
    k = input_data.get("k", 2)
    wiki_loader = WikipediaLoader(query=query, load_max_docs=k, lang="ko")
    wiki_docs = wiki_loader.load()
    return wiki_docs

# 도구 호출에 사용할 입력 스키마 정의
class WikiSearchSchema(BaseModel):
    """Input schema for Wikipedia search."""
    query: str = Field(..., description="The query to search for in Wikipedia")
    k: int = Field(2, description="The number of documents to return (default is 2)")

# [LangChain 내장] RunnableLambda(): 함수를 Runnable로 변환
# RunnableLambda 함수를 사용하여 위키피디아 문서 로더를 Runnable로 변환
runnable = RunnableLambda(search_wiki)
# [LangChain 내장] as_tool(): Runnable 객체를 StructuredTool로 변환하는 메서드
wiki_search = runnable.as_tool(
    name="wiki_search",
    description=dedent("""
        Use this tool when you need to search for information on Wikipedia.
        It searches for Wikipedia articles related to the user's query and returns
        a specified number of documents. This tool is useful when general knowledge
        or background information is required.
    """),
    args_schema=WikiSearchSchema
)
# [LangChain 내장] name, description, args_schema: 모든 도구가 가지는 핵심 속성
# 도구 속성 확인
print("자료형: ")
print(type(wiki_search))
print("-" * 100)

print("name: ")
print(wiki_search.name)
print("-" * 100)

print("description: ")
pprint(wiki_search.description)
print("-" * 100)

print("schema: ")
pprint(wiki_search.args_schema.schema())
print("-" * 100)
자료형:
<class 'langchain_core.tools.structured.StructuredTool'>
----------------------------------------------------------------------------------------------------
name:
wiki_search
----------------------------------------------------------------------------------------------------
description:
('Use this tool when you need to search for information on Wikipedia.\n'
 "It searches for Wikipedia articles related to the user's query and returns\n"
 'a specified number of documents. This tool is useful when general knowledge\n'
 'or background information is required.')
----------------------------------------------------------------------------------------------------
schema:
{'description': 'Input schema for Wikipedia search.',
 'properties': {'k': {'default': 2,
                      'description': 'The number of documents to return (default is 2)',
                      'title': 'K',
                      'type': 'integer'},
                'query': {'description': 'The query to search for in Wikipedia',
                          'title': 'Query',
                          'type': 'string'}},
 'required': ['query'],
 'title': 'WikiSearchSchema',
 'type': 'object'}
----------------------------------------------------------------------------------------------------
# wiki_search는 [사용자 정의 - 바로 위에서 정의] as_tool()로 변환한 위키피디아 검색 도구
# [LangChain 내장] invoke(): 도구를 실행하는 메서드. 딕셔너리 입력을 받는다.
# 위키 검색 실행
query = "파스타의 유래"
wiki_results = wiki_search.invoke({"query": query})

# 검색 결과 출력
for result in wiki_results:
    print(result)
    print("-" * 100)
page_content='오르조(이탈리아어: orzo) 또는 리소니(이탈리아어: risoni)는 이탈리아의 파스타이다. "오르조"는 라틴어:hordeum에서 유래했으며 "보리"를 뜻한다...' metadata={'title': '오르조', 'summary': '오르조 또는 리소니는 이탈리아의 파스타이다...'}
----------------------------------------------------------------------------------------------------
...
# llm은 이전 섹션에서 사용 중인 ChatOpenAI(model="gpt-4o-mini") 인스턴스
# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# search_web은 [사용자 정의 - 섹션 2.2.1 참조] @tool로 생성한 커스텀 웹 검색 도구
# wiki_search는 [사용자 정의 - 바로 위에서 정의] as_tool()로 변환한 위키피디아 검색 도구
# LLM에 도구를 바인딩 (2개의 도구 바인딩)
llm_with_tools = llm.bind_tools(tools=[search_web, wiki_search])

# 도구 호출이 필요한 LLM 호출을 수행
query = "서울 강남의 유명한 파스타 맛집은 어디인가요? 그리고 파스타의 유래를 알려주세요."
ai_msg = llm_with_tools.invoke(query)

# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
[{'name': 'search_web',
  'args': {'query': '서울 강남 파스타 맛집'},
  'id': 'call_q6Z4jxiDKanjJFl3EjGUB4HI',
  'type': 'tool_call'},
 {'name': 'wiki_search',
  'args': {'query': '파스타의 유래'},
  'id': 'call_Gs30osaWBe5hQlfPBtwXqDHc',
  'type': 'tool_call'}]


            - LLM이 질문을 분석하여 맛집 정보에는 search_web을, 유래 정보에는 wiki_search를 호출하는 것을 확인할 수 있다.

 

      2.3.2. LCEL 체인

         - 이 단계에서는 위키피디아 문서를 검색하고 내용을 요약하는 LCEL 체인을 구성한 뒤, 이를 도구로 변환한다.
         - 기존의 LCEL 체인에 as_tool() 메서드를 직접 적용하여 도구로 변환할 수 있다.

 

         2.3.2.1. 실무 코드

from langchain_core.prompts import ChatPromptTemplate
# [LangChain 내장] StrOutputParser: LLM 출력에서 문자열만 추출하는 파서
from langchain_core.output_parsers import StrOutputParser
# [LangChain 내장] RunnableLambda: 일반 파이썬 함수를 Runnable 객체로 변환하는 클래스
from langchain_core.runnables import RunnableLambda
from langchain_community.document_loaders import WikipediaLoader

# [사용자 정의] 위키피디아 문서를 검색하고 텍스트로 반환하는 함수
# WikipediaLoader를 사용하여 위키피디아 문서를 검색하고 텍스트로 반환하는 함수
def wiki_search_and_summarize(input_data: dict):
    wiki_loader = WikipediaLoader(query=input_data["query"], load_max_docs=2, lang="ko")
    wiki_docs = wiki_loader.load()

    formatted_docs = [
        f'<Document source="{doc.metadata["source"]}"/>\n{doc.page_content}\n</Document>'
        for doc in wiki_docs
    ]

    return formatted_docs

# 요약 프롬프트 템플릿
summary_prompt = ChatPromptTemplate.from_template(
    "Summarize the following text in a concise manner:\n\n{context}\n\nSummary:"
)

# [사용자 정의] LCEL 요약 체인: RunnableLambda → 프롬프트 → LLM → StrOutputParser
# LLM 및 요약 체인 설정
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
summary_chain = (
    {"context": RunnableLambda(wiki_search_and_summarize)}
    | summary_prompt | llm | StrOutputParser()
)

# 요약 테스트
summarized_text = summary_chain.invoke({"query": "파스타의 유래"})
pprint(summarized_text)
('오르조(또는 리소니)는 이탈리아의 파스타로, 보리에서 유래한 이름을 가지고 있으며 큰 쌀알 모양이다. '
 '주로 수프와 함께 먹으며, 현재는 박력분으로 만들어진다. 이탈리아 요리는 기원전 4세기부터 발전해왔으며, '
 '지역에 따라 다양한 특색을 지닌다...')
# 도구 호출에 사용할 입력 스키마 정의
class WikiSummarySchema(BaseModel):
    """Input schema for Wikipedia search."""
    query: str = Field(..., description="The query to search for in Wikipedia")

# [LangChain 내장] as_tool(): LCEL 체인(Runnable)을 StructuredTool로 변환하는 메서드
# summary_chain은 [사용자 정의 - 바로 위에서 정의] LCEL 요약 체인
# as_tool 메서드를 사용하여 도구 객체로 변환
wiki_summary = summary_chain.as_tool(
    name="wiki_summary",
    description=dedent("""
        Use this tool when you need to search for information on Wikipedia.
        It searches for Wikipedia articles related to the user's query and returns
        a summarized text. This tool is useful when general knowledge
        or background information is required.
    """),
    args_schema=WikiSummarySchema
)

# [LangChain 내장] name, description: 도구의 핵심 속성
# 도구 속성 확인
print("자료형: ")
print(type(wiki_summary))
print("-" * 100)

print("name: ")
print(wiki_summary.name)
print("-" * 100)

print("description: ")
pprint(wiki_summary.description)
자료형:
<class 'langchain_core.tools.structured.StructuredTool'>
----------------------------------------------------------------------------------------------------
name:
wiki_summary
----------------------------------------------------------------------------------------------------
description:
('Use this tool when you need to search for information on Wikipedia.\n'
 "It searches for Wikipedia articles related to the user's query and returns\n"
 'a summarized text. This tool is useful when general knowledge\n'
 'or background information is required.')
# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# search_web은 [사용자 정의 - 섹션 2.2.1 참조] @tool로 생성한 커스텀 웹 검색 도구
# wiki_summary는 [사용자 정의 - 바로 위에서 정의] LCEL 체인을 as_tool()로 변환한 도구
# LLM에 도구를 바인딩
llm_with_tools = llm.bind_tools(tools=[search_web, wiki_summary])

# 도구 호출이 필요한 LLM 호출을 수행
query = "서울 강남의 유명한 파스타 맛집은 어디인가요? 그리고 파스타의 유래를 알려주세요."
ai_msg = llm_with_tools.invoke(query)

# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
[{'name': 'search_web',
  'args': {'query': '서울 강남 파스타 맛집 추천'},
  'id': 'call_oZnWy550tUfNYOpVfJdeB1wu',
  'type': 'tool_call'},
 {'name': 'wiki_summary',
  'args': {'query': '파스타'},
  'id': 'call_oc5bWdoVowcH9wSAXVC17k01',
  'type': 'tool_call'}]
# [LangChain 내장] invoke(): tool_call 딕셔너리를 전달하면 자동으로 ToolMessage 객체가 생성됨
# ai_msg.tool_calls[1]은 위 실행 결과에서 두 번째 도구 호출 정보 (wiki_summary 호출)
# wiki_summary는 [사용자 정의 - 바로 위에서 정의] LCEL 체인을 as_tool()로 변환한 도구
# 도구 실행
tool_message = wiki_summary.invoke(ai_msg.tool_calls[1])

print(tool_message)
print("-" * 100)
# [LangChain 내장] .content: ToolMessage의 도구 실행 결과 속성
pprint(tool_message.content)
content='The text provides two main topics: 1. Pasta: Pasta, an Italian staple made from durum wheat semolina mixed with water or eggs, is a significant part of Italian cuisine...' name='wiki_summary' tool_call_id='call_oc5bWdoVowcH9wSAXVC17k01'
----------------------------------------------------------------------------------------------------
'The text provides two main topics: 1. Pasta: Pasta, an Italian staple...'

 

            - 참고: 아래 코드는 섹션 2.1.4와 동일한 패턴으로, `llm_chain`을 구성하고 `@chain`으로 전체 파이프라인을 정의합니다. `wiki_summary`는 바로 위에서 as_tool()로 변환한 도구입니다.

from datetime import datetime
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig, chain

# 오늘 날짜 설정
today = datetime.today().strftime("%Y-%m-%d")

# [LangChain 내장] ChatPromptTemplate: 대화형 프롬프트 템플릿
# 프롬프트 템플릿
prompt = ChatPromptTemplate([
    ("system", f"You are a helpful AI assistant. Today's date is {today}."),
    ("human", "{user_input}"),
    ("placeholder", "{messages}"),
])

# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# wiki_summary는 [사용자 정의 - 섹션 2.3.2 참조] LCEL 체인을 as_tool()로 변환한 위키 요약 도구
# LLM에 도구를 바인딩
llm_with_tools = llm.bind_tools(tools=[wiki_summary])

# [사용자 정의] LCEL 체인: prompt와 llm_with_tools를 파이프(|) 연산자로 연결
# LLM 체인 생성
llm_chain = prompt | llm_with_tools

# [LangChain 내장] @chain 데코레이터: 일반 함수를 Runnable 체인으로 변환
# 도구 실행 체인 정의
@chain
def wiki_summary_chain(user_input: str, config: RunnableConfig):
    input_ = {"user_input": user_input}
    # llm_chain은 [사용자 정의 - 바로 위에서 정의] prompt | llm_with_tools LCEL 체인
    ai_msg = llm_chain.invoke(input_, config=config)
    print("ai_msg: \n", ai_msg)
    print("-" * 100)
    # [LangChain 내장] batch(): tool_calls 리스트를 한 번에 실행하여 ToolMessage 리스트 반환
    tool_msgs = wiki_summary.batch(ai_msg.tool_calls, config=config)
    print("tool_msgs: \n", tool_msgs)
    print("-" * 100)
    # messages placeholder에 AIMessage + ToolMessage를 삽입하여 LLM에 재전달
    return llm_chain.invoke({**input_, "messages": [ai_msg, *tool_msgs]}, config=config)

# [LangChain 내장] invoke(): 체인을 실행하는 메서드
# 체인 실행
response = wiki_summary_chain.invoke("파스타의 유래에 대해서 알려주세요.")

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
# 응답 출력
pprint(response.content)
ai_msg:
 content='' additional_kwargs={'tool_calls': [{'id': 'call_RlYLF3vLxa5btvidFHT2JmVE', 'function': {'arguments': '{"query":"파스타의 유래"}', 'name': 'wiki_summary'}, 'type': 'function'}], ...}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content='오르조(또는 리소니)는 이탈리아의 파스타로...', name='wiki_summary', tool_call_id='call_RlYLF3vLxa5btvidFHT2JmVE')]
----------------------------------------------------------------------------------------------------
('파스타의 유래에 대해 설명드리겠습니다...')


            - 아래 다이어그램은 섹션 2.3 전체에서 학습한 **"Runnable 객체를 도구로 변환하는 2가지 방법"**을 요약한 것이다.

            - 시작점이 2개(A, D)인 이유는, 이 두 방법이 **서로 독립적인 변환 경로**이기 때문이다. 하나의 흐름이 분기되는 것이 아니라, 각각 다른 출발점에서 시작하여 동일한 목적지(`LLM.bind_tools()`)로 합류하는 구조이다.

                - 방법 1 (위쪽 경로, 섹션 2.3.1)**: 일반 Python 함수(`search_wiki`)를 먼저 `RunnableLambda`로 감싸서 Runnable 객체로 만든 뒤, `as_tool()`로 도구(`wiki_search`)로 변환한다. 함수 자체는 Runnable이 아니므로 **2단계 변환**이 필요하다.

                - 방법 2 (아래쪽 경로, 섹션 2.3.2)**: LCEL 파이프(`|`)로 구성한 체인(`summary_chain`)은 이미 Runnable 객체이므로, 별도의 `RunnableLambda` 없이 체인에 바로 `as_tool()`을 호출하여 도구(`wiki_summary`)로 변환한다. **1단계 변환**으로 충분하다.

 

            - 두 도구 모두 최종적으로 `LLM.bind_tools()`에 전달되어 LLM이 사용할 수 있게 된다.


   2.4. 벡터저장소 검색기

      - 이 절에서는 벡터저장소(Chroma)에 문서를 인덱싱하고, 검색 기능을 도구로 정의하여 LLM에서 활용하는 방법을 학습한다.
      - @tool 데코레이터를 사용하여 벡터저장소 검색을 도구로 정의한다.

 

      2.4.1. 문서 로드 및 인덱싱

         - 이 단계에서는 레스토랑 메뉴와 와인 데이터를 텍스트 파일에서 로드하고, 문서를 분할한 뒤, Chroma 벡터저장소에 인덱싱한다.

         - OllamaEmbeddings의 bge-m3 모델을 임베딩에 사용한다.

 

      2.4.1.1. 실무 코드

from langchain.document_loaders import TextLoader

# 메뉴판 텍스트 데이터를 로드
loader = TextLoader("./data/restaurant_menu.txt", encoding="utf-8")
documents = loader.load()

print(len(documents))
1
from langchain_core.documents import Document

# [사용자 정의] 메뉴 항목을 정규표현식으로 분리하여 개별 Document 객체로 변환하는 함수
# 문서 분할 (Chunking)
def split_menu_items(document):
    """
    메뉴 항목을 분리하는 함수
    """
    # 정규표현식 정의
    pattern = r'(\d+\.\s.*?)(?=\n\n\d+\.|$)'
    menu_items = re.findall(pattern, document.page_content, re.DOTALL)

    # 각 메뉴 항목을 Document 객체로 변환
    menu_documents = []
    for i, item in enumerate(menu_items, 1):
        # 메뉴 이름 추출
        menu_name = item.split('\n')[0].split('.', 1)[1].strip()

        # 새로운 Document 객체 생성
        menu_doc = Document(
            page_content=item.strip(),
            metadata={
                "source": document.metadata['source'],
                "menu_number": i,
                "menu_name": menu_name
            }
        )
        menu_documents.append(menu_doc)

    return menu_documents


# split_menu_items는 [사용자 정의 - 바로 위에서 정의] 메뉴 분리 함수
# 메뉴 항목 분리 실행
menu_documents = []
for doc in documents:
    menu_documents += split_menu_items(doc)

# 결과 출력
print(f"총 {len(menu_documents)}개의 메뉴 항목이 처리되었습니다.")
for doc in menu_documents[:2]:
    print(f"\n메뉴 번호: {doc.metadata['menu_number']}")
    print(f"메뉴 이름: {doc.metadata['menu_name']}")
    print(f"내용:\n{doc.page_content[:100]}...")
총 10개의 메뉴 항목이 처리되었습니다.

메뉴 번호: 1
메뉴 이름: 시그니처 스테이크
내용:
1. 시그니처 스테이크
   • 가격: ₩35,000
   • 주요 식재료: 최상급 한우 등심, 로즈메리 감자, 그릴드 아스파라거스
   • 설명: 셰프의 특제 시그니처 메뉴로, ...

메뉴 번호: 2
메뉴 이름: 트러플 리조또
내용:
2. 트러플 리조또
   • 가격: ₩22,000
   • 주요 식재료: 이탈리아산 아르보리오 쌀, 블랙 트러플, 파르미지아노 레지아노 치즈
   • 설명: 크리미한 텍스처의 리조...
# Chroma Vectorstore를 사용하기 위한 준비
from langchain_chroma import Chroma
from langchain_ollama import OllamaEmbeddings

embeddings_model = OllamaEmbeddings(model="bge-m3")

# Chroma 인덱스 생성 (메뉴)
menu_db = Chroma.from_documents(
    documents=menu_documents,
    embedding=embeddings_model,
    collection_name="restaurant_menu",
    persist_directory="./chroma_db",
)

# [LangChain 내장] as_retriever(): 벡터저장소를 검색기(Retriever) 객체로 변환하는 메서드
# Retriever 생성
menu_retriever = menu_db.as_retriever(
    search_kwargs={'k': 2},
)

# [LangChain 내장] invoke(): 검색기를 실행하는 메서드
# 쿼리 테스트
query = "시그니처 스테이크의 가격과 특징은 무엇인가요?"
docs = menu_retriever.invoke(query)
print(f"검색 결과: {len(docs)}개")

for doc in docs:
    print(f"메뉴 번호: {doc.metadata['menu_number']}")
    print(f"메뉴 이름: {doc.metadata['menu_name']}")
    print()
검색 결과: 2개
메뉴 번호: 1
메뉴 이름: 시그니처 스테이크

메뉴 번호: 1
메뉴 이름: 시그니처 스테이크
# 와인 메뉴에 대해서도 같은 작업을 처리

# 와인 메뉴 텍스트 데이터를 로드
loader = TextLoader("./data/restaurant_wine.txt", encoding="utf-8")
documents = loader.load()

# split_menu_items는 [사용자 정의 - 위에서 정의] 메뉴 분리 함수 (와인 메뉴에도 동일하게 사용)
# 메뉴 항목 분리 실행
menu_documents = []
for doc in documents:
    menu_documents += split_menu_items(doc)

# 결과 출력
print(f"총 {len(menu_documents)}개의 메뉴 항목이 처리되었습니다.")
for doc in menu_documents[:2]:
    print(f"\n메뉴 번호: {doc.metadata['menu_number']}")
    print(f"메뉴 이름: {doc.metadata['menu_name']}")
    print(f"내용:\n{doc.page_content[:100]}...")


# embeddings_model은 위에서 정의한 OllamaEmbeddings(model="bge-m3") 인스턴스
# Chroma 인덱스 생성 (와인)
wine_db = Chroma.from_documents(
    documents=menu_documents,
    embedding=embeddings_model,
    collection_name="restaurant_wine",
    persist_directory="./chroma_db",
)

wine_retriever = wine_db.as_retriever(
    search_kwargs={'k': 2},
)

query = "스테이크와 어울리는 와인을 추천해주세요."
docs = wine_retriever.invoke(query)
print(f"검색 결과: {len(docs)}개")

for doc in docs:
    print(f"메뉴 번호: {doc.metadata['menu_number']}")
    print(f"메뉴 이름: {doc.metadata['menu_name']}")
    print()
총 10개의 메뉴 항목이 처리되었습니다.

메뉴 번호: 1
메뉴 이름: 샤토 마고 2015
내용:
1. 샤토 마고 2015
   • 가격: ₩450,000
   • 주요 품종: 카베르네 소비뇽, 메를로, 카베르네 프랑, 쁘띠 베르도
   • 설명: 보르도 메독 지역의 프리미엄 ...

메뉴 번호: 2
메뉴 이름: 돔 페리뇽 2012
내용:
2. 돔 페리뇽 2012
   • 가격: ₩380,000
   • 주요 품종: 샤르도네, 피노 누아
   • 설명: 프랑스 샴페인의 대명사로 알려진 프레스티지 큐베입니다. 시트러스...
검색 결과: 2개
메뉴 번호: 6
메뉴 이름: 바롤로 몬프리바토 2017

메뉴 번호: 6
메뉴 이름: 바롤로 몬프리바토 2017


      2.4.2. 도구(tool) 정의하기

         - 벡터저장소 검색 기능을 @tool 데코레이터로 도구로 정의한다.
         - 메뉴 검색 도구(search_menu)와 와인 검색 도구(search_wine)를 각각 정의한다.

 

         - 참고: 이 코드는 섹션 2.4.1에서 생성한 `embeddings_model` (OllamaEmbeddings 인스턴스)과 Chroma 벡터저장소를 사용합니다.

 

         2.4.2.1. 실무 코드

# [LangChain 내장] @tool 데코레이터: 일반 파이썬 함수를 StructuredTool 객체로 변환
from langchain_core.tools import tool
from typing import List
from langchain_core.documents import Document

# embeddings_model은 [사용자 정의 - 섹션 2.4.1 참조] OllamaEmbeddings(model="bge-m3") 인스턴스
# 벡터 저장소 로드 (메뉴)
menu_db = Chroma(
    embedding_function=embeddings_model,
    collection_name="restaurant_menu",
    persist_directory="./chroma_db",
)

# [사용자 정의] @tool로 정의한 메뉴 검색 도구. menu_db에서 유사도 검색을 수행한다.
@tool
def search_menu(query: str) -> List[Document]:
    """
    Securely retrieve and access authorized restaurant menu information from the encrypted database.
    Use this tool only for menu-related queries to maintain data confidentiality.
    """
    docs = menu_db.similarity_search(query, k=2)
    if len(docs) > 0:
        return docs

    return [Document(page_content="관련 메뉴 정보를 찾을 수 없습니다.")]

# [LangChain 내장] name, description, args_schema: 모든 도구가 가지는 핵심 속성
# 도구 속성 확인
print("자료형: ")
print(type(search_menu))
print("-" * 100)

print("name: ")
print(search_menu.name)
print("-" * 100)

print("description: ")
pprint(search_menu.description)
print("-" * 100)

print("schema: ")
pprint(search_menu.args_schema.schema())
print("-" * 100)
# embeddings_model은 [사용자 정의 - 섹션 2.4.1 참조] OllamaEmbeddings(model="bge-m3") 인스턴스
# 벡터 저장소 로드 (와인)
wine_db = Chroma(
   embedding_function=embeddings_model,
   collection_name="restaurant_wine",
   persist_directory="./chroma_db",
)

# [사용자 정의] @tool로 정의한 와인 검색 도구. wine_db에서 유사도 검색을 수행한다.
@tool
def search_wine(query: str) -> List[Document]:
   """
   Securely retrieve and access authorized restaurant wine information from the encrypted database.
   Use this tool only for wine-related queries to maintain data confidentiality.
   """
   docs = wine_db.similarity_search(query, k=2)
   if len(docs) > 0:
      return docs

   return [Document(page_content="관련 와인 정보를 찾을 수 없습니다.")]

# 도구 속성 확인
print("name: ")
print(search_wine.name)
print("-" * 100)

print("description: ")
pprint(search_wine.description)
name:
search_wine
----------------------------------------------------------------------------------------------------
description:
('Securely retrieve and access authorized restaurant wine information from the '
 'encrypted database.\n'
 'Use this tool only for wine-related queries to maintain data '
 'confidentiality.')
# llm은 이전 섹션에서 사용 중인 ChatOpenAI(model="gpt-4o-mini") 인스턴스
# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# search_menu은 [사용자 정의 - 바로 위에서 정의] @tool로 생성한 메뉴 검색 도구
# search_wine은 [사용자 정의 - 바로 위에서 정의] @tool로 생성한 와인 검색 도구
# LLM에 도구를 바인딩 (2개의 도구 바인딩)
llm_with_tools = llm.bind_tools(tools=[search_menu, search_wine])

# 도구 호출이 필요한 LLM 호출을 수행
query = "시그니처 스테이크의 가격과 특징은 무엇인가요? 그리고 스테이크와 어울리는 와인 추천도 해주세요."
ai_msg = llm_with_tools.invoke(query)

# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
# LLM이 호출한 도구 정보 출력
pprint(ai_msg.tool_calls)
[{'name': 'search_menu',
  'args': {'query': '시그니처 스테이크'},
  'id': 'call_iEzcWfJt6liuKOD01HlOPdnh',
  'type': 'tool_call'},
 {'name': 'search_wine',
  'args': {'query': '스테이크'},
  'id': 'call_AGGxfcG1Q1qmJdIReDgCLjp6',
  'type': 'tool_call'}]


      2.4.3. 여러 개의 도구(tool) 호출하기

         - 이 단계에서는 4개의 도구(search_web, wiki_summary, search_menu, search_wine)를 LLM에 바인딩하고, 조건 분기로 적절한 도구를 실행하는 전체 파이프라인을 구성한다.

         - @chain 데코레이터로 전체 파이프라인을 정의하며, tool_calls의 name 필드를 기준으로 어떤 도구를 실행할지 분기한다.
         - 이 패턴은 LangGraph에서 ToolNode가 자동으로 처리하는 로직의 수동 구현 버전이다.

 

         - 참고: 이 코드는 이전 섹션들에서 정의한 4개의 도구를 사용합니다:

            - `search_web`: [사용자 정의 - 섹션 2.2.1] @tool로 생성한 커스텀 웹 검색 도구

            - `wiki_summary`: [사용자 정의 - 섹션 2.3.2] LCEL 체인을 as_tool()로 변환한 위키 요약 도구

            - `search_wine`: [사용자 정의 - 섹션 2.4.2] @tool로 생성한 와인 검색 도구

            - `search_menu`: [사용자 정의 - 섹션 2.4.2] @tool로 생성한 메뉴 검색 도구

 

         2.4.3.1. 실무 코드

# tools 리스트에 4개의 도구를 모아둔다
# search_web은 [사용자 정의 - 섹션 2.2.1 참조] @tool로 생성한 커스텀 웹 검색 도구
# wiki_summary는 [사용자 정의 - 섹션 2.3.2 참조] LCEL 체인을 as_tool()로 변환한 위키 요약 도구
# search_wine은 [사용자 정의 - 섹션 2.4.2 참조] @tool로 생성한 와인 검색 도구
# search_menu는 [사용자 정의 - 섹션 2.4.2 참조] @tool로 생성한 메뉴 검색 도구
tools = [search_web, wiki_summary, search_wine, search_menu]
for tool in tools:
    print(tool.name)
search_web
wiki_summary
search_wine
search_menu
from datetime import datetime
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig, chain

# 오늘 날짜 설정
today = datetime.today().strftime("%Y-%m-%d")

# [LangChain 내장] ChatPromptTemplate: 대화형 프롬프트 템플릿
# 프롬프트 템플릿
prompt = ChatPromptTemplate([
    ("system", f"You are a helpful AI assistant. Today's date is {today}."),
    ("human", "{user_input}"),
    ("placeholder", "{messages}"),
])

# ChatOpenAI 모델 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# tools는 [사용자 정의 - 바로 위에서 정의] 4개 도구를 담은 리스트
# 4개의 검색 도구를 LLM에 바인딩
llm_with_tools = llm.bind_tools(tools=tools)

# [사용자 정의] LCEL 체인: prompt와 llm_with_tools를 파이프(|) 연산자로 연결
# LLM 체인 생성
llm_chain = prompt | llm_with_tools

# [LangChain 내장] @chain 데코레이터: 일반 함수를 Runnable 체인으로 변환
# 도구 실행 체인 정의
@chain
def restaurant_menu_chain(user_input: str, config: RunnableConfig):
    input_ = {"user_input": user_input}
    # llm_chain은 [사용자 정의 - 바로 위에서 정의] prompt | llm_with_tools LCEL 체인
    ai_msg = llm_chain.invoke(input_, config=config)

    tool_msgs = []
    # [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
    # 각 tool_call은 {'name': str, 'args': dict, 'id': str, 'type': 'tool_call'} 구조의 딕셔너리
    for tool_call in ai_msg.tool_calls:
        # tool_call["name"]은 LangChain이 자동 생성한 키로, 호출할 도구의 이름 문자열
        print(f"{tool_call['name']}: \n{tool_call}")
        print("-" * 100)

        # 도구 이름에 따라 적절한 도구를 실행
        # [LangChain 내장] invoke(): tool_call 딕셔너리를 전달하면 자동으로 ToolMessage 생성
        if tool_call["name"] == "search_web":
            tool_message = search_web.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "wiki_summary":
            tool_message = wiki_summary.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "search_wine":
            tool_message = search_wine.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "search_menu":
            tool_message = search_menu.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

    print("tool_msgs: \n", tool_msgs)
    print("-" * 100)
    # AIMessage + ToolMessage를 LLM에 재전달하여 최종 답변 생성
    return llm_chain.invoke({**input_, "messages": [ai_msg, *tool_msgs]}, config=config)

# [LangChain 내장] invoke(): 체인을 실행하는 메서드
# 체인 실행
response = restaurant_menu_chain.invoke("시그니처 스테이크의 가격과 특징은 무엇인가요? 그리고 스테이크와 어울리는 와인 추천도 해주세요.")

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
# 응답 출력
print(response.content)
search_menu:
{'name': 'search_menu', 'args': {'query': '시그니처 스테이크'}, 'id': 'call_nEQEIjzm2VQfn8DpQkJ6XNy7', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
search_wine:
{'name': 'search_wine', 'args': {'query': '스테이크와 어울리는 와인'}, 'id': 'call_L8CnwNsFNSDbuo6lTrDtDdb6', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content="[Document(..., page_content='1. 시그니처 스테이크\n   • 가격: ₩35,000\n   • 주요 식재료: 최상급 한우 등심...')]", ...), ToolMessage(content="[Document(..., page_content='6. 바롤로 몬프리바토 2017...')]", ...)]
----------------------------------------------------------------------------------------------------
시그니처 스테이크의 가격은 ₩35,000이며, 최상급 한우 등심을 21일간 건조 숙성하여 사용합니다...
스테이크와 어울리는 와인으로는 바롤로 몬프리바토 2017을 추천드립니다...
# 다른 쿼리로 체인 실행
response = restaurant_menu_chain.invoke("파스타 메뉴가 있나요? 이 음식의 역사 또는 유래를 알려주세요.")

# 응답 출력
print(response.content)
search_menu:
{'name': 'search_menu', 'args': {'query': '파스타'}, 'id': 'call_7hFxvRhzAkWHRYjL1yvgJL74', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
wiki_summary:
{'name': 'wiki_summary', 'args': {'query': '파스타'}, 'id': 'call_DGTU6oICymtyr1FUZHvgvP1X', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content="[Document(..., page_content='6. 해산물 파스타\n   • 가격: ₩24,000...')]", ...), ToolMessage(content='오르조(또는 리소니)는 이탈리아의 파스타로...', ...)]
----------------------------------------------------------------------------------------------------
네, 해산물 파스타 메뉴가 있습니다! 가격은 ₩24,000이며...
파스타의 유래에 대해서는, 파스타는 이탈리아의 대표적인 음식으로...


            - LLM이 질문의 내용을 분석하여 메뉴 관련에는 search_menu를, 역사/유래에는 wiki_summary를 자동으로 선택하는 것을 확인할 수 있다.


3. Few-shot 프롬프팅

   - 이 절에서는 각 도구의 용도를 구분하여 few-shot 예제로 LLM에 제시하는 방법을 학습한다.
   - Few-shot 프롬프팅을 통해 LLM이 어떤 상황에서 어떤 도구를 호출해야 하는지 구체적인 패턴을 학습하게 할 수 있다.

 

   3.1. Few-shot 도구 호출

      - HumanMessage, AIMessage, ToolMessage로 예시 대화를 구성하여 LLM에 제공한다.

      - 예시 대화에는 도구 호출의 순서와 패턴이 포함되어, LLM이 유사한 상황에서 동일한 패턴으로 도구를 호출하도록 유도한다.

      - ChatPromptTemplate.from_messages에 examples를 포함하여 few-shot 프롬프트를 구성한다.

 

      -참고: 이 코드는 이전 섹션들에서 정의한 `tools` 리스트 (섹션 2.4.3에서 구성한 [search_web, wiki_summary, search_wine, search_menu])를 사용합니다. 아래 예시 대화에서 AIMessage의 tool_calls는 LangChain이 실제로 생성하는 것과 동일한 구조(`{'name': str, 'args': dict, 'id': str}`)를 수동으로 작성한 것입니다.

 

      3.1.1. 실무 코드

from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate

# Few-shot 예시 대화 구성
# 각 메시지는 LangChain의 메시지 클래스를 사용하여 도구 호출 패턴을 LLM에 보여준다.
# AIMessage의 tool_calls 파라미터에 딕셔너리를 직접 전달하여 예시 도구 호출을 정의한다.
# ToolMessage의 tool_call_id는 AIMessage의 tool_calls에 있는 id와 매칭되어야 한다.
examples = [
    HumanMessage("트러플 리조또의 가격과 특징, 그리고 어울리는 와인에 대해 알려주세요.", name="example_user"),
    AIMessage("메뉴 정보를 검색하고, 위키피디아에서 추가 정보를 찾은 후, 어울리는 와인을 검색해보겠습니다.", name="example_assistant"),
    # 1단계: 메뉴 검색 도구 호출
    # [LangChain 내장] AIMessage의 tool_calls 파라미터: 도구 호출 정보를 수동으로 지정할 수 있다
    AIMessage("", name="example_assistant", tool_calls=[
        {"name": "search_menu", "args": {"query": "트러플 리조또"}, "id": "1"}
    ]),
    # [LangChain 내장] ToolMessage: 도구 실행 결과를 담는 메시지. tool_call_id로 AIMessage의 tool_calls와 매칭
    ToolMessage("트러플 리조또: 가격 ₩28,000, 이탈리아 카나롤리 쌀 사용, 블랙 트러플 향과 파르메산 치즈를 듬뿍 넣어 조리", tool_call_id="1"),
    AIMessage("트러플 리조또의 가격은 ₩28,000이며, 이탈리아 카나롤리 쌀을 사용하고 블랙 트러플 향과 파르메산 치즈를 듬뿍 넣어 조리합니다. 이제 추가 정보를 위키피디아에서 찾아보겠습니다.", name="example_assistant"),
    # 2단계: 위키피디아 요약 도구 호출
    AIMessage("", name="example_assistant", tool_calls=[
        {"name": "wiki_summary", "args": {"query": "트러플 리조또", "k": 1}, "id": "2"}
    ]),
    ToolMessage("트러플 리조또는 이탈리아 요리의 대표적인 리조또 요리 중 하나로, 고급 식재료인 트러플을 사용하여 만든 크리미한 쌀 요리입니다. 주로 아르보리오나 카나롤리 등의 쌀을 사용하며, 트러플 오일이나 생 트러플을 넣어 조리합니다.", tool_call_id="2"),
    AIMessage("트러플 리조또의 특징에 대해 알아보았습니다. 이제 어울리는 와인을 검색해보겠습니다.", name="example_assistant"),
    # 3단계: 와인 검색 도구 호출
    AIMessage("", name="example_assistant", tool_calls=[
        {"name": "search_wine", "args": {"query": "트러플 리조또에 어울리는 와인"}, "id": "3"}
    ]),
    ToolMessage("트러플 리조또와 잘 어울리는 와인으로는 주로 중간 바디의 화이트 와인이 추천됩니다. 1. 샤르도네 2. 피노 그리지오 3. 베르나차", tool_call_id="3"),
    # 최종 답변
    AIMessage("트러플 리조또(₩28,000)는 이탈리아의 대표적인 리조또 요리 중 하나로, 이탈리아 카나롤리 쌀을 사용하고 블랙 트러플 향과 파르메산 치즈를 듬뿍 넣어 조리합니다. 트러플 리조또와 잘 어울리는 와인으로는 중간 바디의 화이트 와인이 추천됩니다.", name="example_assistant"),
]

system = """You are an AI assistant providing restaurant menu information and general food-related knowledge.
For information about the restaurant's menu, use the search_menu tool.
For other general information, use the wiki_summary tool.
For wine recommendations or pairing information, use the search_wine tool.
If additional web searches are needed or for the most up-to-date information, use the search_web tool.
"""

# [LangChain 내장] ChatPromptTemplate.from_messages(): 메시지 리스트로 프롬프트 템플릿 생성
# *examples로 위에서 정의한 예시 대화를 프롬프트에 포함한다
# Few-shot 프롬프트 구성
few_shot_prompt = ChatPromptTemplate.from_messages([
    ("system", system),
    *examples,
    ("human", "{query}"),
])

# ChatOpenAI 모델 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# tools는 [사용자 정의 - 섹션 2.4.3 참조] [search_web, wiki_summary, search_wine, search_menu] 리스트
# 검색 도구를 LLM에 바인딩
llm_with_tools = llm.bind_tools(tools=tools)

# [사용자 정의] Few-shot 프롬프트를 사용한 LCEL 체인
# Few-shot 프롬프트를 사용한 체인 구성
fewshot_search_chain = few_shot_prompt | llm_with_tools

# [LangChain 내장] invoke(): 체인을 실행하는 메서드
# 체인 실행
query = "스테이크 메뉴가 있나요? 스테이크와 어울리는 와인을 추천해주세요."
response = fewshot_search_chain.invoke(query)

# [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
# 결과 출력
for tool_call in response.tool_calls:
    print(tool_call)
{'name': 'search_menu', 'args': {'query': '스테이크'}, 'id': 'call_PiCcbedunmCJ59inVsCfNiHP', 'type': 'tool_call'}
{'name': 'search_wine', 'args': {'query': '스테이크에 어울리는 와인'}, 'id': 'call_FTR29j795rpz4Kyo8l1lfvgb', 'type': 'tool_call'}
# 다른 쿼리로 체인 실행
query = "파스타의 유래에 대해서 알고 있나요? 서울 강남의 파스타 맛집을 추천해주세요."
response = fewshot_search_chain.invoke(query)

# 결과 출력
for tool_call in response.tool_calls:
    print(tool_call)
{'name': 'wiki_summary', 'args': {'query': '파스타의 유래'}, 'id': 'call_fmpqw3qaVF6N15Lhd46rd3Rw', 'type': 'tool_call'}
{'name': 'search_web', 'args': {'query': '서울 강남 파스타 맛집 추천'}, 'id': 'call_PVBKXq7IRFUyBZXNBb01QLJH', 'type': 'tool_call'}


         - Few-shot 예시를 통해 LLM이 질문의 유형에 따라 적절한 도구를 선택하는 것을 확인할 수 있다.
            - 메뉴 관련 → search_menu

            - 와인 추천 → search_wine

            - 일반 지식/유래 → wiki_summary

            - 최신 정보/맛집 → search_web

 

   3.2. 답변 생성 체인

      - Few-shot 프롬프트와 도구 호출을 통합하여 최종 답변까지 생성하는 전체 파이프라인을 구성한다.

      - placeholder를 활용하여 도구 실행 결과를 동적으로 프롬프트에 삽입한다.

 

      - 참고: 이 코드는 섹션 3.1에서 정의한 `examples` (Few-shot 예시 대화 리스트)와 `tools` (섹션 2.4.3에서 구성한 4개 도구 리스트)를 사용합니다. `llm_chain`은 이 섹션에서 새로 정의하지만 변수명이 이전 섹션의 `fewshot_search_chain`과 다른 점에 유의하세요.

 

      3.2.1. 실무 코드

from datetime import datetime
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig, chain
from langchain_openai import ChatOpenAI

# 오늘 날짜 설정
today = datetime.today().strftime("%Y-%m-%d")

# 프롬프트 템플릿 (few-shot + placeholder)
system = """You are an AI assistant providing restaurant menu information and general food-related knowledge.
For information about the restaurant's menu, use the search_menu tool.
For other general information, use the wiki_summary tool.
For wine recommendations or pairing information, use the search_wine tool.
If additional web searches are needed or for the most up-to-date information, use the search_web tool.
"""

# [LangChain 내장] ChatPromptTemplate.from_messages(): 메시지 리스트로 프롬프트 템플릿 생성
# examples는 [사용자 정의 - 섹션 3.1 참조] Few-shot 예시 대화 리스트
# ("placeholder", "{messages}")는 동적으로 AIMessage, ToolMessage를 삽입하는 슬롯
few_shot_prompt = ChatPromptTemplate.from_messages([
    ("system", system + f"Today's date is {today}."),
    *examples,
    ("human", "{user_input}"),
    ("placeholder", "{messages}"),  # 동적 메시지 삽입
])

# ChatOpenAI 모델 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# [LangChain 내장] bind_tools(): LLM에 도구를 바인딩
# tools는 [사용자 정의 - 섹션 2.4.3 참조] [search_web, wiki_summary, search_wine, search_menu] 리스트
# 검색 도구를 LLM에 바인딩
llm_with_tools = llm.bind_tools(tools=tools)

# [사용자 정의] Few-shot 프롬프트를 사용한 LCEL 체인
# Few-shot 프롬프트를 사용한 체인 구성
fewshot_search_chain = few_shot_prompt | llm_with_tools

# [LangChain 내장] @chain 데코레이터: 일반 함수를 Runnable 체인으로 변환
# 도구 실행 체인 정의
@chain
def restaurant_menu_chain(user_input: str, config: RunnableConfig):
    input_ = {"user_input": user_input}
    # llm_chain은 [사용자 정의 - 주의: 이 코드에서는 llm_chain이 아닌 fewshot_search_chain을 사용해야 하지만,
    # 원본 코드에서 llm_chain으로 작성되어 있음. 실행 시 fewshot_search_chain으로 변경 필요]
    ai_msg = llm_chain.invoke(input_, config=config)

    tool_msgs = []
    # [LangChain 내장] .tool_calls: LLM이 자동 생성한 도구 호출 정보 리스트
    # 각 tool_call은 {'name': str, 'args': dict, 'id': str, 'type': 'tool_call'} 구조의 딕셔너리
    for tool_call in ai_msg.tool_calls:
        # tool_call["name"]은 LangChain이 자동 생성한 키로, 호출할 도구의 이름 문자열
        print(f"{tool_call['name']}: \n{tool_call}")
        print("-" * 100)

        # 도구 이름에 따라 적절한 도구를 실행
        # [LangChain 내장] invoke(): tool_call 딕셔너리를 전달하면 자동으로 ToolMessage 생성
        if tool_call["name"] == "search_web":
            tool_message = search_web.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "wiki_summary":
            tool_message = wiki_summary.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "search_wine":
            tool_message = search_wine.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

        elif tool_call["name"] == "search_menu":
            tool_message = search_menu.invoke(tool_call, config=config)
            tool_msgs.append(tool_message)

    print("tool_msgs: \n", tool_msgs)
    print("-" * 100)
    # few-shot 프롬프트와 함께 LLM에 재전달
    # messages placeholder에 AIMessage + ToolMessage를 삽입하여 최종 답변 생성
    return fewshot_search_chain.invoke({**input_, "messages": [ai_msg, *tool_msgs]}, config=config)


# [LangChain 내장] invoke(): 체인을 실행하는 메서드
# 체인 실행
query = "스테이크 메뉴가 있나요? 스테이크와 어울리는 와인을 추천해주세요."
response = restaurant_menu_chain.invoke(query)

# [LangChain 내장] .content: AIMessage의 텍스트 응답 속성
# 응답 출력
pprint(response.content)
search_menu:
{'name': 'search_menu', 'args': {'query': '스테이크'}, 'id': 'call_ddEAWGXDdYfCl5ODvtNLFMxw', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
search_wine:
{'name': 'search_wine', 'args': {'query': '스테이크'}, 'id': 'call_8NTCOeh5xDqub9Cqb9dBh46d', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content="[Document(...)]", ...), ToolMessage(content="[Document(...)]", ...)]
----------------------------------------------------------------------------------------------------
('시그니처 스테이크(₩35,000)가 있습니다. 최상급 한우 등심을 21일간 건조 숙성하여 사용하며, '
 '미디엄 레어로 조리하여 육즙을 최대한 보존합니다. 스테이크와 어울리는 와인으로는 '
 '바롤로 몬프리바토 2017을 추천드립니다...')
# 다른 쿼리로 체인 실행
query = "파스타의 유래에 대해서 알고 있나요? 서울 강남의 파스타 맛집을 추천해주세요."
response = restaurant_menu_chain.invoke(query)

# 응답 출력
pprint(response.content)
wiki_summary:
{'name': 'wiki_summary', 'args': {'query': '파스타의 유래'}, 'id': 'call_7g97W3nhzr87CFy8xeQ9VU6a', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
search_menu:
{'name': 'search_menu', 'args': {'query': '서울 강남 파스타 맛집'}, 'id': 'call_pafYlAhrPctxiRusOtupT5Ml', 'type': 'tool_call'}
----------------------------------------------------------------------------------------------------
tool_msgs:
 [ToolMessage(content='오르조(리소니)는 이탈리아의 파스타로...', ...), ToolMessage(content="[Document(...)]", ...)]
----------------------------------------------------------------------------------------------------
('파스타는 이탈리아의 대표적인 음식으로, 기원전부터 존재하던 밀가루 반죽 요리에서 유래했습니다...')


4. LangChain Agent 사용

   - 이 절에서는 create_tool_calling_agent와 AgentExecutor를 사용하여 자동화된 도구 호출 에이전트를 구성한다.
   - 앞서 구현한 수동 도구 호출 파이프라인(@chain으로 조건 분기)을 LangChain Agent가 자동으로 처리해 준다.
   - 유의사항: 프롬프트에 반드시 "agent_scratchpad"와 "input" 변수를 포함해야 한다.
      - agent_scratchpad: 에이전트의 중간 작업 내용(도구 호출 결과 등)이 저장되는 공간
      - input: 사용자 입력이 들어가는 변수
   - MessagesPlaceholder를 활용하여 chat_history도 포함할 수 있다.

   - 참고: 이 코드는 섹션 2.4.3에서 구성한 `tools` 리스트 ([search_web, wiki_summary, search_wine, search_menu])와 이전 섹션에서 정의한 `llm` (ChatOpenAI 인스턴스)을 사용합니다.

 

   4.1. 기본 사용법

# [LangChain 내장] AgentExecutor: 에이전트를 실행하고 도구 호출을 자동으로 관리하는 실행기
# [LangChain 내장] create_tool_calling_agent: LLM + 도구 + 프롬프트로 에이전트를 생성하는 함수
from langchain.agents import AgentExecutor, create_tool_calling_agent
# [LangChain 내장] MessagesPlaceholder: 대화 기록 등 동적 메시지를 삽입하는 플레이스홀더
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# 프롬프트에 agent_scratchpad, input 필수 포함
# agent_scratchpad: 에이전트가 도구 호출/결과를 저장하는 중간 작업 공간
# input: 사용자 질문이 들어가는 변수
prompt = ChatPromptTemplate.from_messages([
    ("system", "시스템 메시지"),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])

# [LangChain 내장] create_tool_calling_agent(): LLM, 도구 목록, 프롬프트를 조합하여 에이전트 생성
# Agent 생성 및 실행
agent = create_tool_calling_agent(llm, tools, prompt)
# [LangChain 내장] AgentExecutor(): 에이전트를 감싸서 도구 호출 루프를 자동 관리
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
# [LangChain 내장] invoke(): 에이전트를 실행. 반환값은 {'input': str, 'output': str} 딕셔너리
response = agent_executor.invoke({"input": "질문"})
(AgentExecutor가 자동으로 도구를 호출하고 최종 답변을 생성한다.)


   4.2. 실무 코드

# [LangChain 내장] MessagesPlaceholder: 대화 기록 등 동적 메시지를 삽입하는 플레이스홀더
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# agent_scratchpad: 에이전트의 중간 작업(도구 호출/결과 등)이 저장되는 공간 (필수)
# input: 사용자 입력 변수 (필수)
# chat_history: 이전 대화 기록 (선택, optional=True)
agent_prompt = ChatPromptTemplate.from_messages([
    ("system", dedent("""
        You are an AI assistant providing restaurant menu information and general food-related knowledge.
        Your main goal is to provide accurate information and effective recommendations to users.

        Key guidelines:
        1. For restaurant menu information, use the search_menu tool.
        2. For general food information, history, and cultural background, utilize the wiki_summary tool.
        3. For wine recommendations or food and wine pairing information, use the search_wine tool.
        4. If additional web searches are needed or for the most up-to-date information, use the search_web tool.
        5. Provide clear and concise responses based on the search results.
        6. If a question is ambiguous or lacks necessary information, politely ask for clarification.
        7. Always maintain a helpful and professional tone.
        8. When providing menu information, describe in the order of price, main ingredients, and distinctive cooking methods.
        9. When making recommendations, briefly explain the reasons.
        10. Maintain a conversational, chatbot-like style in your final responses.

        Remember, understand the purpose of each tool accurately and use them in appropriate situations.
        Combine the tools to provide the most comprehensive and accurate answers to user queries.
        """)),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder(variable_name="agent_scratchpad"),
])
# [LangChain 내장] create_tool_calling_agent(): LLM, 도구 목록, 프롬프트를 조합하여 에이전트 생성
# Tool calling Agent 생성
from langchain.agents import AgentExecutor, create_tool_calling_agent

# tools는 [사용자 정의 - 섹션 2.4.3 참조] [search_web, wiki_summary, search_wine, search_menu] 리스트
# llm은 이전 섹션에서 사용 중인 ChatOpenAI(model="gpt-4o-mini") 인스턴스
# agent_prompt는 [사용자 정의 - 바로 위에서 정의] 에이전트용 프롬프트 템플릿
tools = [search_web, wiki_summary, search_wine, search_menu]
agent = create_tool_calling_agent(llm, tools, agent_prompt)

# [LangChain 내장] AgentExecutor(): 에이전트를 감싸서 도구 호출 루프를 자동 관리
# verbose=True 설정 시 도구 호출 과정이 콘솔에 출력된다
# AgentExecutor 생성
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
# [LangChain 내장] invoke(): AgentExecutor를 실행. 반환값은 {'input': str, 'output': str} 딕셔너리
# agent_executor는 [사용자 정의 - 바로 위에서 정의] AgentExecutor 인스턴스
# AgentExecutor 실행
query = "시그니처 스테이크의 가격과 특징은 무엇인가요? 그리고 스테이크와 어울리는 와인 추천도 해주세요."
agent_response = agent_executor.invoke({"input": query})
> Entering new AgentExecutor chain...

Invoking: `search_menu` with `{'query': '시그니처 스테이크'}`

[Document(metadata={'menu_name': '시그니처 스테이크', 'menu_number': 1, ...}, page_content='1. 시그니처 스테이크\n   • 가격: ₩35,000\n   • 주요 식재료: 최상급 한우 등심...')]

Invoking: `search_wine` with `{'query': '스테이크와 어울리는 와인'}`

[Document(metadata={'menu_name': '바롤로 몬프리바토 2017', ...}, page_content='6. 바롤로 몬프리바토 2017...')]

> Finished chain.
# agent_response는 AgentExecutor.invoke()의 반환값으로, {'input': str, 'output': str} 구조의 딕셔너리
pprint(agent_response)
{'input': '시그니처 스테이크의 가격과 특징은 무엇인가요? 그리고 스테이크와 어울리는 와인 추천도 해주세요.',
 'output': '### 시그니처 스테이크 정보\n'
           '- **가격**: ₩35,000\n'
           '- **주요 재료**: 최상급 한우 등심, 로즈메리 감자, 그릴드 아스파라거스\n'
           '- **특징**: 21일간 건조 숙성한 최상급 한우 등심을 미디엄 레어로 조리하여 '
           '육즙을 최대한 보존합니다.\n\n'
           '### 스테이크와 어울리는 와인 추천\n'
           '- **바롤로 몬프리바토 2017**: 풍부한 과일 향과 함께 복합적인 맛을 제공합니다.\n'
           '이 조합은 스테이크의 부드러운 질감과 깊은 맛을 더욱 강조해줄 것입니다.'}

 


5. Gradio 활용

   - 이 절에서는 Gradio의 ChatInterface를 사용하여 대화형 UI를 구성한다.

   - chat_history를 관리하여 이전 대화 맥락을 에이전트에 전달할 수 있다.

 

   - 참고: 이 코드는 섹션 4에서 정의한 `agent_executor` (AgentExecutor 인스턴스)를 사용합니다. `agent_executor.invoke()`는 `{'input': str, 'output': str}` 구조의 딕셔너리를 반환하며, `response['output']`에서 최종 답변 문자열을 추출합니다.

 

   5.1. 실무 코드

import gradio as gr
from typing import List, Dict

# [사용자 정의] Gradio ChatInterface에 전달할 응답 생성 함수
def answer_invoke(message: str, history: List[Dict[str, str]]) -> str:
    try:
        # Gradio의 history는 [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}] 형태
        # 이를 LangChain의 HumanMessage/AIMessage로 변환한다
        # 채팅 기록을 AI에게 전달할 수 있는 형식으로 변환
        chat_history = []
        for msg in history:
            if msg["role"] == "user":
                chat_history.append(HumanMessage(content=msg["content"]))
            elif msg["role"] == "assistant":
                chat_history.append(AIMessage(content=msg["content"]))

        # agent_executor는 [사용자 정의 - 섹션 4 참조] AgentExecutor 인스턴스
        # [LangChain 내장] invoke(): AgentExecutor를 실행. 반환값은 {'input': str, 'output': str} 딕셔너리
        # agent_executor를 사용하여 응답 생성
        response = agent_executor.invoke({
            "input": message,
            "chat_history": chat_history[-2:]    # 최근 2개의 메시지 기록만을 활용
        })

        # agent_executor의 응답에서 최종 답변 추출
        # response['output']에 최종 답변 문자열이 담겨 있다
        return response['output']
    except Exception as e:
        # 오류 발생 시 사용자에게 알리고 로그 기록
        print(f"Error occurred: {str(e)}")
        return "죄송합니다. 응답을 생성하는 동안 오류가 발생했습니다. 다시 시도해 주세요."

# 예제 질문 정의
example_questions = [
    "시그니처 스테이크의 가격과 특징을 알려주세요.",
    "트러플 리조또와 잘 어울리는 와인을 추천해주세요.",
    "해산물 파스타의 주요 재료는 무엇인가요? 서울 강남 지역에 레스토랑을 추천해주세요.",
    "채식주의자를 위한 메뉴 옵션이 있나요?"
]

# Gradio 인터페이스 생성
demo = gr.ChatInterface(
    fn=answer_invoke,
    title="레스토랑 메뉴 AI 어시스턴트",
    description="메뉴 정보, 추천, 음식 관련 질문에 답변해 드립니다.",
    examples=example_questions,
)
# 데모 실행
demo.launch()
* Running on local URL:  http://127.0.0.1:7860

To create a public link, set `share=True` in `launch()`.
# 데모 종료
demo.close()
Closing server running on port: 7860

 


      - 이 문서에서 학습한 도구 호출의 전체 파이프라인(정의 → 바인딩 → 호출 결정 → 실행 → ToolMessage → 재전달 → 답변 생성)은 LangGraph에서 StateGraph, ToolNode, ReAct Agent 등으로 확장되는 핵심 기반 지식이다.
      - 다음 문서(02_LangGraph_StateGraph.md)에서는 이 도구 호출 메커니즘을 LangGraph의 상태 기반 그래프 구조와 결합하는 방법을 학습한다.

댓글