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

31 KiB
Raw Blame History

설계안 1 LangGraph 전환: Chain Primitive → LangGraph 선언적 그래프

Celery Chain의 선언적 파이프라인을 LangGraph StateGraph로 전환한 설계안


목차

  1. 개요 및 핵심 차이점
  2. 아키텍처 설계
  3. 데이터 흐름 상세
  4. RAG 파이프라인 통합
  5. 코드 구현
  6. 상태 관리 및 체크포인팅
  7. 실패 처리 전략
  8. Celery Chain 대비 비교
  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 시퀀스 다이어그램

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 초안 작성