그래프 01. Graph vs Pattern — 무엇이 다른가?
학습 목표
이 튜토리얼을 마치면 다음을 할 수 있습니다.
- Pattern과 Graph의 관계를 정확히 설명할 수 있다.
- Graph가 Pattern에 추가한 4가지 핵심 기능을 열거할 수 있다.
- LangGraph StateGraph 아키텍처와 공유 상태 모델을 이해한다.
- YAML → IR → LangGraph 컴파일 파이프라인을 설명할 수 있다.
INVALID_CONCURRENT_GRAPH_UPDATE에러가 왜 발생하는지 설명할 수 있다.- 언제 Pattern 대신 Graph를 써야 할지 판단할 수 있다.
사전 준비
- euleragent 설치 완료 (
pip install -e .) docs/tutorials/pattern/01_*.md~03_*.md완료- LangGraph 1.0.9 이상 설치
# 환경 확인
euleragent --version
pip show langgraph | grep Version
euleragent graph --help
1. Pattern과 Graph의 관계
1.1 Graph는 Pattern의 완전한 상위 집합
euleragent의 Pattern과 Graph는 별개의 시스템이 아닙니다. Graph는 Pattern의 완전한 상위 집합(superset)입니다. 이것이 의미하는 바는 다음과 같습니다.
Pattern YAML ──────────────────────────────────────────
┌─────────────────────────────────────────────────┐
│ id, version, category, description │
│ defaults (max_iterations, ...) │
│ nodes (llm, judge, finalize kinds) │
│ edges (from, to, when conditions) │
│ finalize │
└─────────────────────────────────────────────────┘
Graph YAML ─────────────────────────────────────────────
┌─────────────────────────────────────────────────┐
│ [Pattern의 모든 필드 동일하게 사용 가능] │
│ │
│ + state_schema ← 신규 (타입+리듀서 정의) │
│ + parallel_groups ← 신규 (팬아웃/팬인) │
│ + interrupt_before ← 신규 (노드 전 일시 정지) │
│ + interrupt_after ← 신규 (노드 후 일시 정지) │
└─────────────────────────────────────────────────┘
따라서 유효한 Pattern YAML은 대부분 euleragent graph validate도 통과합니다. 단, Graph
모듈은 LangGraph StateGraph를 통해 실행되므로 실행 엔진이 다릅니다.
1.2 실행 엔진의 차이
Pattern 실행 흐름:
YAML → euleragent 순차 실행 엔진 → 결과
Graph 실행 흐름:
YAML → IR 컴파일 → LangGraph StateGraph → 결과
Pattern은 euleragent 내부의 단순한 순차 상태 머신으로 실행됩니다. Graph는 LangGraph의
StateGraph를 통해 실행되며, 이를 통해 병렬 실행, 체크포인트, 조건부 엣지 등을 활용합니다.
2. 무엇이 추가되었나
2.1 state_schema — 공유 상태 정의
state_schema는 그래프 전체가 공유하는 상태 딕셔너리의 구조를 정의합니다. 병렬 브랜치가
서로 같은 상태 키에 접근할 때 충돌을 방지하기 위해 리듀서(reducer)를 선언합니다.
state_schema:
findings: # 상태 키 이름
type: list # 데이터 타입
merge: append_list # 리듀서 전략
score:
type: integer
merge: sum_int
summary:
type: string
merge: last_write
state_schema가 없으면 LangGraph는 기본값으로 last_write를 적용하여, 병렬 브랜치에서
같은 키에 쓸 때 결과가 비결정론적이 됩니다.
2.2 parallel_groups — 팬아웃/팬인
parallel_groups는 여러 노드를 동시에 실행하는 팬아웃(fan-out)과, 모든 브랜치가
완료된 후 하나의 노드로 합치는 팬인(fan-in)을 선언합니다.
parallel_groups:
- id: research_group
branches: [web_search, local_search, doc_search]
join: merge_results
이 선언은 web_search, local_search, doc_search가 동시에 실행되고, 모두 완료된 후
merge_results 노드가 실행됨을 의미합니다.
2.3 interrupt_before / interrupt_after — 노드 수준 일시 정지
LangGraph의 체크포인트(checkpoint) 메커니즘을 활용하여, 특정 노드 실행 전/후에 그래프를 일시 정지할 수 있습니다.
checkpointer: memory # ← interrupt 사용 시 최상위에 반드시 선언 (INTERRUPT_REQUIRES_CHECKPOINTER)
nodes:
- id: publish
kind: llm
interrupt_before: true # publish 실행 전 일시 정지
runner:
mode: execute
필수:
interrupt_before/interrupt_after를 사용하는 그래프는 최상위에checkpointer:필드를 선언해야 합니다. 선언하지 않으면graph validate시INTERRUPT_REQUIRES_CHECKPOINTER오류가 발생합니다.
이는 Pattern의 HITL(Human-In-The-Loop) force_tool 승인 큐와는 다른 메커니즘입니다.
interrupt는 LangGraph 체크포인트 기반이고, force_tool 승인은 euleragent 파일 기반 JSONL
큐를 사용합니다.
2.4 LangGraph 통합
Graph 모듈은 langgraph.graph.StateGraph를 사용하여 그래프를 컴파일하고 실행합니다.
이를 통해 LangGraph의 모든 기능(스트리밍, 체크포인팅, 시각화 등)을 활용할 수 있습니다.
3. LangGraph StateGraph 아키텍처
3.1 공유 상태 모델
LangGraph StateGraph의 핵심 아이디어는 모든 노드가 하나의 공유 상태 딕셔너리를 읽고 쓴다는 것입니다.
# LangGraph가 내부적으로 생성하는 상태 타입 (개념적 표현)
from typing import TypedDict, Annotated
import operator
class GraphState(TypedDict):
findings: Annotated[list, operator.add] # append_list → operator.add
score: Annotated[int, operator.add] # sum_int → operator.add
summary: str # last_write → 기본값
Annotated[list, operator.add]는 LangGraph에서 "이 키에 여러 노드가 동시에 쓸 때,
operator.add(즉, 리스트 연결)로 합치라"는 의미입니다.
3.2 브랜치 격리 vs 공유 상태
Pattern의 노드들은 순차적으로 실행되므로 상태 충돌이 없습니다. Graph의 병렬 브랜치는 격리되어 실행되지만 공유 상태를 통해 통신합니다.
잘못된 이해:
브랜치 A의 상태 ──→ (격리됨) ──→ 독립 상태
브랜치 B의 상태 ──→ (격리됨) ──→ 독립 상태
올바른 이해:
브랜치 A ──→ 공유 상태 딕셔너리 ←── 브랜치 B
↕
리듀서가 충돌을 해결함 (operator.add 등)
3.3 조건부 엣지
LangGraph는 add_conditional_edges를 사용하여 Judge 노드의 라우팅을 구현합니다.
# euleragent가 내부적으로 생성하는 LangGraph 코드 (개념적 표현)
def route_judge(state: GraphState) -> str:
return state.get("route", "finalize")
graph.add_conditional_edges(
"evaluate",
route_judge,
{"finalize": "finalize", "revise": "revise"}
)
4. YAML → IR → LangGraph 컴파일 파이프라인
Graph 모듈의 컴파일 파이프라인은 3단계로 구성됩니다.
┌─────────────────────────────────────────────────────────────────┐
│ 단계 1: YAML 파싱 │
│ your_graph.yaml │
│ ↓ (PyYAML 파싱) │
│ Python 딕셔너리 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 단계 2: 검증 (euleragent graph validate) │
│ - Pattern 기본 검증 (노드 존재, 엣지 연결 등) │
│ - Graph 추가 검증 (병렬 에러 코드 14개 확인) │
│ - state_schema 타입+merge 호환성 검사 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 단계 3: IR 생성 (euleragent graph compile) │
│ - Graph IR JSON 생성 │
│ - graph_type: "graph" 마킹 │
│ - LangGraph StateGraph 빌더 코드 포함 │
│ - parallel_groups → LangGraph 병렬 엣지 변환 │
│ - state_schema → Annotated 타입 변환 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 실행 시: LangGraph StateGraph 실행 │
│ - IR을 LangGraph StateGraph로 구체화 │
│ - .compile() 호출 │
│ - .invoke() / .stream() 실행 │
└─────────────────────────────────────────────────────────────────┘
5. 단계별 실습
단계 1: euleragent doctor로 환경 확인
euleragent doctor
예상 출력:
euleragent doctor 실행 중...
Python 3.11.4 ✓
euleragent 0.9.0 ✓
langgraph 1.0.9 ✓ (graph 모듈 사용 가능)
pyyaml 6.0.1 ✓
click 8.1.7 ✓
모든 진단 항목 통과.
LangGraph 버전이 1.0.9 미만이거나 없으면 다음을 실행하세요.
pip install "langgraph>=1.0.9"
단계 2: graph validate 실행 (예제 파일 사용)
# 예제 디렉터리에 있는 병렬 그래프 검증
euleragent graph validate examples/graphs/parallel/02_research_parallel.yaml
예상 출력:
검증 중: examples/graphs/parallel/02_research_parallel.yaml
단계 1/3: YAML 파싱... 완료
단계 2/3: Pattern 기본 검증...
[✓] 노드 ID 유일성
[✓] 엣지 소스/타겟 존재
[✓] finalize 노드 도달 가능성
[✓] judge route_values 커버리지
완료
단계 3/3: Graph 추가 검증...
[✓] state_schema 존재 (parallel_groups 사용)
[✓] 모든 브랜치 writes_state 선언
[✓] 조인 노드 존재 및 단일성
[✓] 브랜치 수 제한 (≤8)
[✓] 부작용 도구 미사용 (shell.exec, file.write)
[✓] 모든 브랜치 조인 노드로 수렴
[✓] state_schema merge 타입 호환성
완료
결과: 유효 (오류 없음)
단계 3: graph compile 실행
# 단순 선형 그래프 컴파일
euleragent graph compile examples/graphs/linear/01_simple_pipeline.yaml
예상 출력 (IR JSON 발췌):
{
"graph_type": "graph",
"id": "graph.simple_pipeline",
"version": 1,
"compiled_at": "2026-02-23T10:00:00Z",
"langgraph_version": "1.0.9",
"state_schema": null,
"parallel_groups": [],
"nodes": [
{
"id": "research",
"kind": "llm",
"runner": { "mode": "plan", "force_tool": "web.search" },
"interrupt_before": false,
"interrupt_after": false
},
{
"id": "draft",
"kind": "llm",
"runner": { "mode": "execute" }
},
{
"id": "finalize",
"kind": "finalize"
}
],
"edges": [
{ "from": "research", "to": "draft", "when": "approvals_resolved" },
{ "from": "draft", "to": "finalize", "when": "true" }
],
"langgraph_builder": {
"add_nodes": ["research", "draft", "finalize"],
"add_edges": [
["research", "draft"],
["draft", "finalize"]
],
"add_conditional_edges": [],
"interrupt_before": [],
"interrupt_after": []
}
}
graph_type: "graph" 필드가 Pattern IR(graph_type: "pattern")과 구별해 줍니다.
langgraph_builder 섹션은 LangGraph StateGraph가 실행 시 어떻게 구축될지를 미리 보여줍니다.
단계 4: 컴파일 결과를 파일로 저장
euleragent graph compile examples/graphs/linear/01_simple_pipeline.yaml \
--out /tmp/compiled_linear.json
# 내용 확인
python -m json.tool /tmp/compiled_linear.json | head -60
단계 5: JSON 형식 검증 결과 확인
euleragent graph validate examples/graphs/parallel/02_research_parallel.yaml \
--format json | python -m json.tool
예상 출력:
{
"valid": true,
"errors": [],
"warnings": [
{
"code": "PARALLEL_NONDETERMINISTIC_MERGE",
"severity": "warning",
"message": "노드 'summary_branch'가 last_write 키 'summary'에 씁니다. 2개 이상의 브랜치가 이 키를 쓰면 비결정론적 결과가 발생합니다.",
"node": "summary_branch"
}
],
"graph_type": "graph",
"parallel_groups_count": 1,
"branch_count": 2
}
6. INVALID_CONCURRENT_GRAPH_UPDATE 이해하기
이 에러는 LangGraph가 리듀서 없이 동일 키에 병렬 쓰기를 감지할 때 발생합니다. euleragent의
graph validate는 이를 미리 감지하여 PARALLEL_STATE_KEY_MERGE_MISSING 에러로 보고합니다.
발생 시나리오
# 위험한 패턴 — state_schema 없이 parallel_groups 사용
parallel_groups:
- id: grp
branches: [branch_a, branch_b]
join: join_node
nodes:
- id: branch_a
kind: llm
writes_state: [findings] # ← 'findings'에 쓰려고 함
- id: branch_b
kind: llm
writes_state: [findings] # ← 같은 키 'findings'에 쓰려고 함
# state_schema가 없으므로 LangGraph는 'findings'의 리듀서를 모름
# 런타임에 INVALID_CONCURRENT_GRAPH_UPDATE 발생!
수정 방법
# 올바른 패턴 — state_schema에 리듀서 명시
state_schema:
findings:
type: list
merge: append_list # ← 리듀서 선언으로 충돌 해결
parallel_groups:
- id: grp
branches: [branch_a, branch_b]
join: join_node
graph validate를 실행하면 이 문제를 실행 전에 잡을 수 있습니다.
# 에러 감지 예시
euleragent graph validate bad_example.yaml
# 출력:
# [오류] PARALLEL_STATE_SCHEMA_MISSING
# parallel_groups가 선언되어 있지만 state_schema가 없습니다.
# 병렬 실행 시 INVALID_CONCURRENT_GRAPH_UPDATE 예외가 발생합니다.
# 해결: 최상위에 state_schema를 선언하고 각 브랜치의 writes_state
# 키에 대한 리듀서를 정의하세요.
7. 리듀서(Reducer)의 역할
리듀서는 "여러 브랜치가 같은 상태 키에 값을 쓸 때 어떻게 합칠 것인가"를 정의합니다.
| euleragent merge | LangGraph 타입 어노테이션 | 동작 |
|---|---|---|
append_list |
Annotated[list, operator.add] |
리스트 연결 ([a, b] + [c] = [a, b, c]) |
sum_int |
Annotated[int, operator.add] |
정수 합산 (3 + 2 = 5) |
concat_str |
Annotated[str, operator.add] |
문자열 연결 ("hello" + " world") |
last_write |
str / list / int (기본값) |
마지막 쓰기 승리 (비결정론적) |
first_write |
커스텀 리듀서 | 첫 번째 쓰기 유지, 이후 무시 |
# 개념적 Python 코드 — euleragent가 내부적으로 생성
import operator
from typing import Annotated, TypedDict
class GraphState(TypedDict):
findings: Annotated[list, operator.add] # append_list
score: Annotated[int, operator.add] # sum_int
notes: Annotated[str, operator.add] # concat_str
summary: str # last_write (기본)
8. 언제 Pattern 대신 Graph를 쓸까?
결정 가이드
질문 1: 진정한 병렬 실행이 필요한가?
→ 예: 여러 독립적인 데이터 소스를 동시에 조회해야 함
→ 아니오: 순차 실행으로 충분함 → Pattern 사용
질문 2: 노드 수준 일시 정지(interrupt)가 필요한가?
→ 예: 특정 노드 전/후에 사람이 상태를 검사해야 함
→ 아니오: HITL force_tool로 충분 → Pattern 사용
질문 3: LangGraph의 체크포인팅/스트리밍이 필요한가?
→ 예: 그래프 상태를 중간에 저장하고 재개해야 함
→ 아니오: Pattern 사용
모두 아니오라면 → Pattern을 사용하십시오 (더 단순하고 안정적)
하나 이상 예라면 → Graph를 고려하십시오 (00_disclaimer.md 먼저 읽기)
구체적 시나리오
| 시나리오 | 권장 |
|---|---|
| 리서치 → 초안 → 검토 (순차) | Pattern |
| 웹 검색 + 로컬 검색 동시 실행 | Graph |
| 루프 있는 단순 반복 개선 | Pattern |
| 병렬 멀티 에이전트 리서치 | Graph |
| 단순 judge 라우팅 | Pattern |
| 병렬 데이터 수집 + judge 평가 | Graph |
| 긴급 배포 (안정성 최우선) | Pattern |
예상 출력
이 튜토리얼에서 실행한 명령들의 요약된 예상 출력입니다.
# euleragent doctor
Python 3.11.4 ✓ | euleragent 0.9.0 ✓ | langgraph 1.0.9 ✓
# euleragent graph validate (유효한 경우)
결과: 유효 (오류 없음)
# euleragent graph compile
{
"graph_type": "graph",
"id": "...",
"langgraph_builder": { ... }
}
핵심 개념 요약
| 개념 | 설명 |
|---|---|
| Graph ⊃ Pattern | Graph는 Pattern의 모든 기능을 포함한다 |
| state_schema | 공유 상태 키의 타입과 리듀서 정의 |
| parallel_groups | 팬아웃/팬인 동시 실행 선언 |
| interrupt_before/after | LangGraph 체크포인트 기반 노드 일시 정지 |
| 리듀서(reducer) | 병렬 쓰기 충돌을 해결하는 함수 |
| IR JSON | YAML에서 컴파일된 중간 표현 |
| INVALID_CONCURRENT_GRAPH_UPDATE | 리듀서 없이 병렬 쓰기 시 LangGraph 런타임 오류 |
흔한 오류
오류 1: LangGraph 버전 너무 낮음
오류: LANGGRAPH_COMPILE_FAILED
langgraph 버전 0.2.x가 감지되었습니다. 1.0.9 이상이 필요합니다.
해결: pip install "langgraph>=1.0.9"
오류 2: graph 명령을 찾을 수 없음
오류: No such command 'graph'.
해결: pip install -e . 로 재설치 후 euleragent graph --help 확인
오류 3: YAML 파싱 오류
오류: yaml.scanner.ScannerError: mapping values are not allowed here
해결: YAML 들여쓰기 확인 (스페이스 2칸 또는 4칸, 탭 혼용 금지)
오류 4: PARALLEL_STATE_SCHEMA_MISSING
오류: parallel_groups가 선언되어 있지만 state_schema가 없습니다.
해결: 최상위에 state_schema 섹션을 추가하고 각 브랜치의 writes_state
키에 대한 type과 merge를 정의하세요.
이전: 00_disclaimer.md | 다음: 02_linear_graph.md