o2o-castad-backend/docs/plan/celery/celery-plan_2-callback-link...

32 KiB
Raw Blame History

설계안 2 LangGraph 전환: Callback Link 에러격리 → LangGraph 조건부 에러 분기

Celery link/link_error 콜백과 단일 큐 전략을 LangGraph 조건부 엣지 + 에러 서브그래프로 전환한 설계안


목차

  1. 개요 및 핵심 차이점
  2. 아키텍처 설계
  3. 에러 격리 전략 전환
  4. RAG + 에러 격리 통합
  5. 코드 구현
  6. 단일 큐 → 단일 그래프 전환
  7. 실패 처리 및 복구
  8. Celery Callback Link 대비 비교
  9. 프롬프트 및 RAG 최적화

1. 개요 및 핵심 차이점

1.1 설계 철학

Celery 설계안 2의 핵심은 link/link_error 콜백으로 성공/실패 분기를 태스크 외부에서 제어하는 것입니다. LangGraph에서는 **조건부 엣지(conditional_edges)**가 이를 더 유연하게 대체합니다.

┌─────────────────────────────────────────────────────────────────────────────┐
│           Celery link/link_error vs LangGraph conditional_edges              │
├──────────────────────────────────────┬──────────────────────────────────────┤
│     Celery link/link_error           │     LangGraph conditional_edges      │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ lyric.apply_async(                   │ graph.add_conditional_edges(         │
│   link=song.s(),                     │   "lyric",                           │
│   link_error=lyric_err.s()           │   route_lyric_result,                │
│ )                                    │   {"success": "song",                │
│                                      │    "api_error": "lyric_api_recovery",│
│ 이진 분기만 가능                      │    "quality_fail": "lyric_retry",    │
│ (성공 or 실패)                        │    "fatal": "error_handler"}         │
│                                      │ )                                    │
│                                      │                                      │
│ 실패 원인별 분기 불가                 │ 실패 원인별 세분화된 분기 가능        │
│ (lyric_err가 모든 에러 처리)          │ (API 에러, 품질 문제 등 구분)        │
└──────────────────────────────────────┴──────────────────────────────────────┘

1.2 Celery 설계안 2의 3가지 핵심 → LangGraph 대응

┌─────────────────────────────────────────────────────────────────────────────┐
│              설계안 2 핵심 → LangGraph 매핑                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│ 핵심 1: link/link_error 콜백                                                │
│   Celery: link=song.s(), link_error=lyric_err.s()                          │
│   LangGraph: conditional_edges로 다중 분기 (성공/실패/재시도/치명적)         │
│                                                                             │
│ 핵심 2: 단일 큐 + 우선순위                                                  │
│   Celery: pipeline_queue (P=1 lyric, P=5 song, P=9 video)                  │
│   LangGraph: 단일 그래프 (노드 실행 순서로 자연스럽게 제어)                  │
│              우선순위 개념 불필요 (그래프가 순서 보장)                         │
│                                                                             │
│ 핵심 3: 단계별 독립 에러 핸들러                                              │
│   Celery: lyric_error_handler, song_error_handler, video_error_handler      │
│   LangGraph: 각 노드별 에러 서브그래프 + State에 에러 컨텍스트 전달          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

2. 아키텍처 설계

2.1 에러 격리 그래프 구조

