> EulerAgent > 튜토리얼 > 그래프 > 그래프 09. 병렬 리서치 + Judge 품질 루프 —...

그래프 09. 병렬 리서치 + Judge 품질 루프 — 종합 예제

학습 목표

이 튜토리얼을 마치면 다음을 할 수 있습니다.


사전 준비

# 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_searchmode: plan이므로 실행 전 HITL 승인이 필요합니다. 이 브랜치에서 merge_findings로 가는 팬인 엣지에는 approvals_resolved 조건을 사용합니다.

- 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]   # source_count는 쓰지 않음 (문서 수는 별도 집계)

doc_searchsource_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

요구사항

  1. state_schema:
  2. competitor_data: list + append_list (competitor 브랜치들이 씀)
  3. market_size: integer + sum_int (competitor 브랜치들이 발견한 시장 규모 합산)
  4. positioning_report: string + last_write (positioning_draft가 씀)

  5. parallel_groups:

  6. competitor_research: branches=[competitor_a, competitor_b, competitor_c], join=merge_competitors

  7. 노드 상세:

  8. strategy_plan: execute mode
  9. competitor_a/b/c: execute mode, writes_state: [competitor_data, market_size]
  10. merge_competitors: execute mode, reads_state: [competitor_data, market_size]
  11. market_analysis: execute mode (병렬 아님, strategy_plan 이후 순차)
  12. gap_analysis: execute mode, reads_state: [competitor_data] (merge_competitors 결과 의존)
  13. positioning_draft: execute mode, writes_state: [positioning_report]
  14. quality_judge: judge, route_values: [finalize, revise]
  15. positioning_revise: execute mode, max_loops: 2

  16. 엣지:

  17. strategy_plan → competitor_a/b/c (팬아웃) + strategy_plan → market_analysis (순차)
  18. competitor 브랜치 → merge_competitors (팬인)
  19. merge_competitors → gap_analysis + market_analysis → gap_analysis (두 노드 모두 완료 후)
  20. gap_analysis → positioning_draft → quality_judge → [finalize/revise]

  21. 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_schemasource_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

← 이전 목록으로 다음 →