💬 LangGraph의 기초(State·Node·Edge)를 익혔다면, 이제 여기에 LLM을 붙여 챗봇을 만들고 → tool을 붙여 agent로 발전시키고 → 메모리·사람 개입·타임트래블·개발 도구까지 얹는 과정을 따라간다. 이 글은 그 여정을 실습 코드로 정리한 것이다.
LangGraph에 LLM을 적용할 때는 LangChain의 init_chat_model을 import한다.
💡 LangChain vs. LangGraph — LangChain은 _AI 모델과 쉽게 대화_할 수 있게 해 주는 라이브러리로, 에이전트보다는 AI 앱을 만들 때 활용한다. LangGraph는 그 위에서 그래프(=에이전트) 를 조립한다.
from langchain.chat_models import init_chat_model
llm = init_chat_model("openai:gpt-4o-mini")
llm.invoke([{"role": "user", "content": "Hello!"}])
# → AIMessage(content='Hello! How can I assist you today?', ...)챗봇은 대화를 기억해야 하므로 state 안에 messages 공간이 필요하다. 이를 위해 langchain_core에서 AnyMessage를 import한다.
💡 LangChain은 AI 모델과 대화하기 위한 라이브러리라, AI 모델에 보내는 메시지를 표현하는 클래스가 여러 종류(Human/AI/System/Tool 메시지 등) 있다. 그래서
messages는 어떤 타입의 메시지든 담을 수 있는 list가 된다.

