o2o-castad-backend/docs/plan/celery/celery-plan_1-chain-primiti...

662 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 설계안 1 LangGraph 전환: Chain Primitive → LangGraph 선언적 그래프
> **Celery Chain의 선언적 파이프라인을 LangGraph StateGraph로 전환한 설계안**
---
## 목차
1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점)
2. [아키텍처 설계](#2-아키텍처-설계)
3. [데이터 흐름 상세](#3-데이터-흐름-상세)
4. [RAG 파이프라인 통합](#4-rag-파이프라인-통합)
5. [코드 구현](#5-코드-구현)
6. [상태 관리 및 체크포인팅](#6-상태-관리-및-체크포인팅)
7. [실패 처리 전략](#7-실패-처리-전략)
8. [Celery Chain 대비 비교](#8-celery-chain-대비-비교)
9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-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 시퀀스 다이어그램
```mermaid
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 선언 대응)
```python
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 대응)
```python
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 대응)
```python
# 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 | 초안 작성 |