LangGraph가 실제로 구축하는 것: Runnables, Channels, Bubble-Up, Agents 및 Deep Agents
요약
본 글은 LangGraph의 내부 작동 원리를 깊이 있게 분석하며, 그래프 기반 에이전트 구축의 핵심 개념을 설명합니다. State(상태), Channel(채널), Reducer(리듀서)와 같은 추상화된 요소들이 어떻게 상호작용하여 복잡한 에이전트를 구성하는지 상세히 다룹니다.
핵심 포인트
- LangGraph는 그래프, ReAct agent, Deep Agent를 하나의 엔진으로 구현합니다.
- State는 그래프 전체에 흐르는 딕셔너리이며 부분적 업데이트가 이루어집니다.
- Channel은 상태의 특정 슬롯을 정의하며, 병합 규칙(Reducer)을 가집니다.
- Runnable 인터페이스는 LangGraph의 모든 구성 요소가 따라야 하는 핵심 계약입니다.
저는 주말을 LangGraph의 소스 코드를 파헤치며 보냈고, 추상화 아래에서 실제로 무슨 일이 일어나는지 이해하려고 노력했습니다. 이것이 제가 작성한 메모, 실행해 본 실험들, 그리고 최종적으로 갖게 된 정신 모델입니다.
가장 놀라웠던 점은 핵심 아이디어가 얼마나 작은 것인지였습니다. 그래프(Graph), ReAct agent, 그리고 Deep Agent는 세 가지 다른 기계가 아닙니다. 그것들은 하나의 기계(Pregel)에 세 가지 다른 양의 구성(configuration)을 공급하는 것입니다.
아래 내용은 모두 오프라인에서 실행되었습니다: 네트워크 없음, 라이브 모델 없음, 스크립트된 가짜 채팅 모델. 버전은 langgraph 1.2.12, langchain-core 1.6.6, langchain 1.4.3, deepagents 0.7.20으로 고정되어 있습니다. 내부 구조는 릴리스마다 변경되므로 클래스 이름은 스냅샷으로 간주해 주십시오. 코드는 samples repo에 있으며, langgraph_primitives_demo.py가 섹션 1~6의 모든 출력된 결과를 생성했고, structured_cases.py는 섹션 5c를 다룹니다.
시작하기 전 참고 사항: Python 요소와 C# 등가물
만약 C# 출신이라면, 몇 가지 Python/LangGraph 용어가 바로 눈에 띌 것입니다. 그것들은 순수한 Python이거나 순수한 LangGraph입니다.
| 볼 수 있는 것 | 무엇인가요 | 가장 가까운 C# 아이디어 |
|---|---|---|
TypedDict | 키와 값의 타입이 타입 체커를 위해 선언된 딕셔너리이며, 실행 시에는 일반적인 dict입니다. | DTO / record와 유사하지만, 딕셔너리 형태를 유지합니다. |
| ... | ||
| 두 가지 LangGraph 용어는 지금 머릿속으로 정리할 가치가 있습니다. |
State는 그래프를 통해 흐르는 딕셔너리입니다. 모든 노드는 이 State를 받고, 전체 새로운 State가 아닌 부분적인 업데이트(partial update)를 반환합니다. C#을 알고 있다면, 엔진이 대신 적용해 주는 불변 기록(immutable record)에 with { ... } 표현식을 생각하시면 됩니다.
Channel은 상태(state)의 이름이 지정된 슬롯이며, 새로운 값이 병합되는 규칙을 소유합니다. total이라는 상태 키는 total이라는 채널이 됩니다. 이 규칙은 여러 노드가 같은 라운드에 값을 쓸 때 중요해집니다. 새 값이 이전 값을 대체해야 하는지, 아니면 결합되어야 하는가? C# 용어로 말하면, 채널은 자체 병합 함수(merge function)를 가진 필드와 비슷합니다. 예를 들어, Func<T, T, T> merge 같은 형태입니다.
Reducer는 바로 그 병합 함수입니다: reducer(old, new) -> combined. 이는 Annotated를 사용하여 연결됩니다. 만약 LINQ의 Aggregate를 사용해 보았다면, 그것과 모양이 같습니다. 리스트에 대한 operator.add가 reducer로 사용된다는 것은 "새 항목을 이전 항목에 추가한다"라는 의미입니다.
이 두 가지 개념을 가지고 가장 작고 흥미로운 그래프를 살펴보겠습니다.
1. 유일한 계약: Runnable
Runnable은 invoke, ainvoke, stream, astream, batch, 그리고 with_config를 가진 모든 것을 의미합니다. 그게 전부입니다. C#에서는 이것을 모든 것이 구현하는 인터페이스, 즉 IRunnable<TIn, TOut>라고 부릅니다. 파이썬은 클래스가 | 연산자(C#의 operator |와 같은)를 오버로드할 수 있게 허용하여 두 개의 Runnable을 합성합니다:
chain = RunnableLambda(lambda x: x + 1) | RunnableLambda(lambda x: x * 10)
type(chain).__name__ # RunnableSequence
chain.invoke(1) # 20
이것이 중요한 이유: 컴파일된 LangGraph 역시 Runnable입니다. 그 MRO(Method Resolution Order, 메소드 해석 순서)는 CompiledStateGraph → Pregel → PregelProtocol로 시작합니다. (MRO는 파이썬의 상속 체인입니다: CompiledStateGraph : Pregel : PregelProtocol). 따라서 전체 그래프가 체인 안에 있거나 다른 그래프의 노드 안에 존재할 수 있습니다. 이것이 나중에 서브 에이전트(subagents)가 작동하는 방식입니다.
2. compile()이 생성하는 것
가장 작고 흥미로운 그래프를 예로 들어보겠습니다: 상태에 덮어쓰기 필드와 추가(append) 필드를 가진 두 개의 노드가 있는 경우입니다. 노드는 단순히 상태를 받아 부분 업데이트를 반환하는 함수일 뿐입니다:
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
...
StateGraph는 단지 빌더(builder)일 뿐입니다 (예: BuildServiceProvider() 호출 전의 C# ServiceCollection과 같습니다). compile() 메서드는 이를 기본 요소(primitives)로 낮춥니다. 데모가 컴파일된 객체에 대해 출력하는 내용은 다음과 같습니다:
이 줄들은 위 그래프 코드의 일부가 아닙니다. 제가 작성한 작은 검사 헬퍼에서 가져온 것입니다 (이는 데모 스크립트의 show() 함수입니다). 이 헬퍼는 컴파일된 객체인 app.channels와 app.nodes의 공개 속성(public attributes)을 읽습니다:
# `app`은 위에서 컴파일된 그래프입니다 (Pregel 객체)
print("channels:", {name: type(ch).__name__ for name, ch in app.channels.items()
if not name.startswith("branch:")}) # 간결함을 위해 에지별 채널 숨김
...
이 코드는 다음과 같이 출력합니다:
channels: {'total': 'LastValue', 'visited': 'BinaryOperatorAggregate',
'__start__': 'EphemeralValue', '__pregel_tasks': 'Topic'}
node 'a': triggers=['branch:to:a'] bound=RunnableCallable writers=2
...
node으로 시작하는 줄들은 PregelNode 객체를 설명합니다. 따라서 이것이 그 예시입니다. 이 클래스는 langgraph/pregel/_read.py에서 가져온 것이며, 여기에서 중요한 필드만 잘라냈습니다 (오류 처리기(error-handler)와 서브그래프(subgraph) 필드는 생략했으며, 문서 문자열은 원본의 내용을 압축한 저의 것입니다):
class PregelNode:
# Runnable이 아닙니다. 컨테이너입니다. 엔진은 노드가 실행될 때마다 이를 사용하여 runnable 작업을 구축합니다.
...
channels(읽는 것)와 triggers(깨우는 것) 사이의 분리에 주목하십시오. 이들은 의도적으로 분리되어 있습니다: 노드는 여러 채널을 읽을 수 있지만 단지 하나에 의해 깨어날 수 있기 때문입니다. C# 용어로 볼 때, PregelNode는
'total': 'LastValue':total필드는LastValue타입의 채널이 되었습니다. 이 채널은 하나의 값을 보유하며, 쓰기(write)가 발생하면 그 값이 덮어쓰여집니다. 이것이 바로S에서 사용했던 단순한total: int가 의미하던 바입니다.'visited': 'BinaryOperatorAggregate':visited필드는 이전 값과 새 값을 함수(operator.add)로 결합하는 채널이 되었습니다. 이것이Annotated[list, operator.add]가 의미했던 것이며, 리스트가 성장하는 이유입니다.'__start__': 'EphemeralValue': 그래프의invoke(...)입력값을 전달하는 내부 채널입니다.EphemeralValue는 한 라운드 동안만 존재한다는 것을 의미합니다.'__pregel_tasks': 'Topic': 큐와 같은 내부 채널입니다. 여기에Send패킷이 들어갑니다(섹션 5c). 저희 그래프에서는 사용하지 않지만, 모든 그래프가 이 채널을 가지고 있습니다.node 'a': triggers=['branch:to:a']: 노드a는 채널branch:to:a가 업데이트될 때 실행됩니다. 이 채널이 바로 엣지START → a가 변환된 것이므로, 별도의 엣지 객체는 없고
flowchart TD
I["start channel<br/>(your input)"] -->|wakes| CA["channel branch:to:a"
CA -->|wakes| NA["node a<br/>reads: total, visited<br/>bound = a(s)<br/>writer1 → total, visited<br/>writer2 → channel branch:to:b"
NA -->|wakes| NB["node b<br/>reads: total, visited<br/>bound = b(s)<br/>writer1 → total, visited<br/>(no writer2: its edge goes to END)"]
The 노드 사이의 화살표는 두 번째 다이어그램에서 사라집니다. 남아 있는 것은 박스(노드)와 이름이 지정된 메일박스(채널)뿐입니다: "a"는 "b"가 존재한다는 것을 알지 못하며, 단지 branch:to:b에 메시지를 떨어뜨릴 뿐입니다.
따라서 정신적 모델은 다음과 같습니다:
- Channels는 상태를 유지합니다 (위의 주석 참조). 각 상태 키는 채널이 되며, 그 _유형_은 병합 규칙입니다:
LastValue는 단계 전반에 걸쳐 최신 값을 유지하지만 슈퍼스텝당 최대 한 번만 쓰기(write)가 가능합니다 (두 개의 병렬 작성기가InvalidUpdateError를 발생시키는 경우, 5c에서 표시됨);BinaryOperatorAggregate는 리듀서(operator.add)를 적용하며, 이것이visited가 대체하는 대신 추가되는 이유입니다. 추가 채널은 제어 흐름을 전달합니다:__start__는 입력을 담고, 간선a → b는branch:to:b라는 이름의 채널이 됩니다. 이중 밑줄("")은 내부 채널에 대한 명명 규칙일 뿐입니다 (Python의__name__스타일이며 키워드는 아닙니다). - Nodes는
PregelNode입니다. 각 노드는triggers(자신을 깨우는 채널),channels(읽는 채널),bound(실제Runnable, 여기서는 함수를 감싼 래퍼) 및writers(실행 후 작성하는 내용)를 가집니다. - Edges가 사라집니다. 실행 시에는 간선 객체가 없습니다.
a → b는 "a의 작성기가branch:to:b에 쓰고, b가branch:to:b에서 트리거되는 것"을 의미합니다.get_graph()는 그리기 위한 깔끔한 간선 목록을 재구성하지만, 엔진은 결코 이를 순회하지 않습니다.
조건부 간선과 Send는 동일한 메커니즘을 사용합니다: 라우터(Runnable)가 추가 작성기처럼 실행되어 선택한 모든 branch:to:X 채널에 쓰거나 (맵-리듀스용) 자체 입력으로 작업을 보냅니다.
이를 확인하려면 동일한 그래프에 라우터(router)를 추가합니다: a는 이제 g.add_conditional_edges("a", route, ["b", "c"])를 통해 "b" 또는 "c" 중 하나를 선택하며, 여기서 route(s)는 s["total"] > 5이면 "b"를 반환하고 그렇지 않으면 "c"를 반환합니다. 코드로 구현하면 (위의 S, a, b를 재사용하고 노드 c와 라우터를 추가):
def c(s): # node "c": the alternative path, resets total
return {"total": 0, "visited": ["c"]}
...
저는 이 코드를 컴파일하고 실행했으며 동일한 방식으로 검사했습니다:
channels: total=LastValue, visited=BinaryOperatorAggregate, __start__, __pregel_tasks=Topic,
branch:to:a, branch:to:b, branch:to:c (the last three are EphemeralValue)
node a: triggers=['branch:to:a'] writers=[state write, _route]
...
작성하신 내용:
flowchart LR
S([START]) --> a
a --> b --> E1([END])
a --> c --> E2([END])
compile()이 구축한 내용:
flowchart TD
NA["node a<br/>writer1: state write → total, visited<br/>writer2: _route(s) ← your router function"]
NA -->|"router returns 'b'"| CB["writes branch:to:b"] -->|wakes| NB[node b]
NA -->|"router returns 'c'"| CC["writes branch:to:c"] -->|wakes| NC[node c]
선형(linear) 케이스와의 차이점은 a의 두 번째 writer뿐입니다. 고정된 branch:to:b 쓰기 대신, 이는 함수를 호출하고 답변이 지정하는 채널에 쓰는 _route입니다. 다른 채널에는 절대 쓰기가 발생하지 않으므로 해당 노드는 단순히 실행되지 않습니다. total이 10으로 시작하면, a는 이를 20으로 만들고 라우터가 b를 선택하며, invoke는 {'total': 21, 'visited': ['a', 'b']}를 반환합니다. 이 경우 c는 절대 실행되지 않습니다. 대신 1로 시작하면 라우터가 c를 선택합니다. (_route는 제가 본 버전에서 내부 writer의 이름이므로; 구현 세부 사항으로 간주하십시오.)
3. Pregel: superstep 루프
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기