from langchain_core.messages import AnyMessage
import operator
from typing import Annotated
class State(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]⚠️ 그러나
operator.add는 새 메시지를 이전 메시지에 단순히 이어 붙이기만 한다. 특정 메시지를 _수정·삭제_하는 고급 기능까지 필요하다면operator.add로는 부족하다.
이럴 때는 add_messages를 쓴다. add_messages는 이전 메시지와 새 메시지를 합쳐 줄 뿐 아니라, 메모리 안의 메시지를 수정·삭제할 수 있게 해 준다.
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]def chatbot(state: State):
# state 안의 messages를 그대로 모델에 전달
response = llm.invoke(state["messages"])
# LLM이 반환한 response를 messages에 넣어 반환 → add_messages가 누적
return {"messages": [response]}
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
graph = graph_builder.compile()
graph.invoke(
{"messages": [{"role": "user", "content": "how are you?"}]}
)
# → {'messages': [HumanMessage(content='how are you?', ...),
# AIMessage(content="I'm just a computer program, ...", ...)]}💡 참고 —
MessagesStateLangGraph는 위에서 직접 만든 State와 완전히 동일한 역할을 하는MessagesState를 기본 제공한다. 그러므로 아래처럼 축약할 수 있다. (messages필드와add_messagesreducer가 이미 들어 있다.)pythonfrom langgraph.graph import MessagesState class State(MessagesState): pass # 나중에 커스텀 속성 추가 가능
챗봇에 function calling을 추가하면 챗봇이 곧 agent가 된다. tool을 추가하려면 tool 역할의 node를 만들어 chatbot node와 conditional edge로 연결한다. 흐름은 이렇다 — 사용자 입력 → chatbot이 LLM에 전달 → LLM 응답에 tool_call이 있으면 대화를 끝내지 않고 tool node로 연결.
💡 LangGraph는 이를 위한 prebuilt 두 가지를 제공한다.
ToolNode— tool을 호출하는 node. 모델의 response에서 function 이름을 확인하고, 그 function을 실행한 뒤, function output을 다시 메시지로 돌려보낸다.
tools_condition— state의 messages를 꺼내 tool call이 있는지 감지하는 라우팅 함수. 있으면tools로, 없으면END로 보낸다.
from langgraph.prebuilt import ToolNode, tools_condition
tool_node = ToolNode(
tools=[],
)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_conditional_edges("chatbot", tools_condition)
graph_builder.add_edge("tools", "chatbot")
정리하면: 메시지를 chatbot node에 보내면 chatbot이 AI 모델에 전달 → 간단한 응답이면 END, tool call을 요청하면 tools node로 이동해 해당 tool을 실행 → tool output을 MessagesState에 넣고 다시 chatbot node로 돌아와 업데이트된 state로 AI 모델과 재통신하는 loop가 돈다.
from langchain_core.tools import tool
@tool
def get_weather(city: str):
"""Gets weather in city"""
return f"The weather in {city} is sunny"
# AI 모델이 이 tool을 알고 있어야 한다 → LLM을 tool과 바인딩(결합)
llm_with_tools = llm.bind_tools(tools=[get_weather])
def chatbot(state: State):
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
# node가 호출할 tool을 등록
tool_node = ToolNode(
tools=[get_weather],
)💡
@tool데코레이터를 붙이면 일반 함수가 LLM이 호출 가능한 tool이 된다. 함수의 docstring이 tool 설명이 되어 LLM이 언제 이 tool을 쓸지 판단하는 근거가 되므로, docstring을 명확히 쓰는 것이 중요하다.
LangGraph는 Checkpointer로 메모리를 구현한다.
💡 Checkpointer는 단순히 state만 저장하는 게 아니라, 그 state가 어떻게 바뀌어 왔는지(변경 이력) 와 어떤 node가 호출됐는지까지 저장한다. 덕분에 과거로 돌아가 대화를 fork(분기) 할 수 있다 (→ 뒤의 Time Travel).
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
# SQLite connection
conn = sqlite3.connect("memory.db", check_same_thread=False)
# SqliteSaver를 compile 시 checkpointer로 넘긴다
graph = graph_builder.compile(
checkpointer=SqliteSaver(conn),
)
graph.invoke(
{"messages": [{"role": "user", "content": "how are you?"}]},
config={
"configurable": {
"thread_id": "1"
},
# "recursion_limit": 2 # 그래프가 몇 step까지 허용되는지
},
)💡
config/configurable/thread_id란? (공식문서 기준)
config— 그래프를 실행할 때 넘기는 런타임 설정. state(데이터)와 별개로, "어떻게 실행할지"에 대한 값을 담는다.
configurable— config 안에서 LangGraph가 읽는 예약 키들의 네임스페이스. 여기에thread_id,checkpoint_id같은 값을 넣는다.
thread_id— checkpointer의 primary key. 하나의 대화(thread)를 식별한다. 챗봇처럼 여러 사용자가 동시에 접근하는 앱에서는 _어느 세션과 대화 중인지_를 알아야 하는데, 바로 이thread_id로 구분한다. 같은thread_id로 다시 invoke하면 그 대화의 상태를 이어서 재개한다.
# state 이력 탐색 — 어느 대화의 history인지 알아야 하므로 config를 넘긴다
for state in graph.get_state_history(
{"configurable": {"thread_id": "1"}}
):
print(state.next)
# 결과 (아래에서 위로 진행된 순서)
# ()
# ('chatbot',)
# ('__start__',)💡 추가로, 그래프를
invoke대신astream****(async stream) 으로 호출하면 응답의 delta(증분) 를 실시간으로 받아볼 수 있다. 여러 stream mode를 골라 무엇을 스트리밍할지 선택할 수도 있다.
Human-in-the-loop(HITL) 은 인간(유저)이 중간에 승인/거절/피드백으로 개입하는 방식이다. 즉, 그래프를 중단하고 피드백을 제공할 수 있는 기능이다.
원리는 익숙한 파이썬 input() 함수와 같다. 아래 코드에서 input() 앞의 코드까지만 실행되고, 사용자가 무언가 입력하기 전까지 실행이 막힌다(blocking).
print("hello")
print("world")
input() # ← 여기서 대기, 입력 전까지 blocking
print("bye")💡 LangGraph에서 이
input()역할을 하는 것이interrupt함수다. 흐름은 그래프 중단 → 피드백 제공 → 그래프 재개.interrupt가 실행되면 그래프가 멈추고 state를 저장한다. 재개할 때 넘긴 값이interrupt****의 반환값으로 들어간다. (from langgraph.types import interrupt)
from langgraph.types import interrupt
@tool
def get_human_feedback(poem: str):
"""
Asks the user for feedback on the poem.
Use this before returning the final response.
"""
# interrupt 실행 → 그래프 중단. 재개 시 응답이 feedback으로 들어온다
feedback = interrupt(f"Here is the poem, tell me what you think\n{poem}")
return feedback
# interrupt 발생 여부 확인
result = graph.invoke(
{"messages": [{"role": "user", "content": "Please make a poem about Python Code."}]},
config=config,
)
for message in result["messages"]:
# pretty_print — 메시지를 사람이 보기 좋게 변환해 주는 langchain 메서드
message.pretty_print()
Command그러면 피드백은 어떻게 제공할까? Command 를 사용한다.
✅
Command는 (앞서 본) 다른 노드로 점프·state 업데이트 기능 외에, 중단된 그래프를 다시 이어가는(resume) 데에도 쓰인다. 즉,interrupt에 응답을 제공하는 도구다.
from langgraph.types import Command
# interrupt 질문에 대한 응답을 Command로 제공
response = Command(
# resume 값이 곧 interrupt의 반환값이 된다.
# 문자열·딕셔너리 등 모든 형태 가능. 단, interrupt 쪽에서 받을 준비가 되어 있어야 한다.
resume="It looks good!!"
)
# 이 응답으로 그래프를 다시 호출 → 재개
# state 대신 Command를 전달하면 그래프가 재개된다.
# config가 있어야 어떤 thread_id로 재개할지 알 수 있다.
result = graph.invoke(response, config=config)Time Travel은 대화를 여러 방향으로 이어 가는 conversation forking이다. 앞서 Memory에서 checkpointer가 _state + 실행된 노드 + 다음에 실행될 노드_를 저장하기 때문에, 이전 대화 지점에서 분기가 가능하다고 했다.
예를 들어 아래처럼 대화를 나눴는데, "내가 사는 나라"를 수정해서 다시 물어보고 싶다고 하자.

