챗봇에서 에이전트로 넘어가는 순간
지금까지 우리는 LLM API로 요약기, RAG 챗봇, 고객지원 챗봇, 글쓰기 도우미를 만들어 보았다. 이 애플리케이션들은 모두 유용하지만, 대부분은 사용자의 질문에 답하거나 사용자가 지정한 작업 하나를 수행하는 방식이었다. 사용자가 묻고, 모델이 답한다. 사용자가 문서를 올리고, 모델이 요약한다. 사용자가 문장을 선택하고, 모델이 고쳐 준다.
에이전트는 여기서 한 걸음 더 나아간다. 사용자가 세세한 절차를 모두 알려 주지 않아도, 목표를 이해하고, 필요한 일을 나누고, 도구를 선택하고, 결과를 확인한 뒤 다음 행동을 결정한다. 예를 들어 사용자가 이렇게 말한다고 해 보자.
다음 주말 부산 2박 3일 여행 일정을 짜 줘. 비가 올 때 대안도 넣고, 대략적인 예산을 계산해서 마크다운 파일로 저장해 줘.
일반 챗봇이라면 그럴듯한 여행 일정을 바로 써 줄 것이다. Function Calling 챗봇이라면 사용자가 "예산 계산해 줘"라고 했을 때 계산 도구를 한 번 호출할 수 있다. 에이전트는 다르게 움직인다. 먼저 목표를 나눈다. 여행 조건을 파악하고, 관광지와 음식점 정보를 찾고, 비 오는 날 대안을 준비하고, 예산을 계산하고, 일정표를 정리하고, 파일 저장 도구를 호출한다. 중간에 검색 결과가 부족하면 다시 검색하고, 계산 결과가 예산을 초과하면 일정을 조정할 수도 있다.
즉 에이전트는 '프롬프트가 긴 챗봇'이 아니다. LLM을 중심에 두고 계획, 도구, 메모리, 실행 루프를 붙인 응용 시스템이다. OpenAI 문서에서도 Function Calling을 모델이 외부 시스템의 데이터와 동작에 연결되는 방법으로 설명한다. 중요한 점은 모델이 함수를 직접 실행하는 것이 아니라, 호출할 함수 이름과 인자를 구조화해서 내보내고, 실제 실행은 애플리케이션 코드가 담당한다는 것이다.1
에이전트를 이해할 때 가장 중요한 차이는 실행 주도성이다. 누가 다음 단계를 결정하는가? 사용자가 매번 지시하는가, 아니면 시스템이 현재 상태를 보고 다음 행동을 고르는가?
| 구분 | 주된 흐름 | 도구 사용 | 반복 루프 | 예시 |
|---|---|---|---|---|
| 일반 챗봇 | 질문 → 답변 | 거의 없음 | 없음 | "부산 여행 일정 추천해 줘" |
| Function Calling 챗봇 | 질문 → 필요 시 함수 호출 → 답변 | 특정 요청에 반응 | 보통 짧음 | "이 상품 재고 확인해 줘" |
| 에이전트 | 목표 → 계획 → 행동 → 관찰 → 다음 행동 → 최종 답변 | 여러 도구 조합 | 있음 | "자료 조사해서 비교 보고서 작성하고 저장해 줘" |
에이전트라는 단어가 붙었다고 해서 반드시 완전히 자율적인 인공지능을 뜻하지는 않는다. 이 장에서 만드는 것은 위험한 작업을 마음대로 수행하는 자동화 프로그램이 아니라, 제한된 도구와 반복 횟수 안에서 목표를 해결하는 작은 작업 실행기다. 좋은 에이전트는 똑똑한 모델보다 먼저, 명확한 경계가 있는 시스템이다.

