32 KiB
32 KiB
설계안 2 LangGraph 전환: Callback Link 에러격리 → LangGraph 조건부 에러 분기
Celery link/link_error 콜백과 단일 큐 전략을 LangGraph 조건부 엣지 + 에러 서브그래프로 전환한 설계안
목차
- 개요 및 핵심 차이점
- 아키텍처 설계
- 에러 격리 전략 전환
- RAG + 에러 격리 통합
- 코드 구현
- 단일 큐 → 단일 그래프 전환
- 실패 처리 및 복구
- Celery Callback Link 대비 비교
- 프롬프트 및 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. 에러 격리 전략 전환
3.1 Celery link_error vs LangGraph 조건부 에러 분기
┌─────────────────────────────────────────────────────────────────────────────┐
│ 에러 격리 전략 비교 │
└─────────────────────────────────────────────────────────────────────────────┘
[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에서 모든 정보 직접 접근
✓ 복구 후 자연스럽게 원래 노드로 루프백
✓ 별도 큐 소비 없음
✓ 체크포인트로 복구 이력 보존
8. Celery Callback Link 대비 비교
┌──────────────────────────┬─────────────────────────┬───────────────────────────┐
│ 기준 │ 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 | 초안 작성 |