계획서 한 장을 보안, 비용, 일정 세 사람에게 검토받는다고 해 봅니다. 한 사람씩 차례로 받으면 세 번을 기다리고 세 사람에게 동시에 맡기면 한 번만 기다립니다. 세 검토는 서로의 결과를 기다릴 필요가 없기 때문입니다.
이렇게 서로 상관없는 일을 동시에 맡기고 결과를 모으는 방식을 병렬 처리(parallelization)라고 합니다. 두 방식의 시간이 어떻게 다른지 먼저 봅니다.
설명을 위한 예시 값입니다. 모델이 한 번 답하는 시간을 한 칸으로 놓았고 실제 시간은 요청마다 다릅니다.
LangGraph에서는 같은 노드에서 엣지를 여러 개 내보내면 그 노드들이 같은 단계에서 동시에 실행됩니다. 다음 노드는 그 노드들이 모두 끝난 뒤에 실행됩니다. 앞에서 본 대로 노드는 State의 칸 하나를 바꿔 돌려주는 함수라서 세 노드가 서로 다른 칸에 쓰면 부딪히지 않습니다.
Anthropic은 병렬 처리를 두 가지로 나눕니다. 서로 독립된 하위 작업을 동시에 돌리는 방식(sectioning)과 같은 일을 여러 번 시켜 다양한 답을 모으는 방식(voting)입니다. 속도를 높이거나 여러 관점으로 확신을 얻고 싶을 때 씁니다.
for name, key in [("보안", "security"), ("비용", "cost"), ("일정", "schedule")]:
b.add_node(key, view(name, key)) # 1 관점마다 노드 하나
b.add_edge(START, key) # 2 시작에서 세 노드로 동시에
b.add_edge(key, "merge") # 3 셋이 모두 끝나면 merge 실행
b.add_node("merge", merge) # 4 세 칸을 하나로 합치는 노드view는 관점 이름을 받아 노드 함수를 만들어 주는 작은 함수입니다. 각 노드는 자기 전용 칸(security, cost, schedule) 하나에만 씁니다.START에서 엣지가 셋 나가니 세 노드가 한 단계에서 함께 돌아갑니다. 모델 호출도 동시에 나갑니다.merge는 세 노드가 끝난 다음 단계에서 실행되어 세 칸을 읽어 보고서 하나로 만듭니다.그래프를 draw_mermaid()로 그려 보면 __start__에서 세 노드로, 세 노드에서 merge로 화살표가 이어집니다. 코드는 langgraph 1.2를 모의 서버에 붙여 실행해 확인했습니다.
문서 요약을 맡긴다고 해 봅니다. 문서가 2개 올지 5개 올지는 코딩할 때 알 수 없어서 노드를 미리 2개나 5개로 그려 둘 수 없습니다. 이럴 때 Send를 씁니다. 길 고르기 함수가 Send를 문서 수만큼 돌려주면 그 수만큼 워커(worker)가 실행됩니다.
Anthropic은 이 패턴을 Orchestrator-Worker라고 부릅니다. 중앙의 오케스트레이터(orchestrator)가 일을 쪼개 워커에게 맡기고 결과를 모으는 방식이고 하위 작업이 입력에 따라 달라서 미리 정해 둘 수 없는 복잡한 일에 맞습니다. 문서 수를 골라 놓고 스크롤하세요.
State의 docs 칸에 문서가 들어옵니다. 몇 개인지는 이번 입력이 정했고 그래프는 아직 모릅니다.
fan_out이 문서 하나마다 Send를 하나씩 담아 목록으로 돌려줍니다. 돌려준 Send의 개수가 곧 워커의 수입니다.
같은 summarize 노드가 문서 수만큼 동시에 돌아갑니다. 워커는 자기 문서만 받아 서로 영향을 주지 않습니다.
워커가 낸 요약은 summaries 칸에 이어 붙고 모두 끝나면 merge가 하나로 모읍니다.
def fan_out(s: State): # 1 문서마다 Send 하나
return [Send("summarize", {"doc": d}) for d in s["docs"]]
class State(TypedDict):
docs: list[str]
summaries: Annotated[list[str], operator.add] # 2 워커 결과가 쌓이는 칸
c.add_conditional_edges(START, fan_out, ["summarize"]) # 3 시작에서 길을 나눈다
c.add_edge("summarize", "merge")출력은 예시입니다. 모의 서버로 확인한 결과이고 요약 내용은 실제 모델마다 다릅니다. 코드는 langgraph 1.2 기준입니다.
Send("summarize", {"doc": d})는 "summarize 노드를 이 입력으로 한 번 실행하라"는 뜻입니다. 두 번째 값은 그래프 State가 아니라 워커 하나가 받을 입력입니다.operator.add로 목록을 이어 붙이는 규칙을 붙입니다. 규칙이 없으면 마지막 워커의 결과만 남습니다.fan_out이 보낼 수 있는 노드 목록입니다. 이 예시는 문서 목록이 입력으로 오지만 오케스트레이터가 모델이라면 모델이 계획한 하위 작업 목록으로 Send를 만들 수 있습니다.Evaluator-Optimizer는 한 모델이 초안을 쓰고 다른 모델이 평가해 피드백을 주는 일을 반복합니다. Anthropic은 평가 기준이 분명하고 반복해서 고치면 눈에 띄게 나아지는 일에 맞다고 말합니다. 사람이 피드백을 주면 글이 나아지는 일이라면 모델 평가자도 비슷하게 도울 수 있습니다.
이 반복에는 끝이 있어야 합니다. 평가자가 계속 불합격을 주면 호출이 끝없이 늘기 때문에 시도 횟수의 상한을 코드로 둡니다. 세 경우를 눌러 라운드가 어떻게 쌓이는지 보세요.
class Feedback(BaseModel): # 1 평가 결과의 모양
grade: Literal["pass", "fail"]
feedback: str
evaluator = model.with_structured_output(Feedback, method="json_schema")
def route(s: State) -> Literal["generate", "__end__"]: # 2 끝낼지 되돌릴지
return "__end__" if s["grade"] == "pass" or s["tries"] >= 3 else "generate"
d.add_edge("generate", "evaluate")
d.add_conditional_edges("evaluate", route) # 3 불합격이면 generate로 돌아감출력은 예시입니다. 평가 결과는 모의 서버로 확인했고 실제 모델의 평가는 달라질 수 있습니다.
grade가 pass와 fail 중 하나라서 코드가 안전하게 읽고 길을 정합니다. feedback은 다음 초안에 넘길 말입니다.generate로 돌아갑니다. 이 상한은 공식 예제에 들어 있지 않아서 직접 넣어야 합니다.evaluate 뒤의 조건부 엣지가 generate를 가리키므로 그래프에 고리가 생깁니다. 앞에서 만든 고리와 같은 모양입니다.세 방식 모두 코드가 길을 그려 둔 워크플로입니다. 다만 나누는 기준이 다릅니다. 갈래가 미리 정해져 있으면 병렬 분기, 갈래의 수를 입력이 정하면 Send, 기준에 맞을 때까지 고쳐야 하면 평가 반복입니다.
어느 방식이든 속도나 품질을 얻는 대신 호출을 더 씁니다. Anthropic도 이런 시스템은 더 나은 성능을 얻으려고 지연과 비용을 맞바꾸는 경우가 많다고 말합니다. 일을 하나 골라 어느 방식이 맞는지 보세요.
다음 글에서는 Agent를 중간에 멈춰 두고 사람의 확인을 받은 뒤 이어 가는 방법을 알아보겠습니다.