# config를 넘겨야 thread_id로 어떤 대화를 가리키는지 알 수 있다
state_history = graph.get_state_history(config)
for state_snapshot in list(state_history):
print(state_snapshot.next) # 각 단계에서 실행할 노드 이름
print(state_snapshot.values["messages"]) # 메시지만 출력
print("=========\n")기록은 맨 위가 가장 최신 state이고, 아래로 갈수록 과거다. 각 snapshot의 .next는 그 시점에서 다음에 실행될 노드(('chatbot',), ('__start__',), 혹은 완료 시 ())를 보여 준다.
# 포크할 위치 선택 (기록의 -5번째라고 가정)
# a = [1, 2, 3, 4, 5] → a[-1] = 5
state_history = graph.get_state_history(config)
to_fork = list(state_history)[-5]
to_fork.values["messages"]
from langchain_core.messages import HumanMessage
# 그래프 상태 업데이트 (fork)
graph.update_state(
# 기본 config가 아니라, "포크할 위치"를 가리키는 config (checkpoint_id 포함).
# 이걸 넘기면 LangGraph는 이 시점의 state를 업데이트하려는 것으로 인식한다.
to_fork.config,
# 무엇을 — 기존 메시지 ID를 그대로 넘기면 그 메시지를 수정한다
{
"messages": HumanMessage(
content="I live in Japan",
id="8eac45a4-688c-4c2c-870d-29673e964d5f",
)
},
)💡
MessagesState가 제공하는add_messagesreducer는 새 메시지를 _추가_할 뿐 아니라, 같은 메시지 ID를 전달하면 그 메시지를 수정할 수 있게 해 준다. 그래서 위처럼 기존HumanMessage의id를 그대로 주고content만 바꾸면 해당 메시지가 교체된다.
# update_state 후 받은 checkpoint_id로 변경 확인
forked_state = graph.get_state_history(
{"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": "1f17f873-caef-612e-8004-a02fab4321a6",
}}
)
list(forked_state)
# 그 지점에서 그래프를 다시 호출 (state 대신 None → 저장된 state로 재개)
result = graph.invoke(
None,
{"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": "1f17f873-caef-612e-8004-a02fab4321a6",
}},
)
for message in result["messages"]:
message.pretty_print()
LangSmith는 에이전트를 모니터링·관찰하는 도구로, 그중 에이전트를 검사하고 시각화하는 UI(LangGraph Studio)를 제공한다.
이를 활성화하려면 프로젝트 루트에 langgraph.json 파일을 만든다.
{
"dependencies": ["langchain_openai", "./main.py"],
"env": "./.env",
"graphs": {
"mr_poet": "./main.py:graph"
}
}💡 각 필드의 의미는 다음과 같다.
dependencies— 그래프를 실행하는 데 필요한 의존성.
env— 환경 변수 파일 위치. UI에게 가상 환경/설정이 어디 있는지 알려준다.
graphs— 노출할 그래프."mr_poet": "./main.py:graph"는 "__main.py안의 _graph_라는 변수를 _mr_poet_이라는 이름으로 노출한다" 는 뜻이다.
langgraph dev 명령어를 실행하면 LangGraph Studio(에이전트를 검사·시각화하는 UI)가 열린다.
langgraph dev
💡 API_KEY를 요구하는데, LangSmith는 일부 무료이므로 API 키를 발급받아
.env파일에LANGSMITH_API_KEY로 추가하면 된다.
LangGraph 서버를 실행한 채로 https://agentchat.vercel.app 에 접속하면 내가 만든 에이전트와 채팅할 수 있다.
💡 접속하면 LangSmith API 키를 입력하라고 하지만 아무것도 입력할 필요 없다. 다음 두 가지만 넣으면 된다.
서버 URL — LangGraph 서버 실행 시 나오는 주소
http://127.0.0.1:2024그래프 이름 — compile 시 지정한 이름. 예:
graph = graph_builder.compile(name="mr_poet")

🧭 한눈에 정리 — Chatbot(LLM 연결 +
MessagesState) → Tool Nodes(ToolNode+tools_condition로 agent화) → Memory(Checkpointer +thread_id) → Human-in-the-loop(interrupt+Command(resume=…)) → Time Travel(get_state_history+update_statefork) → DevTools(langgraph.json+langgraph dev). 이 순서대로 얹으면 단순 챗봇이 관찰 가능한 완성형 에이전트가 된다.