*이 장을 다 읽고 나면 알게 될 것: 첫 Agent를 다섯 단계로 만드는 구체적 과정, LangGraph StateGraph의 실제 구조, 도구 연결과 상태 관리와 메모리의 작동 방식, 그리고 완벽하지 않아도 돌아가는 첫 Agent가 왜 중요한지*
도입: 2023년 11월의 새벽
2023년 11월의 어느 새벽이었다. 나는 터미널 화면 앞에 앉아 있었다. 화면에는 Python 코드 서른 줄이 떠 있었다. LangChain 0.1 버전. 아직 LangGraph가 나오기 전이었다. 그 서른 줄이 하는 일은 단순했다. 사용자 질문을 받고, LLM에 보내고, 답을 돌려주는 것이다. 챗봇이라고 부르기도 민망한 수준이었다.
그런데 그날 새벽에 나는 한 가지를 추가했다. 웹 검색 도구를 연결한 것이다. LLM이 스스로 판단해서, 필요하면 검색을 하고, 그 결과를 바탕으로 답하도록 만들었다. 코드 열 줄을 더 추가했을 뿐이다.
그것이 돌아간 순간, 무언가가 달라졌다. LLM이 혼자서는 모르는 정보를 알아서 찾아왔다. 오늘의 날씨, 어제의 주가, 방금 나온 뉴스. 단순한 자동완성이 아니었다. 스스로 판단하고, 도구를 쓰고, 결과를 조합하는 것. 그것이 Agent였다. 서른 줄에서 마흔 줄로 늘어난 코드 사이에 질적 도약이 있었다.
나의 시스템의 모든 시스템은 그 새벽의 마흔 줄에서 시작되었다. Article Lingua도, SAF도, KVIC도. 처음부터 거대한 아키텍처를 설계한 것이 아니었다. 돌아가는 가장 작은 것을 먼저 만들었다. 그리고 한 겹씩 덧붙였다.
이 장에서 우리는 같은 길을 걷는다. 다섯 단계로.
11.1 MVP — 첫 Agent는 단순해야 한다
소프트웨어 세계에는 오래된 격언이 있다. "완벽한 것을 만들려다 아무것도 못 만든다." Agent도 마찬가지다. 처음부터 멀티 모달, 멀티 에이전트, RAG, 메모리, 평가 파이프라인까지 갖춘 시스템을 만들려고 하면 높은 확률로 중간에 포기한다.
MVP. Minimum Viable Product. 최소 기능 제품. 이 개념은 에릭 리스의 린 스타트업에서 유명해졌지만, 사실 엔지니어링의 오래된 지혜다. 돌아가는 가장 작은 것을 먼저 만들어라. 그것이 작동하면 한 가지를 추가하라. 또 작동하면 또 한 가지를 추가하라.
Agent의 MVP는 무엇인가. 나는 이렇게 정의한다.
사용자의 질문을 받아서, 필요하면 도구를 쓰고, 답을 돌려주는 것.
이것이 전부다. 메모리도 없고, 복잡한 워크플로우도 없고, 평가 시스템도 없다. 그냥 질문하면 답한다. 필요하면 검색한다. 그것만 된다면 Agent다.
이 장의 다섯 단계는 이 MVP에서 시작해 점진적으로 기능을 추가하는 과정이다.
| 단계 | 추가하는 것 | 결과 |
|---|---|---|
| Step 1 | LLM 호출 | 챗봇 |
| Step 2 | 도구 연결 | Agent |
| Step 3 | 상태 관리 | 구조화된 Agent |
| Step 4 | 조건 분기 | 워크플로우 Agent |
| Step 5 | 메모리 | 기억하는 Agent |
각 단계는 이전 단계 위에 쌓인다. 어느 단계에서 멈춰도 괜찮다. Step 2까지만 해도 실용적인 Agent가 된다. 그러나 Step 5까지 가면 진짜 분신에 가까워진다.
먼저 환경을 세팅하자.
잠시 멈추고 생각해보자
당신이 만들고 싶은 Agent는 무엇인가? 그 Agent의 MVP는 무엇인가? 가장 핵심적인 기능 하나만 남긴다면 그것은 무엇인가?
11.2 프로젝트 설정 — 시작하기 전에
코드를 쓰기 전에 환경을 만든다. Python 3.11 이상이 필요하다. 가상 환경을 만들고, 핵심 패키지를 설치한다.
# 프로젝트 폴더 생성
mkdir my-first-agent && cd my-first-agent
# 가상 환경 생성 및 활성화
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 핵심 패키지 설치
pip install langgraph langchain-openai langchain-postgres python-dotenv
pip install tavily-python # 웹 검색 도구
pip install psycopg2-binary pgvector # Step 5에서 사용
10장에서 다룬 기술 스택의 핵심 세 가지가 여기서 등장한다. LangGraph는 Agent의 뼈대다. LangChain은 LLM과 도구를 연결하는 접착제다. pgvector는 메모리를 담는 그릇이다.
API 키도 준비해야 한다. `.env` 파일에 넣어두는 것이 좋다.
# .env
OPENAI_API_KEY=sk-...
TAVILY_API_KEY=tvly-...
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
여기까지가 준비다. 이제 만든다.
11.3 Step 1 — 단일 노드 Agent: LLM 호출
가장 단순한 형태부터 시작한다. LLM을 호출하고, 답을 받는다. 그것뿐이다.
from langchain_openai import ChatOpenAI
# LLM 초기화
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
# 호출
response = llm.invoke("반도체 공급망에서 HBM이 중요한 이유를 설명해줘")
print(response.content)
네 줄이다. 이것은 Agent가 아니다. 그냥 API 호출이다. 그러나 이것이 출발점이다. 모든 Agent는 결국 이 네 줄 위에 서 있다.
이것을 LangGraph의 구조로 감싸보자. 왜 굳이 감싸는가. 나중에 노드를 추가하고, 분기를 만들고, 상태를 관리하려면 처음부터 LangGraph의 틀 안에서 시작하는 것이 낫다. 나중에 리팩토링하는 것보다 처음부터 구조를 잡는 것이 훨씬 덜 고통스럽다.
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
# 1. 상태 정의
class AgentState(TypedDict):
messages: list # 대화 메시지 목록
# 2. 노드 함수 정의
def call_llm(state: AgentState) -> AgentState:
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
response = llm.invoke(state["messages"])
return {"messages": state["messages"] + [response]}
# 3. 그래프 구성
graph = StateGraph(AgentState)
graph.add_node("llm", call_llm)
graph.add_edge(START, "llm")
graph.add_edge("llm", END)
# 4. 컴파일 및 실행
app = graph.compile()
result = app.invoke({
"messages": [("user", "AI Agent란 무엇인가?")]
})
print(result["messages"][-1].content)
코드가 길어졌다. 네 줄이 스무 줄이 되었다. 그러나 구조가 생겼다. 상태(State), 노드(Node), 엣지(Edge). LangGraph의 세 가지 핵심 개념이 모두 들어 있다.
상태는 Agent가 기억하는 것이다. 지금은 메시지 목록뿐이다. 노드는 Agent가 하는 일이다. 지금은 LLM 호출뿐이다. 엣지는 노드 사이의 연결이다. 지금은 시작에서 LLM으로, LLM에서 끝으로. 일직선이다.
이 일직선을 복잡하게 만드는 것이 나머지 네 단계다.
11.4 Step 2 — 도구 연결: Agent가 되는 순간
챗봇과 Agent의 차이는 무엇인가. 도구를 쓸 수 있느냐다. LLM이 아무리 똑똑해도, 자기가 학습한 데이터 너머의 정보에는 접근하지 못한다. 오늘의 날씨를 모른다. 지금 주가를 모른다. 사용자의 파일을 읽지 못한다. 도구가 이 벽을 허문다.
LangGraph에서 도구를 연결하는 방법은 간결하다.
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
# 도구 정의
search_tool = TavilySearchResults(max_results=3)
tools = [search_tool]
# LLM에 도구 바인딩
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# ReAct Agent 생성
agent = create_react_agent(llm, tools)
# 실행
result = agent.invoke({
"messages": [("user", "2026년 5월 현재 NVIDIA 시가총액은 얼마인가?")]
})
print(result["messages"][-1].content)
`create_react_agent`는 LangGraph가 제공하는 편의 함수다. 내부적으로는 ReAct 패턴을 따른다. ReAct(Reasoning + Acting)는 LLM이 '생각하고 → 행동하고 → 관찰하고'를 반복하는 패턴이다. LLM이 질문을 보고, 도구가 필요한지 판단하고, 필요하면 도구를 호출하고, 결과를 받아 답을 만든다. 이 과정이 자동으로 반복된다.
커스텀 도구를 만들 수도 있다. 예를 들어 로컬 파일을 읽는 도구를 만들어보자.
from langchain_core.tools import tool
@tool
def read_file(file_path: str) -> str:
"""로컬 파일의 내용을 읽어서 반환한다."""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()[:3000] # 최대 3000자까지
except FileNotFoundError:
return f"파일을 찾을 수 없다: {file_path}"
# 도구 목록에 추가
tools = [search_tool, read_file]
`@tool` 데코레이터 하나로 일반 Python 함수가 Agent의 도구가 된다. 함수의 docstring이 LLM에게 "이 도구는 이런 일을 한다"고 알려주는 설명이 된다. LLM은 이 설명을 읽고, 언제 이 도구를 쓸지 판단한다.
여기서 흔한 실수가 하나 있다. 도구의 docstring을 대충 쓰는 것이다. LLM은 docstring을 보고 도구 사용 여부를 결정한다. docstring이 모호하면 LLM은 도구를 잘못된 상황에서 쓰거나, 써야 할 때 안 쓴다. docstring은 짧되, 이 도구가 무엇을 하고, 언제 쓰면 좋은지를 명확히 적어야 한다.
잠시 멈추고 생각해보자
당신의 첫 Agent에 어떤 도구를 연결하고 싶은가? 웹 검색? 데이터베이스 조회? 이메일 발송? 도구 하나가 Agent의 쓸모를 어떻게 바꾸는지 상상해보라.
11.5 Step 3 — 상태 관리: StateGraph의 진짜 힘
Step 2까지는 `create_react_agent`라는 편의 함수를 썼다. 편리하지만 한계가 있다. 복잡한 워크플로우를 만들려면 상태를 직접 관리해야 한다.
LangGraph의 핵심은 StateGraph다. 이것은 유한 상태 기계(Finite State Machine)와 비슷하다. 상태가 있고, 상태를 바꾸는 노드가 있고, 노드 사이를 잇는 엣지가 있다.
상태를 더 풍부하게 정의해보자.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages] # 대화 히스토리
search_results: str # 검색 결과
final_answer: str # 최종 답변
step_count: int # 실행 단계 카운터
`messages`에 붙은 `Annotated[list, add_messages]`가 중요하다. 이것은 LangGraph에게 "이 필드는 덮어쓰기가 아니라 추가 방식으로 업데이트하라"고 알려주는 것이다. 새 메시지가 오면 기존 목록에 이어 붙인다. 대화 히스토리는 당연히 그래야 한다.
나머지 필드는 노드 사이에서 데이터를 전달하는 통로다. 검색 노드가 `search_results`에 결과를 넣으면, 답변 노드가 그것을 읽어서 답을 만든다. 이것이 상태 관리의 본질이다. 노드끼리 직접 데이터를 주고받지 않는다. 상태라는 공유 공간을 통해 소통한다.
이 설계가 왜 좋은가. 노드를 추가하거나 빼기가 쉽다. 노드 A가 노드 B를 직접 호출하는 구조면, A를 바꿀 때 B도 바꿔야 한다. 상태를 매개로 하면 A와 B는 서로를 모른다. 각자 상태에서 읽고, 상태에 쓸 뿐이다. 이것이 Agent가 복잡해져도 관리 가능한 이유다.
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_community.tools.tavily_search import TavilySearchResults
# 노드 함수들
def search_node(state: AgentState) -> dict:
"""웹 검색을 수행하고 결과를 상태에 저장한다."""
search = TavilySearchResults(max_results=3)
query = state["messages"][-1].content
results = search.invoke(query)
summary = "\n".join([r["content"][:200] for r in results])
return {"search_results": summary, "step_count": state.get("step_count", 0) + 1}
def answer_node(state: AgentState) -> dict:
"""검색 결과를 바탕으로 최종 답변을 생성한다."""
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
prompt = f"""다음 검색 결과를 바탕으로 사용자 질문에 답하라.
검색 결과: {state['search_results']}
질문: {state['messages'][-1].content}"""
response = llm.invoke(prompt)
return {"final_answer": response.content,
"step_count": state.get("step_count", 0) + 1}
# 그래프 구성
graph = StateGraph(AgentState)
graph.add_node("search", search_node)
graph.add_node("answer", answer_node)
graph.add_edge(START, "search")
graph.add_edge("search", "answer")
graph.add_edge("answer", END)
agent = graph.compile()
이제 Agent는 두 단계로 작동한다. 먼저 검색하고, 그다음 답한다. 단순하지만 구조가 있다. 이 구조 위에 노드를 추가하는 것이 다음 단계다.
11.6 Step 4 — 멀티 노드 워크플로우: 조건 분기
현실의 Agent는 일직선으로 달리지 않는다. 상황에 따라 다른 경로를 탄다. 질문이 단순하면 바로 답하고, 복잡하면 검색을 먼저 한다. 검색 결과가 부족하면 다시 검색한다. 이것이 조건 분기다.
LangGraph에서 조건 분기는 `add_conditional_edges`로 구현한다.
from langgraph.graph import StateGraph, START, END
def router(state: AgentState) -> str:
"""질문의 성격에 따라 경로를 결정한다."""
last_message = state["messages"][-1].content
# 최신 정보가 필요한 키워드 확인
time_sensitive = ["오늘", "현재", "최근", "2026", "지금", "시가총액"]
needs_search = any(keyword in last_message for keyword in time_sensitive)
if needs_search:
return "search"
return "direct_answer"
def direct_answer_node(state: AgentState) -> dict:
"""검색 없이 LLM만으로 답변한다."""
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
response = llm.invoke(state["messages"])
return {"final_answer": response.content}
# 그래프 구성
graph = StateGraph(AgentState)
graph.add_node("search", search_node)
graph.add_node("answer", answer_node)
graph.add_node("direct_answer", direct_answer_node)
# 조건 분기: 라우터가 경로를 결정
graph.add_conditional_edges(START, router, {
"search": "search",
"direct_answer": "direct_answer"
})
graph.add_edge("search", "answer")
graph.add_edge("answer", END)
graph.add_edge("direct_answer", END)
agent = graph.compile()
`router` 함수가 교차로의 신호등이다. 들어온 질문을 보고, "이 질문은 검색이 필요하다" 또는 "이 질문은 바로 답할 수 있다"를 판단한다. 그 판단에 따라 다른 노드로 보낸다.
위의 라우터는 단순하다. 키워드 매칭이다. 실전에서는 LLM 자체를 라우터로 쓸 수 있다.
def llm_router(state: AgentState) -> str:
"""LLM이 직접 경로를 판단한다."""
llm = ChatOpenAI(model="gpt-4o", temperature=0)
decision = llm.invoke(
f"""다음 질문이 최신 정보를 필요로 하는지 판단하라.
질문: {state['messages'][-1].content}
'search' 또는 'direct'만 답하라."""
)
if "search" in decision.content.lower():
return "search"
return "direct_answer"
LLM을 라우터로 쓰면 판단이 유연해진다. 그러나 비용과 지연 시간이 추가된다. 이 트레이드오프를 어떻게 관리할 것인가는 Agent 설계의 핵심 결정 중 하나다.
여기서 한 가지 더. 순환(cycle)도 가능하다. 검색 결과가 부족하면 다시 검색하는 루프를 만들 수 있다.
def check_quality(state: AgentState) -> str:
"""답변 품질을 검사하고 재검색 여부를 결정한다."""
if state.get("step_count", 0) >= 3:
return "end" # 무한 루프 방지
if "정보가 부족" in state.get("final_answer", ""):
return "retry"
return "end"
graph.add_conditional_edges("answer", check_quality, {
"retry": "search", # 다시 검색
"end": END
})
순환은 강력하지만 위험하다. 반드시 종료 조건을 넣어야 한다. `step_count >= 3`이 그 장치다. 이것이 없으면 Agent가 무한 루프에 빠진다. 내가 나의 시스템 초기 시스템에서 이 실수를 했다. 검색 결과가 항상 불충분하다고 판단하는 버그가 있었고, Agent가 같은 검색을 여덟 번 반복했다. API 비용이 꽤 나왔다. 아프지만 좋은 교훈이었다.
잠시 멈추고 생각해보자
당신의 Agent에는 어떤 분기가 필요한가? 어떤 조건에서 어떤 경로를 타야 하는가? 순환이 필요한 상황은 어떤 것인가?
11.7 Step 5 — 메모리 추가: 기억하는 Agent
Step 4까지의 Agent는 건망증이 심하다. 매번 새로 시작한다. 어제 무슨 대화를 했는지, 사용자가 어떤 선호를 가졌는지, 이전에 검색한 결과가 무엇이었는지 기억하지 못한다. 이것은 비서가 아니라 매번 처음 만나는 사람이다.
메모리는 두 종류가 있다.
단기 메모리. 하나의 대화 세션 안에서의 기억이다. 대화 히스토리가 여기에 해당한다. LangGraph의 체크포인터(checkpointer)가 이것을 관리한다.
from langgraph.checkpoint.memory import MemorySaver
# 체크포인터 생성
memory = MemorySaver()
# 그래프 컴파일 시 체크포인터 연결
agent = graph.compile(checkpointer=memory)
# 대화 실행 — thread_id로 세션을 구분한다
config = {"configurable": {"thread_id": "user-001"}}
# 첫 번째 질문
result1 = agent.invoke(
{"messages": [("user", "나는 반도체 산업에 관심이 많아")]},
config=config
)
# 두 번째 질문 — 이전 대화를 기억한다
result2 = agent.invoke(
{"messages": [("user", "그 산업에서 가장 주목할 기업은?")]},
config=config
)
`thread_id`가 핵심이다. 같은 `thread_id`로 호출하면 이전 대화가 이어진다. 다른 `thread_id`면 새 대화가 시작된다. 단순하지만 효과적이다.
장기 메모리. 세션을 넘어서 지속되는 기억이다. 사용자의 선호, 과거 대화의 요약, 중요한 사실. 이것은 벡터 데이터베이스에 저장한다. 10장에서 최소 시작 스택으로 Chroma를 소개했다. 여기서는 확장성을 고려해 바로 pgvector를 쓴다. Chroma로 시작해도 되지만, 어차피 프로덕션에서는 pgvector로 전환하게 되므로 처음부터 pgvector를 쓰는 것이 효율적이다.
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGVector # langchain_community에서 이전됨
# pgvector 연결
CONNECTION = "postgresql://user:pass@localhost:5432/agent_memory"
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = PGVector(
connection_string=CONNECTION,
embedding_function=embeddings,
collection_name="user_memories"
)
# 기억 저장
def save_memory(text: str, metadata: dict):
"""중요한 정보를 장기 메모리에 저장한다."""
vectorstore.add_texts(
texts=[text],
metadatas=[metadata]
)
# 기억 검색
def recall_memory(query: str, k: int = 3) -> list:
"""관련 기억을 검색한다."""
results = vectorstore.similarity_search(query, k=k)
return [doc.page_content for doc in results]
이것을 Agent의 노드로 통합하면 이렇게 된다.
def memory_node(state: AgentState) -> dict:
"""장기 메모리에서 관련 정보를 검색해 상태에 추가한다."""
query = state["messages"][-1].content
memories = recall_memory(query, k=3)
if memories:
context = "\n".join(memories)
memory_msg = f"[이전 기억에서 찾은 관련 정보]\n{context}"
return {"search_results": memory_msg}
return {}
이 노드를 그래프의 시작 부분에 추가하면, Agent는 매번 대화를 시작할 때 장기 메모리를 먼저 확인한다. "이 사용자와 이 주제에 대해 이전에 나눈 대화가 있는가?" 있으면 그 맥락을 가지고 시작한다.
12장에서 다루겠지만, 메모리에는 프라이버시 문제가 따라온다. 무엇을 기억할 것인가, 얼마나 오래 기억할 것인가, 사용자가 삭제를 요청하면 어떻게 할 것인가. 이 질문들은 기술만으로 답할 수 없다. 설계 철학의 문제다.
11.8 흔한 실수와 디버깅 팁
다섯 단계를 밟으면서 누구나 겪는 실수들이 있다. 내가 겪었고, 내 주변 개발자들도 겪었다. 정리해둔다.
실수 1: 상태를 너무 크게 정의한다. 처음부터 상태에 온갖 필드를 넣으려는 유혹이 있다. 사용자 프로필, 대화 요약, 감정 분석 결과, 도구 사용 로그. 상태는 작게 시작해야 한다. 필요해지는 순간에 필드를 추가하라. 필요하지 않은 것은 넣지 마라.
실수 2: 도구의 docstring을 대충 쓴다. 위에서 언급했지만 반복할 만큼 중요하다. LLM은 docstring만 보고 도구 사용을 결정한다. "파일을 읽는다"보다 "로컬 파일 시스템에서 지정된 경로의 텍스트 파일을 읽어 내용을 반환한다. 파일이 없으면 에러 메시지를 반환한다"가 훨씬 낫다.
실수 3: 에러 처리를 안 한다. API 호출은 실패한다. 네트워크가 끊기고, 키가 만료되고, 요금 한도에 걸린다. 모든 외부 호출에 try-except를 걸어야 한다. Agent가 에러로 멈추면 사용자 경험이 망가진다.
def safe_search_node(state: AgentState) -> dict:
"""에러를 처리하는 검색 노드."""
try:
search = TavilySearchResults(max_results=3)
results = search.invoke(state["messages"][-1].content)
summary = "\n".join([r["content"][:200] for r in results])
return {"search_results": summary}
except Exception as e:
return {"search_results": f"검색 실패: {str(e)}. LLM 지식만으로 답변합니다."}
실수 4: 순환에 종료 조건을 안 넣는다. 위에서 말한 내 경험이다. 무한 루프는 API 비용을 날리고, 사용자를 기다리게 한다. 모든 순환에는 최대 반복 횟수를 걸어두라.
실수 5: 한 번에 다 만들려 한다. 가장 흔하고 가장 치명적인 실수다. Step 1부터 Step 5까지 한 번에 코딩하고, 한 번에 테스트하려 한다. 안 된다. 각 단계를 만들고, 테스트하고, 확인하고, 다음 단계로 가라. 중간에 안 되는 것이 있으면 거기서 고치라. 이것이 디버깅 시간을 줄이는 유일한 방법이다.
디버깅 팁 하나. LangGraph는 각 노드의 입력과 출력을 추적하는 기능이 있다. `stream` 메서드를 쓰면 각 단계의 상태 변화를 볼 수 있다.
for event in agent.stream(
{"messages": [("user", "오늘의 반도체 뉴스를 알려줘")]},
config=config
):
print(f"--- 노드: {list(event.keys())[0]} ---")
print(event)
print()
이것은 Agent 내부에서 무슨 일이 벌어지는지를 눈으로 볼 수 있게 해준다. 어느 노드에서 문제가 생기는지, 상태가 어떻게 변하는지를 추적할 수 있다. 디버깅의 80%는 이것으로 해결된다.
잠시 멈추고 생각해보자
당신이 코드를 짤 때 가장 자주 하는 실수는 무엇인가? 그 실수를 줄이기 위해 어떤 습관을 만들 수 있는가? 디버깅을 잘하는 사람과 못하는 사람의 차이는 무엇이라고 생각하는가?
11.9 돌아가는 첫 Agent가 완벽한 설계보다 낫다
이 장에서 다룬 다섯 단계를 다시 정리한다.
Step 1에서 LLM을 호출했다. Step 2에서 도구를 연결해 진짜 Agent가 되었다. Step 3에서 상태를 관리하는 구조를 만들었다. Step 4에서 조건 분기로 유연한 워크플로우를 만들었다. Step 5에서 메모리를 추가해 기억하는 Agent가 되었다.
다섯 단계 모두를 이 장의 코드만으로 완성할 수 있다. 물론 실전에서는 더 많은 것이 필요하다. 에러 처리를 더 정교하게 해야 하고, 프롬프트를 다듬어야 하고, 테스트를 작성해야 한다. 그것은 다음 장들에서 다룬다.
지금 중요한 것은 하나다. 만들어라.
완벽한 아키텍처를 그리느라 한 달을 보내는 것보다, 불완전하지만 돌아가는 Agent를 하루 만에 만드는 것이 낫다. 돌아가는 것이 있어야 무엇이 부족한지 보인다. 부족한 것이 보여야 개선할 수 있다. 이것이 MVP의 본질이다.
나도 그렇게 시작했다. 2023년의 마흔 줄짜리 Agent는 지금 보면 부끄러울 정도로 단순했다. 에러 처리도 없었고, 메모리도 없었고, 구조도 엉성했다. 그러나 그것이 돌아갔다. 돌아갔기 때문에 다음 단계가 보였다. 다음 단계가 보였기 때문에 지금의 시스템이 만들어졌다.
완벽한 첫 Agent는 없다. 돌아가는 첫 Agent만 있을 뿐이다.
핵심 정리
Agent를 만드는 것은 한 번의 도약이 아니라 다섯 번의 작은 걸음이다. LLM 호출에서 시작해, 도구를 연결하고, 상태를 관리하고, 조건 분기를 추가하고, 메모리를 넣는다. 각 단계는 이전 단계 위에 쌓인다.
LangGraph의 핵심 개념은 세 가지다. 상태(State)는 Agent가 아는 것이다. 노드(Node)는 Agent가 하는 일이다. 엣지(Edge)는 노드 사이의 연결이다. 이 세 가지로 어떤 워크플로우든 표현할 수 있다.
도구 연결이 챗봇과 Agent를 가르는 분기점이다. LLM이 스스로 판단해서 도구를 쓸 수 있게 되는 순간, 단순한 텍스트 생성기에서 행동하는 존재로 바뀐다.
메모리는 두 층이다. 단기 메모리는 대화 세션 내의 히스토리이고, LangGraph의 체크포인터가 관리한다. 장기 메모리는 세션을 넘어 지속되는 정보이고, pgvector 같은 벡터 데이터베이스가 관리한다.
가장 중요한 원칙은 MVP다. 완벽하지 않아도 좋다. 돌아가는 것을 먼저 만들어라. 돌아가는 것이 있어야 다음이 보인다.
반드시 답해봐야 할 질문 5가지
질문 1. 이 장의 다섯 단계 중 당신은 지금 어느 단계에서 시작할 수 있는가? Step 1도 어려운가, Step 3까지는 해볼 만한가? 자기 수준을 정확히 아는 것이 첫걸음이다.
질문 2. 당신의 첫 Agent에 연결할 도구 세 가지를 골라보라. 웹 검색, 데이터베이스 조회, 파일 읽기, 이메일 발송, API 호출 중 무엇이 당신의 사용 사례에 가장 유용한가?
질문 3. 조건 분기를 설계한다면, 당신의 Agent는 어떤 기준으로 경로를 나눌 것인가? 단순 키워드인가, LLM 판단인가, 사용자 명시적 지시인가?
질문 4. 장기 메모리에 무엇을 저장할 것인가? 모든 대화를 저장하는 것과 중요한 것만 골라서 저장하는 것 사이에서, 당신은 어디에 서겠는가? 그 기준은 무엇인가?
질문 5. "돌아가는 첫 Agent"를 이번 주 안에 만들 수 있는가? 만들 수 없다면 무엇이 막고 있는가? 그 장애물은 기술인가, 시간인가, 두려움인가?
더 깊이 탐구하기
LangGraph 공식 문서 (https://langchain-ai.github.io/langgraph/). 이 장에서 다룬 모든 개념의 원천이다. 특히 Tutorials 섹션의 "Quick Start"를 먼저 따라 해보길 권한다.
해리슨 체이스(Harrison Chase), "Introduction to LangGraph" 강연 (2024). LangGraph 창시자가 직접 설명하는 설계 철학과 핵심 개념.
에릭 리스, 「린 스타트업」 (2011). MVP 개념의 원전. Agent 개발에도 그대로 적용된다.
앤드류 응(Andrew Ng), "Building AI Agents with LangGraph" 강좌 (DeepLearning.AI, 2025). Step-by-step으로 Agent를 만드는 실습 과정.
나의 시스템 기술 블로그, "첫 Agent에서 프로덕션까지 — 12개월의 기록" (2025). 이 장의 다섯 단계를 실제 서비스에 적용한 경험기.
다음 장에서는 Personal Memory와 Privacy를 다룬다. Agent가 기억한다는 것은 곧 사용자의 데이터를 보관한다는 것이다. 무엇을 기억하게 할 것인가, 어디에 저장할 것인가, 누구에게 보여줄 것인가. 기술 설계와 윤리적 판단이 교차하는 지점으로 들어간다.