5대 프레임워크 종합 비교, 의사결정 트리, 하이브리드 아키텍처, 마이그레이션 가이드, 프레임워크 독립적 설계 원칙을 다룹니다.
10개의 장에 걸쳐 AI 오케스트레이션의 주요 프레임워크와 핵심 패턴을 살펴보았습니다. 이 마지막 장에서는 그 모든 내용을 종합하여, 실제 프로젝트에서 어떤 결정을 내려야 하는지 구체적인 가이드를 제시합니다.
기억해야 할 핵심은 이것입니다. 완벽한 프레임워크는 없고, 프로젝트에 적합한 프레임워크가 있을 뿐입니다.
| 항목 | LangChain | LangGraph | LlamaIndex | Semantic Kernel | Haystack |
|---|---|---|---|---|---|
| 핵심 강점 | 생태계, 통합 | 에이전트 워크플로우 | 데이터/RAG | 엔터프라이즈 | 최소 오버헤드 |
| 오버헤드 | ~10ms | ~14ms | ~6ms | - | ~5.9ms |
| 토큰 효율 | 중간 | 중간 | ~1.60k | 중간 | ~1.57k |
| 언어 지원 | Python, JS | Python, JS | Python | C#, Python, Java | Python |
| 학습 곡선 | 중간 | 높음 | 중간 | 중간 | 낮음 |
| 기능 | LangChain | LangGraph | LlamaIndex | Semantic Kernel | Haystack |
|---|---|---|---|---|---|
| 체이닝 | LCEL 파이프 | 그래프 엣지 | Workflows | 함수 체이닝 | Pipeline |
| 라우팅 | RunnableBranch | 조건부 엣지 | 이벤트 분기 | 플래너 | ConditionalRouter |
| 메모리 | MessageHistory | 체크포인팅 | ChatMemoryBuffer | SemanticMemory | ChatMessageMemory |
| 스트리밍 | astream/events | astream_events | response_gen | 콜백 | 콜백 |
| 에러 처리 | with_fallbacks | 조건부 엣지 | try-except | 필터 | 커스텀 컴포넌트 |
| 관측성 | OpenTelemetry + LangSmith | OpenTelemetry + LangSmith | 콜백 핸들러 | OpenTelemetry + App Insights | OpenTelemetry |
| 듀러블 상태 | 제한적 | SQLite/PostgreSQL | 제한적 | 제한적 | 제한적 |
| 휴먼인더루프 | 제한적 | interrupt() | 제한적 | 에이전트 그룹 | 제한적 |
| 항목 | LangChain | LangGraph | LlamaIndex | Semantic Kernel | Haystack |
|---|---|---|---|---|---|
| GitHub Stars | 매우 높음 | 높음 | 높음 | 27,500+ | 중간 |
| 서드파티 통합 | 매우 풍부 | LangChain 생태계 | LlamaHub | Azure 생태계 | 중간 |
| 학습 자료 | 매우 풍부 | 풍부 | 풍부 | 풍부 | 중간 |
| 기업 지원 | LangChain Inc. | LangChain Inc. | LlamaIndex Inc. | Microsoft | deepset |
프로젝트에 적합한 프레임워크를 선택하기 위한 의사결정 흐름입니다.
1단계: 팀의 기술 스택 확인
2단계: 주요 사용 사례 파악
3단계: 운영 요구사항 검토
4단계: 팀의 학습 역량 고려
프레임워크를 선택한 후에도 해당 프레임워크에 완전히 종속되지 않도록 설계하는 것이 중요합니다. 이 장의 마지막 섹션에서 프레임워크 독립적 설계 원칙을 다룹니다.
하나의 프레임워크만 사용할 필요는 없습니다. 각 프레임워크의 강점을 조합하는 하이브리드 아키텍처가 실전에서 자주 사용됩니다.
가장 검증된 하이브리드 패턴입니다. LlamaIndex가 데이터 레이어를, LangGraph가 오케스트레이션 레이어를 담당합니다.
from llama_index.core import VectorStoreIndex
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
# --- LlamaIndex: 데이터 레이어 ---
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine(similarity_top_k=5)
# --- LangGraph: 오케스트레이션 레이어 ---
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
context: str
needs_research: bool
def research_node(state: AgentState) -> dict:
"""LlamaIndex를 사용한 연구 노드"""
query = state["messages"][-1].content
response = query_engine.query(query)
return {
"context": str(response),
"needs_research": False,
}
def respond_node(state: AgentState) -> dict:
"""컨텍스트를 활용한 응답 생성"""
context = state.get("context", "")
messages = state["messages"]
response = model.invoke(
f"컨텍스트: {context}\n\n"
f"질문: {messages[-1].content}"
)
return {"messages": [response]}
def route(state: AgentState) -> str:
if state.get("needs_research", True):
return "research"
return "respond"
# 그래프 구성
graph = StateGraph(AgentState)
graph.add_node("research", research_node)
graph.add_node("respond", respond_node)
graph.add_conditional_edges(START, route)
graph.add_edge("research", "respond")
graph.add_edge("respond", END)
app = graph.compile(checkpointer=checkpointer)지연 시간에 민감한 검색 파이프라인에 에이전트 기능을 추가할 때 적합합니다.
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
# Haystack: 고성능 검색 파이프라인
search_pipeline = Pipeline()
search_pipeline.add_component("retriever", bm25_retriever)
search_pipeline.add_component("reranker", reranker)
search_pipeline.connect("retriever", "reranker")
# LangGraph: 에이전트 오케스트레이션에서 Haystack 파이프라인 호출
def search_node(state: AgentState) -> dict:
result = search_pipeline.run({
"retriever": {"query": state["messages"][-1].content}
})
docs = result["reranker"]["documents"]
context = "\n".join(d.content for d in docs[:3])
return {"context": context}하이브리드 아키텍처를 사용할 때는 프레임워크 간 경계를 명확하게 정의해야 합니다. 각 프레임워크의 역할이 겹치면 복잡성만 증가하고 장점은 사라집니다. "데이터 레이어는 LlamaIndex, 오케스트레이션은 LangGraph"처럼 역할을 분명히 분리하세요.
기존 프레임워크에서 다른 프레임워크로 전환해야 하는 상황은 자주 발생합니다.
가장 흔한 마이그레이션 경로입니다. 단순 체인에서 에이전트 워크플로우로 진화할 때 발생합니다.
# Before: LangChain LCEL
chain = (
RunnableParallel(context=retriever, question=RunnablePassthrough())
| prompt
| model
| parser
)
# After: LangGraph (동일 기능 + 에이전트 확장 가능)
from langgraph.graph import StateGraph, START, END
class RAGState(TypedDict):
question: str
context: str
response: str
def retrieve(state: RAGState) -> dict:
docs = retriever.invoke(state["question"])
return {"context": format_docs(docs)}
def generate(state: RAGState) -> dict:
response = chain.invoke({
"context": state["context"],
"question": state["question"],
})
return {"response": response}
graph = StateGraph(RAGState)
graph.add_node("retrieve", retrieve)
graph.add_node("generate", generate)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
# 기존 LCEL 체인을 노드 내부에서 재사용from abc import ABC, abstractmethod
class LLMServicePort(ABC):
"""프레임워크 독립적 LLM 서비스 인터페이스"""
@abstractmethod
async def generate(self, prompt: str, **kwargs) -> str:
pass
@abstractmethod
async def generate_stream(self, prompt: str, **kwargs):
pass
@abstractmethod
async def embed(self, text: str) -> list[float]:
pass
class LangChainAdapter(LLMServicePort):
"""LangChain 어댑터"""
def __init__(self, model, embeddings):
self.model = model
self.embeddings = embeddings
async def generate(self, prompt: str, **kwargs) -> str:
response = await self.model.ainvoke(prompt)
return response.content
async def generate_stream(self, prompt: str, **kwargs):
async for chunk in self.model.astream(prompt):
if chunk.content:
yield chunk.content
async def embed(self, text: str) -> list[float]:
return await self.embeddings.aembed_query(text)
class HaystackAdapter(LLMServicePort):
"""Haystack 어댑터"""
def __init__(self, generator, embedder):
self.generator = generator
self.embedder = embedder
async def generate(self, prompt: str, **kwargs) -> str:
result = self.generator.run(prompt=prompt)
return result["replies"][0]
async def generate_stream(self, prompt: str, **kwargs):
# Haystack 스트리밍 구현
pass
async def embed(self, text: str) -> list[float]:
result = self.embedder.run(text=text)
return result["embedding"]프레임워크에 종속되지 않는 설계를 위한 5가지 핵심 원칙입니다.
비즈니스 로직을 프레임워크와 분리합니다.
# 포트 (인터페이스)
class RetrievalPort(ABC):
@abstractmethod
async def retrieve(self, query: str, k: int = 5) -> list[Document]:
pass
class GenerationPort(ABC):
@abstractmethod
async def generate(self, prompt: str, context: str) -> str:
pass
# 비즈니스 로직 (프레임워크 독립)
class RAGService:
def __init__(
self,
retriever: RetrievalPort,
generator: GenerationPort,
):
self.retriever = retriever
self.generator = generator
async def answer(self, question: str) -> str:
docs = await self.retriever.retrieve(question)
context = "\n".join(d.content for d in docs)
return await self.generator.generate(
prompt=question,
context=context,
)
# 어댑터 (프레임워크별 구현)
class LlamaIndexRetriever(RetrievalPort):
def __init__(self, index):
self.index = index
async def retrieve(self, query: str, k: int = 5) -> list[Document]:
nodes = await self.index.aretrieve(query)
return [Document(content=n.text) for n in nodes[:k]]
class LangChainGenerator(GenerationPort):
def __init__(self, chain):
self.chain = chain
async def generate(self, prompt: str, context: str) -> str:
return await self.chain.ainvoke({
"question": prompt,
"context": context,
})프레임워크 선택을 설정으로 관리합니다.
from pydantic_settings import BaseSettings
class OrchestratorConfig(BaseSettings):
framework: str = "langchain" # langchain | langgraph | llamaindex | haystack
model_provider: str = "openai"
model_name: str = "gpt-4o"
retriever_type: str = "vector"
vector_store: str = "chroma"
class Config:
env_prefix = "AI_"
def create_service(config: OrchestratorConfig) -> RAGService:
"""설정에 따라 적절한 구현체 생성"""
retriever = create_retriever(config)
generator = create_generator(config)
return RAGService(retriever=retriever, generator=generator)프레임워크 간 데이터 교환을 위한 표준 모델을 정의합니다.
from pydantic import BaseModel
from datetime import datetime
class Document(BaseModel):
"""표준 문서 모델"""
content: str
metadata: dict = {}
score: float = 0.0
class ChatMessage(BaseModel):
"""표준 메시지 모델"""
role: str # "user" | "assistant" | "system"
content: str
timestamp: datetime = datetime.now()
metadata: dict = {}
class GenerationResult(BaseModel):
"""표준 생성 결과 모델"""
content: str
model: str
input_tokens: int = 0
output_tokens: int = 0
latency_ms: float = 0.0
metadata: dict = {}프레임워크 의존성을 테스트에서 격리합니다.
import pytest
class MockRetriever(RetrievalPort):
async def retrieve(self, query: str, k: int = 5) -> list[Document]:
return [Document(content="테스트 컨텍스트", score=0.9)]
class MockGenerator(GenerationPort):
async def generate(self, prompt: str, context: str) -> str:
return f"응답: {prompt} (컨텍스트 활용)"
@pytest.mark.asyncio
async def test_rag_service():
"""프레임워크 독립적 테스트"""
service = RAGService(
retriever=MockRetriever(),
generator=MockGenerator(),
)
result = await service.answer("테스트 질문")
assert "테스트 질문" in result기존 시스템에 프레임워크를 점진적으로 도입합니다.
class GradualMigrationService:
"""기능 플래그 기반 점진적 마이그레이션"""
def __init__(self, old_service, new_service, feature_flags):
self.old = old_service
self.new = new_service
self.flags = feature_flags
async def answer(self, question: str) -> str:
if self.flags.is_enabled("use_new_rag"):
try:
return await self.new.answer(question)
except Exception:
# 새 서비스 실패 시 기존 서비스로 폴백
return await self.old.answer(question)
return await self.old.answer(question)프레임워크 독립적 설계는 초기 개발 비용을 증가시킵니다. 프로토타입이나 단기 프로젝트에서는 프레임워크에 직접 의존하는 것이 더 효율적일 수 있습니다. 장기 프로덕션 서비스에서만 이 수준의 추상화를 적용하세요.
AI 오케스트레이션 생태계는 계속 진화하고 있습니다.
어떤 프레임워크를 선택하든, 핵심은 프레임워크가 아니라 문제를 해결하는 능력입니다. 프레임워크는 도구일 뿐이며, 좋은 AI 애플리케이션은 올바른 아키텍처 결정, 효과적인 프롬프트 설계, 그리고 견고한 프로덕션 운영에서 나옵니다.
이것으로 "AI 오케스트레이션 프레임워크 실전" 시리즈를 마칩니다. 11개의 장에 걸쳐 오케스트레이션의 필요성부터 프레임워크 심층 분석, 공통 패턴, 프로덕션 운영, 그리고 아키텍처 설계 원칙까지 폭넓게 다루었습니다. 이 시리즈가 여러분의 AI 애플리케이션 개발에 실질적인 도움이 되기를 바랍니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
재시도 전략, 서킷 브레이커, OpenTelemetry 통합, 비용 추적, 프로덕션 모니터링까지 프로덕션 안정성 패턴을 다룹니다.
SSE/WebSocket, 토큰/이벤트 스트리밍, 구조화된 출력 스트리밍을 각 프레임워크별로 비교하고 프론트엔드 통합을 다룹니다.
대화 메모리, 장기 메모리, 벡터 메모리, 구조화된 상태를 각 프레임워크별로 비교하고 프로덕션 메모리 전략을 정리합니다.