· 문서버전 1.0

LangChain 없이 만들면 — LiteLLM + LangGraph 직접 구성 vs create_agent

튜토리얼의 에이전트들은 create_agent 한 줄로 루프를 만듭니다. 그 한 줄이 감추는 세 계층을 벗겨 보고, LangChain 없이 LiteLLM과 LangGraph만으로 같은 루프를 직접 구성해 봅니다.

코드 샌드박스·웹 스크래핑·웹 검색 튜토리얼의 에이전트는 전부 같은 두 줄로 만들어집니다.

model = ChatLiteLLM(model=os.environ.get("MODEL", "claude-opus-4-8"), temperature=0)
agent = create_agent(model, tools=[run_python])

그런데 임포트를 보면 create_agentlangchain.agents에서, ChatLiteLLMlangchain_litellm에서 옵니다.
글에서는 LangGraph 에이전트라고 해 놓고 코드는 LangChain 아닌가? — 맞는 의문이고, 답은 “계층이 다르다”입니다.
이 글에서는 그 계층을 벗겨 보고, LangChain 없이 같은 루프를 직접 구성하면 무엇이 달라지는지 비교합니다.

세 계층

에이전트 하나에는 서로 직교하는 세 가지 결정이 들어 있습니다.

  • 모델 라우팅 — 이 요청을 Claude로 보낼까, GPT로, Gemini로? LiteLLM이 맡습니다.
  • 루프 런타임 — 추론 → 도구 호출 → 관찰을 언제까지 돌릴까? LangGraph가 맡습니다.
  • 인터페이스 접착 — 위 둘을 잇는 표준 타입과 프리빌트. LangChain이 맡는데, 이 계층만 선택입니다.
flowchart TB
  app["에이전트 코드"] --> loop["루프 런타임 — LangGraph"]
  loop --> glue["인터페이스 접착 — LangChain (선택)"]
  glue --> route["모델 라우팅 — LiteLLM"]
  route --> llm[("Claude · GPT · Gemini")]
  class loop roleTool
  class glue roleFocus
  class route roleModel

헷갈림의 근원은 create_agent의 소속입니다.
임포트는 langchain이지만, 이 함수는 내부에서 LangGraph 상태 그래프를 컴파일해 돌려줍니다.
튜토리얼 샘플의 requirements.txtlanggraph가 들어 있는 이유이고, agent.invoke({"messages": […]})가 LangGraph의 메시지 상태 API인 이유입니다.
즉 “LangGraph ReAct 루프”라는 설명은 사실이고, LangChain은 그 루프를 한 줄로 만들어 주는 포장지입니다.

방법 A — LangChain 접착: create_agent

튜토리얼들이 쓰는 방식입니다. 연결 코드는 이게 전부입니다.

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_litellm import ChatLiteLLM

@tool
def run_python(code: str) -> str:
    """Run a snippet of Python and return its stdout/stderr."""
    ...

model = ChatLiteLLM(model=os.environ.get("MODEL", "claude-opus-4-8"), temperature=0)
agent = create_agent(model, tools=[run_python])
result = agent.invoke({"messages": [{"role": "user", "content": question}]})

공짜로 얻는 것들이 있습니다.

  • @tool 데코레이터가 함수 시그니처·docstring에서 도구 스키마를 자동 생성
  • 모델이 돌려준 tool_calls의 파싱·디스패치·결과 회수를 프리빌트 루프가 처리
  • 메시지 상태 관리(add_messages 리듀서)와 표준 메시지 타입

방법 B — LangChain 없이: litellm + StateGraph

같은 루프를 두 라이브러리만으로 직접 구성하면 이렇게 됩니다.
도구 스키마는 JSON으로 직접 선언하고, 상태는 평범한 dict 리스트입니다.

import json
import os

import litellm
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict

RUN_PYTHON = {
    "type": "function",
    "function": {
        "name": "run_python",
        "description": "Run a snippet of Python and return its stdout/stderr.",
        "parameters": {
            "type": "object",
            "properties": {"code": {"type": "string"}},
            "required": ["code"],
        },
    },
}

def run_python(code: str) -> str:  # 그냥 함수 — 데코레이터 없음
    ...

