AI 에이전트 — 모델이 아니라 그래프 · 병렬 분기와 합치기 · 실습
갈래 하나가 죽자 답이 통째로 사라졌다
목표
한 질문을 여러 자료원으로 동시에 흩뿌렸다가 다시 모은다. 같은 칸에 동시에 쓰면 무슨 예외가 나는지, 합쳐지는 순서가 무엇으로 정해지는지 직접 확인하고, 갯수가 실행 시점에 정해지는 갈래와 폭 상한, 부분 성공까지 만든다.
왜 중요한가
LangGraph 의 실행은 수퍼스텝 단위라, 한 수퍼스텝에서 갈 수 있는 노드가 여럿이면 그 노드들이 함께 돈다. 그래서 갈래를 늘리는 것은 엣지 몇 줄이면 된다. 어려운 것은 합치기다 — 같은 열쇠에 여러 값이 한꺼번에 들어올 때 리듀서가 없으면 InvalidUpdateError 로 그래프가 멈춘다. 순서대로 도는 그래프에서 조용히 덮어써지던 버그가 병렬로 바꾸는 순간 드러나는 것이라, 이 예외는 사실 친절한 편이다.
합쳐지는 순서도 오해하기 쉽다. "먼저 끝난 쪽이 앞" 이 아니다. 이 실습에서 이름을 바꿔 가며 직접 확인합니다.
자료원이 고정이 아니면 엣지를 미리 그릴 수 없다. Send 는 "이 노드를 이 입력으로 돌려라" 는 지시이고, 목록의 길이만큼 그 노드가 돈다. 그리고 그 노드는 전체 상태가 아니라 Send 가 넘긴 조각만 본다.
마지막으로 갈래 하나가 예외를 던지면 그 수퍼스텝 전체가 죽고 이미 성공한 갈래의 결과도 함께 사라진다. 실패를 값으로 돌려주면 부분 성공을 만들 수 있고, 실패한 자료원의 이름을 남기면 부분 결과가 전체 결과인 척하지 않게 된다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 임의의 주제와 임의의 노드 이름으로 돌려 보고, 채점기가 따로 계산한 값과 대조한다.
단계
1. /root/work/agpara/fanout.py 에 SOURCES·hits·State·자료원마다의 노드·build_graph() 를 만드세요. START 에서 자료원 수만큼 갈라졌다가 END 로 갑니다. findings 는 이어 붙이는 리듀서를 씁니다.
2. conflict_demo(topic) 을 더해 리듀서가 없는 열쇠에 두 노드가 같은 수퍼스텝에 쓰면 무슨 예외가 나는지 잡아 {"error": ..., "message": ..., "note": ...} 로 돌려주게 하세요.
3. merge_order(names) 를 더해 노드 이름을 바꿔 가며 합쳐지는 순서를 직접 확인하게 하세요.
4. combine 노드를 더하고 갈래들을 END 대신 combine 으로 보내세요. combine 은 ranked(건수 내림차순, 동점이면 이름 오름차순)와 total 을 만듭니다.
5. plan·fan_out·probe·build_dynamic()·search_dynamic(topic, picked) 을 더해 Send 로 갯수가 실행 시점에 정해지는 갈래를 만드세요.
6. MAX_FANOUT = 2 와 pick_sources(topic, limit=None) 을 더해 폭에 상한을 두세요. 건수가 많은 자료원부터, 동점이면 이름 순으로 고릅니다.
7. failures 열쇠와 run_partial(topic) 을 더해 갈래 하나가 못 찾아도 나머지 답이 살아남게 하세요.
8. /root/work/agpara/fanout_report.json 과 /root/work/agpara/fanout_report.md 에 확인한 것을 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/agpara/fanout.py를 파이썬 모듈로 불러 위에 적은 이름들을 직접 씁니다. 스크립트로 실행하지 않습니다. SOURCES는{자료원이름: {주제: 건수}}입니다.wiki는 환불 3 · 배송 5 · 교환 2 · 포장 4,ticket은 환불 7 · 배송 1 · 교환 4 · 포장 4,manual은 환불 2 · 배송 6 · 교환 9 · 포장 1 입니다. 포장은 wiki 와 ticket 이 동점이라 동점을 무엇으로 가르는지가 드러납니다.hits(source, topic)은 없는 주제에 0 을 돌려줍니다.- 자료원 노드는
{"findings": [{"source": 이름, "hits": 건수}]}를 돌려줍니다. 노드 이름은 자료원 이름과 같게 두세요. 상태 열쇠와는 겹치면 안 됩니다 — 겹치면 컴파일할 때ValueError: 'x' is already being used as a state key가 납니다. conflict_demo는 리듀서가 없는 열쇠 하나와 있는 열쇠 하나를 가진 작은 그래프를 따로 만들어 씁니다. 예외를 잡았으면error에 예외 이름을,message에str(예외)를 그대로 담고note는 빈 문자열입니다. 예외가 안 났으면error와message가 비고note에 남은 값을 담습니다.merge_order(names)는 그 이름들로 노드를 만들어 나란히 두고 한 번 돌린 뒤, 합쳐진 목록을 돌려줍니다.combine은ranked = [건수 내림차순, 동점이면 이름 오름차순으로 정렬한 자료원 이름],total = 건수의 합입니다.Send로 부른 노드는 전체 상태가 아니라Send가 넘긴 딕셔너리를 받습니다.search_dynamic은findings목록을 그대로 돌려줍니다.pick_sources(topic, limit=None)은limit이 없으면MAX_FANOUT을 씁니다. 0 이하이면 빈 목록입니다.run_partial(topic)의 답:{"ranked": [...], "total": 정수, "failures": [...정렬됨], "sources": 찾아낸 자료원 수}.ticket은 자기가 모르는 주제에 대해 예외를 던지지 말고{"failures": ["ticket"]}을 돌려줍니다.- 이 파드에는 인터넷이 없습니다. langgraph 0.2.60 이 이미 들어 있습니다.
- 공식 문서: [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api) · [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) · [Types 레퍼런스](https://reference.langchain.com/python/langgraph/types/)
- 흔한 실수: 갈래마다 END 로 보내 놓고 모으는 노드를 따로 두기(그러면 모으는 노드가 언제 도는지 알 수 없습니다), 합쳐지는 순서를 완료 순서라고 믿기, 갈래 안에서 예외를 던지기, 폭 제한에서 동점을 아무렇게나 가르기.
단계 8개
- 한 번에 세 곳을 뒤진다
- 같은 칸에 동시에 쓰면 멈춘다
- 합쳐지는 순서는 우연이 아니다
- 갈래가 다 끝난 뒤 한 번 모은다
- 갯수가 실행 시점에 정해진다
- 폭에 상한을 둔다
- 하나가 실패해도 나머지는 살린다
- 확인한 것을 기록한다