┌─────────────────────────────────────────────────────────────────────────────┐
│          LangGraph 에러 격리 그래프 (Celery 설계안 2 대응)                     │
└─────────────────────────────────────────────────────────────────────────────┘

    START
      │
      ▼
  ┌────────────────────────┐
  │   marketing_search     │
  │   (30건 외부 검색)      │
  └──────────┬─────────────┘
             ▼
  ┌────────────────────────┐
  │    local_search        │
  │  (랜드마크/축제/여행지)  │
  └──────────┬─────────────┘
             ▼
  ┌────────────────────────┐
  │   rag_retrieval        │
  └──────────┬─────────────┘
             ▼
  ┌────────────────────────┐
  │   embedding_store      │
  └──────────┬─────────────┘
             ▼
  ┌────────────────────────┐
  │   generate_lyric       │──────────────────────────────────┐
  └──────────┬─────────────┘                                  │
             ▼                                                │
  ┌────────────────────────┐                                  │
  │  route_lyric_result    │ ← 조건부 분기 (link/link_error 대응)│
  └──┬───────┬────────┬────┘                                  │
     │       │        │                                        │
 [success] [quality] [api_error]      [fatal]                 │
     │       │        │                  │                     │
     │       │        ▼                  ▼                     │
     │       │  ┌──────────────┐  ┌───────────────┐           │
     │       │  │ lyric_api_   │  │ fatal_error   │           │
     │       │  │ recovery     │  │ _handler      │           │
     │       │  │(API키 확인,  │  │(DLQ저장,알림) │           │
     │       │  │ 대체 모델)   │  └───────┬───────┘           │
     │       │  └──────┬───────┘          ▼                    │
     │       │         │                END (실패)             │
     │       │         └──→ generate_lyric (재시도)            │
     │       │                                                 │
     │       └──→ lyric_quality_retry ──→ generate_lyric      │
     │              (프롬프트 피드백 추가)                       │
     ▼                                                         │
  ┌────────────────────────┐                                   │
  │   generate_song        │───────────────────────┐           │
  └──────────┬─────────────┘                       │           │
             ▼                                     │           │
  ┌────────────────────────┐                       │           │
  │  route_song_result     │ ← 성공/실패 분기       │           │
  └──┬────────────┬────────┘                       │           │
 [success]   [suno_error]     [upload_error]       │           │
     │            │                │                │           │
     │            ▼                ▼                │           │
     │     ┌──────────────┐ ┌──────────────┐       │           │
     │     │ suno_recovery│ │upload_retry  │       │           │
     │     │(크레딧확인,   │ │(Azure재시도) │       │           │
     │     │ 대기후재시도) │ └──────┬───────┘       │           │
     │     └──────┬───────┘        │               │           │
     │            └────────────────┘               │           │
     │                    └──→ generate_song       │           │
     ▼                                             │           │
  ┌────────────────────────┐                       │           │
  │   generate_video       │───────────────────────┘           │
  └──────────┬─────────────┘                                   │
             ▼                                                 │
  ┌────────────────────────┐                                   │
  │  route_video_result    │                                   │
  └──┬────────────┬────────┘                                   │
 [success]   [render_error]                                    │
     │            │                                            │
     │            ▼                                            │
     │     ┌──────────────┐                                    │
     │     │video_recovery│                                    │
     │     │(템플릿확인,   │                                    │
     │     │ 렌더링재시도) │                                    │
     │     └──────┬───────┘                                    │
     │            └──→ generate_video                          │
     ▼                                                         │
  ┌────────────────────────┐                                   │
  │   save_results         │◄──────────────────────────────────┘
  └──────────┬─────────────┘
             ▼
           END

3. 에러 격리 전략 전환

┌─────────────────────────────────────────────────────────────────────────────┐
│           에러 격리 전략 비교                                                 │
└─────────────────────────────────────────────────────────────────────────────┘

[Celery 설계안 2: link_error 핸들러]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lyric.apply_async(
    link=song.s(),
    link_error=lyric_error_handler.s()  ← 모든 에러가 이 하나의 핸들러로
)

lyric_error_handler(request, exc, traceback):
    # API 키 만료? Rate limit? 네트워크 타임아웃? 프롬프트 오류?
    # 모든 에러가 여기로 → 원인별 분기가 핸들러 내부 if문으로 처리

문제:
  - 에러 원인별 분기가 핸들러 내부에서만 가능
  - 에러 복구 후 파이프라인 재개가 복잡
  - link_error 핸들러가 태스크이므로 추가 큐 소비

[LangGraph: 조건부 에러 분기]
━━━━━━━━━━━━━━━━━━━━━━━━━━
graph.add_conditional_edges(
    "generate_lyric",
    route_lyric_result,
    {
        "success": "generate_song",
        "api_rate_limit": "lyric_rate_limit_wait",
        "api_key_expired": "lyric_api_key_recovery",
        "quality_fail": "lyric_quality_retry",
        "network_error": "lyric_network_retry",
        "fatal": "fatal_error_handler",
    }
)

장점:
  ✓ 에러 원인별 전용 복구 노드로 분기
  ✓ 복구 후 자연스럽게 원래 노드로 루프백
  ✓ State에 에러 컨텍스트 전달 (이전 시도 피드백)
  ✓ 그래프 시각화로 에러 흐름 한눈에 파악

3.2 단계별 에러 라우팅 함수

def route_lyric_result(state: PipelineState) -> str:
    """
    가사 생성 결과에 따른 분기

    Celery link_error_handler 내부 if문을 그래프 엣지로 외부화
    """
    error = state.get("error_message")

    if not error:
        # 성공
        if state.get("lyric_score", 0) >= 0.7:
            return "success"
        elif state.get("lyric_retry_count", 0) < 3:
            return "quality_fail"
        else:
            return "success"  # 3회 시도 후 최선의 결과 사용

    # 에러 유형별 분기
    if "rate_limit" in error.lower():
        return "api_rate_limit"
    elif "api_key" in error.lower() or "authentication" in error.lower():
        return "api_key_expired"
    elif "timeout" in error.lower() or "connection" in error.lower():
        return "network_error"
    else:
        return "fatal"


