Graph API 개요
Graph API 개요 (Graph API overview)
출처: 문서
본문
그래프 (Graphs)
핵심적으로 LangGraph는 에이전트 워크플로우를 그래프로 모델링합니다. 세 가지 핵심 구성 요소로 에이전트의 동작을 정의해요:
-
State: 애플리케이션의 현재 스냅샷을 나타내는 공유 데이터 구조. 어떤 데이터 타입이든 될 수 있지만, 보통 공유 상태 스키마로 정의합니다. -
Nodes: 에이전트의 로직을 인코딩하는 함수. 현재 상태를 입력으로 받아 어떤 계산이나 부작용을 수행하고 갱신된 상태를 반환합니다. -
Edges: 현재 상태에 기반해 다음에 어떤Node를 실행할지 결정하는 함수. 조건부 분기이거나 고정 전이일 수 있습니다.
Nodes와 Edges를 조합하면 시간이 지나며 상태를 진화시키는 복잡한 루프 워크플로우를 만들 수 있어요. 하지만 진짜 힘은 LangGraph가 그 상태를 관리하는 방식에서 나옵니다.
강조하자면, Nodes와 Edges는 함수일 뿐입니다 — LLM을 담거나, 그냥 좋은 옛날 코드를 담을 수 있어요.
요약하면: 노드는 작업을 하고, 엣지는 무엇을 다음에 할지 알려줍니다.
LangGraph의 기반 그래프 알고리즘은 메시지 전달(message passing)로 일반 프로그램을 정의합니다. 노드가 작업을 완료하면 하나 이상의 엣지를 따라 다른 노드(들)에 메시지를 보냅니다. 이 수신 노드들은 함수를 실행하고 결과 메시지를 다음 노드 집합에 전달하며, 이 과정이 계속됩니다. Google의 Pregel 시스템에서 영감을 받아 이 프로그램은 불연속적인 "슈퍼스텝(super-steps)"으로 진행됩니다.
슈퍼스텝은 그래프 노드에 대한 단일 반복으로 볼 수 있어요. 병렬로 실행되는 노드들은 같은 슈퍼스텝에 속하고, 순차로 실행되는 노드들은 별도의 슈퍼스텝에 속합니다. 그래프 실행이 시작되면 모든 노드는 inactive 상태에서 시작합니다. 노드는 들어오는 엣지("채널") 중 하나에서 새 메시지(상태)를 받으면 active가 됩니다. 활성 노드는 함수를 실행하고 갱신으로 응답합니다. 각 슈퍼스텝이 끝나면 들어오는 메시지가 없는 노드는 스스로 inactive로 표시해 halt에 투표합니다. 모든 노드가 inactive이고 전송 중인 메시지가 없으면 그래프 실행이 종료됩니다.
StateGraph
StateGraph 클래스가 사용할 주요 그래프 클래스입니다. 사용자 정의 State 객체로 파라미터화됩니다.
그래프 컴파일 (Compiling your graph)
그래프를 만들려면 먼저 state를 정의하고, nodes와 edges를 추가한 뒤, 컴파일합니다. 그래프를 컴파일한다는 것은 정확히 무엇이고 왜 필요한 걸까요?
컴파일은 꽤 단순한 단계입니다. 그래프 구조에 대한 몇 가지 기본 검사(고아 노드 없음 등)를 제공해요. 또한 체크포인터나 브레이크포인트 같은 런타임 인자를 지정할 수 있는 곳이기도 합니다. .compile 메서드를 호출하면 됩니다:
graph = graph_builder.compile(...)
상태 (State)
그래프를 정의할 때 가장 먼저 하는 일은 그래프의 State를 정의하는 것입니다. State는 그래프의 스키마와, 상태에 업데이트를 적용하는 방법을 지정하는 reducer 함수로 구성돼요. State의 스키마는 그래프의 모든 Nodes와 Edges의 입력 스키마가 되며, TypedDict 또는 Pydantic 모델일 수 있습니다. 모든 Nodes는 State에 업데이트를 내보내고, 이는 지정된 reducer 함수를 사용해 적용됩니다.
스키마 (Schema)
그래프 스키마를 지정하는 주요 문서화된 방법은 TypedDict를 사용하는 것입니다. 상태에 기본값을 제공하려면 dataclass를 사용하세요. 재귀 데이터 검증을 원한다면 그래프 상태로 Pydantic BaseModel을 사용하는 것도 지원합니다(단, Pydantic은 TypedDict나 dataclass보다 성능이 낮습니다).
기본적으로 그래프는 같은 입력·출력 스키마를 가집니다. 이것을 바꾸려면 명시적 입력·출력 스키마를 직접 지정할 수도 있습니다. 키가 많고 일부는 입력 전용, 일부는 출력 전용일 때 유용합니다. 자세한 내용은 가이드를 참고하세요.
여러 스키마 (Multiple schemas)
일반적으로 모든 그래프 노드는 단일 스키마로 통신합니다. 이는 같은 상태 채널을 읽고 쓴다는 뜻입니다. 하지만 이를 더 제어하고 싶은 경우가 있습니다:
- 내부 노드는 그래프의 입력/출력에 필요 없는 정보를 전달할 수 있습니다.
- 그래프의 입력/출력 스키마를 다르게 사용하고 싶을 수도 있습니다. 예를 들어 출력은 단일 관련 출력 키만 포함할 수 있어요.
노드가 그래프 안의 비공개 상태 채널에 써서 내부 노드 통신을 하는 것이 가능합니다. 간단히 비공개 스키마 PrivateState를 정의하면 됩니다.
그래프에 대해 명시적 입력·출력 스키마를 정의하는 것도 가능합니다. 이런 경우 그래프 작업과 관련된 모든 키를 포함하는 "내부" 스키마를 정의합니다. 하지만 그래프의 입력과 출력을 제약하기 위해 "내부" 스키마의 부분 집합인 input과 output 스키마도 정의합니다. 자세한 내용은 입력·출력 스키마 정의를 참고하세요.
예를 살펴볼게요:
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class InputState(TypedDict):
user_input: str
class OutputState(TypedDict):
graph_output: str
class OverallState(TypedDict):
foo: str
user_input: str
graph_output: str
class PrivateState(TypedDict):
bar: str
def node_1(state: InputState) -> OverallState:
# Write to OverallState
return {"foo": state["user_input"] + " name"}
def node_2(state: OverallState) -> PrivateState:
# Read from OverallState, write to PrivateState
return {"bar": state["foo"] + " is"}
def node_3(state: PrivateState) -> OutputState:
# Read from PrivateState, write to OutputState
return {"graph_output": state["bar"] + " Lance"}
builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", "node_3")
builder.add_edge("node_3", END)
graph = builder.compile()
graph.invoke({"user_input": "My"})
# {'graph_output': 'My name is Lance'}
여기서 두 가지 미묘하고 중요한 점을 짚어야 해요:
-
node_1의 입력 스키마로state: InputState를 전달합니다. 하지만OverallState의 채널인foo에 씁니다. 입출력 스키마에 포함되지 않은 상태 채널에 어떻게 쓸 수 있을까요? 노드는 그래프 상태의 어떤 상태 채널에도 쓸 수 있기 때문입니다. 그래프 상태는 초기화 시 정의된 상태 채널의 합집합이며, 여기에는OverallState와 필터InputState·OutputState가 포함됩니다. -
그래프를 다음으로 초기화합니다:
StateGraph( OverallState, input_schema=InputState, output_schema=OutputState )node_2에서PrivateState에 어떻게 쓸 수 있을까요?StateGraph초기화에 전달되지 않았는데 그래프가 이 스키마에 어떻게 접근할까요?이는
_nodes가 상태 스키마 정의가 존재하는 한 추가 상태channels_를 선언할 수 있기 때문입니다. 이 경우PrivateState스키마가 정의되어 있으므로 그래프에bar를 새 상태 채널로 추가하고 그곳에 쓸 수 있습니다.
입력·출력·비공개 스키마는 각 노드가 읽는 것(입력 스키마)과 invoke가 반환하는 것(출력 스키마)을 제약합니다. stream에서 채널을 숨기지는 않습니다.
stream_mode="values"로 스트리밍하면 그래프는 기본적으로 비공개 채널을 포함한 모든 상태 채널을 방출합니다. values 스트리밍이 기본적으로 전체 상태 채널 집합을 사용하기 때문입니다(출력 스키마가 아니라). 그래서 bar 같은 비공개 채널은 invoke에서는 숨겨지지만 스트리밍 중에는 보입니다:
stream = graph.stream_events({"user_input": "My"}, version="v3")
for snapshot in stream.values:
print(snapshot)
# {'user_input': 'My'}
# {'foo': 'My name', 'user_input': 'My'}
# {'foo': 'My name', 'user_input': 'My', 'bar': 'My name is'} # <-- private channel
# {'foo': 'My name', 'user_input': 'My', 'graph_output': 'My name is Lance', 'bar': 'My name is'}
스트리밍 값을 특정 채널 집합(예: 출력 스키마만)으로 제한하려면 output_keys를 전달하세요:
stream = graph.stream_events(
{"user_input": "My"},
version="v3",
output_keys=["graph_output"], # [!code highlight]
)
for snapshot in stream.values:
print(snapshot)
# {'graph_output': 'My name is Lance'}
각 단계에서 노드가 실제로 만든 채널만 필요하다면(전체 누적 상태가 아니라) stream_mode="updates"를 대신 사용하세요.
리듀서 (Reducers)
리듀서는 노드의 업데이트가 State에 어떻게 적용되는지 이해하는 열쇠입니다. State의 각 키는 독립적인 리듀서 함수를 가집니다. 리듀서 함수가 명시적으로 지정되지 않으면 해당 키에 대한 모든 업데이트가 그것을 오버라이드한다고 가정합니다. 기본 타입부터 시작해 몇 가지 다른 타입의 리듀서가 있어요:
리듀서 인자 (Reducer arguments)
모든 리듀서는 두 개의 위치 인자를 가진 이진 함수입니다:
- 왼쪽 인자: 해당 키에 대해 상태에 이미 저장된 현재 값.
- 오른쪽 인자: 노드가 반환한 해당 키에 대한 업데이트.
노드가 부분 업데이트를 반환하면 LangGraph는 갱신된 각 키에 대해 리듀서를 호출하고 반환 값을 새 상태 값으로 저장합니다:
new_value = reducer(left=current_state[key], right=node_update[key])
왼쪽 인자는 항상 누적 상태에서 옵니다. 오른쪽 인자는 항상 최신 노드 업데이트에서 옵니다. 다음 예시는 두 인자를 모두 명시적으로 이름 짓습니다:
from typing import Annotated
from typing_extensions import TypedDict
def append_strings(left: list[str], right: list[str]) -> list[str]:
"""Combine the existing state value (left) with a node update (right)."""
return left + right
class State(TypedDict):
tags: Annotated[list[str], append_strings]
상태가 {"tags": ["draft"]}이고 노드가 {"tags": ["review"]}를 반환한다고 가정해 봅시다. LangGraph는 다음을 호출합니다:
append_strings(left=["draft"], right=["review"]) # returns ["draft", "review"]
tags의 새 상태 값은 ["draft", "review"]입니다.
커스텀 리듀서는 왼쪽과 오른쪽 인자를 결합합니다. 기본 리듀서는 왼쪽 인자를 버리고 오른쪽만 유지합니다.
기본 리듀서 (Default reducer)
기본 리듀서는 왼쪽 인자를 무시하고 상태 값을 오른쪽 인자로 대체합니다. 다음 예시는 기본 리듀서를 사용하는 방법을 보여줍니다:
from typing_extensions import TypedDict
class State(TypedDict):
foo: int
bar: list[str]
이 예시에서는 어떤 키에도 리듀서 함수가 지정되지 않았습니다. 그래프 입력이 {"foo": 1, "bar": ["hi"]}라고 가정해 보겠습니다. 첫 Node가 {"foo": 2}를 반환한다고 가정합니다. 이는 상태에 대한 업데이트로 처리됩니다. Node가 전체 State 스키마를 반환할 필요는 없습니다 — 업데이트만 반환하면 되죠. 이 업데이트를 적용한 뒤 State는 {"foo": 2, "bar": ["hi"]}가 됩니다. 두 번째 노드가 {"bar": ["bye"]}를 반환하면 State는 {"foo": 2, "bar": ["bye"]}가 됩니다.
커스텀 리듀서 (Custom reducers)
커스텀 리듀서는 상태 값을 대체하는 대신 왼쪽과 오른쪽 인자를 결합합니다. 이는 목록에 업데이트를 추가하는 것처럼 값을 누적할 때 유용합니다. 다음 예시는 커스텀 리듀서를 지정하는 방법을 보여줍니다:
from operator import add
from typing import Annotated
from typing_extensions import TypedDict
class State(TypedDict):
foo: int
bar: Annotated[list[str], add]
이 예시에서는 두 번째 키(bar)에 리듀서 함수(operator.add)를 지정하기 위해 Annotated 타입을 사용했습니다. 첫 번째 키는 그대로입니다. 그래프 입력이 {"foo": 1, "bar": ["hi"]}라고 가정합시다. 첫 Node가 {"foo": 2}를 반환한다고 가정합니다. 이는 상태에 대한 업데이트로 처리됩니다. Node는 전체 State 스키마를 반환할 필요가 없습니다 — 업데이트만 반환하면 되죠. 이 업데이트를 적용한 뒤 State는 {"foo": 2, "bar": ["hi"]}가 됩니다. 두 번째 노드가 {"bar": ["bye"]}를 반환하면 State는 {"foo": 2, "bar": ["hi", "bye"]}가 됩니다. 여기서 bar 키가 두 리스트를 더해 갱신됩니다.
덮어쓰기 (Overwrite)
리듀서 필드 리셋 (Resetting a reducer field)
리듀서의 흔한 혼동 지점: 병합 리듀서에서 빈 값을 반환해도 필드가 지워지지 않습니다. 리듀서가 왼쪽 인자에 오른쪽 인자를 병합하므로, 빈 업데이트가 병합되고 이전에 누적된 값은 유지됩니다.
이 패턴은 재시도 시도 사이에 지워야 하는 오류 버퍼나 재시도 카운터에 중요합니다:
from operator import add
from typing import Annotated
from typing_extensions import TypedDict
class State(TypedDict):
errors: Annotated[list[str], add]
# node A returns {"errors": ["bad sql"]}
# node B returns {"errors": []}
# state["errors"] is still ["bad sql"]; the empty list is merged in, not cleared
병합 리듀서를 유지하면서 필드를 지우려면 업데이트를 Overwrite로 감싸세요:
from operator import add
from typing import Annotated
from langgraph.types import Overwrite
from typing_extensions import TypedDict
class State(TypedDict):
errors: Annotated[list[str], add]
def clear_errors(state: State):
# Bypass the merging reducer and clear the field
return {"errors": Overwrite([])}
자세한 내용은 Overwrite로 리듀서 우회를 참고하세요.
추적되지 않는 값 (Untracked values)
UntrackedValue는 그래프 실행 중에는 존재해야 하지만 체크포인트에 절대 저장되지 않아야 하는 상태 필드에 사용됩니다. 그래프가 체크포인트에서 재개되면 추적되지 않는 값은 초기 상태로 리셋됩니다(또는 사용 불가).
이것은 다음에 유용합니다:
- 직렬화할 수 없는 데이터베이스 연결
- 재개 시 재구축해야 하는 임시 캐시
- 영속화하고 싶지 않은 큰 객체
- 매번 새로 전달해야 하는 런타임 전용 구성
import { StateSchema, UntrackedValue, MessagesValue } from "@langchain/langgraph";
import { z } from "zod/v4";
const State = new StateSchema({
messages: MessagesValue,
// Untracked: throws if multiple nodes write in same step (guard: true is default)
dbConnection: new UntrackedValue<DatabaseConnection>(),
// Untracked with guard: false allows multiple writes, keeps last value
tempCache: new UntrackedValue(
z.record(z.string(), z.unknown()),
{ guard: false }
),
// Untracked without a schema (for maximum flexibility)
runtimeConfig: new UntrackedValue(),
});
동작:
- 실행 중: 값은 일반 상태처럼 저장·접근됩니다.
- 체크포인트 시: 추적되지 않는 값은 체크포인트 데이터에서 제외됩니다.
- 재개 시: 추적되지 않는 값은 새로 시작합니다(비어 있거나 기본값).
guard: true(기본값): 같은 단계에서 여러 노드가 쓰면 오류 발생.guard: false: 여러 쓰기 허용, 마지막 값이 승리.
타입 유틸리티 (Type utilities)
LangGraph는 노드와 조건부 엣지를 정의할 때 더 나은 TypeScript 타입 안전성을 위한 여러 타입 유틸리티를 제공합니다.
GraphNode
그래프 빌더 밖에서 정의한 노드 함수를 타입 지정하려면 GraphNode를 사용하세요:
import { GraphNode, StateSchema, Command } from "@langchain/langgraph";
import { z } from "zod/v4";
const State = new StateSchema({
count: z.number().default(0),
result: z.string(),
});
// Basic node - receives state, returns partial update
const incrementNode: GraphNode<typeof State> = (state) => {
return { count: state.count + 1 };
};
// Async node
const fetchNode: GraphNode<typeof State> = async (state, config) => {
const response = await fetch(`/api/data/${state.count}`);
return { result: await response.text() };
};
// Node with Command routing - specify valid destinations
const routerNode: GraphNode<{ InputSchema: typeof State; Nodes: "process" | "done" }> = (state) => {
if (state.count >= 10) {
return new Command({ goto: "done" });
}
return new Command({
update: { count: state.count + 1 },
goto: "process"
});
};
State.Node 축약 (State.Node shorthand)
각 StateSchema 인스턴스는 노드를 타입 지정하는 축약을 제공하는 Node 속성을 가집니다:
const State = new StateSchema({
messages: MessagesValue,
step: z.string(),
});
// These are equivalent:
const myNode1: GraphNode<typeof State> = (state) => ({ step: "done" });
const myNode2: typeof State.Node = (state) => ({ step: "done" });
ConditionalEdgeRouter
조건부 엣지의 라우팅 함수(상태 업데이트 없이 라우팅만)에는 ConditionalEdgeRouter를 사용하세요:
import { ConditionalEdgeRouter, END } from "@langchain/langgraph";
const State = new StateSchema({
shouldContinue: z.boolean(),
step: z.string(),
});
// Router returns node name(s) or END
const router: ConditionalEdgeRouter<{ InputSchema: typeof State; Nodes: "process" | "summarize" }> = (state) => {
if (!state.shouldContinue) {
return END;
}
return state.step === "initial" ? "process" : "summarize";
};
// Use in graph
graph.addConditionalEdges("check", router);
StateSchema.State와 StateSchema.Update
스키마에서 상태·업데이트 타입을 추출해 커스텀 타입 정의에 사용합니다:
import { StateSchema } from "@langchain/langgraph";
const MyStateSchema = new StateSchema({
messages: MessagesValue,
count: z.number().default(0),
});
// Extract the full state type
type MyState = typeof MyStateSchema.State;
// { messages: BaseMessage[], count: number }
// Extract the update type (partial, with reducer input types)
type MyUpdate = typeof MyStateSchema.Update;
// { messages?: Messages, count?: number }
그래프 상태에서 메시지 다루기 (Working with messages in graph state)
왜 메시지를 쓸까 (Why use messages?)
대부분의 현대 LLM 프로바이더는 메시지 목록을 입력으로 받는 채팅 모델 인터페이스를 가집니다. 특히 LangChain의 chat model 인터페이스는 메시지 객체 목록을 입력으로 받아요. 이러한 메시지는 HumanMessage(사용자 입력) 또는 AIMessage(LLM 응답) 같은 다양한 형태로 옵니다.
메시지 객체가 무엇인지 더 읽으려면 Messages 개념 가이드를 참고하세요.
그래프에서 메시지 사용 (Using messages in your graph)
많은 경우 그래프 상태에 이전 대화 이력을 메시지 목록으로 저장하는 것이 유용합니다. 이렇게 하려면 그래프 상태에 Message 객체 목록을 저장하는 키(채널)를 추가하고 리듀서 함수로 어노테이션을 답니다(아래 예시의 messages 키 참고). 리듀서 함수는 각 상태 업데이트(예: 노드가 업데이트를 보낼 때)에서 상태의 Message 객체 목록을 어떻게 갱신할지 그래프에 알려주는 데 중요합니다. 리듀서를 지정하지 않으면 모든 상태 업데이트가 메시지 목록을 가장 최근 제공된 값으로 덮어씁니다. 단순히 기존 목록에 메시지를 추가하려면 operator.add를 리듀서로 사용할 수 있어요.
하지만 그래프 상태에서 메시지를 수동으로 업데이트하고 싶을 수도 있습니다(예: human-in-the-loop). operator.add를 사용하면 그래프에 보내는 수동 상태 업데이트가 기존 메시지를 갱신하는 대신 기존 메시지 목록에 추가됩니다. 이를 피하려면 메시지 ID를 추적하고 업데이트 시 기존 메시지를 덮어쓰는 리듀서가 필요합니다. 이를 위해 사전 빌드된 add_messages 함수를 사용할 수 있어요. 새 메시지에는 단순히 기존 목록에 추가하지만, 기존 메시지의 업데이트도 올바르게 처리합니다.
직렬화 (Serialization)
메시지 ID를 추적하는 것 외에도 add_messages 함수는 messages 채널에 상태 업데이트가 수신될 때마다 메시지를 LangChain Message 객체로 역직렬화하려 시도합니다.
자세한 내용은 LangChain 직렬화/역직렬화를 참고하세요. 이렇게 하면 그래프 입력/상태 업데이트를 다음 형식으로 보낼 수 있습니다:
# this is supported
{"messages": [HumanMessage(content="message")]}
# and this is also supported
{"messages": [{"type": "human", "content": "message"}]}
add_messages를 사용하면 상태 업데이트가 항상 LangChain Messages로 역직렬화되므로, state["messages"][-1].content처럼 점 표기법으로 메시지 속성에 접근해야 합니다.
아래는 add_messages를 리듀서 함수로 사용하는 그래프의 예시입니다.
from langchain.messages import AnyMessage
from langgraph.graph.message import add_messages
from typing import Annotated
from typing_extensions import TypedDict
class GraphState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
MessagesState
상태에 메시지 목록이 있는 것이 매우 흔하기 때문에, 메시지를 쉽게 사용할 수 있게 해주는 사전 빌드된 상태 MessagesState가 있습니다. MessagesState는 add_messages 리듀서를 사용하고 AnyMessage 객체 목록인 단일 messages 키로 정의됩니다. 보통 메시지보다 추적할 상태가 더 많으므로, 이 상태를 서브클래스화하고 필드를 추가하는 것을 봅니다:
from langgraph.graph import MessagesState
class State(MessagesState):
documents: list[str]
노드 (Nodes)
LangGraph에서 노드는 다음 인자를 받는 Python 함수(동기 또는 비동기)입니다:
state— 그래프의 stateconfig—thread_id같은 구성 정보와tags같은 트레이싱 정보를 담는RunnableConfig객체runtime— 런타임context와store,stream_writer,execution_info,server_info,heartbeat(유휴 타임아웃 갱신용),control(graceful shutdown용) 같은 정보를 담는Runtime객체
NetworkX와 비슷하게 add_node 메서드로 이 노드들을 그래프에 추가합니다:
from dataclasses import dataclass
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.runtime import Runtime
class State(TypedDict):
input: str
results: str
@dataclass
class Context:
user_id: str
builder = StateGraph(State)
def plain_node(state: State):
return state
def node_with_runtime(state: State, runtime: Runtime[Context]):
print("In node: ", runtime.context.user_id)
return {"results": f"Hello, {state['input']}!"}
def node_with_execution_info(state: State, runtime: Runtime):
print("In node with thread_id: ", runtime.execution_info.thread_id) # [!code highlight]
return {"results": f"Hello, {state['input']}!"}
builder.add_node("plain_node", plain_node)
builder.add_node("node_with_runtime", node_with_runtime)
builder.add_node("node_with_execution_info", node_with_execution_info)
...
내부적으로 함수는 RunnableLambda로 변환되어, 네이티브 트레이싱·디버깅과 함께 배치·async 지원을 함수에 추가합니다.
이름을 지정하지 않고 그래프에 노드를 추가하면 함수 이름과 동일한 기본 이름이 주어집니다.
builder.add_node(my_node)
# You can then create edges to/from this node by referencing it as `"my_node"`
재실행과 멱등성 (Re-execution and idempotency)
체크포인터로 컴파일하면 LangGraph는 노드 함수 중간이 아니라 슈퍼스텝 경계에서 체크포인트를 저장합니다. 실행이 멈추고 나중에 재개되면(예: interrupt나 retry 후) 해당 노드가 함수 시작부터 다시 실행됩니다. 일시 중지 전의 코드와 부작용이 다시 실행돼요.
멱등성. 재실행이 상태를 손상시키지 않도록 노드 로직을 설계하세요. 노드가 데이터베이스 행을 삽입한다면, 의도적이지 않는 한 두 번 실행해 중복 행이 생기지 않아야 합니다. 멱등성 키, upsert, 또는 읽기-전-쓰기 검사를 사용하세요. interrupt() 주변의 부작용은 Side effects called before interrupt must be idempotent를 참고하세요.
그래프 변경. Determinism 규칙 중 코드 변경에 관한 것은 그래프 구조에는 적용되지 않습니다. 기존 스레드의 재개를 깨뜨리지 않고 노드와 엣지를 추가·제거할 수 있어요. 재개된 런은 저장된 상태를 사용하고 현재 컴파일하는 그래프를 실행합니다.
노드 안의 태스크와 인터럽트. 노드가 tasks나 interrupt를 호출하면, 재개 시 더 엄격한 결정성 규칙이 적용됩니다. LangGraph는 완료된 task 결과를 체크포인터에서 복원하지만, 재개 지점 전의 코드에서 task나 interrupt 순서를 바꾸면 캐시된 값이 어긋날 수 있어요. Functional API entrypoint는 전체 엔트리포인트 메서드를 이런 방식으로 실행하는 단일 노드로 컴파일됩니다. Determinism, Idempotency, 노드에서 태스크 사용을 참고하세요.
노드에서 태스크 사용 (Using tasks in nodes)
노드에 여러 작업이 있다면, 로직을 여러 노드로 나누는 대신 각 작업을 task로 구현하는 것이 더 쉬울 수 있어요. 그래프가 체크포인터를 사용하면 태스크 결과는 체크포인트되므로, 스레드를 재개하면 노드 안에서 완료된 task 작업을 건너뛸 수 있습니다.
원본 — API 요청을 하는 노드:
from typing import NotRequired
import requests
from langchain_core.utils.uuid import uuid7
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
class State(TypedDict):
url: str
result: NotRequired[str]
def call_api(state: State):
"""Example node that makes an API request."""
result = requests.get(state["url"]).text[:100] # [!code highlight]
return {"result": result}
builder = StateGraph(State)
builder.add_node("call_api", call_api)
builder.add_edge(START, "call_api")
builder.add_edge("call_api", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
thread_id = str(uuid7())
config = {"configurable": {"thread_id": thread_id}}
graph.invoke({"url": "https://www.example.com"}, config)
태스크로 — 체크포인트되는 태스크로 API 요청을 하는 노드:
from typing import NotRequired
import requests
from langchain_core.utils.uuid import uuid7
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.func import task
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
class State(TypedDict):
urls: list[str]
results: NotRequired[list[str]]
@task
def _make_request(url: str):
"""Make a request."""
return requests.get(url).text[:100] # [!code highlight]
def call_api(state: State):
"""Example node that makes API requests as checkpointed tasks."""
futures = [_make_request(url) for url in state["urls"]] # [!code highlight]
results = [f.result() for f in futures]
return {"results": results}
builder = StateGraph(State)
builder.add_node("call_api", call_api)
builder.add_edge(START, "call_api")
builder.add_edge("call_api", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
thread_id = str(uuid7())
config = {"configurable": {"thread_id": thread_id}}
graph.invoke({"urls": ["https://www.example.com"]}, config)
START 노드
START 노드는 사용자 입력을 그래프로 보내는 노드를 나타내는 특수 노드입니다. 이 노드를 참조하는 주된 이유는 어떤 노드를 먼저 호출해야 하는지 결정하기 위함입니다.
from langgraph.graph import START
graph.add_edge(START, "node_a")
END 노드
END 노드는 터미널 노드를 나타내는 특수 노드입니다. 완료 후 더 이상 동작이 없는 엣지를 나타낼 때 참조됩니다.
from langgraph.graph import END
graph.add_edge("node_a", END)
노드 캐싱 (Node caching)
LangGraph는 노드에 대한 입력에 기반한 태스크/노드 캐싱을 지원합니다. 캐싱을 사용하려면:
- 그래프를 컴파일할 때 캐시를 지정(또는 엔트리포인트 지정)
- 노드에 대해 캐시 정책을 지정. 각 캐시 정책은 다음을 지원:
- 노드 입력에 기반해 캐시 키를 생성하는
key_func. 기본은 입력을 pickle로hash한 값. ttl, 캐시의 만료 시간(초). 지정하지 않으면 캐시는 만료되지 않습니다.
- 노드 입력에 기반해 캐시 키를 생성하는
예를 들어:
import time
from typing_extensions import TypedDict
from langgraph.graph import StateGraph
from langgraph.cache.memory import InMemoryCache
from langgraph.types import CachePolicy
class State(TypedDict):
x: int
result: int
builder = StateGraph(State)
def expensive_node(state: State) -> dict[str, int]:
# expensive computation
time.sleep(2)
return {"result": state["x"] * 2}
builder.add_node("expensive_node", expensive_node, cache_policy=CachePolicy(ttl=3))
builder.set_entry_point("expensive_node")
builder.set_finish_point("expensive_node")
graph = builder.compile(cache=InMemoryCache())
print(graph.invoke({"x": 5}, stream_mode='updates')) # [!code highlight]
# [{'expensive_node': {'result': 10}}]
print(graph.invoke({"x": 5}, stream_mode='updates')) # [!code highlight]
# [{'expensive_node': {'result': 10}, '__metadata__': {'cached': True}}]
set_finish_point(node)는 그래프의 마지막 노드를 정의합니다. builder.add_edge(node, END)와 동등합니다.
두 메서드 모두 유효하지만 add_edge(START, ...)와 add_edge(..., END)가 권장되는 현대 구문입니다.
- 첫 실행은 (모의된 비싼 계산 때문에) 2초가 걸립니다.
- 두 번째 실행은 캐시를 사용해 빠르게 반환합니다.
엣지 (Edges)
엣지는 로직이 어떻게 라우팅되는지, 그래프가 멈추기로 어떻게 결정하는지 정의합니다. 이것은 에이전트가 작동하는 방식과 서로 다른 노드가 통신하는 방식의 큰 부분입니다. 몇 가지 주요 엣지 타입이 있습니다:
- 일반 엣지: 한 노드에서 다음 노드로 직접 이동합니다.
- 조건부 엣지: 함수를 호출해 다음에 갈 노드(들)을 결정합니다.
- 진입점(Entry Point): 사용자 입력이 도착했을 때 먼저 호출할 노드.
- 조건부 진입점(Conditional Entry Point): 사용자 입력이 도착했을 때 먼저 호출할 노드(들)을 결정하는 함수 호출.
노드는 여러 개의 나가는 엣지를 가질 수 있습니다. 노드가 여러 나가는 엣지를 가지면 그 목적지 노드 모두가 다음 슈퍼스텝의 일부로 병렬로 실행됩니다.
일반 엣지 (Normal edges)
노드 A에서 노드 B로 항상 가고 싶다면 add_edge 메서드를 직접 사용할 수 있어요.
graph.add_edge("node_a", "node_b")
조건부 엣지 (Conditional edges)
하나 이상의 엣지로 선택적으로 라우팅(또는 선택적으로 종료)하고 싶다면 add_conditional_edges 메서드를 사용할 수 있어요. 이 메서드는 노드 이름과, 그 노드가 실행된 후 호출할 "라우팅 함수"를 받습니다:
graph.add_conditional_edges("node_a", routing_function)
노드와 유사하게 routing_function은 그래프의 현재 state를 받아 값을 반환합니다.
기본적으로 routing_function의 반환 값은 다음에 상태를 보낼 노드(또는 노드 목록)의 이름으로 사용됩니다. 그 모든 노드는 다음 슈퍼스텝의 일부로 병렬로 실행됩니다.
routing_function의 출력을 다음 노드 이름에 매핑하는 사전을 선택적으로 제공할 수 있어요.
graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"})
진입점 (Entry point)
진입점은 그래프가 시작될 때 먼저 실행되는 첫 노드(들)입니다. 가상의 START 노드에서 첫 실행 노드로 add_edge 메서드를 사용해 그래프의 진입 위치를 지정할 수 있어요.
from langgraph.graph import START
graph.add_edge(START, "node_a")
조건부 진입점 (Conditional entry point)
조건부 진입점을 사용하면 커스텀 로직에 따라 서로 다른 노드에서 시작할 수 있습니다. 가상의 START 노드에서 add_conditional_edges를 사용해 이를 달성할 수 있어요.
from langgraph.graph import START
graph.add_conditional_edges(START, routing_function)
routing_function의 출력을 다음 노드 이름에 매핑하는 사전을 선택적으로 제공할 수 있어요.
graph.add_conditional_edges(START, routing_function, {True: "node_b", False: "node_c"})
Send
기본적으로 Nodes와 Edges는 미리 정의되고 같은 공유 상태에서 동작합니다. 하지만 정확한 엣지를 미리 알 수 없거나, 같은 시점에 서로 다른 버전의 State가 존재하게 하려는 경우가 있을 수 있어요. 흔한 예는 map-reduce 설계 패턴입니다. 이 패턴에서 첫 노드는 객체 목록을 생성할 수 있고, 그 모든 객체에 어떤 다른 노드를 적용하려 할 수 있어요. 객체의 수는 미리 알 수 없고(즉 엣지 수를 알 수 없고), 다운스트림 Node의 입력 State는 서로 달라야 합니다(생성된 객체마다 하나씩).
이 설계 패턴을 지원하기 위해 LangGraph는 조건부 엣지에서 Send 객체를 반환하는 것을 지원합니다. Send는 두 인자를 받습니다. 첫 번째는 노드 이름, 두 번째는 그 노드에 전달할 상태입니다.
from langgraph.types import Send
def continue_to_jokes(state: OverallState):
return [Send("generate_joke", {"subject": s}) for s in state['subjects']]
graph.add_conditional_edges("node_a", continue_to_jokes)
Command
Command는 그래프 실행을 제어하는 다재다능한 원시형입니다. 네 가지 파라미터를 받습니다:
update: 상태 업데이트 적용(노드에서 업데이트 반환과 유사).goto: 특정 노드로 이동(조건부 엣지와 유사).graph: 서브그래프에서 탐색 시 부모 그래프를 대상으로 지정.resume: 인터럽트 후 실행을 재개할 값 제공.
Command는 세 가지 상황에서 사용됩니다:
- 노드에서 반환:
update,goto,graph를 사용해 상태 업데이트를 제어 흐름과 결합. invoke또는stream에 입력:resume을 사용해 인터럽트 후 실행을 계속.- 도구에서 반환: 노드에서 반환과 유사하게 도구 내부에서 상태 업데이트와 제어 흐름을 결합.
노드에서 반환 (Return from nodes)
update와 goto
노드 함수에서 Command를 반환해 단일 단계에서 상태를 갱신하고 다음 노드로 라우팅합니다:
def my_node(state: State) -> Command[Literal["my_other_node"]]:
return Command(
# state update
update={"foo": "bar"},
# control flow
goto="my_other_node"
)
Command로 동적 제어 흐름 동작도 달성할 수 있습니다(조건부 엣지와 동일):
def my_node(state: State) -> Command[Literal["my_other_node"]]:
if state["foo"] == "bar":
return Command(update={"foo": "baz"}, goto="my_other_node")
상태를 갱신하고 다른 노드로 라우팅해야 할 때 Command를 사용하세요. 상태를 갱신하지 않고 라우팅만 필요하다면 조건부 엣지를 사용하세요.
Command를 사용하는 종단 간 예시는 how-to guide를 확인하세요.
graph
서브그래프를 사용한다면 서브그래프 안의 노드에서 Command에 graph=Command.PARENT를 지정해 부모 그래프의 다른 노드로 이동할 수 있어요:
def my_node(state: State) -> Command[Literal["other_subgraph"]]:
return Command(
update={"foo": "bar"},
goto="other_subgraph", # where `other_subgraph` is a node in the parent graph
graph=Command.PARENT
)
서브그래프 노드에서 부모·서브그래프 상태 스키마가 모두 공유하는 키에 부모 그래프 노드로 업데이트를 보낼 때는, 부모 그래프 상태에서 갱신하는 키에 대해 리듀서를 반드시 정의해야 합니다. 이 예시를 참고하세요.
이는 특히 multi-agent handoffs를 구현할 때 유용합니다. 자세한 내용은 부모 그래프의 노드로 이동을 참고하세요.
invoke 또는 stream에 입력 (Input to invoke or stream)
# WRONG - graph resumes from the latest checkpoint
# (last step that ran), appears stuck
graph.invoke(Command(update={ # [!code --]
"messages": [{"role": "user", "content": "follow up"}] # [!code --]
}), config) # [!code --]
# CORRECT - plain dict restarts from __start__
graph.invoke( { # [!code ++]
"messages": [{"role": "user", "content": "follow up"}] # [!code ++]
}, config) # [!code ++]
resume
Command(resume=...)로 값을 제공하고 인터럽트 후 그래프 실행을 재개합니다. resume에 전달된 값은 일시 중지된 노드 안의 interrupt() 호출의 반환 값이 됩니다:
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict):
messages: list[dict]
def human_review(state: State):
# Pauses the graph and waits for a value
answer = interrupt("Do you approve?")
return {"messages": [{"role": "user", "content": answer}]}
graph = (
StateGraph(State)
.add_node("human_review", human_review)
.add_edge(START, "human_review")
.add_edge("human_review", END)
.compile(checkpointer=InMemorySaver())
)
config = {"configurable": {"thread_id": "graph-api-resume"}}
# First run - hits the interrupt and pauses
stream = graph.stream_events({"messages": []}, config, version="v3")
_ = stream.output # drive the stream to completion
print(stream.interrupts)
# Resume with a value - the interrupt() call returns "yes"
resumed = graph.stream_events(Command(resume="yes"), config, version="v3")
final = resumed.output
여러 인터럽트와 검증 루프를 포함해 인터럽트 패턴에 대한 전체 내용은 interrupts 개념 가이드를 확인하세요.
도구에서 반환 (Return from tools)
도구에서 Command를 반환해 그래프 상태를 갱신하고 제어 흐름을 관리할 수 있어요. update로 상태를 수정하고(예: 대화 중 조회한 고객 정보 저장), goto로 도구가 끝난 후 특정 노드로 라우팅합니다.
자세한 내용은 도구 안에서 사용을 참고하세요.
그래프 마이그레이션 (Graph migrations)
LangGraph는 상태를 추적하는 체크포인터를 사용하더라도 그래프 정의(노드, 엣지, 상태)의 마이그레이션을 쉽게 처리합니다.
- 그래프 끝에 있는 스레드(즉 인터럽트되지 않은)는 그래프의 전체 토폴로지를 변경할 수 있습니다(모든 노드·엣지, 제거·추가·이름 변경 등).
- 현재 인터럽트된 스레드는 노드 이름 변경/제거 외의 모든 토폴로지 변경을 지원합니다(그 스레드가 더 이상 존재하지 않는 노드로 들어가려 할 수 있으므로). 이것이 블로커라면 연락 주시면 해결책을 우선순위화하겠습니다.
- 상태 수정에 대해서는 키 추가·제거에 완전한 하위·상위 호환성을 가집니다.
- 이름이 바뀐 상태 키는 기존 스레드에서 저장된 상태를 잃습니다.
- 호환되지 않는 방식으로 타입이 바뀐 상태 키는 변경 이전 상태가 있는 스레드에서 현재 문제를 일으킬 수 있어요. 이것이 블로커라면 연락 주시면 해결책을 우선순위화하겠습니다.
런타임 컨텍스트 (Runtime context)
그래프를 만들 때 노드에 전달되는 런타임 컨텍스트에 대해 context_schema를 지정할 수 있어요. 이는 그래프 상태가 아닌 정보를 노드에 전달하는 데 유용합니다. 예를 들어 모델 이름이나 데이터베이스 연결 같은 의존성을 전달하고 싶을 수 있어요.
@dataclass
class ContextSchema:
llm_provider: str = "openai"
graph = StateGraph(State, context_schema=ContextSchema)
그런 다음 invoke 메서드의 context 파라미터로 이 컨텍스트를 그래프에 전달할 수 있어요.
graph.invoke(inputs, context={"llm_provider": "anthropic"})
노드나 조건부 엣지 안에서 이 컨텍스트에 접근해 사용할 수 있어요:
from langgraph.runtime import Runtime
def node_a(state: State, runtime: Runtime[ContextSchema]):
llm = get_llm(runtime.context.llm_provider)
# ...
구성에 대한 전체 설명은 런타임 구성 추가를 참고하세요.
재귀 한도 (Recursion limit)
재귀 한도는 그래프가 단일 실행 중 실행할 수 있는 최대 슈퍼스텝 수를 설정합니다. 한도에 도달하면 LangGraph는 GraphRecursionError를 일으킵니다. 1.0.6 버전부터 기본 재귀 한도는 1000 단계입니다. 재귀 한도는 어떤 그래프에서든 런타임에 설정할 수 있고, config 사전을 통해 invoke/stream에 전달됩니다. 중요하게 recursion_limit은 독립적인 config 키이며, 다른 사용자 정의 구성처럼 configurable 키 안에 넣지 않아야 합니다. 아래 예시를 보세요:
graph.invoke(inputs, config={"recursion_limit": 5}, context={"llm": "anthropic"})
재귀 한도가 어떻게 작동하는지 더 읽으려면 Recursion limit을 참고하세요.
재귀 카운터 접근·처리 (Accessing and handling the recursion counter)
현재 단계 카운터는 어떤 노드에서든 config["metadata"]["langgraph_step"]으로 접근할 수 있어, 재귀 한도에 도달하기 전에 사전 예방적으로 재귀를 처리할 수 있습니다. 이를 통해 그래프 로직 안에서 우아한 성능 저하(graceful degradation) 전략을 구현할 수 있어요.
작동 방식 (How it works)
단계 카운터는 config["metadata"]["langgraph_step"]에 저장됩니다. LangGraph는 그래프가 실행됨에 따라 이 카운터를 증가시키고, 구성된 recursion_limit을 초과하면 GraphRecursionError를 일으킵니다.
현재 단계 카운터 접근 (Accessing the current step counter)
어떤 노드에서든 현재 단계 카운터에 접근해 실행 진행을 모니터링할 수 있어요.
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph
def my_node(state: dict, config: RunnableConfig) -> dict:
current_step = config["metadata"]["langgraph_step"]
print(f"Currently on step: {current_step}")
return state
사전 예방적 재귀 처리 (Proactive recursion handling)
LangGraph는 재귀 한도에 도달하기 전 몇 단계가 남았는지 추적하는 RemainingSteps 관리 값을 제공합니다. 이는 그래프 안에서 우아한 성능 저하를 허용합니다.
from typing import Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.managed import RemainingSteps
class State(TypedDict):
messages: Annotated[list, lambda x, y: x + y]
remaining_steps: RemainingSteps # Managed value - tracks steps until limit
def reasoning_node(state: State) -> dict:
# RemainingSteps is automatically populated by LangGraph
remaining = state["remaining_steps"]
# Check if we're running low on steps
if remaining <= 2:
return {"messages": ["Approaching limit, wrapping up..."]}
# Normal processing
return {"messages": ["thinking..."]}
def route_decision(state: State) -> Literal["reasoning_node", "fallback_node"]:
"""Route based on remaining steps"""
if state["remaining_steps"] <= 2:
return "fallback_node"
return "reasoning_node"
def fallback_node(state: State) -> dict:
"""Handle cases where recursion limit is approaching"""
return {"messages": ["Reached complexity limit, providing best effort answer"]}
# Build graph
builder = StateGraph(State)
builder.add_node("reasoning_node", reasoning_node)
builder.add_node("fallback_node", fallback_node)
builder.add_edge(START, "reasoning_node")
builder.add_conditional_edges("reasoning_node", route_decision)
builder.add_edge("fallback_node", END)
graph = builder.compile()
# RemainingSteps works with any recursion_limit
result = graph.invoke({"messages": []}, {"recursion_limit": 10})
사전 예방적 vs 반응적 접근 (Proactive vs reactive approaches)
재귀 한도를 처리하는 두 가지 주요 접근이 있습니다: 사전 예방적(프로엑티브, 그래프 안에서 모니터링)과 반응적(리액티브, 외부에서 오류 포착).
from typing import Annotated, Literal, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.managed import RemainingSteps
from langgraph.errors import GraphRecursionError
class State(TypedDict):
messages: Annotated[list, lambda x, y: x + y]
remaining_steps: RemainingSteps
# Proactive Approach (recommended) - using RemainingSteps
def agent_with_monitoring(state: State) -> dict:
"""Proactively monitor and handle recursion within the graph"""
remaining = state["remaining_steps"]
# Early detection - route to internal handling
if remaining <= 2:
return {
"messages": ["Approaching limit, returning partial result"]
}
# Normal processing
return {"messages": [f"Processing... ({remaining} steps remaining)"]}
def route_decision(state: State) -> Literal["agent", END]:
if state["remaining_steps"] <= 2:
return END
return "agent"
# Build graph
builder = StateGraph(State)
builder.add_node("agent", agent_with_monitoring)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route_decision)
graph = builder.compile()
# Proactive: Graph completes gracefully
result = graph.invoke({"messages": []}, {"recursion_limit": 10})
# Reactive Approach (fallback) - catching error externally
try:
result = graph.invoke({"messages": []}, {"recursion_limit": 10})
except GraphRecursionError as e:
# Handle externally after graph execution fails
result = {"messages": ["Fallback: recursion limit exceeded"]}
이 두 접근의 주요 차이점:
| 접근 | 감지 | 처리 | 제어 흐름 |
|---|---|---|---|
사전 예방적 (RemainingSteps 사용) |
한도 도달 전 | 그래프 안에서 조건부 라우팅 통해 | 그래프가 완료 노드까지 계속 |
반응적 (GraphRecursionError 포착) |
한도 초과 후 | 그래프 밖에서 try/catch로 | 그래프 실행 종료 |
사전 예방적 장점:
- 그래프 안에서 우아한 성능 저하
- 체크포인트에 중간 상태 저장 가능
- 부분 결과로 더 나은 사용자 경험
- 그래프가 정상 완료(예외 없음)
반응적 장점:
- 더 단순한 구현
- 그래프 로직 수정 불필요
- 중앙화된 오류 처리
기타 사용 가능한 메타데이터 (Other available metadata)
langgraph_step 외에도 다음 메타데이터를 config["metadata"]에서 사용할 수 있어요:
def inspect_metadata(state: dict, config: RunnableConfig) -> dict:
metadata = config["metadata"]
print(f"Step: {metadata['langgraph_step']}")
print(f"Node: {metadata['langgraph_node']}")
print(f"Triggers: {metadata['langgraph_triggers']}")
print(f"Path: {metadata['langgraph_path']}")
print(f"Checkpoint NS: {metadata['langgraph_checkpoint_ns']}")
return state
시각화 (Visualization)
그래프를 시각화할 수 있으면 특히 복잡해질 때 유용합니다. LangGraph는 그래프를 시각화하는 여러 내장 방법을 제공합니다. 자세한 내용은 그래프 시각화를 참고하세요.
관찰 가능성과 트레이싱 (Observability and Tracing)
에이전트를 트레이스·디버그·평가하려면 LangSmith를 사용하세요.