SK플래닛 ai활용 데이터엔지니어 과정 2기/ML & DL

LangGraph 2 - Tool + LLM

dev-lee 2026. 6. 2. 12:43

1. 이번 단계에서 달라지는 것

고정된 단방향 흐름에서 벗어나, LLM이 "직접 답할지 / 도구를 쓸지"를 스스로 판단하는 구조로 발전시킴.

①편의 그래프는 경로가 코드에 박혀 있었다. 이번에는 add_conditional_edges로 분기를 주고, LLM이 도구 호출 여부를 결정하게 만든다. 핵심 추가 요소는 다음과 같음.

  • LLM 노드 — AWS Bedrock 모델을 호출해 추론하는 "판단 노드"임.
  • Tool 노드 — 실제 기능(여기서는 곱셈)을 수행하는 "행동 노드"임.
  • 조건부 엣지 — LLM의 응답이 도구 호출인지 텍스트인지에 따라 경로가 갈림.

①편이 직선 도로였다면, 이번 단계는 LLM이 갈림길에서 핸들을 잡는 구조다.


2. 준비 — 모델과 환경변수

Bedrock LLM 객체를 만들고 `.env`에서 모델 정보를 로드함.
from langgraph.graph import StateGraph, END, MessagesState, START
from langchain_core.tools import tool                # 툴 정의용 데코레이터
from langchain_core.messages import HumanMessage     # 사용자 메시지를 편하게 구성
from langchain_aws import ChatBedrock, ChatBedrockConverse  # AWS Bedrock LLM 호출
from langgraph.prebuilt import ToolNode, tools_condition    # 툴->노드 변환, 조건부 분기
from dotenv import load_dotenv
import os, boto3

load_dotenv()

# LLM 추론용 객체(전역). 모델별로 ChatBedrock / ChatBedrockConverse 교체 적용
# 예: us.anthropic.claude-haiku-4-5-20251001-v1:0
llm = ChatBedrockConverse(
    model=os.getenv('MODEL_ID'),
    client=boto3.client('bedrock-runtime', region_name=os.getenv('AWS_REGION'))
)
`MessagesState`는 LangGraph가 미리 제공하는 상태로, 대화 메시지 누적에 특화되어 있음.

2.1 MessagesState를 쓰는 이유

①편에서는 TypedDict로 상태를 직접 정의했음. 대화형 에이전트는 메시지 리스트를 계속 누적해야 하므로, 라이브러리가 제공하는 MessagesState(내부에 messages 키 보유)를 그대로 사용함.


3. 도구 정의와 등록

`@tool` 데코레이터로 함수를 LLM이 이해할 수 있는 도구로 변환함.
@tool   # LLM이 이해할 수 있는 형식으로 자동 변환됨
def multiply(a: int, b: int) -> int:
    '''두 수를 곱한 후 반환'''
    print(f'       [TOOL 실행] {a}x{b} 계산중..')
    return a * b

# 여러 툴을 모아 LLM에 등록(여기서는 1개)
tools = [multiply]
llm_with_tools = llm.bind_tools(tools)   # "이런 도구를 쓸 수 있다"고 LLM에 알림
`bind_tools`는 LLM에게 도구의 존재와 사용법을 알릴 뿐, 강제하지는 않음. 사용 여부는 LLM이 판단함.

함수의 docstring과 타입 힌트가 곧 LLM이 읽는 "도구 설명서"가 된다. 그래서 docstring을 명확히 쓰는 게 중요하다.


4. 노드 구성

사용자 입력을 LLM에 전달해 "직접 답변" 또는 "도구 호출"을 받아오는 판단 노드를 만듦.
def chatbot_node(state: MessagesState):
    print('[chatbot_node 호출 전 상태값]', state)

    # 사용자 프롬프트를 LLM에 전달 -> LLM이 직접 해결할지/도구를 쓸지 판단 -> 응답
    res = llm_with_tools.invoke(state['messages'])  # messages 키는 MessagesState에 정의됨
    new_state = {"messages": [res]}                  # MessagesState 형식에 맞춤

    print('[chatbot_node 호출 후 상태값]', new_state)
    return new_state
LLM이 도구가 필요하다고 판단하면, `res` 안에 `tool_calls` 정보가 담겨 돌아옴.

5. 조건부 엣지로 그래프 구성

`tools_condition`이 LLM 응답을 보고 도구 노드로 보낼지 종료할지 자동 분기함.
workflow = StateGraph(MessagesState)

# 노드 추가
workflow.add_node('chatbot', chatbot_node)   # 생각/판단 노드
workflow.add_node('tools', ToolNode(tools))  # 도구 수행(곱하기) 행동 노드

# 시작점 (= set_entry_point)
workflow.add_edge(START, 'chatbot')          # 데이터 주입 시 가장 먼저 작동

# 조건부 엣지: 이전 노드 응답에 따라 경로 분기
workflow.add_conditional_edges(
    'chatbot',          # 텍스트 응답이면 -> END
    tools_condition     # 도구가 필요하다는 응답이면 -> tools 노드
)

# 도구 사용 -> 결과 -> 다시 chatbot으로 (순환 구조)
workflow.add_edge('tools', 'chatbot')

app = workflow.compile()

이 구조가 만들어내는 두 가지 시나리오는 다음과 같음.

시나리오  흐름
직접 응답 질의 → chatbot → LLM 추론 → 응답 → END
도구 사용 질의 → chatbot → 도구 필요 판단 → tools → 결과 → chatbot → 응답 → END

①편의 단방향 엣지가 여기서 "순환 가능한" 엣지로 진화했다. tools → chatbot이 다시 이어지면서 사이클이 생긴다.


6. 실행 — 스트리밍 루프

`stream` 모드로 응답을 실시간 출력하며 대화함.
if __name__ == '__main__':
    while True:
        user_input = input('\n유저:').lower()
        if user_input == 'q':
            break
        # 프롬프트 구성
        prompt = {"messages": [HumanMessage(content=user_input)]}
        print(prompt)
        # invoke: 동기식 / stream: 비동기식(점진 출력)
        for evt in app.stream(prompt, stream_mode='values'):
            msg = evt['messages'][-1]   # 마지막에 추가된 응답
            print("Agent", msg.content)
`stream_mode='values'`는 매 노드 실행 후 갱신된 전체 상태를 흘려보내, 진행 과정을 실시간으로 볼 수 있게 함.

6.1 모델별 동작 차이

같은 그래프라도 LLM에 따라 도구 사용 성향이 달랐음.

  • OpenAI 계열 — LLM이 직접 추론해 응답. 도구를 쓰지 않는 경우가 있었음.
  • Claude 계열 — 도구를 적극 사용. 도구 호출 후 재추론까지 LLM이 2회 추론하는 경우가 있었음.

요약

도구와 조건부 엣지를 더해, LLM이 경로를 스스로 결정하는 순환형 에이전트로 발전시켰음.
항목  LangGraph 1 LangGraph 2
상태 TypedDict 직접 정의 MessagesState 활용
흐름 고정 단방향 조건부 분기 + 순환
판단 주체 코드(개발자) LLM
외부 기능 없음 @tool 도구 호출

이제 한 번의 질의는 잘 처리한다. 그러나 새 질문마다 기억이 초기화되어 이전 대화를 모른다. 다음 단계에서 단기기억을 붙인다.