class State(TypedDict):
    messages: list  # OpenAI 형식의 평범한 dict 리스트

def call_model(state: State) -> State:
    resp = litellm.completion(
        model=os.environ.get("MODEL", "claude-opus-4-8"),
        messages=state["messages"],
        tools=[RUN_PYTHON],
    )
    msg = resp.choices[0].message
    return {"messages": state["messages"] + [msg.model_dump()]}

def call_tools(state: State) -> State:
    results = [
        {
            "role": "tool",
            "tool_call_id": c["id"],
            "content": run_python(json.loads(c["function"]["arguments"])["code"]),
        }
        for c in state["messages"][-1]["tool_calls"]
    ]
    return {"messages": state["messages"] + results}

def route(state: State) -> str:
    return "tools" if state["messages"][-1].get("tool_calls") else "end"

graph = StateGraph(State)
graph.add_node("model", call_model)
graph.add_node("tools", call_tools)
graph.add_edge(START, "model")
graph.add_conditional_edges("model", route, {"tools": "tools", "end": END})
graph.add_edge("tools", "model")
agent = graph.compile()

result = agent.invoke({"messages": [{"role": "user", "content": question}]})

방법 A가 감춰 주던 일이 전부 표면에 드러납니다.

  • 도구 스키마를 손으로 — docstring 자동 변환 대신 JSON 스키마를 직접 선언
  • 디스패치를 손으로tool_calls에서 이름·인자를 꺼내 파싱하고, tool_call_id를 달아 결과를 되돌려 주는 것까지 직접
  • 상태도 날것으로 — LangChain 메시지 타입과 add_messages 리듀서 대신, dict 리스트를 노드가 직접 이어 붙임
  • 대신 모든 단계가 투명 — 루프에 마법이 없어서, 어디서 무엇이 오가는지 코드에 다 보임

라우팅은 여전히 LiteLLM 몫이라 MODEL 환경변수 하나로 공급자를 바꾸는 것은 그대로고, 그래프·체크포인트·스트리밍 같은 런타임 기능도 LangGraph 것이라 그대로입니다.
이 방식을 실제로 돌아가는 에이전트로 완성한 튜토리얼이 코드 샌드박스 에이전트 — 직접 구성입니다.

무엇을 얻고 잃나

방법 A — create_agent방법 B — 직접 구성
연결 코드두 줄약 50줄
도구 정의@tool + docstring 자동 변환JSON 스키마 직접 선언
tool_calls 처리프리빌트가 파싱·디스패치직접 파싱·디스패치
메시지 상태LangChain 메시지 타입 + 리듀서평범한 dict 리스트
공급자 전환MODEL 환경변수MODEL 환경변수 — 동일
의존성langchain·langchain-litellm·langgraph·litellmlanggraph·litellm 둘뿐
루프 투명성프리빌트 내부는 블랙박스전 단계가 내 코드

언제 무엇을

  • 프로토타입·튜토리얼·도구가 주인공인 코드라면 방법 A입니다. 연결 코드가 두 줄이라 본론(도구 설계, 격리, 프롬프트)에 집중할 수 있고, 이 카탈로그의 튜토리얼들이 전부 이 방식을 쓰는 이유입니다.
  • 루프 자체를 배우거나 세밀하게 제어하려면 — 단계별 로깅, 커스텀 종료 조건, 메시지 가공 — 방법 B가 낫습니다. 의존성 둘을 덜어내는 값으로 연결 코드를 떠안는 거래입니다.
  • 중간 지점도 있습니다. LangGraph 카탈로그 페이지의 두 번째 예제(langgraph_2)는 그래프는 StateGraph로 직접 구성하되 모델·도구는 LangChain 부품(ChatLiteLLM·ToolNode)을 재사용합니다 — 루프는 투명하게, 접착은 공짜로 가져가는 절충입니다.

어느 쪽이든 라우팅은 LiteLLM, 런타임은 LangGraph — 두 도구는 처음부터 별개의 문제를 푸는 별개의 계층입니다.
갈리는 것은 그 사이를 LangChain에게 맡길지, 내 코드로 채울지뿐입니다.

관련 도구

관련 글