에이전트를 이루는 네 가지 부품
처음 에이전트를 만들 때는 복잡한 프레임워크부터 시작하지 않는 편이 좋다. 에이전트가 실제로 무엇을 하는지 보이지 않기 때문이다. 먼저 작은 부품을 직접 이어 붙여 보자. NVIDIA의 에이전트 입문 자료도 에이전트를 agent core, memory, tools, planning module 같은 구성 요소로 나누어 설명한다.2 이름은 프레임워크마다 조금씩 다르지만, 기본 생각은 비슷하다.
에이전트의 중심에는 agent core가 있다. agent core는 현재 상태를 보고 다음에 무엇을 할지 결정하는 부분이다. LLM 호출, 도구 호출 결과 반영, 종료 여부 판단이 여기에서 일어난다. 사람에 비유하면 "지금까지 뭘 했고, 다음에 뭘 해야 하지?"라고 묻는 작업 관리자에 가깝다.
그 옆에는 planning module이 있다. planning module은 사용자의 큰 목표를 더 작은 작업으로 나눈다. "여행 일정 만들기"는 너무 크다. "여행 조건 확인하기", "후보 장소 검색하기", "동선 만들기", "예산 계산하기", "최종 일정표 작성하기"처럼 나누면 실행하기 쉬워진다. 계획은 확정된 정답이 아니라 출발점이다. 도구 실행 결과에 따라 바뀔 수 있다.
tools는 에이전트가 바깥세상과 만나는 손이다. 검색, 계산, 파일 저장, 데이터베이스 조회, 이메일 발송, 캘린더 등록 같은 일이 모두 도구가 될 수 있다. LLM은 언어를 잘 다루지만 최신 정보 검색, 정확한 산술 계산, 실제 파일 작성은 애플리케이션 코드가 맡는 편이 낫다.
마지막으로 memory가 있다. 여기서 말하는 메모리는 반드시 거대한 벡터 데이터베이스를 뜻하지 않는다. 이번 목표, 생성된 계획, 지금까지 호출한 도구, 도구 결과, 사용자가 준 제약 조건을 보관하는 간단한 리스트만 있어도 훌륭한 작업 메모리가 된다. 나중에 장기 기억이 필요해지면 데이터베이스나 벡터 저장소로 확장하면 된다.
이 네 부품을 코드 관점으로 보면 다음과 같다.
from dataclasses import dataclass, field
from typing import Any
@dataclass
class StepLog:
step: int
action: str
detail: dict[str, Any]
@dataclass
class AgentMemory:
goal: str
plan: list[str] = field(default_factory=list)
observations: list[str] = field(default_factory=list)
logs: list[StepLog] = field(default_factory=list)
def add_log(self, step: int, action: str, detail: dict[str, Any]):
self.logs.append(StepLog(step=step, action=action, detail=detail))
def add_observation(self, text: str):
self.observations.append(text)아직 LLM도 없고 도구도 없다. 하지만 중요한 골격은 이미 생겼다. 에이전트는 그냥 한 번 답하는 함수가 아니라, 목표와 상태를 들고 여러 단계를 지나가는 프로그램이다.
계획은 작게, 도구는 좁게
에이전트에게 "알아서 다 해 줘"라고 말하면 멋져 보이지만, 실제 서비스에서는 위험하다. 좋은 에이전트는 자유도가 무한한 시스템이 아니라, 할 수 있는 일이 명확한 시스템이다.
여행 일정 에이전트를 만든다고 해 보자. 처음부터 실제 항공권을 결제하고 호텔을 예약하게 만들 필요는 없다. 오히려 그러면 확인해야 할 보안, 결제, 환불, 개인정보 문제가 너무 많아진다. 첫 버전의 목표는 단순해야 한다.
사용자의 여행 목표를 하위 작업으로 나누고, 제공된 검색 도구와 계산 도구를 사용해 일정 초안을 만들고, 최종 결과를 마크다운 파일로 저장한다.
이 정도면 충분히 에이전트답다. 도구도 작게 시작한다. 검색 도구, 계산기 도구, 파일 저장 도구 세 개면 된다.
import json
import os
from pathlib import Path
TRAVEL_DATA = [
{
"city": "부산",
"category": "sightseeing",
"name": "감천문화마을",
"summary": "골목길과 전망이 유명한 산복도로 마을. 사진 촬영과 산책에 좋다.",
"cost": 0,
},
{
"city": "부산",
"category": "sightseeing",
"name": "해운대 블루라인파크",
"summary": "해안선을 따라 이동하는 관광 열차. 날씨가 좋을 때 만족도가 높다.",
"cost": 16000,
},
{
"city": "부산",
"category": "rainy",
"name": "국립해양박물관",
"summary": "실내 관람이 가능해 비 오는 날 대안으로 좋다.",
"cost": 0,
},
{
"city": "부산",
"category": "food",
"name": "돼지국밥",
"summary": "부산을 대표하는 든든한 한 끼. 1인 예산을 낮추기 좋다.",
"cost": 10000,
},
]
def search_travel_info(city: str, category: str, query: str = "") -> dict:
"""작은 로컬 여행 데이터에서 조건에 맞는 정보를 찾는다."""
results = []
for item in TRAVEL_DATA:
if item["city"] == city and item["category"] == category:
if not query or query in item["name"] or query in item["summary"]:
results.append(item)
return {
"city": city,
"category": category,
"query": query,
"results": results[:5],
}
def calculate_budget(items: list[dict]) -> dict:
"""항목별 비용을 합산한다. items는 name, unit_price, quantity를 가진다."""
total = 0
rows = []
for item in items:
name = item["name"]
unit_price = int(item["unit_price"])
quantity = int(item.get("quantity", 1))
subtotal = unit_price * quantity
total += subtotal
rows.append({
"name": name,
"unit_price": unit_price,
"quantity": quantity,
"subtotal": subtotal,
})
return {"items": rows, "total": total}
def save_markdown_file(filename: str, content: str) -> dict:
"""outputs 폴더 아래에 마크다운 파일을 저장한다."""
safe_name = filename.replace("/", "_").replace("\\", "_")
if not safe_name.endswith(".md"):
safe_name += ".md"
out_dir = Path("outputs")
out_dir.mkdir(exist_ok=True)
path = out_dir / safe_name
path.write_text(content, encoding="utf-8")
return {"path": str(path), "bytes": len(content.encode("utf-8"))}여기서는 웹 검색 대신 작은 로컬 데이터를 사용했다. 이유는 단순하다. 독자가 API 키 없이도 흐름을 이해할 수 있어야 하기 때문이다. 실제 서비스에서는 search_travel_info 안쪽을 Tavily, SerpAPI, 사내 검색 API, 벡터 데이터베이스 검색으로 바꾸면 된다. 에이전트 입장에서는 도구 이름, 설명, 입력 스키마가 같다면 내부 구현이 로컬 리스트인지 외부 API인지는 중요하지 않다.
도구 설명과 입력 스키마 만들기
LLM에게 도구를 쓰게 하려면 "검색 도구가 있어"라고 말하는 것만으로는 부족하다. 모델이 도구를 고를 수 있도록 이름, 용도, 입력 형식을 알려 주어야 한다. 이때 스키마는 계약서 역할을 한다. 모델은 이 형식에 맞춰 인자를 만들고, 애플리케이션 코드는 그 인자를 검증한 뒤 실제 함수를 실행한다.
tool_schemas = [
{
"type": "function",
"function": {
"name": "search_travel_info",
"description": "여행지, 음식, 비 오는 날 대안 등 여행 정보를 검색한다.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "검색할 도시 이름. 예: 부산, 경주, 제주",
},
"category": {
"type": "string",
"enum": ["sightseeing", "food", "rainy"],
"description": "검색 범주",
},
"query": {
"type": "string",
"description": "선택 검색어. 없으면 빈 문자열",
},
},
"required": ["city", "category", "query"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "calculate_budget",
"description": "여행 항목별 예상 비용을 합산한다.",
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"unit_price": {"type": "integer"},
"quantity": {"type": "integer"},
},
"required": ["name", "unit_price", "quantity"],
"additionalProperties": False,
},
}
},
"required": ["items"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "save_markdown_file",
"description": "최종 여행 일정이나 리서치 보고서를 마크다운 파일로 저장한다.",
"parameters": {
"type": "object",
"properties": {
"filename": {"type": "string"},
"content": {"type": "string"},
},
"required": ["filename", "content"],
"additionalProperties": False,
},
},
},
]
TOOL_REGISTRY = {
"search_travel_info": search_travel_info,
"calculate_budget": calculate_budget,
"save_markdown_file": save_markdown_file,
}이 구조는 이전 장에서 다룬 Function Calling과 거의 같다. 다만 에이전트에서는 이 호출이 한 번으로 끝나지 않는다. 검색 결과를 관찰하고, 계산 도구를 호출하고, 저장 결과를 확인한 뒤, 최종 답변을 만든다. AWS의 LLM 에이전트 설명에서도 사용자 요청을 받은 뒤 다음 행동을 계획하고, 도구 요청을 파싱·검증하고, 도구를 실행하고, 결과를 해석한 뒤 종료하거나 계속 반복하는 흐름을 제시한다.3
도구를 설계할 때는 다음 원칙을 기억하자.
도구 하나는 가능한 한 작은 일을 해야 한다.
도구 이름은 모델이 헷갈리지 않을 만큼 구체적이어야 한다.
입력 스키마에는 타입, 필수 값, 허용 범위를 명시해야 한다.
실제 실행 전에는 애플리케이션 코드에서 다시 검증해야 한다.
파일 저장, 이메일 발송, 결제, 삭제처럼 부작용이 있는 도구는 사용자 확인 단계를 두어야 한다.
LLM이 똑똑해 보여도 도구 이름이나 인자를 틀릴 수 있다. 그래서 에이전트는 "모델이 시키는 대로 실행하는 프로그램"이 아니라 "모델의 제안을 검증하고 실행하는 프로그램"이어야 한다.
계획 생성 모듈 만들기
이제 planning module을 만들어 보자. 계획은 꼭 별도 LLM 호출로 만들지 않아도 된다. 작은 애플리케이션에서는 system prompt 안에 "먼저 계획을 세워라"라고 넣는 것만으로도 충분하다. 하지만 처음 구현할 때는 계획을 별도 함수로 분리하면 로그를 보기 쉽다.
아래 코드는 LLM에게 사용자 목표를 작은 작업 목록으로 나누게 한다. 응답을 JSON으로 받도록 요구한다. 실제 서비스에서는 구조화 출력 기능을 쓰거나 Pydantic 같은 라이브러리로 검증하면 더 안정적이다.
import json
from openai import OpenAI
client = OpenAI()
MODEL = os.getenv("LLM_MODEL", "gpt-4.1-mini")
def create_plan(goal: str) -> list[str]:
messages = [
{
"role": "system",
"content": (
"너는 작업 계획을 세우는 planning module이다. "
"사용자 목표를 실행 가능한 3~6개의 하위 작업으로 나누어라. "
"반드시 JSON만 출력하라. 형식은 {\"plan\": [\"...\"]} 이다."
),
},
{"role": "user", "content": goal},
]
response = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=0,
)
text = response.choices[0].message.content
data = json.loads(text)
return data["plan"]예를 들어 목표가 다음과 같다면,
부산 2박 3일 여행 일정을 만들어 줘. 비 오는 날 대안과 1인 예산도 넣고, 결과를 파일로 저장해 줘.계획은 대략 이렇게 나올 수 있다.
{
"plan": [
"부산의 주요 관광지 후보를 검색한다.",
"부산의 음식 후보를 검색한다.",
"비 오는 날 대체 일정을 검색한다.",
"2박 3일 일정표를 구성한다.",
"1인 예상 예산을 계산한다.",
"최종 결과를 마크다운 파일로 저장한다."
]
}여기서 주의할 점이 있다. 계획은 실행 가능한 단위여야 한다. "최고의 여행을 만든다"는 계획이 아니다. "관광지 후보를 검색한다", "예산을 계산한다", "파일로 저장한다"처럼 도구 호출이나 답변 생성으로 이어질 수 있어야 한다.
실행 루프 구현하기
이제 에이전트의 심장인 실행 루프를 만들 차례다. 루프는 복잡해 보이지만 기본 흐름은 단순하다.
사용자 목표가 들어온다. 계획을 만든다. LLM에게 현재 목표, 계획, 관찰 결과를 보여 주고 다음 행동을 고르게 한다. 모델이 도구 호출을 요청하면 애플리케이션 코드가 도구를 실행한다. 실행 결과를 다시 모델에게 보여 준다. 모델이 최종 답변을 내면 종료한다. 너무 오래 반복하면 강제로 멈춘다.
import time
SYSTEM_PROMPT = """
너는 작은 여행 리서치 에이전트다.
사용자의 목표를 해결하기 위해 필요한 경우 제공된 도구를 사용한다.
규칙:
- 검색이 필요한 사실은 search_travel_info 도구를 사용한다.
- 숫자 계산은 calculate_budget 도구를 사용한다.
- 파일 저장 요청이 있으면 save_markdown_file 도구를 사용한다.
- 도구 결과에 없는 사실을 확정적으로 꾸며 내지 않는다.
- 충분한 정보를 모으면 최종 답변을 작성한다.
- 최종 답변에는 수행한 작업, 핵심 결과, 저장 파일 경로가 있으면 포함한다.
"""
def execute_tool_call(tool_call):
name = tool_call.function.name
raw_args = tool_call.function.arguments or "{}"
if name not in TOOL_REGISTRY:
return {"error": f"허용되지 않은 도구입니다: {name}"}
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
return {"error": "도구 인자를 JSON으로 해석할 수 없습니다.", "raw": raw_args}
try:
result = TOOL_REGISTRY[name](**args)
return result
except Exception as e:
return {"error": str(e), "tool": name, "args": args}
def run_agent(goal: str, max_steps: int = 8, max_seconds: int = 60) -> dict:
started_at = time.time()
memory = AgentMemory(goal=goal)
plan = create_plan(goal)
memory.plan = plan
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"사용자 목표:n{goal}nn"
f"초기 계획:n" + "n".join(f"- {p}" for p in plan)
),
},
]
for step in range(1, max_steps + 1):
if time.time() - started_at > max_seconds:
memory.add_log(step, "timeout", {"max_seconds": max_seconds})
return {
"status": "stopped",
"reason": "time_limit",
"memory": memory,
"answer": "시간 제한에 도달해 작업을 중단했습니다.",
}
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tool_schemas,
tool_choice="auto",
temperature=0.2,
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
tool_calls = assistant_message.tool_calls or []
if not tool_calls:
final_answer = assistant_message.content or "작업을 마쳤지만 응답이 비어 있습니다."
memory.add_log(step, "final_answer", {"answer": final_answer})
return {
"status": "completed",
"memory": memory,
"answer": final_answer,
}
for tool_call in tool_calls:
tool_name = tool_call.function.name
memory.add_log(
step,
"tool_call",
{
"tool": tool_name,
"arguments": tool_call.function.arguments,
},
)
result = execute_tool_call(tool_call)
result_text = json.dumps(result, ensure_ascii=False)
memory.add_observation(f"{tool_name} 결과: {result_text}")
memory.add_log(step, "tool_result", {"tool": tool_name, "result": result})
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result_text,
}
)
memory.add_log(max_steps, "max_steps_reached", {"max_steps": max_steps})
return {
"status": "stopped",
"reason": "max_steps",
"memory": memory,
"answer": "최대 반복 횟수에 도달해 작업을 중단했습니다.",
}이 코드에서 가장 중요한 줄은 while이나 for 자체가 아니다. 도구 결과를 다시 messages에 넣는 부분이다. 모델은 도구를 직접 실행하지 않는다. 모델은 "이 도구를 이런 인자로 호출하고 싶다"고 말한다. 애플리케이션이 실행한다. 그 결과를 다시 모델에게 보여 준다. 그러면 모델은 "이제 충분한가? 아니면 다음 도구가 필요한가?"를 판단한다.
이것이 에이전트 루프다. 목표 입력, 계획 생성, 도구 선택, 도구 실행, 결과 관찰, 다음 행동 결정, 최종 응답이 하나의 고리로 이어진다.
실행해 보기
이제 에이전트를 실행해 보자.
if __name__ == "__main__":
goal = (
"부산 2박 3일 여행 일정을 만들어 줘. "
"비 오는 날 대안과 1인 예산도 넣고, 결과를 markdown 파일로 저장해 줘."
)
result = run_agent(goal)
print("상태:", result["status"])
print("n최종 답변:n")
print(result["answer"])
print("n실행 로그:")
for log in result["memory"].logs:
print(log)실행 로그는 대략 이런 식으로 남는다.
StepLog(step=1, action='tool_call', detail={'tool': 'search_travel_info', ...})
StepLog(step=1, action='tool_result', detail={'tool': 'search_travel_info', ...})
StepLog(step=2, action='tool_call', detail={'tool': 'search_travel_info', ...})
StepLog(step=2, action='tool_result', detail={'tool': 'search_travel_info', ...})
StepLog(step=3, action='tool_call', detail={'tool': 'calculate_budget', ...})
StepLog(step=3, action='tool_result', detail={'tool': 'calculate_budget', ...})
StepLog(step=4, action='tool_call', detail={'tool': 'save_markdown_file', ...})
StepLog(step=4, action='tool_result', detail={'tool': 'save_markdown_file', ...})
StepLog(step=5, action='final_answer', detail={'answer': '...'} )이 로그는 단순한 디버깅 정보 이상이다. 에이전트가 왜 그런 결정을 했는지 확인하는 창이다. 사용자가 "왜 이 장소를 추천했어?"라고 물었을 때, 로그가 없다면 우리는 모델의 최종 답변만 보고 추측해야 한다. 로그가 있으면 어떤 검색 결과를 보고 어떤 계산을 했는지 추적할 수 있다.
LangChain의 RAG agent 예제도 검색 기능을 하나의 도구로 만들고, 에이전트가 필요할 때 검색 도구를 호출한 뒤 그 결과를 바탕으로 답변하는 흐름을 보여 준다. 또한 에이전트가 복잡해질수록 중간 실행 과정을 관찰하는 추적 도구가 중요해진다고 설명한다.4 프레임워크를 쓰든 직접 만들든, 관찰 가능성은 선택 사항이 아니다.
메모리는 대화 기록이 아니라 작업 상태다
많은 사람이 에이전트의 메모리를 "예전 대화를 기억하는 기능"으로만 생각한다. 물론 장기 기억도 중요하다. 하지만 에이전트 구현에서 먼저 필요한 것은 작업 상태다.
현재 목표는 무엇인가? 초기 계획은 무엇인가? 어떤 도구를 이미 호출했는가? 검색 결과는 무엇이었는가? 계산 결과는 얼마인가? 파일 저장은 성공했는가? 이런 정보가 있어야 다음 행동을 정할 수 있다.
우리가 만든 AgentMemory는 매우 단순하지만 핵심 역할을 한다.
memory = AgentMemory(goal=goal)
memory.plan = plan
memory.add_observation("관광지 검색 결과 2개를 찾음")
memory.add_log(1, "tool_call", {"tool": "search_travel_info"})실제 서비스에서는 메모리를 세 층으로 나누면 좋다.
작업 메모리: 현재 실행 중인 목표, 계획, 도구 결과를 저장한다.
세션 메모리: 현재 사용자와의 대화 안에서 유지해야 할 선호와 제약을 저장한다.
장기 메모리: 사용자가 동의한 선호, 반복 작업 설정, 과거 결과 요약을 저장한다.
장기 메모리를 만들 때는 특히 조심해야 한다. 사용자가 "나는 조용한 숙소를 선호해"라고 말한 것을 저장하는 것과 여권번호, 결제 정보, 건강 정보 같은 민감 정보를 저장하는 것은 완전히 다르다. 에이전트는 오래 기억할수록 편리해지지만, 동시에 개인정보와 보안 책임도 커진다.
에이전트가 멈추게 만들기
초보자가 만든 에이전트에서 가장 흔한 문제는 "계속 돈다"는 것이다. 검색하고, 다시 검색하고, 또 검색한다. 계산을 했는데 다시 계산한다. 파일을 저장했는데 또 저장한다. 모델 입장에서는 아직 더 잘할 수 있을 것처럼 보이기 때문이다.
그래서 에이전트에는 반드시 브레이크가 있어야 한다.
def run_agent(
goal: str,
max_steps: int = 8,
max_seconds: int = 60,
):
...max_steps는 최대 행동 횟수다. max_seconds는 전체 실행 시간 제한이다. 여기에 비용 한도까지 추가할 수 있다. API 응답의 토큰 사용량을 누적해 "이번 실행은 최대 1,000원까지만 사용" 같은 제한을 둘 수 있다.
조금 더 현실적인 안전 설정은 다음과 같다.
AGENT_LIMITS = {
"max_steps": 8,
"max_seconds": 60,
"allowed_tools": [
"search_travel_info",
"calculate_budget",
"save_markdown_file",
],
"require_confirmation_tools": [
"send_email",
"delete_file",
"purchase_item",
"book_hotel",
],
}이번 예제에는 결제나 예약 도구가 없다. 하지만 나중에 이메일 발송, 캘린더 등록, 티켓 생성, 환불 처리 같은 도구를 붙이면 이야기가 달라진다. 사용자의 확인 없이 실행하면 안 되는 작업이 생긴다.
에이전트에 넣어야 할 대표적인 안전장치는 다음과 같다.
최대 반복 횟수: 무한 루프를 막는다.
시간 제한: 외부 API 지연이나 모델 반복으로 인한 대기를 막는다.
비용 한도: 토큰과 외부 API 사용량을 제한한다.
허용 도구 목록: 모델이 존재하지 않거나 금지된 도구를 호출하지 못하게 한다.
사용자 확인 단계: 결제, 삭제, 발송, 예약 같은 되돌리기 어려운 작업을 보호한다.
종료 조건: 충분한 정보가 모였을 때 최종 답변으로 빠져나오게 한다.
에이전트는 자율적으로 움직이기 때문에 안전장치가 더 중요하다. 챗봇이 틀린 답을 하면 사용자가 알아차리고 다시 물을 수 있다. 하지만 에이전트가 잘못된 도구를 호출하면 파일을 덮어쓰거나, 잘못된 고객에게 메시지를 보내거나, 불필요한 API 비용을 만들 수 있다.
리서치 에이전트로 확장하기
여행 일정 예제는 이해하기 쉽지만, 실제 제품에서는 리서치 에이전트가 더 자주 등장한다. 예를 들어 "경쟁사 세 곳의 가격 정책을 조사하고 표로 정리해 줘", "이번 분기 고객 문의 로그에서 반복되는 불만을 찾아 줘", "내 문서 폴더에서 RAG 관련 내용을 찾아 요약 보고서를 만들어 줘" 같은 작업이다.
구조는 거의 같다. 달라지는 것은 도구뿐이다. 여행 검색 도구 대신 문서 검색 도구를 넣으면 된다.
def search_documents(query: str, top_k: int = 3) -> dict:
"""벡터 데이터베이스나 문서 인덱스에서 관련 문서를 검색한다고 가정한다."""
# 실제 구현에서는 vector_store.similarity_search(query, k=top_k)를 호출한다.
sample_docs = [
{
"title": "RAG 운영 메모",
"content": "검색 결과가 부족할 때는 모른다고 답하고 추가 자료를 요청해야 한다.",
"source": "docs/rag_ops.md",
},
{
"title": "고객지원 챗봇 정책",
"content": "환불, 개인정보, 결제 관련 문의는 사람 상담원에게 넘긴다.",
"source": "docs/support_policy.md",
},
]
return {"query": query, "results": sample_docs[:top_k]}이 도구를 추가하면 에이전트는 RAG와 결합된다. 이전 장에서 만든 문서 기반 Q&A 챗봇은 사용자가 질문하면 검색하고 답했다. 리서치 에이전트는 질문 하나에 답하는 것을 넘어, 여러 번 검색하고, 내용을 비교하고, 보고서 형식으로 정리하고, 파일로 저장한다.
차이는 크지만 원리는 같다. RAG는 에이전트의 도구가 될 수 있다. 계산기도 도구가 될 수 있다. 파일 저장도 도구가 될 수 있다. 에이전트는 이 도구들을 목표에 맞게 조합하는 실행 루프다.
프레임워크를 써야 할까?
직접 만든 루프는 학습에 좋다. 하지만 제품으로 갈수록 해야 할 일이 늘어난다. 스트리밍, 병렬 도구 호출, 재시도, 추적, 권한, 배포, 평가, 장기 메모리, 멀티 에이전트 협업까지 직접 구현하려면 부담이 커진다.
이때 LangGraph, LangChain, LlamaIndex, Haystack, AutoGen, CrewAI 같은 프레임워크를 검토할 수 있다. 프레임워크는 에이전트 루프, 도구 등록, 상태 관리, 추적 기능을 제공한다. 특히 상태가 분기되는 워크플로, 여러 에이전트가 역할을 나누는 구조, 문서 검색이 중심인 애플리케이션에서는 프레임워크의 장점이 크다.
그렇다고 처음부터 프레임워크에 모든 것을 맡기지는 말자. 프레임워크를 쓰더라도 지금 만든 기본 구조를 이해해야 한다. 그래야 문제가 생겼을 때 어디를 봐야 하는지 안다.
에이전트 프레임워크가 대신해 주는 것은 대체로 다음과 같다.
도구 정의와 호출 결과 연결
상태 저장과 다음 노드 선택
실행 로그와 트레이싱
재시도와 오류 처리
RAG, 데이터베이스, 외부 API 연결
멀티 에이전트 작업 분배
하지만 어떤 도구를 허용할지, 어떤 작업에 사용자 확인을 요구할지, 실패했을 때 어떻게 멈출지, 최종 답변의 품질을 어떻게 평가할지는 여전히 개발자의 몫이다.
좋은 에이전트는 많이 하는 에이전트가 아니다
에이전트라는 말은 상상력을 자극한다. 마치 목표만 말하면 모든 일을 알아서 끝내 줄 것 같다. 하지만 실제로 좋은 에이전트는 "많이 하는 시스템"이 아니라 "정해진 일을 안정적으로 하는 시스템"이다.
처음 만드는 에이전트의 범위는 좁을수록 좋다. "무엇이든 해 주는 비서"보다 "문서 세 개를 검색해 비교 요약 보고서를 만드는 에이전트"가 낫다. "여행을 전부 예약해 주는 에이전트"보다 "후보 일정을 만들고 예산을 계산해 파일로 저장하는 에이전트"가 낫다.
좋은 에이전트 설계 질문은 다음과 같다.
사용자가 맡기려는 목표는 무엇인가?
목표를 어떤 하위 작업으로 나눌 수 있는가?
각 하위 작업에 필요한 도구는 무엇인가?
모델이 직접 답해도 되는 부분과 도구가 반드시 필요한 부분은 무엇인가?
어떤 작업은 사용자 확인 없이는 실행하면 안 되는가?
실패했을 때 어떻게 멈추고 설명할 것인가?
중간 결과를 어떻게 로그로 남길 것인가?
이 질문에 답할 수 있으면, 에이전트는 더 이상 마법처럼 보이지 않는다. 입력과 출력 사이에 계획, 도구 호출, 관찰, 종료 조건이 있는 시스템으로 보인다.
이번 장에서 만든 것
이번 장에서는 간단한 AI 에이전트를 직접 설계했다. 핵심은 LLM 하나가 모든 일을 하는 구조가 아니었다. LLM은 목표를 해석하고 다음 행동을 고르는 중심 역할을 맡았다. planning module은 목표를 하위 작업으로 나누었다. tools는 검색, 계산, 파일 저장을 담당했다. memory는 계획과 관찰 결과와 로그를 보관했다. agent core는 이 모든 것을 실행 루프로 묶었다.
우리가 만든 에이전트는 작다. 하지만 구조는 실제 에이전트와 같다. 더 강력한 검색 API를 붙이고, 벡터 데이터베이스를 연결하고, 사용자 확인 UI를 추가하고, 실행 로그를 대시보드에 저장하면 실전 서비스에 가까워진다.
다음 부터는 이렇게 만든 LLM 애플리케이션을 운영 관점에서 바라본다. 에이전트든 RAG 챗봇이든 글쓰기 도우미든, 실제 사용자가 늘어나면 새로운 질문이 생긴다. 비용은 얼마나 들까? 응답 속도는 어떻게 줄일까? 품질은 어떻게 측정할까? 이제 LLM 서비스를 만드는 일은 모델 호출을 넘어 운영과 제품화의 문제로 이어진다.
참고
2NVIDIA, Building Your First LLM Agent Application
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.