StateGraph 세 가지 — 상태 · 노드 · 엣지
한 줄 요약
상태는 딕셔너리, 노드는 함수, 엣지는 화살표입니다. 그리고 상태에 붙이는 add_messages 표기가 "덮어쓰지 말고 이어 붙여라"를 뜻합니다. 이 네 가지가 전부입니다.
1. 상태(State) — 계속 들고 다니는 값
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
이 세 줄을 읽는 법
class 로 시작하는 줄이 처음 나왔습니다. 1장에서 "클래스 설계는 하나도 안 나옵니다" 라고 했는데 약속을 어긴 걸까요? 아닙니다. 클래스처럼 생겼지만 하는 일은 "딕셔너리의 모양을 적어 두는 것" 뿐입니다.
class State(TypedDict):는 클래스를 만드는 게 아닙니다. 딕셔너리의 설계도에State라는 이름을 붙이는 것입니다. 괄호 안의TypedDict(타입드 딕트, "타입이 정해진 딕셔너리")가 그 표시입니다.- 실제로 오가는 것은 평범한 딕셔너리입니다 —
{"messages": [...]}.State라는 객체가 따로 만들어지지 않습니다. - 그래서 상속·메서드·
self같은 것은 전혀 안 나옵니다. 이 과정에서class는 여기와 8장(계획의 모양을 정의하는 곳)에서만 나오고, 둘 다 "모양을 적어 두는" 용도입니다. 상속·메서드·self는 끝까지 나오지 않습니다.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
{"messages": [...]}
2장의 history 리스트가 여기 들어 있습니다. 이름만 messages 로 바뀌었습니다.
Annotated[list, add_messages] — 이게 핵심
이 표기가 낯설 텐데, 뜻은 단순합니다.
대괄호는 리스트가 아닙니다. 3장에서 배운 타입힌트는
def f(x: str) -> str:처럼 타입 이름만 적는 형태였죠. 여기 나온Annotated[Type, metadata](어노테이티드 — "주석을 단")는Type[metadata]라고 쓰는 파이썬의 타입 표기입니다. 리스트도 아니고 함수 호출도 아닙니다. 대괄호 모양만 같은 표기라고 보면 됩니다.
붙이면 어떻게 되고 안 붙이면 어떻게 되는지를 나란히 보세요.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
plan: list # 부가정보 없음 → 덮어쓴다
messages: Annotated[list, add_messages] # 부가정보 있음 → 이어 붙인다
노드가 값을 돌려줄 때 어떻게 반영할지를 정합니다.
| 표기 | 노드가 {"messages": [새메시지]} 를 돌려주면 |
|---|---|
messages: list |
덮어쓴다 — 기존 대화가 사라진다 |
messages: Annotated[list, add_messages] |
뒤에 이어 붙인다 — 대화가 쌓인다 |
add_messages 하나로 2장의 history.append(...) 가 자동이 됩니다.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
# 2장 — 손으로
history.append(say("user", message))
answer = chat_multi(SYSTEM, history)
history.append(say("model", answer))
# 7장 — add_messages 가 대신
def counselor(state):
answer = llm.invoke(...)
return {"messages": [answer]} # 이어 붙이기는 add_messages 가
이런 "어떻게 합칠지 정하는 함수"를 리듀서(reducer)라고 부릅니다.
add_messages는 메시지용 리듀서입니다. 직접 만들 수도 있지만, 이 과정에서는 쓸 일이 없습니다.
다른 값도 넣을 수 있습니다
messages 만 넣어야 하는 건 아닙니다.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
class State(TypedDict):
messages: Annotated[list, add_messages] # 이어 붙임
plan: list # 덮어씀
results: list # 덮어씀
리듀서를 안 붙이면 덮어씁니다. 8장에서 이 형태를 씁니다 — 대화는 쌓고, 이번 턴의 계획과 결과는 매번 새로 씁니다.
2. 노드(node) — 상태를 받아 일을 하는 함수
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
def counselor(state: State) -> dict:
answer = llm.invoke([{"role": "system", "content": SYSTEM}] + state["messages"])
return {"messages": [answer]}
규칙 두 개만 지키면 됩니다.
| 규칙 | 뜻 |
|---|---|
| 상태를 인자로 받는다 | state["messages"] 로 지금까지의 대화를 꺼내 쓴다 |
| 바뀐 부분만 딕셔너리로 돌려준다 | 전체를 돌려줄 필요 없다 |
두 번째가 중요합니다.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
return {"messages": [answer]} # ✓ 새로 생긴 것만
return {"messages": state["messages"] + [answer]} # 이래도 되긴 하지만…
위의 두 번째 줄처럼 전체를 돌려줘도 결과는 같습니다. add_messages 는 메시지마다 붙어 있는 id 로 같은 것을 걸러내기 때문입니다. 그래서 이미 상태에 있던 메시지는 이어 붙지 않고 교체됩니다.
그래도 위처럼 쓰는 것이 맞습니다. 두 가지 이유입니다.
- 리듀서가 무슨 일을 하는지 코드에서 안 보이게 됩니다
- 상태 전체를 매번 복사해 넘기는 비용이 붙습니다
"바뀐 부분만 돌려준다" 가 노드를 쓰는 규칙입니다.
그럼 원래 있던 것들은 누가 합치나
노드 바깥에서 리듀서가 합칩니다. 노드 하나를 통과하는 동안 벌어지는 일을 그림으로 보세요.
노드는 새것만 돌려주고, 합치는 일은 리듀서가 합니다. 그래서 노드 함수가 두 줄로 끝납니다.
리듀서가 없는 필드는 어떻게 되나요? 그냥 갈아 끼웁니다.
이어 붙이기(messages)와 덮어쓰기(plan) — 이 둘의 차이가 8장의 뼈대입니다.
시스템 지시는 상태에 안 넣습니다
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
answer = llm.invoke([{"role": "system", "content": SYSTEM}] + state["messages"])
└─ 매번 앞에 붙인다
시스템 지시를 messages 에 넣어 두면 대화 기록에 쌓여 지저분해집니다. 매번 앞에 붙이는 편이 깔끔합니다.
2장에서 system_instruction 을 별도 자리에 두었던 것과 같은 이유입니다.
3. 엣지(edge) — 다음에 어디로
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
from langgraph.graph import StateGraph, START, END
graph = StateGraph(State)
graph.add_node("counselor", counselor)
graph.add_edge(START, "counselor")
graph.add_edge("counselor", END)
| 코드 | 뜻 |
|---|---|
StateGraph(State) |
이 상태를 쓰는 그래프를 만든다 |
add_node("이름", 함수) |
노드를 등록한다 |
add_edge(START, "counselor") |
시작하면 상담원으로 |
add_edge("counselor", END) |
상담원이 끝나면 종료 |
START 와 END 는 LangGraph가 주는 특별한 표시입니다. 진짜 노드가 아니라 입구와 출구를 뜻합니다.
그림으로 그리면 이렇습니다.
START ──▶ [상담원] ──▶ END
세상에서 가장 단순한 그래프입니다. 그래도 그래프는 그래프입니다.
갈라지는 엣지도 있습니다
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
graph.add_conditional_edges("model", route)
조건에 따라 다음 노드가 달라지는 엣지입니다. 5장 4절에서 본 그림의 갈림길이 이것입니다.
이 과정에서는 쓰지 않습니다. create_agent 가 이미 만들어 주고, 8장의 그래프도 일직선이기 때문입니다. 이런 게 있다는 것만 알아 두세요.
4. 컴파일 — 실행 가능한 형태로
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
chatbot = graph.compile()
조립한 그래프를 실행 가능한 형태로 만드는 단계입니다. 컴파일하면 invoke 로 부를 수 있게 됩니다.
아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.
result = chatbot.invoke({"messages": [{"role": "user", "content": "안녕하세요"}]})
print(result["messages"][-1].text)
넣는 것도 받는 것도 상태입니다. 5장의 agent.invoke({"messages": [...]}) 와 똑같은 모양이죠. create_agent 도 그래프이기 때문입니다.
그리고 컴파일할 때 기억을 붙입니다. 다음다음 절의 주제입니다.
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
chatbot = graph.compile(checkpointer=InMemorySaver())
5. 전체를 한눈에
아래는 3절에서 만들 code/ch07_memory.py 의 일부를 미리 보는 것입니다 — 지금 붙여 넣지 않아도 됩니다.
# ① 상태
class State(TypedDict):
messages: Annotated[list, add_messages]
# ② 노드
def counselor(state: State) -> dict:
answer = llm.invoke([{"role": "system", "content": SYSTEM}] + state["messages"])
return {"messages": [answer]}
# ③ 조립
graph = StateGraph(State)
graph.add_node("counselor", counselor)
graph.add_edge(START, "counselor")
graph.add_edge("counselor", END)
# ④ 컴파일
chatbot = graph.compile(checkpointer=InMemorySaver())
네 단계, 열 줄 남짓. 다음 절에서 이걸 그대로 만들어 실행합니다.
6. 2장과 나란히 놓기
| 2장 (손으로) | 7장 (LangGraph) |
|---|---|
history = [] |
class State(TypedDict): messages: ... |
history.append(say("user", message)) |
invoke({"messages": [{"role":"user", ...}]}) |
chat_multi(SYSTEM, history) |
노드 함수 안의 llm.invoke(...) |
history.append(say("model", answer)) |
return {"messages": [answer]} + add_messages |
손님마다 history 따로 |
thread_id |
| 파일에 저장 | 체크포인터 교체 |
왼쪽을 해 봤기 때문에 오른쪽이 이해됩니다. 2장에서 일부러 손으로 해 본 이유입니다.
핵심 정리
- 상태(State) 는 그래프가 들고 다니는 딕셔너리입니다. 2장의
history자리입니다. Annotated[list, add_messages]는 "덮어쓰지 말고 이어 붙여라" 를 뜻합니다. 이것 하나로append가 자동이 됩니다.- 리듀서를 안 붙이면 덮어씁니다. 8장에서 둘 다 씁니다.
- 합치는 일은 노드 밖에서 리듀서가 합니다. 노드는 새로 생긴 것만 돌려주면 됩니다.
- 노드는 함수입니다. 규칙 두 개 — 상태를 인자로 받고, 바뀐 부분만 돌려준다.
- 시스템 지시는 상태에 안 넣고 매번 앞에 붙입니다.
- 엣지는 화살표입니다.
START와END는 입구와 출구를 뜻합니다. - 조건에 따라 갈라지는
add_conditional_edges도 있습니다. 이 과정에서는 안 씁니다. compile()하면invoke로 부를 수 있게 됩니다. 넣는 것도 받는 것도 상태입니다.