def route_song_result(state: PipelineState) -> str:
    """노래 생성 결과 분기"""
    error = state.get("error_message")

    if not error and state.get("song_result_url"):
        return "success"

    if "credit" in str(error).lower() or "quota" in str(error).lower():
        return "suno_credit_error"
    elif "upload" in str(error).lower() or "blob" in str(error).lower():
        return "upload_error"
    elif state.get("song_retry_count", 0) < 3:
        return "suno_retry"
    else:
        return "fatal"


def route_video_result(state: PipelineState) -> str:
    """비디오 생성 결과 분기"""
    error = state.get("error_message")

    if not error and state.get("video_result_url"):
        return "success"

    if "template" in str(error).lower():
        return "template_error"
    elif "render" in str(error).lower():
        return "render_retry"
    else:
        return "fatal"

4. RAG + 에러 격리 통합

4.1 에러 복구 시 RAG 활용

┌─────────────────────────────────────────────────────────────────────────────┐
│           에러 복구에서의 RAG 활용 (Celery에서 불가능했던 기능)                 │
└─────────────────────────────────────────────────────────────────────────────┘

[시나리오: 가사 품질 검증 실패 → 재생성]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1차 시도: score=0.45 (지역 특색 부족)
  │
  ▼
lyric_quality_retry 노드:
  - State에서 1차 시도 결과와 검증 피드백 확인
  - 벡터 DB에서 "지역 특색이 잘 반영된 성공 사례" 추가 검색
  - 프롬프트에 피드백 주입:
    "이전 시도에서 지역 특색이 부족했습니다.
     아래 성공 사례를 참고하여 {region}의 특색을 강화하세요:
     {additional_rag_context}"
  │
  ▼
2차 시도: score=0.78 (통과!)

Celery link_error에서는:
  - 실패 → 동일 프롬프트로 단순 재시도
  - 1차 시도의 피드백을 2차 시도에 반영할 방법 없음
  - 추가 RAG 검색으로 컨텍스트 보강 불가

LangGraph에서는:
  - State에 이전 시도 결과 + 검증 피드백 보존
  - 재시도 시 피드백 기반으로 프롬프트 동적 수정
  - 추가 RAG 검색으로 부족한 컨텍스트 보강

4.2 에러 복구 노드 구현

async def lyric_quality_retry_node(state: PipelineState) -> dict:
    """
    가사 품질 실패 시 피드백 기반 재시도

    Celery link_error와 달리:
    - 이전 시도 결과를 State에서 참조
    - 검증 피드백을 프롬프트에 반영
    - 추가 RAG 검색으로 컨텍스트 보강
    """
    previous_lyric = state["lyric_result"]
    score = state["lyric_score"]
    retry_count = state["lyric_retry_count"]

    # 부족한 영역 분석
    feedback = await analyze_quality_gaps(previous_lyric, state)

    # 부족한 영역에 대해 추가 RAG 검색
    additional_docs = await search_additional_context(
        vectorstore,
        query=f"{state['region']} {feedback['weak_area']}",
        filter={"category": feedback["needed_category"]},
        k=5,
    )

    return {
        "enriched_context": state.get("enriched_context", "") + "\n\n"
            + f"[재시도 #{retry_count} 피드백]\n"
            + f"이전 점수: {score:.2f}\n"
            + f"개선 필요 영역: {feedback['weak_area']}\n"
            + f"추가 참조:\n" + format_docs(additional_docs),
        "error_message": None,  # 에러 클리어
        "messages": [f"품질 재시도 준비 (#{retry_count})"],
    }

5. 코드 구현

5.1 에러 격리 그래프 빌더

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver

