838 lines
39 KiB
Markdown
838 lines
39 KiB
Markdown
# 설계안 3 LangGraph 전환: Beat 상태머신 → LangGraph 체크포인트 기반 상태 그래프
|
||
|
||
> **Celery Beat + DB 폴링 상태 머신을 LangGraph 네이티브 체크포인팅 + 이벤트 소싱 그래프로 전환한 설계안**
|
||
|
||
---
|
||
|
||
## 목차
|
||
|
||
1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점)
|
||
2. [아키텍처 설계](#2-아키텍처-설계)
|
||
3. [상태 머신 전환](#3-상태-머신-전환)
|
||
4. [RAG 통합 + 이벤트 소싱](#4-rag-통합-이벤트-소싱)
|
||
5. [코드 구현](#5-코드-구현)
|
||
6. [Beat 폴링 제거 → 이벤트 기반 전환](#6-beat-폴링-제거--이벤트-기반-전환)
|
||
7. [실패 처리 및 자동 복구](#7-실패-처리-및-자동-복구)
|
||
8. [Celery Beat 대비 비교](#8-celery-beat-대비-비교)
|
||
9. [프롬프트 및 RAG 최적화](#9-프롬프트-및-rag-최적화)
|
||
|
||
---
|
||
|
||
## 1. 개요 및 핵심 차이점
|
||
|
||
### 1.1 설계 철학
|
||
|
||
Celery 설계안 3은 **Beat 스케줄러가 DB를 폴링하여 다음 단계를 디스패치**하는 이벤트 소싱 패턴입니다.
|
||
LangGraph는 **체크포인터가 네이티브로 상태를 관리**하므로, Beat 폴링이 불필요해집니다.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Celery Beat 상태머신 vs LangGraph 체크포인트 상태 그래프 │
|
||
├──────────────────────────────────────┬──────────────────────────────────────┤
|
||
│ Celery Beat + 상태머신 │ LangGraph + Checkpointer │
|
||
├──────────────────────────────────────┼──────────────────────────────────────┤
|
||
│ DB에 Pipeline 테이블 추가 필요 │ 체크포인터가 상태를 자동 저장 │
|
||
│ Beat가 10초마다 DB 폴링 │ 폴링 불필요 (이벤트 기반) │
|
||
│ Beat가 "다음 단계" 결정 │ 그래프 엣지가 "다음 단계" 결정 │
|
||
│ 워커가 DB 상태만 변경하고 종료 │ 노드가 State 변경하고 다음으로 진행 │
|
||
│ 10초 지연 (폴링 간격) │ 지연 없음 (즉시 실행) │
|
||
│ DB가 진실의 원천 (ACID) │ 체크포인터 + DB 이중 보장 │
|
||
│ Beat 단일 장애점 │ 단일 장애점 없음 (stateless 실행) │
|
||
│ Pipeline 모델 마이그레이션 필요 │ 추가 테이블 불필요 │
|
||
│ SQL 쿼리로 모니터링 │ LangSmith + 체크포인트 이력 │
|
||
│ stuck 감지 (15분 타임아웃) │ 예외 즉시 감지 (동기 실행) │
|
||
└──────────────────────────────────────┴──────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.2 핵심 개념 매핑
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Celery Beat 개념 → LangGraph 매핑 │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Pipeline 테이블 → LangGraph State + Checkpointer │
|
||
│ PipelineStatus enum → State의 current_stage 필드 │
|
||
│ PipelineStage enum → 그래프 노드 이름 │
|
||
│ Beat 스케줄러 → 그래프 엔진 (자동 노드 전이) │
|
||
│ dispatch_pipelines() → 그래프 엣지 (자동 다음 노드 실행) │
|
||
│ next_stage_created 플래그 → 불필요 (그래프가 자동 전이) │
|
||
│ stuck 감지 → 타임아웃 데코레이터 + 예외 처리 │
|
||
│ DB 폴링 사이클 → 불필요 (이벤트 기반 실행) │
|
||
│ retry_count / max_retries → State에서 관리 + 조건부 엣지 │
|
||
│ config_json → State 객체 (구조화된 타입) │
|
||
│ DLQ (Dead Letter Queue) → fatal_error_handler 노드 + DB 기록 │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.3 Beat의 핵심 이점을 LangGraph에서 보존
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Beat 설계안의 강점 보존 방법 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[강점 1: 완전한 태스크 독립성]
|
||
Beat: 각 태스크가 "다음 단계가 있다"는 것조차 모름
|
||
LangGraph: 각 노드는 State만 변경, 다음 노드를 모름
|
||
→ 동일하게 보존됨
|
||
|
||
[강점 2: DB가 진실의 원천]
|
||
Beat: Pipeline 테이블이 모든 상태 관리 (ACID)
|
||
LangGraph: 체크포인터(SQLite/Postgres) + 비즈니스 DB 이중 기록
|
||
→ 체크포인터로 상태 관리 + save_to_db 노드로 비즈니스 DB 저장
|
||
|
||
[강점 3: 이벤트 소싱 (상태 이력)]
|
||
Beat: Pipeline 테이블에 상태 변경 이력 축적
|
||
LangGraph: 체크포인터에 모든 State 스냅샷 저장
|
||
→ 더 풍부한 이력 (State 전체를 매 노드마다 저장)
|
||
|
||
[강점 4: 자동 복구 (stuck 감지)]
|
||
Beat: 15분 폴링으로 stuck 감지 → 재디스패치
|
||
LangGraph: 노드에 타임아웃 설정 → 예외 → 조건부 복구 노드
|
||
→ 더 즉각적인 감지 (10초 폴링 대기 없음)
|
||
|
||
[강점 5: 런타임 제어]
|
||
Beat: DB 수정으로 재시도 횟수/상태 변경
|
||
LangGraph: 체크포인트에서 State 수정 후 재개
|
||
→ API를 통한 런타임 제어 (update_state)
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 아키텍처 설계
|
||
|
||
### 2.1 전체 아키텍처 비교
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Beat 아키텍처 → LangGraph 아키텍처 전환 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Celery Beat 아키텍처] [LangGraph 아키텍처]
|
||
━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
|
||
|
||
Client Client
|
||
│ │
|
||
▼ ▼
|
||
FastAPI FastAPI
|
||
│ │
|
||
▼ ▼
|
||
DB (Pipeline 생성) LangGraph Engine
|
||
↑ │
|
||
│ 10초마다 폴링 ├── marketing_search
|
||
│ ├── local_search
|
||
Celery Beat ──→ 큐에 디스패치 ├── rag_retrieval
|
||
│ ├── embedding_store
|
||
▼ ├── generate_lyric
|
||
Workers (lyric/song/video) ├── validate_lyric
|
||
│ ├── generate_song
|
||
▼ ├── generate_video
|
||
DB (상태 변경) └── save_results
|
||
│
|
||
▼
|
||
Checkpointer (SQLite/Postgres)
|
||
+
|
||
비즈니스 DB (PostgreSQL)
|
||
|
||
제거된 컴포넌트:
|
||
✗ Celery Beat 프로세스
|
||
✗ Pipeline 테이블 (별도 마이그레이션)
|
||
✗ Redis 브로커
|
||
✗ 3종류 Worker 프로세스
|
||
✗ scheduler_queue
|
||
✗ DB 폴링 로직
|
||
```
|
||
|
||
### 2.2 LangGraph 그래프 구조
|
||
|
||
```
|
||
START
|
||
│
|
||
▼
|
||
┌────────────────────────┐
|
||
│ marketing_search │ ← RAG (신규)
|
||
│ 30건 외부 검색 │
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #1 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ local_search │ ← 지역 정보 (신규)
|
||
│ 각 10건 → 3건 선택 │
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #2 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ rag_retrieval │ ← 벡터 DB 검색
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #3 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ embedding_store │ ← 임베딩 저장
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #4 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ generate_lyric │ ← Beat의 lyric Worker 대응
|
||
│ (RAG 강화 ChatGPT) │
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #5 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ validate_lyric │
|
||
└──┬──────────┬──────────┘
|
||
[pass] [retry] [fail]
|
||
│ │ │
|
||
│ └──→ generate_lyric (루프)
|
||
│ │
|
||
│ fatal_handler → END
|
||
│ ◄── Checkpoint #6 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ generate_song │ ← Beat의 song Worker 대응
|
||
│ (Suno API + 폴링) │
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #7 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ generate_video │ ← Beat의 video Worker 대응
|
||
│ (Creatomate 렌더링) │
|
||
└──────────┬─────────────┘
|
||
│ ◄── Checkpoint #8 저장
|
||
▼
|
||
┌────────────────────────┐
|
||
│ save_results │ ← 비즈니스 DB 저장
|
||
└──────────┬─────────────┘
|
||
▼
|
||
END
|
||
|
||
매 노드 실행 후 Checkpoint 자동 저장
|
||
→ Beat의 DB 상태 기록과 동일하지만, 폴링 불필요
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 상태 머신 전환
|
||
|
||
### 3.1 Pipeline 상태 머신 → LangGraph State
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Pipeline 상태 머신 → LangGraph State 전환 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Celery Beat: Pipeline 모델]
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
class PipelineStage(Enum):
|
||
LYRIC = "lyric"
|
||
SONG = "song"
|
||
VIDEO = "video"
|
||
|
||
class PipelineStatus(Enum):
|
||
PENDING = "pending"
|
||
DISPATCHED = "dispatched"
|
||
PROCESSING = "processing"
|
||
STAGE_COMPLETED = "stage_completed"
|
||
PIPELINE_COMPLETED = "pipeline_completed"
|
||
FAILED = "failed"
|
||
DEAD = "dead"
|
||
|
||
→ DB 테이블 + Beat 폴링 + 4단계 처리 사이클
|
||
|
||
[LangGraph: State + 그래프 노드]
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
class PipelineState(TypedDict):
|
||
current_stage: str # 현재 노드 이름
|
||
# ... 나머지 필드들
|
||
|
||
→ 그래프 엔진이 자동으로 상태 전이
|
||
→ PENDING, DISPATCHED 상태 불필요 (그래프가 즉시 실행)
|
||
→ stuck 감지 불필요 (동기 실행이므로 타임아웃으로 처리)
|
||
|
||
상태 전이 매핑:
|
||
PENDING → (제거: 그래프가 즉시 실행)
|
||
DISPATCHED → (제거: 큐 발행 불필요)
|
||
PROCESSING → 노드 실행 중 (자동)
|
||
STAGE_COMPLETED → 다음 노드로 자동 전이
|
||
FAILED → 조건부 엣지로 에러 핸들링 노드
|
||
DEAD → fatal_error_handler → END
|
||
PIPELINE_COMPLETED → END 노드 도달
|
||
```
|
||
|
||
### 3.2 Beat의 4단계 폴링 사이클 제거
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Beat 폴링 4단계 → LangGraph에서의 제거 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Beat의 매 10초 사이클]
|
||
━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
Step 1: pending 레코드 → 큐에 디스패치
|
||
LangGraph: 제거! 그래프가 즉시 다음 노드 실행
|
||
→ 10초 지연 → 0초 지연
|
||
|
||
Step 2: stage_completed → 다음 stage 생성
|
||
LangGraph: 제거! 그래프 엣지가 자동으로 다음 노드로 전이
|
||
→ Pipeline 레코드 생성 불필요
|
||
|
||
Step 3: failed → 재시도 판단
|
||
LangGraph: 조건부 엣지가 즉시 에러 복구 노드로 분기
|
||
→ 폴링 대기 없이 즉각 복구
|
||
|
||
Step 4: stuck 감지 → 재디스패치
|
||
LangGraph: 노드에 타임아웃 설정
|
||
→ soft_time_limit 대신 asyncio.wait_for(coro, timeout=300)
|
||
→ 타임아웃 발생 시 즉시 에러 처리
|
||
|
||
결과: Beat의 4단계 폴링이 모두 불필요해짐
|
||
- 지연: 10초 → 0초
|
||
- DB 부하: 중간 → 없음 (폴링 쿼리 제거)
|
||
- 복잡도: Pipeline 모델 + 디스패처 → 그래프 정의만
|
||
```
|
||
|
||
---
|
||
|
||
## 4. RAG 통합 + 이벤트 소싱
|
||
|
||
### 4.1 이벤트 소싱: 체크포인트 기반
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 이벤트 소싱: Pipeline 테이블 → Checkpointer │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Celery Beat: Pipeline 테이블 이벤트 시퀀스]
|
||
id │ stage │ status │ created_at
|
||
────┼───────┼─────────────────┼───────────
|
||
1 │ lyric │ pending │ 12:00:00
|
||
1 │ lyric │ dispatched │ 12:00:10 ← 10초 대기
|
||
1 │ lyric │ processing │ 12:00:11
|
||
1 │ lyric │ stage_completed │ 12:00:16
|
||
2 │ song │ pending │ 12:00:20 ← 4초 대기 (다음 폴링)
|
||
...
|
||
|
||
[LangGraph: Checkpointer 이벤트 시퀀스]
|
||
step │ node_name │ state_snapshot │ created_at
|
||
──────┼───────────────────┼──────────────────────────┼───────────
|
||
1 │ marketing_search │ {marketing_docs: [...]} │ 12:00:00
|
||
2 │ local_search │ {local_*: [...]} │ 12:00:02
|
||
3 │ rag_retrieval │ {rag_similar: [...]} │ 12:00:03
|
||
4 │ embedding_store │ {messages: [...]} │ 12:00:04
|
||
5 │ generate_lyric │ {lyric_result: "..."} │ 12:00:09
|
||
6 │ validate_lyric │ {lyric_score: 0.78} │ 12:00:10
|
||
7 │ generate_song │ {song_url: "..."} │ 12:01:15 ← 즉시 실행!
|
||
8 │ generate_video │ {video_url: "..."} │ 12:03:30
|
||
9 │ save_results │ {current_stage: "done"} │ 12:03:31
|
||
|
||
장점:
|
||
✓ 각 단계의 전체 State 스냅샷 보존 (Pipeline 테이블보다 풍부)
|
||
✓ 폴링 대기 없이 즉시 다음 단계 실행
|
||
✓ RAG 검색 결과까지 이력에 포함
|
||
✓ 어떤 지점에서든 State를 복원하여 재실행 가능
|
||
```
|
||
|
||
### 4.2 RAG 결과의 이벤트 소싱 통합
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ RAG 결과 + 이벤트 소싱 통합 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
Beat에서 불가능했던 것:
|
||
- Pipeline 테이블에 RAG 검색 결과를 저장하려면 추가 컬럼/테이블 필요
|
||
- config_json에 모든 것을 JSON으로 직렬화 → 쿼리 불편
|
||
|
||
LangGraph Checkpointer에서 가능한 것:
|
||
- State에 RAG 결과가 자연스럽게 포함
|
||
- 매 노드마다 전체 State 스냅샷 저장
|
||
- 특정 시점의 RAG 컨텍스트를 조회 가능
|
||
|
||
예: "이 가사가 참조한 마케팅 문서는 무엇이었나?"
|
||
→ step 5 (generate_lyric) 시점의 State에서
|
||
marketing_docs, local_landmarks 등 조회 가능
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 코드 구현
|
||
|
||
### 5.1 그래프 빌더 (Beat 디스패처 대응)
|
||
|
||
```python
|
||
from langgraph.graph import StateGraph, END
|
||
from langgraph.checkpoint.postgres import PostgresSaver
|
||
|
||
def build_state_machine_graph() -> StateGraph:
|
||
"""
|
||
Celery Beat + 상태머신 → LangGraph 전환
|
||
|
||
Beat의 dispatch_pipelines() 함수가 담당하던 역할을
|
||
그래프 엣지 정의로 대체합니다.
|
||
|
||
Beat의 4단계 사이클:
|
||
Step 1 (pending → dispatch): 엣지로 자동 전이
|
||
Step 2 (completed → next stage): 엣지로 자동 전이
|
||
Step 3 (failed → retry): 조건부 엣지로 복구
|
||
Step 4 (stuck → reset): 타임아웃 + 에러 처리
|
||
"""
|
||
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)
|
||
|
||
# ─── 메인 노드 (Beat의 Worker 대응) ───
|
||
graph.add_node("generate_lyric", lyric_node_with_timeout)
|
||
graph.add_node("validate_lyric", lyric_validation_node)
|
||
graph.add_node("generate_song", song_node_with_timeout)
|
||
graph.add_node("generate_video", video_node_with_timeout)
|
||
graph.add_node("save_results", save_results_node)
|
||
|
||
# ─── 에러 복구 노드 ───
|
||
graph.add_node("lyric_recovery", lyric_recovery_node)
|
||
graph.add_node("song_recovery", song_recovery_node)
|
||
graph.add_node("video_recovery", video_recovery_node)
|
||
graph.add_node("fatal_handler", fatal_handler_node)
|
||
|
||
# ─── 엣지 (Beat의 NEXT_STAGE_MAP 대응) ───
|
||
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")
|
||
|
||
# ─── 조건부 분기 (Beat의 재시도 판단 대응) ───
|
||
graph.add_conditional_edges(
|
||
"validate_lyric",
|
||
route_after_lyric_validation,
|
||
{
|
||
"pass": "generate_song",
|
||
"retry": "lyric_recovery",
|
||
"fail": "fatal_handler",
|
||
},
|
||
)
|
||
graph.add_edge("lyric_recovery", "generate_lyric")
|
||
|
||
# Song 에러 분기
|
||
graph.add_conditional_edges(
|
||
"generate_song",
|
||
route_after_song,
|
||
{
|
||
"success": "generate_video",
|
||
"retry": "song_recovery",
|
||
"fail": "fatal_handler",
|
||
},
|
||
)
|
||
graph.add_edge("song_recovery", "generate_song")
|
||
|
||
# Video 에러 분기
|
||
graph.add_conditional_edges(
|
||
"generate_video",
|
||
route_after_video,
|
||
{
|
||
"success": "save_results",
|
||
"retry": "video_recovery",
|
||
"fail": "fatal_handler",
|
||
},
|
||
)
|
||
graph.add_edge("video_recovery", "generate_video")
|
||
|
||
graph.add_edge("save_results", END)
|
||
graph.add_edge("fatal_handler", END)
|
||
|
||
# ─── 체크포인터 (Beat의 Pipeline 테이블 대응) ───
|
||
# PostgreSQL 체크포인터로 ACID 보장 (Beat의 DB 기반 장점 보존)
|
||
checkpointer = PostgresSaver.from_conn_string(
|
||
"postgresql://user:pass@localhost/castad"
|
||
)
|
||
|
||
return graph.compile(checkpointer=checkpointer)
|
||
```
|
||
|
||
### 5.2 타임아웃 노드 래퍼 (Beat의 stuck 감지 대응)
|
||
|
||
```python
|
||
import asyncio
|
||
from functools import wraps
|
||
|
||
def with_timeout(timeout_seconds: int):
|
||
"""
|
||
Beat의 stuck 감지를 대체하는 타임아웃 데코레이터
|
||
|
||
Beat: dispatched_at < NOW() - 15분 → 재디스패치
|
||
LangGraph: 노드 실행이 timeout 초과 → 예외 → 조건부 복구
|
||
"""
|
||
def decorator(node_func):
|
||
@wraps(node_func)
|
||
async def wrapper(state: PipelineState) -> dict:
|
||
try:
|
||
result = await asyncio.wait_for(
|
||
node_func(state),
|
||
timeout=timeout_seconds,
|
||
)
|
||
return result
|
||
except asyncio.TimeoutError:
|
||
return {
|
||
"error_message": f"Timeout after {timeout_seconds}s",
|
||
"current_stage": f"{state.get('current_stage', 'unknown')}_timeout",
|
||
"messages": [f"타임아웃: {timeout_seconds}초 초과"],
|
||
}
|
||
return wrapper
|
||
return decorator
|
||
|
||
|
||
# Beat에서 soft_time_limit=540이었던 Song 태스크:
|
||
@with_timeout(540)
|
||
async def song_node_with_timeout(state: PipelineState) -> dict:
|
||
"""Suno API 호출 (최대 9분)"""
|
||
# ... Suno API 호출 로직
|
||
pass
|
||
|
||
|
||
# Beat에서 time_limit=900이었던 Video 태스크:
|
||
@with_timeout(900)
|
||
async def video_node_with_timeout(state: PipelineState) -> dict:
|
||
"""Creatomate 렌더링 (최대 15분)"""
|
||
# ... Creatomate 호출 로직
|
||
pass
|
||
```
|
||
|
||
### 5.3 API (Beat의 "DB 레코드 생성만" 대응)
|
||
|
||
```python
|
||
@router.post("/start")
|
||
async def start_pipeline(request: StartPipelineRequest):
|
||
"""
|
||
Beat 방식: Pipeline 레코드만 생성, Beat가 나중에 감지
|
||
LangGraph 방식: 그래프 직접 실행 (즉시 시작)
|
||
|
||
Beat: "파이프라인이 생성되었습니다. Beat가 곧 처리를 시작합니다."
|
||
LangGraph: "파이프라인이 즉시 시작됩니다."
|
||
"""
|
||
initial_state = build_initial_state(request)
|
||
config = {"configurable": {"thread_id": request.task_id}}
|
||
|
||
# Beat에서는 DB INSERT만 하고 반환했지만,
|
||
# LangGraph에서는 즉시 실행 (또는 백그라운드 실행)
|
||
|
||
# 옵션 1: 동기 실행 (완료까지 대기)
|
||
# result = await pipeline_graph.ainvoke(initial_state, config)
|
||
|
||
# 옵션 2: 백그라운드 실행 (Beat 방식과 유사한 비동기)
|
||
import asyncio
|
||
asyncio.create_task(
|
||
pipeline_graph.ainvoke(initial_state, config)
|
||
)
|
||
|
||
return {
|
||
"task_id": request.task_id,
|
||
"message": "파이프라인이 즉시 시작됩니다.",
|
||
# Beat: "Beat가 곧 처리를 시작합니다." (10초 후)
|
||
}
|
||
|
||
|
||
@router.get("/status/{task_id}")
|
||
async def get_status(task_id: str):
|
||
"""
|
||
Beat: Pipeline 테이블 SELECT 쿼리
|
||
LangGraph: 체크포인터에서 최신 State 조회
|
||
"""
|
||
config = {"configurable": {"thread_id": task_id}}
|
||
state = await pipeline_graph.aget_state(config)
|
||
|
||
if not state or not state.values:
|
||
raise HTTPException(404, "Pipeline not found")
|
||
|
||
values = state.values
|
||
return {
|
||
"task_id": task_id,
|
||
"current_stage": values.get("current_stage", "unknown"),
|
||
"lyric_score": values.get("lyric_score"),
|
||
"song_url": values.get("song_result_url"),
|
||
"video_url": values.get("video_result_url"),
|
||
"error": values.get("error_message"),
|
||
"messages": values.get("messages", []),
|
||
# Beat에서의 stages 정보도 제공
|
||
"checkpoint_history": [
|
||
{"step": s.step, "node": s.metadata.get("source")}
|
||
for s in pipeline_graph.get_state_history(config)
|
||
],
|
||
}
|
||
|
||
|
||
@router.post("/retry/{task_id}")
|
||
async def retry_pipeline(task_id: str):
|
||
"""
|
||
Beat: failed Pipeline의 status를 pending으로 변경, Beat가 재디스패치
|
||
LangGraph: 체크포인트에서 State 수정 후 재개
|
||
|
||
Beat 방식:
|
||
UPDATE pipelines SET status='pending', dispatched_at=NULL WHERE ...
|
||
→ Beat가 다음 사이클에서 감지 (10초 후)
|
||
|
||
LangGraph 방식:
|
||
State의 에러 클리어 → 즉시 재개
|
||
"""
|
||
config = {"configurable": {"thread_id": task_id}}
|
||
|
||
# State 수정: 에러 클리어
|
||
await pipeline_graph.aupdate_state(
|
||
config,
|
||
{"error_message": None},
|
||
)
|
||
|
||
# 즉시 재개 (Beat처럼 10초 대기 없음)
|
||
result = await pipeline_graph.ainvoke(None, config)
|
||
|
||
return {
|
||
"task_id": task_id,
|
||
"resumed": True,
|
||
"status": result.get("current_stage"),
|
||
}
|
||
```
|
||
|
||
### 5.4 가사 생성 노드 (Beat의 Worker 대응)
|
||
|
||
```python
|
||
async def lyric_node_with_timeout(state: PipelineState) -> dict:
|
||
"""
|
||
Beat 방식의 lyric Worker → LangGraph 노드
|
||
|
||
Beat Worker 특징:
|
||
- pipeline_id를 받아 DB에서 모든 데이터 조회
|
||
- 결과를 반환하지 않음 (DB 상태만 변경)
|
||
- "다음 단계가 있다"는 것조차 모름
|
||
|
||
LangGraph 노드 특징:
|
||
- State에서 모든 데이터 접근 (DB 조회 최소화)
|
||
- State 변경으로 결과 반환
|
||
- 다음 노드를 모름 (그래프 엣지가 결정)
|
||
- RAG 컨텍스트 접근 가능
|
||
"""
|
||
|
||
# Beat에서는 pipeline_id로 DB 조회했지만,
|
||
# LangGraph에서는 State에서 직접 접근
|
||
customer_name = state["customer_name"]
|
||
region = state["region"]
|
||
|
||
# ─── Beat에서 불가능했던 RAG 컨텍스트 활용 ───
|
||
marketing_ctx = format_docs(state.get("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])
|
||
|
||
# 재시도 시 피드백 포함
|
||
feedback = state.get("enriched_context", "")
|
||
|
||
# Structured Output
|
||
llm = ChatOpenAI(model="gpt-4o", temperature=0.8)
|
||
structured_llm = llm.with_structured_output(LyricOutput)
|
||
|
||
result = await structured_llm.ainvoke(
|
||
prompt.format(
|
||
customer_name=customer_name,
|
||
region=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,
|
||
feedback=feedback,
|
||
output_schema=LyricOutput.model_json_schema(),
|
||
)
|
||
)
|
||
|
||
lyric_text = "\n".join(line.text for line in result.lines)
|
||
|
||
# Beat Worker: DB 상태만 변경하고 return 없음
|
||
# LangGraph 노드: State 변경 dict 반환
|
||
return {
|
||
"lyric_result": lyric_text,
|
||
"lyric_structured": result.model_dump(),
|
||
"lyric_retry_count": state.get("lyric_retry_count", 0) + 1,
|
||
"current_stage": "lyric_completed",
|
||
"error_message": None,
|
||
"messages": [f"가사 생성 완료 (시도 #{state.get('lyric_retry_count', 0) + 1})"],
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Beat 폴링 제거 → 이벤트 기반 전환
|
||
|
||
### 6.1 폴링 vs 이벤트 기반 비교
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Beat 폴링 → LangGraph 이벤트 기반 비교 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Beat: 폴링 기반]
|
||
━━━━━━━━━━━━━━━
|
||
T+0.0 API: Pipeline(status=pending) INSERT
|
||
T+0.0~10.0 대기 (Beat 다음 폴링까지)
|
||
T+10.0 Beat: SELECT WHERE status=pending → 발견!
|
||
T+10.0 Beat: status=dispatched, lyric_queue에 디스패치
|
||
T+10.1 Worker: 태스크 수신, 처리 시작
|
||
T+15.0 Worker: 처리 완료, status=stage_completed
|
||
T+15.0~25.0 대기 (Beat 다음 폴링까지)
|
||
T+25.0 Beat: stage_completed 발견! → song pending 생성
|
||
...
|
||
|
||
총 파이프라인 지연: 단계당 ~10초 × 3단계 = ~30초 추가 지연
|
||
|
||
[LangGraph: 이벤트 기반]
|
||
━━━━━━━━━━━━━━━━━━━━━━
|
||
T+0.0 API: graph.ainvoke(state)
|
||
T+0.0 marketing_search 즉시 실행
|
||
T+2.0 local_search 즉시 실행
|
||
T+3.0 rag_retrieval 즉시 실행
|
||
T+4.0 embedding_store 즉시 실행
|
||
T+9.0 generate_lyric 즉시 실행
|
||
T+10.0 validate_lyric 즉시 실행
|
||
T+10.0 generate_song 즉시 실행 (대기 없음!)
|
||
...
|
||
|
||
총 파이프라인 지연: 0초 (폴링 없음)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 실패 처리 및 자동 복구
|
||
|
||
### 7.1 Beat의 재시도 vs LangGraph 재시도
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 재시도 메커니즘 비교 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
[Beat: DB 기반 재시도]
|
||
━━━━━━━━━━━━━━━━━━━━
|
||
1. Worker 실패 → Pipeline.status = 'failed', retry_count += 1
|
||
2. Beat 폴링: WHERE status='failed' AND retry_count < max_retries
|
||
3. 재시도 간격 확인: last_failed_at < NOW() - retry_delay
|
||
4. pending으로 변경 → 다음 폴링에서 디스패치
|
||
|
||
장점: DB 수정으로 런타임 제어 가능
|
||
단점: 재시도까지 최대 20초+ 지연 (폴링 + 재시도 간격)
|
||
|
||
[LangGraph: 그래프 기반 재시도]
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
1. 노드 실행 결과 → 조건부 엣지에서 판단
|
||
2. retry 라우트 → recovery 노드 → 원래 노드 루프백
|
||
3. recovery 노드에서 대기 시간, 설정 변경 등 처리
|
||
|
||
장점: 즉각 복구, 피드백 기반 재시도
|
||
단점: 런타임 제어는 API로 State 수정 필요
|
||
|
||
[런타임 제어 비교]
|
||
Beat:
|
||
UPDATE pipelines SET max_retries=5 WHERE task_id='xxx';
|
||
UPDATE pipelines SET status='pending' WHERE task_id='xxx';
|
||
|
||
LangGraph:
|
||
await graph.aupdate_state(config, {"lyric_retry_count": 0})
|
||
await graph.ainvoke(None, config) # 재개
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Celery Beat 대비 비교
|
||
|
||
```
|
||
┌──────────────────────────┬──────────────────────────┬───────────────────────────┐
|
||
│ 기준 │ Celery Beat + 상태머신 │ LangGraph + Checkpointer │
|
||
├──────────────────────────┼──────────────────────────┼───────────────────────────┤
|
||
│ 상태 저장소 │ Pipeline 테이블 (MySQL) │ Checkpointer (Postgres) │
|
||
│ 다음 단계 결정 │ Beat 폴링 (10초마다) │ 그래프 엣지 (즉시) │
|
||
│ 지연 │ ~10초/단계 │ 0초 │
|
||
│ DB 부하 │ 중간 (폴링 쿼리) │ 낮음 (체크포인트 쓰기만) │
|
||
│ 추가 인프라 │ Beat + scheduler_queue │ 없음 │
|
||
│ 마이그레이션 │ Pipeline 테이블 필요 │ 체크포인터 테이블 자동 │
|
||
│ 태스크 독립성 │ 최고 (다음 단계 모름) │ 최고 (다음 노드 모름) │
|
||
│ 이벤트 소싱 │ Pipeline 테이블 │ 체크포인트 히스토리 │
|
||
│ 상태 이력 풍부도 │ 상태 + 시간만 │ 전체 State 스냅샷 │
|
||
│ 자동 복구 │ stuck 감지 (15분) │ 타임아웃 + 즉시 복구 │
|
||
│ 런타임 제어 │ DB UPDATE │ aupdate_state API │
|
||
│ 단일 장애점 │ Beat 프로세스 │ 없음 │
|
||
│ RAG 통합 │ config_json에 직렬화 │ State로 자연스럽게 통합 │
|
||
│ 모니터링 │ SQL 쿼리 │ LangSmith + 체크포인트 │
|
||
│ 동시 처리 │ Worker 수 × 동시성 │ asyncio 기반 │
|
||
│ 적합한 상황 │ 복잡한 워크플로 │ 품질 중심 + RAG 강화 │
|
||
└──────────────────────────┴──────────────────────────┴───────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 프롬프트 및 RAG 최적화
|
||
|
||
### 9.1 Beat 설계안 특화 최적화 (이벤트 소싱 활용)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 이벤트 소싱 + RAG 최적화 전략 │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
1. 체크포인트 히스토리 기반 학습
|
||
─────────────────────────────
|
||
과거 파이프라인 실행의 State 히스토리를 분석:
|
||
- 높은 lyric_score를 받은 실행의 marketing_docs 패턴
|
||
- 재시도 없이 통과한 실행의 RAG 컨텍스트 특성
|
||
→ 성공 패턴을 학습하여 검색 쿼리 최적화
|
||
|
||
2. State 스냅샷 기반 디버깅
|
||
─────────────────────────
|
||
실패한 파이프라인의 정확한 State 복원:
|
||
- 어떤 검색 결과가 프롬프트에 포함되었는지
|
||
- 어떤 RAG 문서가 참조되었는지
|
||
- 프롬프트 전문 확인
|
||
→ 실패 원인 분석 후 프롬프트/RAG 개선
|
||
|
||
3. A/B 테스트 (체크포인트 비교)
|
||
────────────────────────────
|
||
동일 입력에 대해 다른 RAG 설정으로 실행:
|
||
Thread A: chunk_size=500, reranker=cross-encoder
|
||
Thread B: chunk_size=300, reranker=cohere
|
||
→ 체크포인트의 lyric_score 비교로 최적 설정 도출
|
||
|
||
4. 누적 지식 그래프
|
||
────────────────
|
||
매 파이프라인 실행 시:
|
||
- 검색 결과 → 벡터 DB 저장 (임베딩)
|
||
- 성공 가사 → "성공 사례" 컬렉션 저장
|
||
- 실패 패턴 → "주의 사항" 컬렉션 저장
|
||
→ 실행할수록 RAG 품질 향상
|
||
|
||
5. 동적 폴링 간격 (하이브리드)
|
||
────────────────────────────
|
||
즉시 실행이 부담스러운 경우 (대량 요청):
|
||
- LangGraph를 Celery 태스크 내에서 실행
|
||
- Beat 폴링은 유지하되 LangGraph로 내부 처리
|
||
→ 분산 실행 + RAG 강화의 장점 결합
|
||
|
||
6. 컨텍스트 캐싱 전략
|
||
──────────────────
|
||
동일 region 반복 요청 시:
|
||
- 체크포인터에서 이전 실행의 local_search 결과 확인
|
||
- 7일 이내면 외부 검색 스킵 → State에서 복사
|
||
→ API 호출 비용 90% 절감 (동일 지역)
|
||
```
|
||
|
||
---
|
||
|
||
## 문서 버전
|
||
|
||
| 버전 | 날짜 | 변경 내용 |
|
||
|------|------|-----------|
|
||
| 1.0 | 2024-XX-XX | 초안 작성 |
|