31 KiB
31 KiB
설계안 1 LangGraph 전환: Chain Primitive → LangGraph 선언적 그래프
Celery Chain의 선언적 파이프라인을 LangGraph StateGraph로 전환한 설계안
목차
- 개요 및 핵심 차이점
- 아키텍처 설계
- 데이터 흐름 상세
- RAG 파이프라인 통합
- 코드 구현
- 상태 관리 및 체크포인팅
- 실패 처리 전략
- Celery Chain 대비 비교
- 프롬프트 및 RAG 최적화
1. 개요 및 핵심 차이점
1.1 설계 철학
Celery Chain은 chain(A.s() | B.s() | C.s())으로 선언적 파이프라인을 정의합니다.
LangGraph는 StateGraph로 더 유연한 선언적 그래프를 정의하면서,
조건부 분기, 루프, 상태 체크포인팅을 네이티브로 지원합니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Chain vs LangGraph StateGraph │
├──────────────────────────────────┬──────────────────────────────────────────┤
│ Celery Chain │ LangGraph StateGraph │
├──────────────────────────────────┼──────────────────────────────────────────┤
│ chain(A.s() | B.s() | C.s()) │ graph.add_edge("A", "B") │
│ │ graph.add_edge("B", "C") │
│ │ │
│ 직선형 파이프라인만 가능 │ 분기, 루프, 병렬 모두 가능 │
│ 이전 태스크 반환값 → 다음 입력 │ State 객체로 전체 상태 공유 │
│ 실패 시 chain 전체 중단 │ 체크포인트에서 재시작 가능 │
│ Celery 엔진이 연결 관리 │ 그래프 정의로 연결 관리 │
│ 부분 재시작: 새 chain 생성 필요 │ thread_id로 정확한 지점에서 재개 │
│ 결과 전파: 메시지 크기 제한 │ State 객체: 크기 제한 없음 │
└──────────────────────────────────┴──────────────────────────────────────────┘
1.2 핵심 이점: Chain의 약점 해결
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Chain의 약점 → LangGraph 해결 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 약점 1: 직선형만 가능 │
│ Chain: lyric → song → video (분기 불가) │
│ Graph: lyric → [검증 통과?] → song / [재생성] → lyric (루프) │
│ │
│ 약점 2: 부분 재시작 어려움 │
│ Chain: song 실패 시 → 새 chain(song.s(), video.s()) 생성 필요 │
│ Graph: checkpointer에서 song 노드부터 자동 재개 │
│ │
│ 약점 3: 메시지 크기 제한 │
│ Chain: 반환값 < 1KB 권장 (Redis 메시지 크기) │
│ Graph: State 객체에 제한 없음 (RAG 컨텍스트 등 대량 데이터 가능) │
│ │
│ 약점 4: RAG 통합 불가 │
│ Chain: 각 태스크가 독립적이라 RAG 컨텍스트 공유 어려움 │
│ Graph: State에 RAG 컨텍스트를 포함하여 모든 노드에서 접근 가능 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. 아키텍처 설계
2.1 LangGraph 그래프 구조
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Chain → LangGraph 선언적 그래프 전환 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Chain 원본]
chain(
generate_lyric.s(data).set(queue='lyric_queue'),
generate_song.s().set(queue='song_queue'),
generate_video.s().set(queue='video_queue'),
)
↓↓↓ LangGraph 전환 ↓↓↓
[LangGraph StateGraph]
START
│
▼
┌───────────────────┐
│ marketing_search │ ← RAG 강화 (신규)
│ (30건 외부 검색) │
└────────┬──────────┘
▼
┌───────────────────┐
│ local_search │ ← 지역 정보 (신규)
│ (랜드마크/축제) │
└────────┬──────────┘
▼
┌───────────────────┐
│ rag_retrieval │ ← 벡터 DB 검색 (신규)
│ (유사 문서 검색) │
└────────┬──────────┘
▼
┌───────────────────┐
│ embedding_store │ ← 임베딩 저장 (신규)
└────────┬──────────┘
▼
┌───────────────────┐
│ generate_lyric │ ← Celery lyric_task 대응
│ (RAG 강화 프롬프트)│ + Pydantic 정형화
└────────┬──────────┘
▼
┌───────────────────┐
│ validate_lyric │ ← 조건부 분기 (신규)
└────┬─────────┬────┘
[pass] [retry: count < 3]
│ │
│ └──→ generate_lyric (루프백)
▼
┌───────────────────┐
│ generate_song │ ← Celery song_task 대응
│ (Suno API) │
└────────┬──────────┘
▼
┌───────────────────┐
│ generate_video │ ← Celery video_task 대응
│ (Creatomate) │
└────────┬──────────┘
▼
┌───────────────────┐
│ save_results │
└────────┬──────────┘
▼
END
2.2 Celery Chain과의 매핑
┌────────────────────────────────────────────────────────────────────┐
│ Celery Chain 요소 → LangGraph 매핑 │
├───────────────────────┬────────────────────────────────────────────┤
│ Celery Chain 요소 │ LangGraph 대응 │
├───────────────────────┼────────────────────────────────────────────┤
│ chain() │ StateGraph().compile() │
│ .s() (signature) │ graph.add_node("name", func) │
│ | (pipe 연산자) │ graph.add_edge("A", "B") │
│ apply_async() │ graph.ainvoke(state, config) │
│ 반환값 전파 │ State 객체 공유 │
│ link_error │ 조건부 엣지 + 에러 핸들링 노드 │
│ .set(queue=...) │ (LangGraph는 큐 개념 없음 - 단일 프로세스) │
│ AsyncResult.get() │ checkpointer.get_tuple(config) │
│ chain_id │ thread_id (configurable) │
│ result.parent │ state history (체크포인트 이력) │
└───────────────────────┴────────────────────────────────────────────┘
3. 데이터 흐름 상세
3.1 Chain의 결과 전파 vs State 공유
┌─────────────────────────────────────────────────────────────────────────────┐
│ 데이터 전달 방식 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Chain: 반환값 전파]
━━━━━━━━━━━━━━━━━━━━━━━━━
lyric_task(data) → {"task_id":"xxx", "lyric_result":"..."} → song_task(prev)
─────────────────────────────────────
이 반환값이 메시지로 전달 (크기 제한)
문제:
- lyric_result가 크면 메시지 크기 초과
- RAG 컨텍스트 같은 대량 데이터 전달 불가
- 이전 단계의 중간 결과 접근 불가
[LangGraph: State 공유]
━━━━━━━━━━━━━━━━━━━━━━
State = {
task_id: "xxx",
marketing_docs: [...30건...], ← 모든 노드에서 접근 가능
local_landmarks: [...3건...],
rag_similar_docs: [...10건...],
lyric_result: "가사 전문...",
lyric_structured: {title, lines, keywords...},
song_result_url: "...",
video_result_url: "...",
}
모든 노드가 State 전체를 읽고 수정 가능
→ RAG 컨텍스트, 검색 결과, 중간 결과 모두 공유
→ 크기 제한 없음
3.2 시퀀스 다이어그램
sequenceDiagram
participant C as Client
participant API as FastAPI
participant G as LangGraph
participant Search as Tavily Search
participant VDB as Vector DB
participant LLM as ChatGPT
participant Suno as Suno API
participant CM as Creatomate
participant DB as PostgreSQL
C->>API: POST /pipeline/start
API->>G: graph.ainvoke(initial_state)
Note over G: marketing_search_node
G->>Search: 5개 쿼리 × 6건 = 30건 검색
Search-->>G: 검색 결과 → State.marketing_docs
Note over G: local_search_node
G->>Search: 랜드마크/축제/여행지 각 10건
Search-->>G: 결과 → State.local_*
Note over G: rag_retrieval_node
G->>VDB: 유사 문서 검색 (벡터 + BM25)
VDB-->>G: 유사 문서 → State.rag_similar_docs
Note over G: embedding_store_node
G->>VDB: 새 검색 결과 임베딩 저장
Note over G: lyric_generation_node
G->>LLM: RAG 강화 프롬프트 + Pydantic 스키마
LLM-->>G: 정형화된 가사 → State.lyric_structured
Note over G: lyric_validation_node
G->>G: 규칙 + LLM 점수 계산
alt score >= 0.7
Note over G: song_generation_node
G->>Suno: 가사 → 음악 생성
Suno-->>G: song_url → State.song_result_url
Note over G: video_generation_node
G->>CM: 렌더링 요청
CM-->>G: video_url → State.video_result_url
else score < 0.7 && retry < 3
G->>G: lyric_generation_node로 루프백
end
Note over G: save_results_node
G->>DB: 최종 결과 저장
G-->>API: 최종 State 반환
API-->>C: 결과 응답
4. RAG 파이프라인 통합
4.1 Chain에서 불가능했던 RAG 통합
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery Chain에서 RAG가 어려웠던 이유 → LangGraph 해결 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Chain의 한계]
━━━━━━━━━━━━━━━━━━━
chain(
lyric_task.s(data), # ← data에 RAG 컨텍스트를 넣으려면?
song_task.s(), # 메시지 크기 1KB 제한에 걸림
video_task.s(),
)
방법 1: data에 RAG 결과 포함 → 메시지 너무 커짐
방법 2: 각 태스크 내에서 개별 RAG → 중복 검색, 일관성 없음
방법 3: Redis에 RAG 결과 저장 → 추가 인프라, 동기화 문제
[LangGraph의 해결]
━━━━━━━━━━━━━━━━━
State에 RAG 결과를 자연스럽게 포함:
graph.add_node("marketing_search", search_30_docs)
graph.add_node("local_search", search_local_info)
graph.add_node("rag_retrieval", vector_db_search)
graph.add_node("lyric_gen", use_all_rag_context) # State에서 모두 접근
→ 검색은 한 번만, 결과는 모든 노드에서 공유
→ State 크기 제한 없음
→ 체크포인트로 검색 결과까지 저장/복구
4.2 RAG 노드 상세
Chain 원본의 각 태스크 앞에 RAG 노드를 삽입:
[Celery Chain 원본]
lyric_task → song_task → video_task
[LangGraph 전환]
search(30건) → local(30건) → rag_retrieve → embed_store
→ lyric_gen(RAG강화) → validate → song_gen → video_gen
추가된 4개 노드:
1. marketing_search: 외부 검색 30건 수집, 리랭크, 필터링
2. local_search: 지역 정보 3카테고리 × 10건, 상위 3건 선택
3. rag_retrieval: 벡터 DB에서 기존 유사 문서 검색
4. embedding_store: 새 검색 결과를 벡터 DB에 저장
5. 코드 구현
5.1 그래프 정의 (Chain 선언 대응)
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
def build_chain_style_graph() -> StateGraph:
"""
Celery chain(A|B|C)에 대응하는 LangGraph 그래프
+ RAG 강화 노드 추가
"""
graph = StateGraph(PipelineState)
# ─── Celery Chain에 없던 RAG 노드들 (신규) ───
graph.add_node("marketing_search", marketing_search_node)
graph.add_node("local_search", local_search_node)
graph.add_node("rag_retrieval", rag_retrieval_node)
graph.add_node("embedding_store", embedding_store_node)
# ─── Celery Chain의 기존 태스크 대응 ───
graph.add_node("generate_lyric", lyric_generation_node)
graph.add_node("validate_lyric", lyric_validation_node)
graph.add_node("generate_song", song_generation_node)
graph.add_node("generate_video", video_generation_node)
graph.add_node("save_results", save_results_node)
# ─── 엣지 연결 (Chain의 | 연산자 대응) ───
graph.set_entry_point("marketing_search")
# chain과 동일한 직선 연결
graph.add_edge("marketing_search", "local_search")
graph.add_edge("local_search", "rag_retrieval")
graph.add_edge("rag_retrieval", "embedding_store")
graph.add_edge("embedding_store", "generate_lyric")
graph.add_edge("generate_lyric", "validate_lyric")
# ─── Chain에서 불가능했던 조건부 분기 (신규) ───
graph.add_conditional_edges(
"validate_lyric",
lambda state: (
"pass" if state["lyric_score"] >= 0.7
else "retry" if state["lyric_retry_count"] < 3
else "fail"
),
{
"pass": "generate_song",
"retry": "generate_lyric", # 루프백!
"fail": "save_results",
},
)
graph.add_edge("generate_song", "generate_video")
graph.add_edge("generate_video", "save_results")
graph.add_edge("save_results", END)
# ─── 체크포인터 (Chain의 부분 재시작 한계 해결) ───
checkpointer = SqliteSaver.from_conn_string("./checkpoints.db")
return graph.compile(checkpointer=checkpointer)
5.2 가사 노드 (Chain의 generate_lyric 대응)
async def lyric_generation_node(state: PipelineState) -> dict:
"""
Celery chain의 generate_lyric 태스크를 LangGraph 노드로 전환
차이점:
- Celery: data dict를 입력받아 처리 후 반환값으로 전달
- LangGraph: State에서 RAG 컨텍스트 포함 전체 데이터 접근
"""
# ─── Chain에서는 불가능했던 RAG 컨텍스트 활용 ───
marketing_ctx = format_docs(state["marketing_docs"][:5])
landmark_ctx = format_docs(state.get("local_landmarks", []))
festival_ctx = format_docs(state.get("local_festivals", []))
travel_ctx = format_docs(state.get("local_travel", []))
rag_ctx = format_docs(state.get("rag_similar_docs", [])[:3])
# Structured Output (Pydantic 정형화)
llm = ChatOpenAI(model="gpt-4o", temperature=0.8)
structured_llm = llm.with_structured_output(LyricOutput)
prompt = ChatPromptTemplate.from_template(LYRIC_GENERATION_TEMPLATE)
result: LyricOutput = await structured_llm.ainvoke(
prompt.format(
customer_name=state["customer_name"],
region=state["region"],
detail_region_info=state["detail_region_info"],
language=state["language"],
marketing_context=marketing_ctx,
landmark_context=landmark_ctx,
festival_context=festival_ctx,
travel_context=travel_ctx,
rag_similar_context=rag_ctx,
output_schema=LyricOutput.model_json_schema(),
)
)
lyric_text = "\n".join(line.text for line in result.lines)
# ─── Chain에서는 경량 dict만 반환했지만 ───
# ─── LangGraph에서는 State에 직접 저장 ───
return {
"lyric_result": lyric_text,
"lyric_structured": result.model_dump(),
"lyric_retry_count": state.get("lyric_retry_count", 0) + 1,
"current_stage": "lyric_completed",
"messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"],
}
5.3 FastAPI 통합 (Chain의 pipeline.py 대응)
# app/api/routers/v1/pipeline.py
"""
Celery chain().apply_async() 대신 graph.ainvoke() 사용
비교:
Celery: pipeline = chain(A.s(data) | B.s() | C.s())
result = pipeline.apply_async()
LangGraph: result = await graph.ainvoke(state, config)
"""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/pipeline", tags=["Pipeline"])
pipeline_graph = build_chain_style_graph()
@router.post("/start")
async def start_pipeline(request: StartPipelineRequest):
initial_state = {
"task_id": request.task_id,
"customer_name": request.customer_name,
"region": request.region,
"detail_region_info": request.detail_region_info,
"language": request.language,
"orientation": request.orientation,
"genre": request.genre,
# RAG 관련 초기값
"marketing_docs": [],
"local_landmarks": [],
"local_festivals": [],
"local_travel": [],
"rag_similar_docs": [],
"enriched_context": "",
# 생성 결과 초기값
"lyric_result": None,
"lyric_structured": None,
"lyric_score": 0.0,
"lyric_retry_count": 0,
"song_result_url": None,
"song_duration": None,
"video_result_url": None,
# 메타
"current_stage": "started",
"error_message": None,
"messages": [],
}
# Chain의 apply_async() 대응
# thread_id = task_id로 체크포인트 관리
config = {"configurable": {"thread_id": request.task_id}}
result = await pipeline_graph.ainvoke(initial_state, config)
return {
"task_id": request.task_id,
"status": result["current_stage"],
"video_url": result.get("video_result_url"),
"lyric_score": result.get("lyric_score"),
}
@router.post("/resume/{task_id}")
async def resume_pipeline(task_id: str):
"""
Chain에서는 새 chain을 만들어야 했지만,
LangGraph에서는 체크포인트에서 자동 재개
"""
config = {"configurable": {"thread_id": task_id}}
# 마지막 체크포인트에서 자동 재개
result = await pipeline_graph.ainvoke(None, config)
return {
"task_id": task_id,
"resumed": True,
"status": result["current_stage"],
}
6. 상태 관리 및 체크포인팅
6.1 Chain의 parent 추적 vs 체크포인트 히스토리
┌─────────────────────────────────────────────────────────────────────────────┐
│ 상태 추적 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Chain]
result.id → video 태스크 ID
result.parent.id → song 태스크 ID
result.parent.parent.id → lyric 태스크 ID
※ chain_id로는 마지막 태스크만 조회 가능
[LangGraph]
checkpointer.list(config)로 전체 이력 조회:
Step 1: marketing_search → State snapshot #1
Step 2: local_search → State snapshot #2
Step 3: rag_retrieval → State snapshot #3
Step 4: embedding_store → State snapshot #4
Step 5: generate_lyric → State snapshot #5
Step 6: validate_lyric → State snapshot #6
Step 7: generate_lyric → State snapshot #7 (재생성)
Step 8: validate_lyric → State snapshot #8 (통과)
Step 9: generate_song → State snapshot #9
Step 10: generate_video → State snapshot #10
Step 11: save_results → State snapshot #11
→ 모든 단계의 전체 State를 타임라인으로 조회 가능
→ 어떤 단계에서든 정확한 재시작 가능
7. 실패 처리 전략
7.1 Chain의 실패 vs LangGraph의 실패
┌─────────────────────────────────────────────────────────────────────────────┐
│ 실패 처리 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[Celery Chain 실패 시나리오]
━━━━━━━━━━━━━━━━━━━━━━━━
chain(lyric.s() | song.s() | video.s())
song 실패 시:
1. song 태스크 자체 재시도 (max_retries=3)
2. 모든 재시도 실패 → chain 전체 FAILURE
3. 부분 재시작: 새 chain(song.s(prev), video.s()) 수동 생성 필요
4. lyric 결과를 DB에서 다시 조회해야 함
[LangGraph 실패 시나리오]
━━━━━━━━━━━━━━━━━━━━━━━
generate_song 노드 실패 시:
1. 예외 발생 → 마지막 체크포인트 저장됨
2. State에 lyric_result, RAG 컨텍스트 등 모든 데이터 보존
3. resume: graph.ainvoke(None, config)
→ generate_song 노드부터 정확히 재시작
4. DB 재조회 불필요 (State에 모두 있음)
추가 기능:
- 실패 노드에서 에러 메시지를 State에 기록
- 조건부 엣지로 대체 경로 실행 가능
- Human-in-the-loop: 사람이 확인 후 재개 가능
8. Celery Chain 대비 비교
┌──────────────────────────┬──────────────────────┬──────────────────────────┐
│ 기준 │ Celery Chain │ LangGraph StateGraph │
├──────────────────────────┼──────────────────────┼──────────────────────────┤
│ 파이프라인 정의 │ chain(A|B|C) │ add_node + add_edge │
│ 데이터 전달 │ 반환값 전파 (<1KB) │ State 공유 (무제한) │
│ 조건부 분기 │ 불가능 │ conditional_edges │
│ 루프 │ 불가능 │ 자연스러운 루프백 │
│ RAG 통합 │ 메시지 크기 제한 │ State로 자유롭게 통합 │
│ 부분 재시작 │ 새 chain 수동 생성 │ 체크포인트 자동 재개 │
│ 상태 이력 │ parent 체인 제한적 │ 전체 State 타임라인 │
│ 선언적 수준 │ 높음 (1줄 선언) │ 높음 (그래프 빌더) │
│ 태스크 독립성 │ 높음 (반환값만) │ 높음 (State 읽기/쓰기) │
│ 분산 실행 │ 다중 워커 수평 확장 │ 단일 프로세스 │
│ 에러 핸들링 │ link_error │ try/except + 조건부 분기 │
│ 모니터링 │ Flower │ LangSmith │
│ 실시간 추적 │ chain_id polling │ 스트리밍 이벤트 │
└──────────────────────────┴──────────────────────┴──────────────────────────┘
9. 프롬프트 및 RAG 최적화
9.1 Chain 방식에서 할 수 없었던 최적화
┌─────────────────────────────────────────────────────────────────────────────┐
│ LangGraph에서만 가능한 최적화 │
└─────────────────────────────────────────────────────────────────────────────┘
1. 반복 개선 루프 (Iterative Refinement)
─────────────────────────────────────
generate_lyric → validate → [점수 낮으면] → 피드백 포함 재생성
Chain에서는 불가능했지만, LangGraph에서는 자연스러운 루프
2. 적응형 RAG (Adaptive RAG)
─────────────────────────
첫 검색 결과가 부족하면 → 쿼리 재구성 → 재검색
State에 검색 이력 저장 → 점진적 품질 향상
3. 멀티 에이전트 협업
─────────────────────
마케팅 분석 에이전트 → 가사 작성 에이전트 → 품질 평가 에이전트
각 에이전트가 State를 통해 협업
4. 동적 프롬프트 구성
─────────────────────
검색된 문서의 양과 품질에 따라 프롬프트 구조를 동적으로 변경
지역 정보가 풍부하면 지역 참조 강화, 부족하면 일반 마케팅 강화
5. 피드백 기반 임베딩 갱신
─────────────────────────
사용자가 높이 평가한 가사 → "성공 사례"로 벡터 DB에 저장
이후 유사 요청 시 우선 참조 → 시간이 갈수록 품질 향상
문서 버전
| 버전 | 날짜 | 변경 내용 |
|---|---|---|
| 1.0 | 2024-XX-XX | 초안 작성 |