def build_error_isolation_graph() -> StateGraph:
    """
    Celery 설계안 2 (link/link_error + 단일 큐) → LangGraph 전환
    에러 격리 + RAG 강화 그래프
    """
    graph = StateGraph(PipelineState)

    # ─── 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)

    # ─── 메인 파이프라인 노드 ───
    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)

    # ─── 에러 복구 노드 (link_error 핸들러 대응) ───
    graph.add_node("lyric_quality_retry", lyric_quality_retry_node)
    graph.add_node("lyric_api_recovery", lyric_api_recovery_node)
    graph.add_node("song_suno_recovery", song_suno_recovery_node)
    graph.add_node("song_upload_retry", song_upload_retry_node)
    graph.add_node("video_render_recovery", video_render_recovery_node)
    graph.add_node("fatal_error_handler", fatal_error_handler_node)

    # ─── RAG 엣지 (직선) ───
    graph.set_entry_point("marketing_search")
    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")

    # ─── 가사 검증 후 분기 (link/link_error 대응) ───
    graph.add_conditional_edges(
        "validate_lyric",
        route_lyric_result,
        {
            "success": "generate_song",
            "quality_fail": "lyric_quality_retry",
            "api_rate_limit": "lyric_api_recovery",
            "api_key_expired": "lyric_api_recovery",
            "network_error": "lyric_api_recovery",
            "fatal": "fatal_error_handler",
        },
    )

    # 에러 복구 후 재시도 루프백
    graph.add_edge("lyric_quality_retry", "generate_lyric")
    graph.add_edge("lyric_api_recovery", "generate_lyric")

    # ─── 노래 생성 후 분기 (song_error_handler 대응) ───
    graph.add_conditional_edges(
        "generate_song",
        route_song_result,
        {
            "success": "generate_video",
            "suno_retry": "song_suno_recovery",
            "suno_credit_error": "song_suno_recovery",
            "upload_error": "song_upload_retry",
            "fatal": "fatal_error_handler",
        },
    )

    graph.add_edge("song_suno_recovery", "generate_song")
    graph.add_edge("song_upload_retry", "generate_song")

    # ─── 비디오 생성 후 분기 (video_error_handler 대응) ───
    graph.add_conditional_edges(
        "generate_video",
        route_video_result,
        {
            "success": "save_results",
            "template_error": "video_render_recovery",
            "render_retry": "video_render_recovery",
            "fatal": "fatal_error_handler",
        },
    )

    graph.add_edge("video_render_recovery", "generate_video")

    # ─── 최종 ───
    graph.add_edge("save_results", END)
    graph.add_edge("fatal_error_handler", END)

    checkpointer = SqliteSaver.from_conn_string("./checkpoints.db")
    return graph.compile(checkpointer=checkpointer)

5.2 API 라우터 (콜백 중첩 → 그래프 invoke)

# Celery 설계안 2의 콜백 중첩:
#   lyric.apply_async(
#       link=song.s().set(link=video.s().set(link_error=video_err.s()),
#                         link_error=song_err.s()),
#       link_error=lyric_err.s()
#   )
#
# LangGraph에서는 단순히:
#   result = await graph.ainvoke(state, config)

@router.post("/start")
async def start_pipeline(request: StartPipelineRequest):
    initial_state = build_initial_state(request)
    config = {"configurable": {"thread_id": request.task_id}}

    # Celery의 복잡한 콜백 중첩 대신 단일 invoke
    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"),
        "errors": result.get("error_history", []),
    }

6. 단일 큐 → 단일 그래프 전환

6.1 Celery 단일 큐 장점의 LangGraph 보존

┌─────────────────────────────────────────────────────────────────────────────┐
│          Celery 단일 큐 장점 → LangGraph에서의 보존                           │
└─────────────────────────────────────────────────────────────────────────────┘

[Celery 설계안 2: 단일 큐 + 우선순위]
장점:
  ✓ 인프라 단순 (1개 큐)
  ✓ 리소스 효율 (유휴 워커 없음)
  ✓ 스케일링 단순

[LangGraph: 더 단순]
장점:
  ✓ 큐 자체가 불필요 (그래프 내 순차 실행)
  ✓ 별도 워커 프로세스 불필요
  ✓ Redis 브로커 불필요
  ✓ 우선순위 설정 불필요 (그래프가 순서 보장)

인프라 비교:
  Celery: Redis + pipeline_queue + Worker × N + Flower
  LangGraph: FastAPI 프로세스 + 벡터 DB + LLM API

단, 높은 동시 처리가 필요하면:
  → LangGraph + asyncio.TaskGroup으로 동시 요청 처리
  → 또는 LangGraph를 Celery 태스크 내에서 실행 (하이브리드)

7. 실패 처리 및 복구

7.1 에러 핸들러 비교

┌─────────────────────────────────────────────────────────────────────────────┐
│           에러 핸들러 비교                                                    │
└─────────────────────────────────────────────────────────────────────────────┘

[Celery: 단계별 에러 핸들러 태스크]
lyric_error_handler(request, exc, traceback):
    # request에서 원본 태스크 정보 추출 (복잡)
    task_id = request.kwargs.get('task_id') or \
              request.args[0].get('task_id')
    # Redis에 에러 상태 기록
    redis.hset(f"pipeline:{task_id}:lyric", "status", "failed")
    # DLQ에 저장
    redis.lpush("failed_tasks", json.dumps({...}))

