LabHub
배우기 러닝패스 코스

AI 에이전트 — 모델이 아니라 그래프 · 병렬 분기와 합치기 · 이론

병렬에서 어려운 것은 흩뿌리기가 아니라 모으기다

LabHub 에서 이어서 보기

한 줄 요약

갈래를 늘리는 것은 엣지 몇 줄이면 되지만, 합치는 규칙을 안 정하면 그래프가 예외로 죽는다. 그리고 합쳐지는 순서는 우연이 아니라 정해져 있다.

왜 이게 필요했나

한 질문을 사내 위키·지난 티켓·제품 문서 세 곳에서 동시에 찾게 만들었다. 엣지 세 줄이면 되는 일이었다.

for source in ("wiki", "ticket", "manual"):    graph.add_node(source, search(source))    graph.add_edge(START, source)    graph.add_edge(source, END)

첫 실행에서 바로 죽었다.

langgraph.errors.InvalidUpdateError: At key 'note':Can receive only one value per step. Use an Annotated key to handle multiple values.

세 노드가 같은 수퍼스텝에 같은 열쇠에 썼다. 순서대로 도는 그래프였다면 마지막 값이 남고 끝났을 텐데(그것도 문제이지만 조용히 지나간다), 동시에 쓰면 LangGraph 가 "둘 중 어느 것을 남길지 나는 모른다" 며 멈춘다.

이 예외는 친절한 것이다. 순서대로 도는 그래프에서 조용히 덮어써지던 버그가, 병렬로 바꾸는 순간 드러난 것뿐이다.

어떻게 동작하나

LangGraph 의 실행은 수퍼스텝 단위다. 한 수퍼스텝에서 갈 수 있는 노드가 여럿이면 그 노드들이 함께 돈다. 그래서 나란히 둔 노드는 몇 개든 한 수퍼스텝이고, 재귀 한도도 하나만 먹는다.

합치기는 채널마다의 리듀서가 한다. 같은 수퍼스텝에서 여러 값이 한 채널에 들어오면,

그러니까 병렬로 바꾸기 전에 물어야 할 질문은 "몇 개나 동시에 돌릴까" 가 아니라 "이 갈래들이 같은 칸에 쓰는가" 다.

합쳐지는 순서는 정해져 있다 — 직접 재어 본 것

자주 오해하는 곳이다. "병렬이니 먼저 끝난 쪽이 앞에 오겠지" 라고 생각하기 쉽다. 이 실습 이미지에서 직접 재어 보면 아니다.

노드를 z, a, m 순으로 더함 →  합쳐진 결과 ['a', 'm', 'z']노드를 m, z, a 순으로 더함 →  합쳐진 결과 ['a', 'm', 'z']'aaa' 를 일부러 느리게 만들고 'bbb' 를 즉시 끝나게 함 → ['aaa', 'bbb']

노드 이름 순이다. 더한 순서도, 끝난 순서도 아니다. 느린 노드가 먼저 이름을 가지면 여전히 앞에 온다.

이것이 왜 중요한가. 결과가 결정적이라는 뜻이기 때문이다. 같은 입력에 같은 답이 나오므로 시험을 쓸 수 있고, 회귀를 비교할 수 있다. 다만 이름에 기대지는 말자 — 순서를 뜻으로 쓰려면 합치는 노드에서 명시적으로 정렬한다.

갯수가 실행 시점에 정해질 때 — Send

자료원이 고정이 아니면 엣지를 미리 그릴 수 없다. 이럴 때 [Send](https://docs.langchain.com/oss/python/langgraph/use-graph-api) 를 쓴다.

def fan_out(state):    return [Send("probe", {"source": name, "topic": state["topic"]})            for name in state["picked"]]graph.add_conditional_edges("plan", fan_out, ["probe"])

Send 는 "이 노드를 이 입력으로 돌려라" 는 지시다. 두 가지가 보통과 다르다.

돌아오는 값은 평소처럼 리듀서로 합쳐진다. 직접 재어 보면 결과 순서는 Send 를 만든 순서를 따른다.

폭을 제한한다

"자료원을 다 뒤진다" 는 자료원이 셋일 때만 좋은 생각이다. 스무 개가 되면 스무 번의 호출이 한 번에 나가고, 각각이 돈과 시간이다. 그래서 폭(fan-out width)을 상한으로 둔다.

상한을 둘 때 중요한 것은 고르는 규칙이 결정적이어야 한다는 점이다. 동점일 때 아무렇게나 고르면 같은 질문에 다른 답이 나오고, 그러면 "어제는 됐는데 오늘은 안 된다" 를 설명할 수 없다. 점수로 정렬하고 동점은 이름으로 가르는 식으로 완전한 순서를 만든다.

하나가 실패하면 전체가 죽는다

병렬에서 가장 아픈 자리다. 갈래 하나가 예외를 던지면 그 수퍼스텝 전체가 예외로 끝나고, 이미 성공한 나머지 갈래의 결과도 함께 사라진다.

고치는 방법은 단순하다. 실패를 예외가 아니라 값으로 돌려준다.

def node(state):    if 못 찾겠다:        return {"failures": ["ticket"]}       # 예외를 던지지 않는다    return {"findings": [...]}

그러면 합치는 노드가 "셋 중 둘에서 찾았고 하나는 실패했다" 를 알 수 있고, 사용자에게도 그렇게 말할 수 있다. 실패한 자료원의 이름을 남기는 것이 핵심이다 — 남기지 않으면 부분 결과가 전체 결과인 척하게 된다.

현장에서 만나는 모습

첫째, 병렬로 바꾸자마자 예외가 난다. 위의 InvalidUpdateError 다. 원인은 병렬이 아니라 원래 있던 덮어쓰기다.

둘째, 모으는 노드가 일찍 돈다. 갈래마다 END 로 보내 놓고 모으는 노드를 따로 두면, 모으는 노드가 언제 도는지 알 수 없다. 갈래들을 모으는 노드로 모아 보내면 그 노드는 갈래가 전부 끝난 뒤에 한 번 돈다.

셋째, 부분 실패가 전체 실패가 된다. 자료원 하나가 죽어서 답을 아예 못 주는 일이 자주 난다.

넷째, 폭이 통제되지 않는다. 목록을 그대로 흩뿌리면 목록이 길어지는 날 비용이 그만큼 커진다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/agpara/fanout.py 를 한 단계씩 키운다. 먼저 자료원 셋으로 갈라졌다 모이는 그래프를 만들고, 리듀서가 없는 열쇠에 둘이 동시에 쓰면 무슨 예외가 나는지 직접 잡아 본다. 합쳐지는 순서가 무엇으로 정해지는지 이름을 바꿔 가며 확인하고, 모으는 노드를 따로 두어 순위를 매긴다. 그다음 Send 로 갯수가 실행 시점에 정해지는 갈래를 만들고, 폭에 상한을 두고, 마지막으로 갈래 하나가 실패해도 나머지 답이 살아남게 만든다.