하나의 프롬프트로는 안 됐다 — LangGraph Multi-Agent 라우팅
RAG와 Text2SQL 질문을 Router, Worker, Synthesizer로 나누고 실패를 추적 가능하게 만든 설계.
1편에서 저는 디지털 트윈에 자연어로 질문하는 제품을 MCP 서버로 출시한 이야기를 했습니다. 이번엔 그 안에서 “질문을 어떻게 올바른 처리 경로로 보내는가”, 즉 라우팅 설계를 다뤄봅니다.
1. 왜 단일 프롬프트로는 안 됐나
처음엔 순진하게 생각했습니다. “좋은 프롬프트 하나면 되지 않을까?” 안 됐습니다. 들어오는 질문의 성격이 근본적으로 달랐거든요.
- “이 벽체가 도면대로 시공됐어?” → 문서·의미를 뒤져야 하는 질문 (RAG)
- “3층 진척률 몇 %야?” → 정형 데이터를 집계해야 하는 질문 (Text2SQL)
- “도면과 다르게 시공된 층의 진척률은?” → 둘 다 필요한 복합 질문
하나의 프롬프트에 “알아서 잘 해줘”라고 맡기면, 어떤 질문은 되고 어떤 질문은 엉뚱한 답을 냈습니다. 문제를 나눠야 했습니다.
2. 왜 LangGraph였나
에이전트를 나누기로 했을 때, 선택지는 여러 개였습니다. 단순 if-else 라우터부터 프레임워크까지. LangGraph를 고른 이유는 세 가지였습니다.
- 상태(State)를 그래프로 관리 — 질문, 라우팅 결과, 중간 검색 결과, 최종 답을 하나의 상태로 흐르게. 대화 context를 노드 사이로 넘기기 편했습니다.
- 조건부 라우팅이 1급 시민 — “이 조건이면 이 노드로”를 그래프 엣지로 선언. 흐름이 코드가 아니라 그래프로 보였습니다.
- 관측성 — 어느 노드에서 무슨 일이 있었는지 추적이 됩니다. Multi-Agent의 최대 약점이 “어디서 틀렸는지 모른다”인데, 이게 중요했습니다.
3. 아키텍처 — Router / Worker / Synthesizer
구조는 단순하게 갔습니다. 복잡한 걸 풀려고 만드는 건데 구조까지 복잡하면 본말전도니까요.
┌─────────┐
질문 ──▶│ Router │ 질문 유형 분류 (semantic / structured / both)
└────┬────┘
│
┌───────┼───────┐
▼ ▼
┌─────────┐ ┌───────────┐
│ RAG │ │ Text2SQL │ 경로별 처리
│ (검색) │ │ (집계) │
└────┬────┘ └─────┬─────┘
└───────┬───────┘
▼
┌────────────┐
│ Synthesize │ 결과 종합 + 자연어 답변 생성
└────────────┘
의사코드로 옮기면 이런 골격입니다. 아래 코드는 개념 재현용이며 실제 사내 구현이 아닙니다.
from langgraph.graph import StateGraph, END
from typing import TypedDict, Literal
class QueryState(TypedDict):
question: str
route: Literal["semantic", "structured", "both"]
retrieved: list # RAG 결과
rows: list # Text2SQL 결과
answer: str
def router(state: QueryState):
# LLM 분류기로 질문 유형 판단 (few-shot + 스키마 힌트)
state["route"] = classify(state["question"])
return state
def rag_node(state: QueryState):
state["retrieved"] = hybrid_search(state["question"]) # + re-ranking
return state
def text2sql_node(state: QueryState):
sql = generate_sql(state["question"], schema_hint)
state["rows"] = run_validated(sql) # 검증 후 실행
return state
def both_node(state: QueryState):
# 복합 질문은 검색과 집계를 모두 수행
return text2sql_node(rag_node(state))
def synthesize(state: QueryState):
state["answer"] = compose(state) # 근거와 함께 답 생성
return state
g = StateGraph(QueryState)
g.add_node("router", router)
g.add_node("rag", rag_node)
g.add_node("text2sql", text2sql_node)
g.add_node("both", both_node)
g.add_node("synthesize", synthesize)
g.set_entry_point("router")
g.add_conditional_edges("router", lambda s: s["route"], {
"semantic": "rag",
"structured": "text2sql",
"both": "both",
})
g.add_edge("rag", "synthesize")
g.add_edge("text2sql", "synthesize")
g.add_edge("both", "synthesize")
g.add_edge("synthesize", END)
app = g.compile()
4. 실제로 어려웠던 지점들
① 라우팅 오분류 — 가장 자주 터진 곳
Router가 질문 유형을 틀리면 그 뒤가 전부 무너집니다. 초기엔 애매한 질문(“이 층 상태 어때?”)에서 오분류가 잦았습니다. 해결은 두 갈래였습니다. 분류기 프롬프트에 경계 사례 few-shot을 넣어 감을 잡게 하고, 확신이 낮으면 both로 폴백해 안전하게 둘 다 시도하도록. “틀리느니 조금 더 하자”는 쪽을 택한 거죠.
② 노드가 늘수록 느려진다
경로를 나누면 품질은 오르지만 지연이 붙습니다. 특히 Text2SQL은 쿼리 생성이 무거워 초기 15초에 달했습니다. 프롬프트·경로를 최적화해 약 5초(66% 단축) 까지 줄였습니다. Multi-Agent를 쓸 땐 “노드 하나 = 지연 하나”라는 비용을 항상 계산해야 합니다.
③ 어디서 틀렸는지 추적하기
단일 프롬프트는 틀리면 “프롬프트가 문제”지만, Multi-Agent는 Router인지, RAG인지, Text2SQL인지, Synthesizer인지 범인을 찾아야 합니다. 각 노드의 입출력을 남겨 실패를 노드 단위로 격리한 게 디버깅을 살렸습니다. LangGraph를 고른 이유(관측성)가 여기서 빛을 봤죠.
5. Multi-Agent의 진짜 값어치
이 작업이 남긴 교훈은 이겁니다.
Multi-Agent는 “똑똑함”을 늘리려고 만드는 게 아니라, “책임”을 나누려고 만든다.
Router는 분류만, RAG는 검색만, Text2SQL은 집계만 책임집니다. 각 노드가 하는 일이 작아지니, 틀렸을 때 고칠 지점이 명확해지고 테스트도 쉬워졌습니다. 복잡한 문제를 복잡한 한 덩어리로 푸는 대신, 단순한 여러 조각으로 나눈 것 — 그게 Multi-Agent의 본질이었습니다.
다음 글에서는 이 파이프라인의 품질을 어떻게 측정하고(evaluation), retrieval 70%의 벽을 출력 설계로 넘겼는지를 더 파보겠습니다.