📚 Agentic AI - 기업용 자율 에이전트 개발 7장 · 기억하는 에이전트 Agentic AI란

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장에서 "클래스 설계는 하나도 안 나옵니다" 라고 했는데 약속을 어긴 걸까요? 아닙니다. 클래스처럼 생겼지만 하는 일은 "딕셔너리의 모양을 적어 두는 것" 뿐입니다.

  1. class State(TypedDict): 는 클래스를 만드는 게 아닙니다. 딕셔너리의 설계도에 State 라는 이름을 붙이는 것입니다. 괄호 안의 TypedDict(타입드 딕트, "타입이 정해진 딕셔너리")가 그 표시입니다.
  2. 실제로 오가는 것은 평범한 딕셔너리입니다 — {"messages": [...]}. State 라는 객체가 따로 만들어지지 않습니다.
  3. 그래서 상속·메서드·self 같은 것은 전혀 안 나옵니다. 이 과정에서 class 는 여기와 8장(계획의 모양을 정의하는 곳)에서만 나오고, 둘 다 "모양을 적어 두는" 용도입니다. 상속·메서드·self 는 끝까지 나오지 않습니다.

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

{"messages": [...]}

2장의 history 리스트가 여기 들어 있습니다. 이름만 messages 로 바뀌었습니다.

Annotated[list, add_messages] — 이게 핵심

이 표기가 낯설 텐데, 뜻은 단순합니다.

Annotated[list, add_messages]타입 — 무엇을 담나리듀서 — 이 값을 어떻게 갱신할지리듀서를 지정하지 않으면 '덮어쓰기'가 기본입니다. 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 로 같은 것을 걸러내기 때문입니다. 그래서 이미 상태에 있던 메시지는 이어 붙지 않고 교체됩니다.

그래도 위처럼 쓰는 것이 맞습니다. 두 가지 이유입니다.

  1. 리듀서가 무슨 일을 하는지 코드에서 안 보이게 됩니다
  2. 상태 전체를 매번 복사해 넘기는 비용이 붙습니다

"바뀐 부분만 돌려준다" 가 노드를 쓰는 규칙입니다.

그럼 원래 있던 것들은 누가 합치나

노드 바깥에서 리듀서가 합칩니다. 노드 하나를 통과하는 동안 벌어지는 일을 그림으로 보세요.

노드에 들어가는 상태{"messages": [사람1, AI1, 사람2]}노드 counselor함수 안에서는 state["messages"]를읽기만 한다return {"messages": [AI2]} ← 새 메시지만add_messages (리듀서)기존 메시지 + 새 메시지합치는 일은 노드 밖에서일어난다노드에서 나온 상태{"messages": [사람1, AI1, 사람2, AI2]}그래서 노드가 전체를 돌려줘도 중복되지 않습니다 — add_messages가 메시지 id로 같은 것을 걸러 냅니다.
노드는 새것만 돌려주고, 기존 것과 합치는 일은 리듀서가 노드 밖에서 한다

노드는 새것만 돌려주고, 합치는 일은 리듀서가 합니다. 그래서 노드 함수가 두 줄로 끝납니다.

리듀서가 없는 필드는 어떻게 되나요? 그냥 갈아 끼웁니다.

plan: [이전 계획]return {"plan": [새 계획]}plan: [새 계획]↳ 이전 값은 사라진다messages만 이어 붙고, 리듀서를 달지 않은 키는 매번 덮어쓰기입니다.
리듀서를 달지 않은 키는 매번 덮어쓰기다

이어 붙이기(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) 상담원이 끝나면 종료

STARTEND 는 LangGraph가 주는 특별한 표시입니다. 진짜 노드가 아니라 입구와 출구를 뜻합니다.

그림으로 그리면 이렇습니다.

  START ──▶ [상담원] ──▶ END

세상에서 가장 단순한 그래프입니다. 그래도 그래프는 그래프입니다.

갈라지는 엣지도 있습니다

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

graph.add_conditional_edges("model", route)

조건에 따라 다음 노드가 달라지는 엣지입니다. 5장 4절에서 본 그림의 갈림길이 이것입니다.

모델도구 호출이 있나?있다없다도구 실행END다시 모델로노드는 상자, 엣지는 화살표입니다. 조건부 엣지는 '무엇을 보고 갈림길을 정할지'를 함수로 적어 줍니다.
조건부 엣지가 만드는 갈림길 — 도구 호출이 있으면 도구로, 없으면 END 로

이 과정에서는 쓰지 않습니다. 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장에서 둘 다 씁니다.
  • 합치는 일은 노드 밖에서 리듀서가 합니다. 노드는 새로 생긴 것만 돌려주면 됩니다.
  • 노드는 함수입니다. 규칙 두 개 — 상태를 인자로 받고, 바뀐 부분만 돌려준다.
  • 시스템 지시는 상태에 안 넣고 매번 앞에 붙입니다.
  • 엣지는 화살표입니다. STARTEND 는 입구와 출구를 뜻합니다.
  • 조건에 따라 갈라지는 add_conditional_edges 도 있습니다. 이 과정에서는 안 씁니다.
  • compile() 하면 invoke 로 부를 수 있게 됩니다. 넣는 것도 받는 것도 상태입니다.
← 이전 절01. 2장에서 손으로 쌓던 대화 이제는 라이브러리에게다음 절 →03. 따라하기 상태를 정의하고 노드로 잇기