1 왜 Chain에서 Graph로 가는가
Phase B에서 MINERVA의 RAG 파이프라인을 LCEL(prompt | llm | StrOutputParser)로 묶어 동작시켰다. 단일 호출에서 빠르고 코드도 짧다. 그런데 데이터 흐름 추적 편에서 본 것처럼 실제로는 “검색 → 리랭크 → Parent 매핑 → 컨텍스트 조립 → LLM → 응답 후처리”의 6~7 단계가 _prepare() 함수 안에 순차적으로 묶여 있다.
이 묶음은 다음 네 가지 상황에서 한계를 드러낸다.
- 분기 처리 — 검색 실패 시 Web 검색으로 폴백, 인용 없는 답변일 때 재시도, 사용자 의도에 따라 Sub-Agent로 위임 — 이런 분기가
if사슬로 함수 안에 누적된다. - 순환 처리 — Self-RAG처럼 “답변 평가 → 부족하면 재검색”의 루프, ReAct처럼 “도구 호출 → 관찰 → 다시 추론”의 반복이 LCEL 단일 그래프로는 표현되지 않는다.
- 중단·재개 — 긴 작업에서 사람의 승인을 받거나, 실패 지점부터 재실행하려면 중간 상태를 외부에 영속화해야 한다.
- 노드별 관찰성 — 어느 단계에서 시간을 잃었는지, 어느 노드가 실패했는지 추적하려면 단계가 함수가 아니라 일급(first-class) 단위여야 한다.
LangGraph는 위 네 가지를 표현할 수 있는 명시적 그래프 실행 엔진이다. Chain이 “함수 합성”이라면 LangGraph는 “상태 머신”이다.
- MINERVA 데이터 흐름 추적 — 현재 Chain 구조의 단계별 변환
- MINERVA 상태 관리 해부 — State 영속화 계층
- MINERVA 에러 전파 경로 분석 — Chain 단계의 에러 처리 한계
이 포스트는 MINERVA 적용 관점이다. LangGraph 자체의 API를 처음 보는 독자는 다음 두 편을 함께 읽으면 좋다.
- LangGraph 개요
- LangGraph 소개
- Agent 기술 스택의 진화 — Chain → Graph → Agentic의 큰 흐름
2 LangGraph 핵심 개념 4가지
LangGraph는 LangChain 위에서 동작하는 그래프 기반 실행 프레임워크다. “노드(함수) + 엣지(전이) + 상태(State)” 세 요소로 에이전트의 실행 경로를 명시적으로 정의하며, 순환·분기·중단·재개를 일급(first-class) 기능으로 제공한다.
- 핵심 단위:
StateGraph— 노드와 엣지를 등록한 후.compile()하여 실행 가능한 그래프 생성 - 차별점: LCEL Chain은 DAG(비순환). LangGraph는 순환 가능
2.1 Node — 상태를 받아 상태를 갱신하는 함수
Node는 state -> dict 시그니처의 순수 함수다. 입력으로 현재 State를 받고, 갱신할 필드만 dict로 반환한다.
def retrieve_node(state: AgentState) -> dict:
docs = retriever.invoke(state["question"])
return {"docs": docs} # docs 필드만 갱신반환 dict의 키는 State에 정의된 필드여야 한다. 반환되지 않은 필드는 그대로 유지된다.
2.2 Edge — 노드 간 전이
Edge는 다음에 어느 Node로 갈지 결정한다. 두 종류가 있다.
| 종류 | 정의 | 사용 |
|---|---|---|
| 정적 Edge | 항상 같은 다음 노드로 전이 | graph.add_edge("retrieve", "generate") |
| Conditional Edge | 함수가 다음 노드 이름을 반환 | graph.add_conditional_edges("grade", route_fn, {"good": "generate", "bad": "rewrite"}) |
Conditional Edge가 분기·순환의 핵심이다. 라우팅 함수는 State를 받아 string을 반환한다.
2.3 State — 노드 간 공유 자료 구조
State는 그래프 전체에서 노드 사이를 흘러다니는 “백팩”이다. TypedDict로 정의하며, 각 필드의 누적·덮어쓰기 전략을 Annotated 타입으로 지정할 수 있다.
from typing import Annotated, TypedDict
from operator import add
from langchain_core.documents import Document
class AgentState(TypedDict):
question: str
docs: list[Document]
answer: str
# 누적되는 필드 — 여러 노드가 이어붙임
log_messages: Annotated[list[str], add]Annotated[list[str], add]로 표시한 필드는 각 노드 반환값이 누적된다. 미표시 필드는 가장 최근 반환값으로 덮어쓴다. State 설계의 자세한 패턴은 15편 — State 설계에서 다룬다.
2.4 Conditional Edge — 분기와 순환의 단위
LangGraph의 차별성은 Conditional Edge가 자기 자신 또는 이전 노드로 돌아갈 수 있다는 점이다. 이로써 ReAct, Self-RAG, Plan-and-Execute 같은 반복 패턴이 자연스럽게 표현된다.
[generate] ──→ [grade_answer] ──good──→ END
│
└──bad──→ [retrieve] ← 순환
LCEL Chain은 위와 같은 그림을 표현할 수 없다. 함수 합성은 한 방향뿐이다.
3 Chain vs StateGraph 직접 비교
같은 RAG를 두 방식으로 작성해 차이를 본다. MINERVA의 단순화한 흐름 — 질문 → 검색 → 답변 만 다룬다.
3.1 LCEL Chain 버전 (Phase B 현재)
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableLambda
prompt = PromptTemplate.from_template(
"다음 문서를 참고해 질문에 답하라.\n\n{context}\n\n질문: {question}"
)
def build_inputs(payload: dict) -> dict:
docs = retriever.invoke(payload["question"])
context = "\n\n".join(d.page_content for d in docs)
return {"context": context, "question": payload["question"]}
chain = RunnableLambda(build_inputs) | prompt | llm | StrOutputParser()
answer = chain.invoke({"question": "데이터 표준화의 핵심 원칙은?"})특징
- 한 줄로 묶이고, 각 단계가 함수다.
- 중간 결과(
docs,context)는 함수 내부에서만 보이며, 그래프 차원에서 추적되지 않는다. - 분기·순환은 표현 불가.
if로 분기하려면 별도의RunnableBranch또는 함수 안에서 처리.
3.2 LangGraph StateGraph 버전
from typing import Annotated, TypedDict
from operator import add
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langgraph.graph import StateGraph, START, END
class AgentState(TypedDict):
question: str
docs: list[Document]
context: str
answer: str
log_messages: Annotated[list[str], add]
def retrieve(state: AgentState) -> dict:
docs = retriever.invoke(state["question"])
return {"docs": docs, "log_messages": [f"retrieved {len(docs)} docs"]}
def build_context(state: AgentState) -> dict:
context = "\n\n".join(d.page_content for d in state["docs"])
return {"context": context, "log_messages": ["built context"]}
def generate(state: AgentState) -> dict:
prompt = PromptTemplate.from_template(
"다음 문서를 참고해 질문에 답하라.\n\n{context}\n\n질문: {question}"
)
chain = prompt | llm | StrOutputParser()
answer = chain.invoke({
"context": state["context"],
"question": state["question"],
})
return {"answer": answer, "log_messages": ["generated answer"]}
graph = StateGraph(AgentState)
graph.add_node("retrieve", retrieve)
graph.add_node("build_context", build_context)
graph.add_node("generate", generate)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "build_context")
graph.add_edge("build_context", "generate")
graph.add_edge("generate", END)
app = graph.compile()
final_state = app.invoke({"question": "데이터 표준화의 핵심 원칙은?"})
print(final_state["answer"])
print(final_state["log_messages"]) # 각 노드의 로그가 누적되어 있음무엇이 달라졌는가
| 측면 | Chain | StateGraph |
|---|---|---|
| 단계 표현 | 함수 합성 (파이프 연산자) | 명시적 add_node/add_edge |
| 중간 상태 가시성 | 함수 내부에 매몰 | State 필드로 외부 관찰 가능 |
| 노드별 단위 테스트 | 함수 단위로 가능하나 결합 강함 | State 입출력만 mocking → 단순 |
| 분기 | RunnableBranch 또는 함수 내 if |
add_conditional_edges 일급 지원 |
| 순환 | 불가 | Conditional Edge로 자연 표현 |
| 중단·재개 | 별도 영속화 필요 | Checkpointer 내장 |
| 코드량 | 짧음 | 길지만 명시적 |
코드는 길어진다. 그러나 분기·순환·관찰성·재개라는 네 가지 능력이 생긴다. 1.5명이 PoC를 만들 때는 Chain이 빠르지만, 50명이 같은 그래프를 협업으로 다듬으려면 명시성이 비용을 정당화한다.
4 Conditional Edge로 자기 평가 루프 만들기
위 그래프에 “답변이 인용을 충분히 포함하지 않으면 재검색”이라는 평가 루프를 더한다. Self-RAG의 단순화 버전이다.
루프를 위해 AgentState에 두 필드를 더했다고 가정한다 — citation_count: int(평가 결과 점수)와 retry_count: int(재시도 누적). 이렇게 노드가 늘어날 때마다 State가 함께 자라는 것이 일반적이며, 자세한 설계 패턴은 15편 — State 설계에서 다룬다.
def grade_answer(state: AgentState) -> dict:
"""답변에 [N] 마커가 2개 이상 있어야 통과."""
citation_count = state["answer"].count("[")
return {
"citation_count": citation_count,
"log_messages": [f"graded answer: {citation_count} citations"],
}
def route_after_grading(state: AgentState) -> str:
if state.get("citation_count", 0) >= 2:
return "ok"
if state.get("retry_count", 0) >= 1:
return "ok" # 한 번 재시도 후에는 그대로 종료
return "retry"
def increment_retry(state: AgentState) -> dict:
return {
"retry_count": state.get("retry_count", 0) + 1,
"log_messages": ["retry"],
}
graph = StateGraph(AgentState)
graph.add_node("retrieve", retrieve)
graph.add_node("build_context", build_context)
graph.add_node("generate", generate)
graph.add_node("grade", grade_answer)
graph.add_node("retry", increment_retry)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "build_context")
graph.add_edge("build_context", "generate")
graph.add_edge("generate", "grade")
graph.add_conditional_edges(
"grade",
route_after_grading,
{"ok": END, "retry": "retry"},
)
graph.add_edge("retry", "retrieve") # 순환!
app = graph.compile()그래프는 다음 모양이 된다.
START → retrieve → build_context → generate → grade ──ok──→ END
▲ │
└──────────── retry ←────────────────┘ (citations < 2)
LCEL로는 이 그림을 직접 그릴 수 없다. Chain의 합성 연산자(|)는 단방향이라 grade -> retrieve 같은 역방향 엣지를 표현할 수단이 없다. 함수 안에 while 루프를 넣을 수는 있지만 그 순간 “그래프”가 아니라 “스크립트”가 된다 — 외부 도구가 그래프를 그릴 수도, 중간에 끊을 수도 없다.
5 MINERVA 현재 구조의 LangGraph 매핑
데이터 흐름 추적 편에서 본 _prepare() 흐름을 노드로 분해하면 다음과 같다.
[현재 — 단일 함수]
_prepare(query):
_ensure_initialized()
_retrieve_docs(query.text, document_id, source_filter)
_build_chain_inputs(query, docs) # context + chat_history + question
chain = self._prompt | llm | StrOutputParser()
return docs, chain, chain_inputs, model_used
run(query):
docs, chain, chain_inputs, model_used = _prepare(query)
answer = chain.invoke(chain_inputs)
return _build_response(answer, docs, query)
[LangGraph 분해 — 5개 노드]
START
▼
[ensure_init_node] lazy init (prompt·llm 로드)
▼
[retrieve_node] _retrieve_docs → docs
▼
[build_inputs_node] _build_chain_inputs → chain_inputs
▼
[generate_node] chain.invoke(chain_inputs) → answer
▼
[build_response_node] _build_response → response
▼
END
각 단계가 일급 노드가 되면 다음이 가능해진다.
- 단계별 timing 자동 수집 —
[timing] prepare:로그를 직접 찍지 않아도, LangGraph의stream_events가 노드별 시작/종료 이벤트를 토해낸다. - 검색 실패 시 폴백 노드 추가 —
retrieve_node가docs=[]를 반환하면web_search_node로 우회. - Reranker A/B를 노드 교체로 처리 —
rerank_cosinevsrerank_flashrank를 두 노드로 두고, A/B 실험 arm에 따라 그래프를 두 가지로 컴파일. - HITL — 위험한 답변 전 사람 승인 —
generate_node직후 interrupt를 걸어 검토 후 재개. 자세한 흐름은 16편 — Checkpointing과 HITL에서 다룬다.
이 분해의 실제 코드는 14편 — RAG Chain 분해에서 작성한다.
QnA Chatbot이 단일 LCEL 체인이라 위 분해가 깔끔하지만, Data Standardizer는 이미 agent.py가 RagRecommender·DomainAuditor·post_processing/{tables,code} 4개 sub_agent를 직접 호출하는 정적 파이프라인이다(02·08·10편 참조). LangGraph로 옮길 때 두 가지 선택이 있다:
- 각 sub_agent를 일급 노드로 평탄화 —
recommend_node→post_process_node→audit_node로 펼친다. 단순하지만 sub_agent 내부 분기(코드 모드 vs 데이터 모드)가 다시 노드 폭증을 일으킨다. - 각 sub_agent를 sub-graph로 캡슐화 —
recommend_subgraph·audit_subgraph를 별도 StateGraph로 만들고 부모 그래프가 호출. 노드 수는 적게 유지되고, sub_agent 단위 재사용성이 보장된다.
상위 노드 수가 4개 이하로 유지되면 (1)이 가독성이 좋고, sub_agent가 5개 이상이거나 ReAct 루프가 들어가면 (2)가 합리적이다. MINERVA의 경우 Phase C-3에서 ToolNode를 도입하면 sub_agent 일부가 도구로 흡수되므로 (1)에 가까운 형태로 수렴할 가능성이 크다.
6 컴파일과 실행 — 그래프의 생애 주기
StateGraph는 정의 단계와 실행 단계가 분리된다.
# 정의 단계
graph = StateGraph(AgentState)
graph.add_node(...)
graph.add_edge(...)
# 컴파일 — 검증 + 최적화
app = graph.compile() # checkpointer / interrupt_before 옵션 지정 가능
# 실행 — 세 가지 모드
result = app.invoke(initial_state) # 동기 — 최종 State 반환
for chunk in app.stream(initial_state): # 노드별 출력 스트리밍
print(chunk)
async for event in app.astream_events(...): # 토큰·이벤트 단위 비동기 스트리밍
...compile() 시점에 다음이 검증된다.
- 모든 노드가 등록되어 있는가
- 도달 불가능한 노드가 있는가 (시작점에서 닿지 않는 고립 노드)
- Conditional Edge의 라우팅 함수 반환값이 등록된 노드 이름인가
이 검증은 LCEL Chain에 없던 안전망이다. Chain은 런타임에 함수가 호출되어야 비로소 형 미스매치가 드러난다.
7 응용 분야 — MINERVA 외 사례
| 분야 | LangGraph 적용 시나리오 |
|---|---|
| QnA Chatbot | Self-RAG 자기 평가 루프 + 인용 부족 시 재검색 |
| Data Standardizer | 후보 생성 → 사용자 검토(HITL) → 승인 후 영속화 |
| Plan-and-Execute Agent | 계획 노드 → 단계별 실행 노드 → 자기 수정 루프 |
| Multi-Agent Supervisor | Supervisor 노드가 라우팅 함수로 하위 에이전트 선택 |
| 코드 리뷰 에이전트 | Static analyzer → LLM 리뷰 → 신뢰도 낮으면 재분석 |
| 데이터 파이프라인 | 추출 → 검증 → 실패 시 재시도 → 적재 |
위 사례들은 모두 “분기 또는 순환”을 본질적으로 갖는다. Chain으로는 표현할 수 없거나, 표현하려면 함수 안에 로직이 매몰된다.
8 자주 발생하는 오류 패턴
def retrieve_node(state: AgentState) -> dict:
state["docs"] = retriever.invoke(state["question"]) # State 직접 변경
return stateCORRECT:
def retrieve_node(state: AgentState) -> dict:
docs = retriever.invoke(state["question"])
return {"docs": docs} # 갱신할 필드만 dict로 반환LangGraph는 State 직접 변경이 아닌 “갱신 dict 반환” 모델이다. 직접 변경하면 누적 필드(Annotated)의 reducer가 작동하지 않는다.
CORRECT:
graph.add_conditional_edges(
"grade",
route_fn,
{"good": "generate", "bad": "rewrite"}, # 라우팅 함수 반환값 → 노드 이름 dict
)Conditional Edge는 dict로 매핑한다. 리스트는 라우팅 함수가 노드 이름을 직접 반환하는 경우에만 허용된다.
CORRECT:
순환 그래프는 반드시 END로 가는 분기를 포함해야 한다. 컴파일 시점에 도달 불가능 검사는 통과해도, 실행 중 루프 카운터(recursion_limit)에 걸려 예외가 발생한다.
9 다음 편 예고 — RAG Chain의 실제 분해
이번 편은 LangGraph가 무엇이고 왜 필요한지를 다뤘다. 다음 편(14 — RAG Chain 분해)에서는 MINERVA의 _prepare() 함수를 5개 노드로 분해해 실제 코드를 작성한다.
분해 후 다음을 확인한다.
- 노드별 단위 테스트가 어떻게 단순해지는가 (mock 표면적 감소)
- A/B 실험 arm을 노드 교체로 표현하는 방법
- 검색 실패 시 폴백 노드를 추가하는 conditional edge
10 정리
| 항목 | 핵심 |
|---|---|
| Chain의 한계 | 분기·순환·중단·노드별 관찰성 표현 불가 |
| StateGraph 4 요소 | Node(함수), Edge(전이), State(공유 자료), Conditional Edge(분기/순환) |
| 같은 RAG 비교 | Chain은 짧고, Graph는 길지만 명시적 — 50명 협업 단계에서 비용 정당화 |
| MINERVA 매핑 | _prepare의 6단계 → 5개 일급 노드로 분해 가능 |
| 다음 단계 | 14편에서 실제 분해 코드 작성, 15편에서 State 설계 패턴 |
Chain은 “기능이 동작하면 끝”인 단계에 적합하다. LangGraph는 “기능에 분기·순환·관찰성·거버넌스를 더해야 하는 단계”에서 비용을 회수한다. MINERVA가 1.5명 PoC에서 50명 플랫폼으로 진화하는 길목에서 LangGraph 전환은 선택이 아니라 전제다.
11 관련 주제
선행 학습
- MINERVA 데이터 흐름 추적 — 현재 Chain 구조의 단계별 변환
- MINERVA 상태 관리 해부 — State 영속화 계층의 4단 구조
- LangGraph 개요 — LangGraph API 자체 입문
- Agent 기술 스택의 진화 — Chain → Graph → Agentic 흐름
후속 주제
- RAG Chain 분해 — retriever/reranker/generator 노드화
- State 설계 — TypedDict 기반 에이전트 상태
- Checkpointing과 Human-in-the-Loop
- BaseAgent 계약 v2 — LangGraph 호환 인터페이스
다른 카테고리 연결
- LangGraph Naive RAG — RAG 그래프 기본형
- LangGraph Self-RAG — 자기 평가 루프 구현 사례
- LangGraph 조건 분기 — Conditional Edge 심화