그래프 09. 병렬 리서치 + Judge 품질 루프 — 종합 예제
학습 목표
이 튜토리얼을 마치면 다음을 할 수 있습니다.
- 병렬 팬아웃/팬인과 Judge 품질 루프를 하나의 그래프에 결합할 수 있다.
- 3개 브랜치(web_search, local_search, doc_search)를 각자 다른 실행 모드로 설계할 수 있다.
- HITL
approvals_resolved조건과 병렬 팬인을 올바르게 결합할 수 있다. - 전체 state_schema, parallel_groups, nodes, edges를 처음부터 완성할 수 있다.
graph validate로 전체 병렬+품질루프 체크를 통과시킬 수 있다.- 종합 그래프를 기반으로 새로운 응용 패턴을 독립적으로 설계할 수 있다.
사전 준비
# 07, 08 튜토리얼 완료 확인
euleragent graph validate examples/graphs/parallel/my_first_parallel.yaml
euleragent graph validate examples/graphs/parallel/three_branch_research.yaml
mkdir -p examples/graphs/capstone
캡스톤 그래프 전체 토폴로지
plan
│
├──→ web_search (plan mode + HITL) ──┐
├──→ local_search (execute mode) ──┤──→ merge_findings → evaluate ⇄ revise
└──→ doc_search (execute mode) ──┘ ↓ finalize
각 브랜치의 특징:
- web_search: mode: plan + force_tool: web.search — HITL 승인 필요, 인터넷 검색
- local_search: mode: execute — 자율 실행, 로컬 문서/벡터 DB 검색
- doc_search: mode: execute — 자율 실행, 특정 문서 조회
이 토폴로지의 핵심 도전:
- web_search는 HITL 승인을 기다려야 하므로 approvals_resolved 조건이 필요
- 그러나 팬인은 모든 브랜치가 완료된 후 조인 노드로 이동해야 함
- approvals_resolved와 병렬 팬인의 조합을 올바르게 처리해야 함
단계별 실습
단계 1: state_schema 설계
state_schema:
# 3개 브랜치가 모두 수집 결과를 합산
findings:
type: list
merge: append_list # web_search, local_search, doc_search 모두 씀 → 결정론적
# 각 브랜치가 찾은 소스 수를 합산
source_count:
type: integer
merge: sum_int # 3개 브랜치 모두 씀 → 결정론적
# merge_findings가 작성하는 최종 요약 (단일 노드)
final_summary:
type: string
merge: last_write # merge_findings만 씀 → 안전
설계 근거:
- findings: 3개 브랜치가 각자 수집한 결과를 하나의 리스트에 합산 → append_list
- source_count: 각 브랜치가 "나는 N개의 소스를 찾았다"고 기여 → sum_int
- final_summary: 병렬 이후 단일 노드가 씀 → last_write 안전
단계 2: parallel_groups 선언
parallel_groups:
- id: research_group
branches: [web_search, local_search, doc_search]
join: merge_findings
단계 3: plan 노드 설계
- id: plan
kind: llm
runner:
mode: execute # 검색 방향 결정 (자율 실행)
artifacts:
primary: search_plan.md
plan 노드는 어떤 검색 전략을 사용할지 결정합니다. 병렬 브랜치 실행 전 단계이므로
execute 모드로 자율 실행합니다.
단계 4: web_search 브랜치 설계 (HITL)
- id: web_search
kind: llm
runner:
mode: plan # HITL: 검색 계획 제안 → 사람 승인 → 실행
force_tool: web.search # 반드시 web.search 도구 사용
min_proposals: 2 # 최소 2개 검색 쿼리 제안
guardrails:
tool_call_budget:
web.search: 5 # 웹 검색 최대 5회
writes_state: [findings, source_count]
중요: web_search는 mode: plan이므로 실행 전 HITL 승인이 필요합니다. 이 브랜치에서
merge_findings로 가는 팬인 엣지에는 approvals_resolved 조건을 사용합니다.
단계 5: local_search 브랜치 설계 (자율)
- id: local_search
kind: llm
runner:
mode: execute # 자율 실행 (승인 불필요)
exclude_tools: [shell.exec, file.write] # 안전 설정
writes_state: [findings, source_count]
단계 6: doc_search 브랜치 설계 (자율, 제한된 쓰기)
- id: doc_search
kind: llm
runner:
mode: execute
exclude_tools: [shell.exec, file.write, web.search]
writes_state: [findings] # source_count는 쓰지 않음 (문서 수는 별도 집계)
doc_search는 source_count를 쓰지 않습니다. 이 브랜치는 특정 문서 컬렉션에서 조회하므로
"소스 수" 개념이 다릅니다. 이처럼 각 브랜치의 writes_state는 다를 수 있습니다.
단계 7: merge_findings 조인 노드
- id: merge_findings
kind: llm
runner:
mode: execute
reads_state: [findings, source_count]
writes_state: [final_summary]
artifacts:
primary: research_report.md
단계 8: evaluate judge 및 revise 노드
- id: evaluate
kind: judge
judge:
schema: evaluator_v1
route_values: [finalize, revise]
- id: revise
kind: llm
runner:
mode: execute
max_loops: 2
artifacts:
primary: research_report.md
단계 9: 엣지 연결 (핵심: approvals_resolved 처리)
edges:
# plan → 팬아웃 (3개 브랜치 동시 시작)
- from: plan
to: web_search
when: "true"
- from: plan
to: local_search
when: "true"
- from: plan
to: doc_search
when: "true"
# 팬인: web_search → merge_findings
# web_search는 HITL이므로 approvals_resolved 사용
- from: web_search
to: merge_findings
when: "approvals_resolved" # ← HITL 승인 완료 후 조인
# 팬인: local_search, doc_search → merge_findings
# 자율 실행 브랜치는 "true" 사용
- from: local_search
to: merge_findings
when: "true"
- from: doc_search
to: merge_findings
when: "true"
# 하류 순차 실행
- from: merge_findings
to: evaluate
when: "true"
- from: evaluate
to: finalize
when: "judge.route == finalize"
- from: evaluate
to: revise
when: "judge.route == revise"
- from: revise
to: evaluate
when: "true"
핵심 설명:
web_search (HITL) ──[approvals_resolved]──┐
local_search ──[true]────────────────┤──→ merge_findings
doc_search ──[true]────────────────┘
LangGraph는 3개의 팬인 엣지를 모두 기다린 후 merge_findings를 실행합니다.
web_search가 HITL로 지연되어도 LangGraph가 모든 브랜치 완료를 기다립니다.
단계 10: 완전한 YAML 작성
# examples/graphs/capstone/parallel_research_with_quality.yaml
id: graph.parallel_research_with_quality
version: 1
category: research
description: |
3개 소스 병렬 리서치 + Judge 품질 루프
plan → [web_search(HITL) | local_search | doc_search] → merge_findings → evaluate ⇄ revise
state_schema:
findings:
type: list
merge: append_list
source_count:
type: integer
merge: sum_int
final_summary:
type: string
merge: last_write
defaults:
max_iterations: 4
max_total_tool_calls: 60
max_web_search_calls: 10
parallel_groups:
- id: research_group
branches: [web_search, local_search, doc_search]
join: merge_findings
nodes:
- id: plan
kind: llm
runner:
mode: execute
artifacts:
primary: search_plan.md
- id: web_search
kind: llm
runner:
mode: plan
force_tool: web.search
min_proposals: 2
guardrails:
tool_call_budget:
web.search: 5
writes_state: [findings, source_count]
- id: local_search
kind: llm
runner:
mode: execute
exclude_tools: [shell.exec, file.write]
writes_state: [findings, source_count]
- id: doc_search
kind: llm
runner:
mode: execute
exclude_tools: [shell.exec, file.write, web.search]
writes_state: [findings]
- id: merge_findings
kind: llm
runner:
mode: execute
reads_state: [findings, source_count]
writes_state: [final_summary]
artifacts:
primary: research_report.md
- id: evaluate
kind: judge
judge:
schema: evaluator_v1
route_values: [finalize, revise]
- id: revise
kind: llm
runner:
mode: execute
max_loops: 2
artifacts:
primary: research_report.md
edges:
# plan → 팬아웃
- from: plan
to: web_search
when: "true"
- from: plan
to: local_search
when: "true"
- from: plan
to: doc_search
when: "true"
# 팬인
- from: web_search
to: merge_findings
when: "approvals_resolved" # HITL 승인 후
- from: local_search
to: merge_findings
when: "true"
- from: doc_search
to: merge_findings
when: "true"
# 하류 순차
- from: merge_findings
to: evaluate
when: "true"
- from: evaluate
to: finalize
when: "judge.route == finalize"
- from: evaluate
to: revise
when: "judge.route == revise"
- from: revise
to: evaluate
when: "true"
finalize:
artifact: research_report.md
단계 11: graph validate 전체 통과 확인
euleragent graph validate examples/graphs/capstone/parallel_research_with_quality.yaml
예상 출력:
검증 중: examples/graphs/capstone/parallel_research_with_quality.yaml
단계 1/3: YAML 파싱...
id: graph.parallel_research_with_quality
노드 수: 7 (plan, web_search, local_search, doc_search, merge_findings, evaluate, revise)
엣지 수: 10
parallel_groups: 1개 (research_group, 3개 브랜치)
완료
단계 2/3: Pattern 기본 검증...
[✓] 노드 ID 유일성
[✓] 엣지 소스/타겟 존재
[✓] finalize 도달 가능성
[✓] judge route_values 커버리지
evaluate: [finalize, revise] ← 엣지와 일치 ✓
[✓] 순환 감지됨: evaluate → revise → evaluate
max_iterations: 4 ✓
완료
단계 3/3: Graph 추가 검증...
[✓] state_schema 존재
[✓] state_schema 타입+merge 호환성:
findings: list + append_list ✓
source_count: integer + sum_int ✓
final_summary: string + last_write ✓
[✓] parallel_groups 검증:
research_group:
branches: [web_search, local_search, doc_search] (3개)
join: merge_findings ✓
[✓] 각 브랜치 writes_state:
web_search: [findings, source_count] ✓
local_search: [findings, source_count] ✓
doc_search: [findings] ✓ (source_count 미선언 — 허용)
[✓] findings (append_list): 3개 브랜치 사용 → 결정론적 ✓
[✓] source_count (sum_int): 2개 브랜치 사용 → 결정론적 ✓
[✓] 팬아웃 엣지: plan → web_search, local_search, doc_search ✓
[✓] 팬인 엣지:
web_search → merge_findings (approvals_resolved) ✓
local_search → merge_findings (true) ✓
doc_search → merge_findings (true) ✓
[✓] 브랜치가 finalize로 직접 라우팅 안 함 ✓
[✓] 병렬 브랜치 부작용 도구:
web_search: force_tool=web.search (OK, web.search는 허용)
local_search: exclude_tools=[shell.exec, file.write] ✓
doc_search: exclude_tools=[shell.exec, file.write, web.search] ✓
완료
결과: 유효 ✓ (오류 없음, 경고 없음)
단계 12: graph compile로 IR 전체 확인
euleragent graph compile \
examples/graphs/capstone/parallel_research_with_quality.yaml \
--out examples/graphs/capstone/parallel_research_compiled.json
# IR 전체 구조 요약 출력
python -m json.tool examples/graphs/capstone/parallel_research_compiled.json | \
python -c "
import sys, json
d = json.load(sys.stdin)
print(f'graph_type: {d[\"graph_type\"]}')
print(f'id: {d[\"id\"]}')
print(f'compiled_at: {d[\"compiled_at\"]}')
print()
print('=== state_schema ===')
for key, val in d['state_schema'].items():
print(f' {key}: {val[\"type\"]} + {val[\"merge\"]}')
print()
print('=== parallel_groups ===')
for g in d['parallel_groups']:
print(f' {g[\"id\"]}: branches={g[\"branches\"]}, join={g[\"join\"]}')
print()
print('=== nodes ===')
for n in d['nodes']:
ib = n.get('interrupt_before', False)
ia = n.get('interrupt_after', False)
ws = n.get('writes_state', [])
rs = n.get('reads_state', [])
print(f' [{n[\"kind\"]}] {n[\"id\"]} writes={ws} reads={rs} ib={ib} ia={ia}')
print()
print('=== langgraph_builder ===')
lb = d['langgraph_builder']
print(f' interrupt_before: {lb[\"interrupt_before\"]}')
print(f' interrupt_after: {lb[\"interrupt_after\"]}')
print(f' conditional_edges: {len(lb[\"add_conditional_edges\"])}개')
"
예상 출력:
graph_type: graph
id: graph.parallel_research_with_quality
compiled_at: 2026-02-23T11:00:00Z
=== state_schema ===
findings: list + append_list
source_count: integer + sum_int
final_summary: string + last_write
=== parallel_groups ===
research_group: branches=['web_search', 'local_search', 'doc_search'], join=merge_findings
=== nodes ===
[llm] plan writes=[] reads=[] ib=False ia=False
[llm] web_search writes=['findings', 'source_count'] reads=[] ib=False ia=False
[llm] local_search writes=['findings', 'source_count'] reads=[] ib=False ia=False
[llm] doc_search writes=['findings'] reads=[] ib=False ia=False
[llm] merge_findings writes=['final_summary'] reads=['findings', 'source_count'] ib=False ia=False
[judge] evaluate writes=[] reads=[] ib=False ia=False
[llm] revise writes=[] reads=[] ib=False ia=False
=== langgraph_builder ===
interrupt_before: []
interrupt_after: []
conditional_edges: 1개
실습 과제: 경쟁사 분석 + 시장 포지셔닝 이중 병렬 그래프
지금까지 배운 모든 개념을 활용하여 더 복잡한 그래프를 설계하세요.
토폴로지
strategy_plan
│
├──→ [competitor_a | competitor_b | competitor_c] → merge_competitors
│ (parallel_group_1: competitor_research)
│
└──→ market_analysis (순차, 병렬 아님)
↓
gap_analysis (merge_competitors + market_analysis 결과 모두 읽기)
↓
positioning_draft
↓
quality_judge ⇄ positioning_revise
↓ finalize
요구사항
- state_schema:
competitor_data: list + append_list (competitor 브랜치들이 씀)market_size: integer + sum_int (competitor 브랜치들이 발견한 시장 규모 합산)-
positioning_report: string + last_write (positioning_draft가 씀) -
parallel_groups:
-
competitor_research: branches=[competitor_a, competitor_b, competitor_c], join=merge_competitors -
노드 상세:
strategy_plan: execute modecompetitor_a/b/c: execute mode, writes_state: [competitor_data, market_size]merge_competitors: execute mode, reads_state: [competitor_data, market_size]market_analysis: execute mode (병렬 아님, strategy_plan 이후 순차)gap_analysis: execute mode, reads_state: [competitor_data] (merge_competitors 결과 의존)positioning_draft: execute mode, writes_state: [positioning_report]quality_judge: judge, route_values: [finalize, revise]-
positioning_revise: execute mode, max_loops: 2 -
엣지:
- strategy_plan → competitor_a/b/c (팬아웃) + strategy_plan → market_analysis (순차)
- competitor 브랜치 → merge_competitors (팬인)
- merge_competitors → gap_analysis + market_analysis → gap_analysis (두 노드 모두 완료 후)
-
gap_analysis → positioning_draft → quality_judge → [finalize/revise]
-
defaults: max_iterations: 3, max_total_tool_calls: 80
# 작성 후 전체 검증
euleragent graph validate examples/graphs/capstone/competitor_positioning.yaml
euleragent graph compile examples/graphs/capstone/competitor_positioning.yaml \
--out examples/graphs/capstone/competitor_positioning_compiled.json
이 튜토리얼에서 사용한 모든 개념 정리
| 튜토리얼 | 이 그래프에서 사용된 개념 |
|---|---|
| 01_concepts | Graph ⊃ Pattern, LangGraph StateGraph |
| 02_linear_graph | 기본 노드/엣지 구조, finalize |
| 03_judge_route | evaluate 노드, route_values [finalize, revise] |
| 04_bounded_loop | max_iterations: 4, evaluate ⇄ revise 루프 |
| 05_interrupt_hooks | (이 그래프에는 없음 — 추가 실습으로 web_search에 추가 가능) |
| 06_state_schema | findings (append_list), source_count (sum_int), final_summary (last_write) |
| 07_parallel_basics | parallel_groups, writes_state, reads_state, 팬아웃/팬인 엣지 |
| 08_parallel_advanced | 3-브랜치, 브랜치별 다른 writes_state, exclude_tools |
예상 출력 요약
| 명령 | 예상 결과 |
|---|---|
graph validate parallel_research_with_quality.yaml |
유효 ✓, 오류 0개, 경고 0개 |
graph compile parallel_research_with_quality.yaml |
IR 생성, graph_type: "graph" |
IR parallel_groups |
research_group: 3개 브랜치 |
IR state_schema |
findings: append_list, source_count: sum_int |
IR langgraph_builder.add_conditional_edges |
1개 (evaluate 라우팅) |
핵심 개념 요약
| 개념 | 이 그래프에서의 역할 |
|---|---|
| 병렬 팬아웃 | plan → [web_search, local_search, doc_search] |
| 병렬 팬인 | 세 브랜치 → merge_findings |
| approvals_resolved + 팬인 | web_search(HITL) → merge_findings |
| append_list | findings: 세 브랜치 결과를 하나의 리스트로 |
| sum_int | source_count: 두 브랜치 기여 수치 합산 |
| last_write | final_summary: 단일 조인 노드가 씀 |
| judge 루프 | evaluate ⇄ revise, max_iterations: 4 |
| exclude_tools | 병렬 브랜치에서 위험 도구 명시적 제외 |
흔한 오류
오류 1: approvals_resolved를 모든 팬인 엣지에 사용
# 잘못된 예: local_search는 HITL이 아닌데 approvals_resolved 사용
- from: local_search
to: merge_findings
when: "approvals_resolved" # ← local_search는 execute mode! 항상 true여야 함
approvals_resolved는 실제로 HITL 승인이 있는 브랜치에만 사용하세요. HITL 없는
브랜치에 approvals_resolved를 쓰면 승인 큐가 비어 있어 즉시 통과될 수 있지만,
의미론적으로 혼란스럽습니다.
오류 2: doc_search가 source_count를 쓰지 않았는데 state_schema에 없다고 오류?
writes_state에 없는 키를 쓰려 하면 오류가 발생하지만, writes_state에 일부 키만
선언하는 것은 허용됩니다.
# OK: doc_search는 findings만 씀, source_count는 안 씀
- id: doc_search
writes_state: [findings] # source_count 없음 — 허용
state_schema에 source_count가 있어도, doc_search가 쓰지 않으면 이 브랜치는
source_count에 기여하지 않습니다. sum_int 리듀서는 없는 기여를 0으로 처리합니다.
오류 3: merge_findings가 조인 전에 실행 시도
LangGraph는 모든 팬인 엣지의 소스 브랜치가 완료될 때까지 조인 노드를 실행하지 않습니다. 이것은 자동으로 처리되며, 별도의 조건을 설정할 필요가 없습니다.
자동 동작:
web_search (HITL 대기 중)
local_search 완료 → merge_findings 대기
doc_search 완료 → merge_findings 대기
web_search 승인 완료 → merge_findings 실행! (세 브랜치 모두 완료)
이전: 08_parallel_advanced.md | 다음: 10_reference.md