AI 에이전트 직접 만들어보기: Function Calling부터 에이전트 패턴까지
AI 세계가 완전히 달라졌어요. 단순히 "질문하면 답변하는" 시대를 지나, 이제는 AI 에이전트가 화두죠. 스스로 생각하고, 계획 세우고, 여러 단계에 걸쳐 알아서 일 처리까지 해주는 녀석들 말이에요.
혹시 직접 만들어보려고 도전해보셨나요? 해보신 분들은 아실 거예요. "그냥 API 호출하면 되겠지"라는 생각이 얼마나 순진했는지를요. 😅 루프 관리, 도구 정의, 에러 처리, 아키텍처 설계... 할 게 한두 개가 아니더라고요.
이 글에서는 처음부터 실무에서 쓸 수 있는 AI 에이전트를 같이 만들어볼 거예요. 개념부터 차근차근 짚어보고, TypeScript로 직접 코드도 짜보면서, 데모용이 아닌 진짜 쓸 수 있는 에이전트를 완성해봐요.
챗봇이랑 AI 에이전트, 대체 뭐가 다를까요?
한 줄로 정리하면:
챗봇은 대답하고, 에이전트는 행동해요.
이 차이를 만드는 게 바로 **에이전트 루프(Agentic Loop)**예요. 쉽게 말해서 이런 사이클을 돌 수 있다는 거죠:
- 상황 파악 — 지금 뭘 해야 하지?
- 생각 — 어떻게 접근할까?
- 실행 — 외부 도구나 API 호출!
- 결과 확인 — 잘 됐나? 다음은 뭐지?
- 반복 — 끝날 때까지 계속!
이 루프 덕분에 "경쟁사 분석해서 보고서 만들어줘"나 "50만원 이하 항공권 찾아서 제일 싼 거 예약해줘" 같은 복잡한 요청도 처리할 수 있어요. 그냥 텍스트 뱉는 게 아니라, 목표 달성까지 알아서 이것저것 해주는 거죠.
┌─────────────────────────────────────────────────────┐
│ 에이전틱 루프 │
├─────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 관찰 │ ◄── 사용자 요청 / 환경 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 사고 │ ◄── LLM 추론 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 행동 │ ◄── 도구 실행 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 반성 │ ◄── 결과 평가 │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 작업 완료? │──── 예 ──► 결과 반환 │
│ └──────┬───────┘ │
│ │ 아니오 │
│ └────────────► 관찰 단계로 돌아가기 │
│ │
└─────────────────────────────────────────────────────┘
Function Calling 제대로 이해하기
Function Calling(도구 호출)은 LLM이 "나 이 함수 좀 실행해줘"라고 요청할 수 있게 해주는 기능이에요. 텍스트 대신 "이 함수를 이 파라미터로 호출해"라는 구조화된 요청을 내뱉는 거죠.
어떻게 돌아가는 걸까요?
함수 정의를 같이 보내면, LLM은 두 가지 중 하나를 선택해요:
- 그냥 텍스트로 답하기 — 평범한 챗봇 모드
- 함수 호출 요청하기 — 에이전트 모드 ON!
OpenAI API 기준으로 함수 정의는 이렇게 생겼어요:
const tools = [ { type: "function", function: { name: "get_weather", description: "특정 위치의 현재 날씨를 가져옵니다", parameters: { type: "object", properties: { location: { type: "string", description: "도시 이름, 예: '서울특별시'" }, unit: { type: "string", enum: ["celsius", "fahrenheit"], description: "온도 단위" } }, required: ["location"] } } } ];
description이 핵심이에요. LLM은 이 설명 보고 "아, 이럴 때 이 함수 쓰면 되겠구나" 판단하거든요.
Claude에서는 어떻게 다를까요?
Claude도 비슷한데, 구조가 살짝 달라요:
const tools = [ { name: "get_weather", description: "특정 위치의 현재 날씨를 가져옵니다", input_schema: { type: "object", properties: { location: { type: "string", description: "도시 이름, 예: '서울특별시'" }, unit: { type: "string", enum: ["celsius", "fahrenheit"], description: "온도 단위" } }, required: ["location"] } } ];
parameters 대신 input_schema 쓴다는 것만 다르고, 개념은 똑같아요.
진짜 AI 에이전트 만들어보기
이제 웹 검색도 하고, 문서도 읽고, 계산도 할 수 있는 에이전트를 직접 만들어볼게요. TypeScript + OpenAI API로 구현하지만, 다른 LLM에도 똑같이 적용할 수 있어요.
Step 1: 도구 인터페이스 정의
먼저 도구들의 타입부터 정의해요:
interface Tool { name: string; description: string; parameters: { type: "object"; properties: Record<string, { type: string; description: string; enum?: string[]; }>; required: string[]; }; execute: (args: Record<string, unknown>) => Promise<string>; }
Step 2: 도구 구현하기
이제 에이전트가 실제로 쓸 도구들을 만들어요:
const webSearchTool: Tool = { name: "web_search", description: "웹에서 최신 정보를 검색합니다. 최근 이벤트, 뉴스, 또는 학습 데이터에 없는 정보에 사용하세요.", parameters: { type: "object", properties: { query: { type: "string", description: "검색 쿼리" } }, required: ["query"] }, execute: async (args) => { const { query } = args as { query: string }; // 프로덕션에서는 Google Search API, Bing, 또는 Tavily를 연동하세요 const response = await fetch(`https://api.search.example/search?q=${encodeURIComponent(query)}`); const data = await response.json(); return JSON.stringify(data.results.slice(0, 5)); } }; const calculatorTool: Tool = { name: "calculator", description: "수학 계산을 수행합니다. 기본 연산, 퍼센트, 일반적인 수학 함수를 지원합니다.", parameters: { type: "object", properties: { expression: { type: "string", description: "계산할 수학 표현식, 예: '(100 * 1.15) + 50'" } }, required: ["expression"] }, execute: async (args) => { const { expression } = args as { expression: string }; try { // 프로덕션에서는 mathjs 같은 안전한 수학 파서를 사용하세요 const result = Function(`"use strict"; return (${expression})`)(); return `결과: ${result}`; } catch (error) { return `오류: 잘못된 표현식`; } } }; const readUrlTool: Tool = { name: "read_url", description: "URL에서 텍스트 콘텐츠를 읽고 추출합니다. 글, 문서, 웹 페이지를 읽을 때 사용하세요.", parameters: { type: "object", properties: { url: { type: "string", description: "읽을 URL" } }, required: ["url"] }, execute: async (args) => { const { url } = args as { url: string }; try { const response = await fetch(url); const html = await response.text(); // 프로덕션에서는 적절한 HTML-to-text 변환기를 사용하세요 const text = html.replace(/<[^>]*>/g, ' ').slice(0, 5000); return text; } catch (error) { return `URL 읽기 오류: ${error}`; } } };
Step 3: 에이전트 루프 구현
여기가 진짜 핵심이에요. 이 루프가 전체 흐름을 컨트롤해요:
import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); interface Message { role: "system" | "user" | "assistant" | "tool"; content: string; tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string }; }>; tool_call_id?: string; } async function runAgent( userMessage: string, tools: Tool[], maxIterations: number = 10 ): Promise<string> { const toolDefinitions = tools.map(tool => ({ type: "function" as const, function: { name: tool.name, description: tool.description, parameters: tool.parameters } })); const messages: Message[] = [ { role: "system", content: `당신은 도구에 접근할 수 있는 유용한 AI 어시스턴트입니다. 질문에 정확하게 답하기 위해 필요할 때 도구를 사용하세요. 도구를 사용하기 전에 항상 추론 과정을 설명하세요. 도구 결과를 받은 후, 정보를 종합하여 유용한 응답을 제공하세요.` }, { role: "user", content: userMessage } ]; for (let i = 0; i < maxIterations; i++) { const response = await openai.chat.completions.create({ model: "gpt-4-turbo-preview", messages: messages, tools: toolDefinitions, tool_choice: "auto" }); const assistantMessage = response.choices[0].message; messages.push(assistantMessage as Message); // 완료 확인 (도구 호출 없음) if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { return assistantMessage.content || "응답을 생성할 수 없습니다."; } // 각 도구 호출 실행 for (const toolCall of assistantMessage.tool_calls) { const tool = tools.find(t => t.name === toolCall.function.name); if (!tool) { messages.push({ role: "tool", tool_call_id: toolCall.id, content: `오류: '${toolCall.function.name}' 도구를 찾을 수 없습니다` }); continue; } try { const args = JSON.parse(toolCall.function.arguments); const result = await tool.execute(args); messages.push({ role: "tool", tool_call_id: toolCall.id, content: result }); } catch (error) { messages.push({ role: "tool", tool_call_id: toolCall.id, content: `도구 실행 오류: ${error}` }); } } } return "최대 반복 횟수에 도달했습니다. 작업이 완료되지 않았을 수 있습니다."; }
Step 4: 에이전트 실행!
const tools = [webSearchTool, calculatorTool, readUrlTool]; const result = await runAgent( "도쿄의 현재 인구가 얼마이고, 이게 일본 전체 인구의 몇 퍼센트인가요?", tools ); console.log(result);
에이전트는 다음과 같이 동작합니다:
- 도쿄의 현재 인구 검색
- 일본의 전체 인구 검색
- 계산기를 사용해 퍼센트 계산
- 최종 답변 종합
ReAct 패턴으로 더 똑똑하게
ReAct(Reasoning + Acting)은 에이전트를 훨씬 안정적으로 만들어주는 패턴이에요. 그냥 바로 행동하는 게 아니라, 매 단계마다 "왜 이걸 하는지" 생각하게 만드는 거죠.
ReAct 프롬프트 예시
const REACT_SYSTEM_PROMPT = `당신은 ReAct 패턴을 따르는 AI 어시스턴트입니다. 각 단계에서 다음을 수행해야 합니다: 1. THOUGHT: 현재 상황과 다음 할 일에 대해 추론 2. ACTION: 행동 선택 (도구 사용 또는 최종 답변 제공) 3. OBSERVATION: 행동 결과 분석 정확히 다음 형식으로 응답하세요: THOUGHT: [여기에 추론 내용] ACTION: [도구_이름과 인자 또는 "FINAL_ANSWER"] OBSERVATION: [도구 결과로 채워짐] 최종 답변을 제공할 준비가 되면: THOUGHT: 이제 답변할 충분한 정보가 있습니다. ACTION: FINAL_ANSWER [여기에 완전한 답변]`;
이렇게 하면 디버깅도 쉽고, 에이전트가 왜 그런 결정을 내렸는지 추적하기도 좋아요.
에러 처리는 필수
실서비스에 올릴 에이전트라면 에러 처리가 정말 중요해요. 핵심 패턴 몇 가지 볼게요.
1. 재시도 with 백오프
async function executeWithRetry<T>( fn: () => Promise<T>, maxRetries: number = 3, baseDelay: number = 1000 ): Promise<T> { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { if (attempt === maxRetries - 1) throw error; const delay = baseDelay * Math.pow(2, attempt); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error("재시도 실패"); }
2. 타임아웃 처리
async function executeWithTimeout<T>( fn: () => Promise<T>, timeoutMs: number = 30000 ): Promise<T> { return Promise.race([ fn(), new Promise<T>((_, reject) => setTimeout(() => reject(new Error("타임아웃!")), timeoutMs) ) ]); }
3. 실패해도 죽지 않게
async function safeToolExecute(tool: Tool, args: Record<string, unknown>): Promise<string> { try { return await executeWithTimeout( () => executeWithRetry(() => tool.execute(args)), 30000 ); } catch (error) { return `"${tool.name}" 도구 실패: ${error}. 다른 접근 방식을 시도해 주세요.`; } }
여러 도구 함께 쓰기
복잡한 작업은 도구 여러 개를 같이 써야 할 때가 많아요. 캐싱까지 곁들인 패턴을 볼게요:
interface ToolResult { toolName: string; input: Record<string, unknown>; output: string; timestamp: Date; } class AgentContext { private history: ToolResult[] = []; private cache: Map<string, string> = new Map(); async executeToolWithCache(tool: Tool, args: Record<string, unknown>): Promise<string> { const cacheKey = `${tool.name}:${JSON.stringify(args)}`; if (this.cache.has(cacheKey)) { return this.cache.get(cacheKey)!; } const result = await tool.execute(args); this.cache.set(cacheKey, result); this.history.push({ toolName: tool.name, input: args, output: result, timestamp: new Date() }); return result; } getHistory(): ToolResult[] { return [...this.history]; } getSummary(): string { return this.history .map(r => `${r.toolName}(${JSON.stringify(r.input)}) → ${r.output.slice(0, 100)}...`) .join('\n'); } }
병렬 실행으로 속도 올리기
서로 의존 관계 없는 도구들은 동시에 돌리면 훨씬 빨라요:
async function executeToolsInParallel( toolCalls: Array<{ tool: Tool; args: Record<string, unknown> }> ): Promise<Map<string, string>> { const results = new Map<string, string>(); const promises = toolCalls.map(async ({ tool, args }) => { const result = await safeToolExecute(tool, args); results.set(tool.name, result); }); await Promise.all(promises); return results; }
모니터링: 뭐가 어떻게 돌아가는지 알아야죠
실서비스 에이전트는 로깅이 생명이에요:
interface AgentTrace { traceId: string; startTime: Date; endTime?: Date; steps: Array<{ type: "thought" | "action" | "observation"; content: string; timestamp: Date; metadata?: Record<string, unknown>; }>; totalTokens: number; status: "running" | "completed" | "failed"; } function createTracer(): { trace: AgentTrace; addStep: (type: "thought" | "action" | "observation", content: string) => void; complete: () => void; } { const trace: AgentTrace = { traceId: crypto.randomUUID(), startTime: new Date(), steps: [], totalTokens: 0, status: "running" }; return { trace, addStep: (type, content) => { trace.steps.push({ type, content, timestamp: new Date() }); }, complete: () => { trace.endTime = new Date(); trace.status = "completed"; } }; }
이것만은 조심하세요
1. 무한 루프
증상: 같은 도구만 계속 호출하면서 끝나질 않아요.
해결: 반복 감지 로직을 넣어요:
function detectLoop(messages: Message[], threshold: number = 3): boolean { const recentToolCalls = messages .filter(m => m.tool_calls) .slice(-threshold) .map(m => JSON.stringify(m.tool_calls)); return new Set(recentToolCalls).size === 1 && recentToolCalls.length === threshold; }
2. 컨텍스트 터짐
증상: 대화 내역이 너무 길어져서 토큰 한도 초과.
해결: 중간중간 요약해서 압축해요:
async function summarizeHistory(messages: Message[]): Promise<Message[]> { if (messages.length <= 10) return messages; const toSummarize = messages.slice(1, -5); // 시스템 + 마지막 5개 유지 const summary = await openai.chat.completions.create({ model: "gpt-4-turbo-preview", messages: [ { role: "system", content: "이 대화를 간결하게 요약하세요." }, { role: "user", content: JSON.stringify(toSummarize) } ] }); return [ messages[0], // 시스템 프롬프트 { role: "assistant", content: `이전 컨텍스트: ${summary.choices[0].message.content}` }, ...messages.slice(-5) // 최근 메시지 ]; }
3. 도구 설명이 애매하면
증상: LLM이 언제 이 도구 써야 하는지 헷갈려해요.
해결: 구체적으로 써야 해요:
// ❌ 나쁜 예 description: "날씨 API" // ✅ 좋은 예 description: "도시의 현재 날씨 상태를 가져옵니다. 사용자가 날씨, 기온, 기후에 대해 물을 때 사용하세요. 온도, 습도, 상태를 반환합니다. 도시에만 작동하며, 국가나 지역에는 작동하지 않습니다."
보안, 절대 빼먹지 마세요
실서비스라면 보안이 제일 중요해요.
1. 입력값 검증
function validateToolArgs(schema: Tool['parameters'], args: Record<string, unknown>): boolean { for (const required of schema.required) { if (!(required in args)) return false; } for (const [key, value] of Object.entries(args)) { const propSchema = schema.properties[key]; if (!propSchema) continue; if (propSchema.enum && !propSchema.enum.includes(value as string)) { return false; } } return true; }
2. 샌드박스에서만 실행
유저가 넣은 코드를 그냥 실행하면 큰일나요. 꼭 격리된 환경에서 돌리세요:
// isolated-vm 같은 라이브러리 쓰거나, 아예 별도 컨테이너에서 실행 import ivm from 'isolated-vm'; async function safeEval(code: string): Promise<string> { const isolate = new ivm.Isolate({ memoryLimit: 128 }); const context = await isolate.createContext(); try { const result = await context.eval(code, { timeout: 5000 }); return String(result); } finally { isolate.dispose(); } }
3. Rate Limiting (호출량 제한)
class RateLimiter { private requests: number[] = []; constructor( private limit: number, private windowMs: number ) {} async check(): Promise<boolean> { const now = Date.now(); this.requests = this.requests.filter(t => t > now - this.windowMs); if (this.requests.length >= this.limit) { return false; } this.requests.push(now); return true; } }
마무리
AI 에이전트의 핵심은 결국 이 루프예요: 상황 파악 → 생각 → 실행 → 확인 → 반복. 기억해야 할 포인트들 정리할게요:
- 단순하게 시작하기 — 복잡한 건 나중에. 일단 Function Calling부터 제대로!
- 도구 설명 잘 쓰기 — LLM은 설명 보고 판단해요. 대충 쓰면 이상하게 써요.
- 에러 처리 꼼꼼히 — 에이전트는 무조건 어디선가 터져요. 미리 대비!
- 로깅은 필수 — 뭐가 어떻게 돌아갔는지 모르면 디버깅 지옥이에요.
- 제한 걸어두기 — 무한 루프, 무한 API 호출 방지용 안전장치 필수!
- 보안 신경쓰기 — LLM 출력 그대로 믿으면 안 돼요. 꼭 검증하세요.
여기서 다룬 패턴들이 실무용 AI 에이전트의 기초가 될 거예요. 고객 응대 자동화든, 리서치 봇이든, 코딩 에이전트든 — 원칙은 다 똑같아요.
다음 스텝은 멀티 에이전트, 장기 기억, 학습하는 에이전트... 하지만 그것도 다 오늘 배운 기초 위에 쌓이는 거예요. 이 기반이 탄탄하면 진짜 "행동하는" AI를 만들 준비 완료! 🚀
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요