문제:
  - request 객체 파싱이 복잡 (args/kwargs 구조 일관성 없음)
  - Redis와 DB 이중 상태 관리
  - 에러 핸들러가 별도 태스크 → 큐 소비

[LangGraph: 에러 복구 노드]
async def lyric_api_recovery_node(state: PipelineState) -> dict:
    # State에서 모든 정보에 바로 접근
    task_id = state["task_id"]
    error = state["error_message"]
    retry_count = state["lyric_retry_count"]

    # 에러 유형별 복구
    if "rate_limit" in error:
        await asyncio.sleep(60)  # 1분 대기
    elif "api_key" in error:
        # 대체 API 키 사용
        state["api_key"] = get_backup_api_key()

    return {
        "error_message": None,  # 에러 클리어
        "lyric_retry_count": retry_count,
        "messages": [f"API 복구 시도: {error}"],
    }

장점:
  ✓ State에서 모든 정보 직접 접근
  ✓ 복구 후 자연스럽게 원래 노드로 루프백
  ✓ 별도 큐 소비 없음
  ✓ 체크포인트로 복구 이력 보존

┌──────────────────────────┬─────────────────────────┬───────────────────────────┐
│         기준              │ Celery link/link_error  │ LangGraph conditional_edges│
├──────────────────────────┼─────────────────────────┼───────────────────────────┤
│ 분기 유형                │ 이진 (성공/실패)         │ 다중 (N개 조건)             │
│ 에러 원인별 분기          │ 핸들러 내부 if문         │ 그래프 엣지로 외부화        │
│ 에러 복구 후 재시도       │ 수동 (새 콜백 구성)      │ 자연스러운 루프백           │
│ State 접근               │ request 파싱 필요       │ State 직접 접근             │
│ 피드백 기반 재시도        │ 불가                    │ State에 피드백 누적         │
│ RAG 활용 복구            │ 불가 (메시지 크기 제한)  │ State로 RAG 컨텍스트 공유   │
│ 인프라                   │ 단일 큐 + 워커          │ 단일 그래프 (큐/워커 불필요)│
│ 모니터링                 │ Flower                  │ LangSmith                  │
│ 콜백 중첩 가독성          │ 낮음 (깊은 중첩)        │ 높음 (그래프 정의)          │
│ 동시 처리                │ 워커 수 × 동시성        │ asyncio (비동기)            │
│ 수평 확장                │ 워커 추가               │ 인스턴스 추가              │
└──────────────────────────┴─────────────────────────┴───────────────────────────┘

9. 프롬프트 및 RAG 최적화

9.1 에러 격리 특화 최적화

┌─────────────────────────────────────────────────────────────────────────────┐
│           에러 격리 설계 특화 RAG 최적화                                      │
└─────────────────────────────────────────────────────────────────────────────┘

1. 실패 피드백 기반 프롬프트 진화
   ──────────────────────────────
   매 재시도마다 이전 실패 원인을 프롬프트에 반영:
   "이전 시도 (score: 0.45)에서 지역 특색이 부족했습니다.
    다음 참조 자료를 활용하여 {region}의 특색을 강화하세요."
   → 동일 실수 반복 방지

2. 에러 패턴 학습 및 사전 방지
   ──────────────────────────────
   자주 실패하는 조합을 벡터 DB에 "주의 사항"으로 저장:
   metadata: {"type": "caution", "region": "군산", "issue": "지역 특색 부족"}
   → 동일 지역 요청 시 사전에 주의 사항을 프롬프트에 포함

3. 대체 프롬프트 전략 (Fallback Prompts)
   ──────────────────────────────────────
   API 에러 복구 후 재시도 시, 간소화된 프롬프트 사용:
   - 1차: 풀 RAG 컨텍스트 + 상세 지침
   - 2차: 핵심 컨텍스트만 + 간략 지침
   - 3차: 최소 프롬프트 (안정성 우선)

4. 점진적 컨텍스트 보강
   ──────────────────────
   품질 실패 시, 부족한 영역에 대해 추가 RAG 검색:
   - 1차 실패: "지역 특색 부족" → 지역 정보 추가 검색
   - 2차 실패: "마케팅 메시지 약함" → 마케팅 사례 추가 검색
   → 에러에서 학습하여 컨텍스트 보강

5. 멀티 모델 Fallback
   ──────────────────────
   GPT-4o 실패 시 Claude, 다시 실패 시 GPT-4o-mini로 대체
   각 모델에 최적화된 프롬프트 템플릿 사용
   → API 장애 시에도 서비스 지속

문서 버전

버전 날짜 변경 내용
1.0 2024-XX-XX 초안 작성