
AI Agent 패턴: Tool Use, ReAct, Chain of Thought
AI Agent를 만들 때 반복적으로 등장하는 핵심 패턴 세 가지—Tool Use, ReAct, Chain of Thought—를 실제 TypeScript 코드와 함께 정리했다. 이 패턴을 이해하면 Agent 설계가 훨씬 명확해진다.

AI Agent를 만들 때 반복적으로 등장하는 핵심 패턴 세 가지—Tool Use, ReAct, Chain of Thought—를 실제 TypeScript 코드와 함께 정리했다. 이 패턴을 이해하면 Agent 설계가 훨씬 명확해진다.
ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

둘 다 같은 Transformer 자식인데 왜 다를까? '빈칸 채우기'와 '이어 쓰기' 비유로 알아보는 BERT와 GPT의 결정적 차이. 프로젝트에서 겪은 시행착오와 선택 가이드.

고객 상담 챗봇이 엉뚱한 대답을 해서 식은땀 흘린 경험, 그리고 RAG(검색 증강 생성)를 도입해 '오픈북 테스트'를 치르게 한 과정을 공유합니다. 벡터 DB, 임베딩, 그리고 하이브리드 검색까지 파헤쳐봅니다.

ChatGPT가 처음 나왔을 때 개발자들은 공포에 떨었습니다. '이제 코딩은 끝났구나.' 저도 그랬습니다. 하지만 1년간 LLM을 실제에 도입하며 깨달았습니다. AI는 신이 아니라, 엄청나게 똑똑하지만 가끔 헛소리하는 인턴이라는 것을요.

처음 AI Agent를 만들어봤을 때, 아무 계획 없이 LLM 호출을 이어붙였다. 결과? 코드는 동작하는 것 같은데, 에러가 나면 어디서 잘못됐는지 전혀 알 수 없었다. 모델이 왜 그 결정을 내렸는지, 어떤 도구를 왜 선택했는지 추적이 안 됐다.
그때 깨달은 게 있다. Agent는 그냥 LLM 호출이 아니라, 검증된 패턴 위에 쌓아야 한다는 것.
ChatGPT처럼 단순 Q&A만 하는 시스템과, 실제로 코드를 실행하고 파일을 읽고 API를 호출하는 Agent 사이에는 엄청난 차이가 있다. 그 차이를 메우는 게 바로 오늘 정리할 세 가지 패턴이다.
하나씩 뜯어보자.
LLM 자체는 텍스트 입력을 받아 텍스트를 출력하는 함수다. 아무리 똑똑해도 혼자서는 파일을 못 읽고, API를 못 호출하고, 계산기를 못 쓴다. Tool Use는 이 한계를 극복하는 패턴이다.
비유하자면, LLM은 엄청나게 똑똑한 전략가인데 팔다리가 없다. Tool은 그 전략가에게 팔다리를 달아주는 것이다. 전략가가 "파일 A를 읽어봐"라고 말하면, 시스템이 실제로 파일을 읽고 결과를 돌려준다.
현대 LLM은 Function Calling(또는 Tool Calling)을 지원한다. 모델이 일반 텍스트 대신 구조화된 JSON을 출력해서 "이 함수를 이 인자로 호출해"라고 요청하는 방식이다.
// 도구 정의 - 모델에게 어떤 도구가 있는지 알려준다
const tools = [
{
type: "function" as const,
function: {
name: "read_file",
description: "로컬 파일 시스템에서 파일 내용을 읽어옵니다",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "읽을 파일의 절대 경로",
},
},
required: ["path"],
},
},
},
{
type: "function" as const,
function: {
name: "search_web",
description: "웹에서 최신 정보를 검색합니다",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "검색할 쿼리",
},
max_results: {
type: "number",
description: "반환할 최대 결과 수 (기본값: 5)",
},
},
required: ["query"],
},
},
},
];
모델이 이 도구 목록을 받으면, 필요할 때 이렇게 응답한다:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\": \"Next.js 16 release notes\", \"max_results\": 3}"
}
}
]
}
그러면 우리 시스템이 실제로 search_web을 실행하고, 결과를 다시 모델에 넘긴다.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// 실제 도구 실행 함수들
const toolImplementations: Record<string, (args: unknown) => Promise<string>> =
{
read_file: async (args) => {
const { path } = args as { path: string };
const fs = await import("fs/promises");
try {
return await fs.readFile(path, "utf-8");
} catch (e) {
return `Error: 파일을 읽을 수 없습니다 - ${path}`;
}
},
search_web: async (args) => {
const { query } = args as { query: string };
// 실제 구현에서는 Brave Search API, Tavily 등을 사용
return `검색 결과: "${query}"에 대한 결과입니다 (mock)`;
},
};
async function runAgentLoop(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
while (true) {
const response = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
tools: tools as Anthropic.Tool[],
messages,
});
// 도구 호출이 없으면 최종 응답
if (response.stop_reason === "end_turn") {
const textBlock = response.content.find((b) => b.type === "text");
return textBlock?.type === "text" ? textBlock.text : "";
}
// 모델 응답을 히스토리에 추가
messages.push({ role: "assistant", content: response.content });
// 도구 호출 실행
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "tool_use") {
const toolFn = toolImplementations[block.name];
const result = toolFn
? await toolFn(block.input)
: `Error: 알 수 없는 도구 - ${block.name}`;
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
}
// 도구 실행 결과를 히스토리에 추가
messages.push({ role: "user", content: toolResults });
}
}
핵심은 while (true) 루프다. 모델이 도구를 요청하는 한 계속 실행하고, end_turn이 나오면 멈춘다.
| 원칙 | 나쁜 예 | 좋은 예 |
|---|---|---|
| 도구 이름은 동사+명사 | data, process | read_file, search_web |
| description은 구체적으로 | "데이터 처리" | "CSV 파일을 파싱해서 JSON 배열로 반환" |
| 파라미터 타입 명확히 | value: any | count: number (1-100) |
| 오류는 예외 말고 문자열로 | throw Error | return "Error: ..." |
ReAct는 Reasoning + Acting의 합성어다. 2022년 구글 연구팀이 발표한 논문에서 나온 패턴으로, LLM이 생각(Thought)과 행동(Action)을 번갈아 하면서 문제를 풀게 한다.
단순 Tool Use와의 차이? Tool Use는 "어떤 도구를 쓸지"에 집중하지만, ReAct는 왜 그 도구를 쓰는지, 결과를 보고 어떻게 생각하는지를 명시적으로 만든다.
Thought: 사용자가 파이썬 코드 오류를 디버깅해달라고 했다. 먼저 파일을 읽어야겠다.
Action: read_file(path="./buggy_script.py")
Observation: def calculate(x, y):\n return x / y\n\ncalculate(10, 0)
Thought: ZeroDivisionError가 날 것 같다. 실제로 실행해서 확인해보자.
Action: execute_code(code="...")
Observation: ZeroDivisionError: division by zero
Thought: 예상대로다. y가 0일 때 처리하는 로직을 추가해야 한다.
Action: write_file(path="./fixed_script.py", content="...")
Observation: 파일 저장 완료
Thought: 수정이 완료됐다. 사용자에게 설명해주자.
Final Answer: ...
이 패턴의 강점은 추적 가능성이다. 모델이 왜 그 결정을 내렸는지 Thought에 남기기 때문에, 문제가 생겼을 때 어디서 잘못됐는지 알 수 있다.
const REACT_SYSTEM_PROMPT = `당신은 문제를 단계적으로 해결하는 AI 어시스턴트입니다.
반드시 다음 형식을 따르세요:
Thought: [현재 상황을 분석하고 다음 행동을 결정하는 추론]
Action: [사용할 도구와 인자]
Observation: [도구 실행 결과 - 시스템이 채워줌]
... (필요한 만큼 반복)
Thought: [최종 결론]
Final Answer: [사용자에게 전달할 최종 답변]
규칙:
- 항상 Thought로 시작하세요
- 한 번에 하나의 Action만 수행하세요
- Observation을 받기 전에 다음 Action을 가정하지 마세요
- Final Answer를 내리기 전에 충분히 검증하세요`;
interface ReActStep {
thought: string;
action?: {
tool: string;
args: Record<string, unknown>;
};
observation?: string;
finalAnswer?: string;
}
function parseReActOutput(output: string): ReActStep {
const thoughtMatch = output.match(/Thought:\s*(.+?)(?=Action:|Final Answer:|$)/s);
const actionMatch = output.match(/Action:\s*(\w+)\((.+?)\)/s);
const finalAnswerMatch = output.match(/Final Answer:\s*(.+?)$/s);
const step: ReActStep = {
thought: thoughtMatch?.[1]?.trim() ?? "",
};
if (finalAnswerMatch) {
step.finalAnswer = finalAnswerMatch[1].trim();
} else if (actionMatch) {
try {
// "search_web(query="Next.js", max_results=3)" 형식 파싱
const argsStr = actionMatch[2];
const args: Record<string, unknown> = {};
// 간단한 파서 - 실제로는 더 robust하게 만들어야 함
const argPairs = argsStr.split(",").map((s) => s.trim());
for (const pair of argPairs) {
const [key, ...valueParts] = pair.split("=");
const value = valueParts.join("=").replace(/^["']|["']$/g, "");
args[key.trim()] = value;
}
step.action = {
tool: actionMatch[1],
args,
};
} catch {
step.action = { tool: actionMatch[1], args: {} };
}
}
return step;
}
async function runReActAgent(question: string): Promise<string> {
const history: ReActStep[] = [];
const maxSteps = 10;
for (let i = 0; i < maxSteps; i++) {
// 히스토리를 프롬프트 형식으로 변환
const historyText = history
.map((step) => {
let text = `Thought: ${step.thought}\n`;
if (step.action) {
text += `Action: ${step.action.tool}(${JSON.stringify(step.action.args)})\n`;
text += `Observation: ${step.observation ?? ""}\n`;
}
return text;
})
.join("");
const prompt = `${question}\n\n${historyText}`;
const response = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 1024,
system: REACT_SYSTEM_PROMPT,
messages: [{ role: "user", content: prompt }],
});
const output =
response.content[0].type === "text" ? response.content[0].text : "";
const step = parseReActOutput(output);
if (step.finalAnswer) {
return step.finalAnswer;
}
if (step.action) {
const toolFn = toolImplementations[step.action.tool];
step.observation = toolFn
? await toolFn(step.action.args)
: `Error: 알 수 없는 도구`;
}
history.push(step);
}
return "최대 반복 횟수에 도달했습니다.";
}
Chain of Thought(CoT)는 사실 Tool Use나 ReAct처럼 시스템 아키텍처 패턴이라기보다는 프롬프트 엔지니어링 기법이다. 하지만 Agent 설계에서 빠질 수 없다.
핵심 아이디어는 단순하다. LLM에게 "답만 줘"가 아니라 "풀이 과정을 보여주면서 답해줘"라고 하면 정확도가 크게 올라간다.
왜 효과가 있을까? LLM은 토큰을 순서대로 생성하는 모델이다. 답을 먼저 쓰면 그 이후 생성을 답에 맞추려는 경향이 생긴다. 하지만 풀이 과정을 먼저 쓰면, 논리적인 흐름이 다음 토큰 생성을 이끌어간다.
수학 시험 비유: 답만 쓰면 찍는 것과 다를 바 없지만, 풀이 과정을 쓰다 보면 실수를 잡아낼 수 있는 것과 같다.
// Zero-Shot CoT: "단계적으로 생각해봐"만 추가하면 된다
const zeroShotCoTPrompt = `
다음 문제를 단계적으로 생각하며 풀어주세요.
문제: 어떤 회사의 월 서버 비용이 $1,200이다.
트래픽이 3배 늘어나면 비용이 2.5배가 된다고 할 때,
연간 비용 증가분은 얼마인가?
단계적으로 생각해봅시다:
`;
// Few-Shot CoT: 예시를 보여주는 방식
const fewShotCoTPrompt = `
문제를 단계적으로 푸는 예시입니다:
예시 문제: 5명이 5일 동안 5개의 위젯을 만든다면, 100명이 100일 동안 만드는 위젯은?
풀이:
1. 1명이 5일 동안 만드는 위젯: 5/5 = 1개
2. 1명이 1일 동안 만드는 위젯: 1/5개
3. 100명이 1일 동안 만드는 위젯: 100 * (1/5) = 20개
4. 100명이 100일 동안 만드는 위젯: 20 * 100 = 2,000개
답: 2,000개
이제 다음 문제를 같은 방식으로 풀어주세요:
[실제 문제]
`;
CoT는 특히 Agent의 계획 수립(Planning) 단계에서 유용하다. 복잡한 작업을 받았을 때, 바로 도구를 호출하는 게 아니라 먼저 계획을 세우게 한다.
const PLANNING_PROMPT = `
당신은 복잡한 작업을 체계적으로 분해하는 전문가입니다.
사용자의 요청을 받으면:
1. 먼저 전체 작업을 이해합니다
2. 필요한 하위 작업들을 나열합니다
3. 각 하위 작업의 순서와 의존성을 파악합니다
4. 사용할 도구를 매핑합니다
5. 예상되는 어려움이나 위험 요소를 체크합니다
그 다음 실행 계획을 세우세요.
형식:
## 작업 이해
[전체 목표 재서술]
## 하위 작업 분해
1. [하위 작업 1] → 사용 도구: [도구명]
2. [하위 작업 2] → 사용 도구: [도구명]
...
## 의존성
- [작업 A]는 [작업 B] 완료 후 실행 가능
...
## 위험 요소
- [잠재적 문제점]
## 실행 시작
[첫 번째 단계 수행]
`;
async function planAndExecute(task: string): Promise<void> {
// 1단계: 계획 수립 (CoT 활용)
const planResponse = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 2048,
system: PLANNING_PROMPT,
messages: [{ role: "user", content: task }],
});
const plan =
planResponse.content[0].type === "text"
? planResponse.content[0].text
: "";
console.log("계획:\n", plan);
// 2단계: 계획 기반으로 ReAct 루프 실행
const executionResult = await runReActAgent(
`다음 계획을 따라 작업을 수행해주세요:\n\n${plan}`
);
console.log("결과:\n", executionResult);
}
| 패턴 | 핵심 | 언제 쓰나 | 주의점 |
|---|---|---|---|
| Tool Use | 외부 시스템 연동 | 항상 (기본 빌딩 블록) | 도구 description 품질이 전부 |
| ReAct | 추론+행동 교차 | 다단계 문제, 디버깅 필요 시 | 파싱 실패 처리 필수 |
| Chain of Thought | 단계적 추론 | 복잡한 계산, 계획 수립 | 토큰 소비량 증가 |
| Plan-and-Execute | CoT + ReAct | 장시간 복잡 작업 | 계획이 틀리면 전체가 틀림 |
이론은 충분히 봤으니, 실제로 돌아가는 미니 Agent를 만들어보자. 파일을 읽고, 웹 검색을 하고, 코드를 실행할 수 있는 Agent다.
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs/promises";
import * as readline from "readline";
const client = new Anthropic();
// --- 도구 정의 ---
const TOOLS: Anthropic.Tool[] = [
{
name: "read_file",
description: "로컬 파일을 읽어 내용을 반환합니다",
input_schema: {
type: "object" as const,
properties: {
path: { type: "string", description: "파일 경로" },
},
required: ["path"],
},
},
{
name: "write_file",
description: "로컬 파일에 내용을 씁니다",
input_schema: {
type: "object" as const,
properties: {
path: { type: "string", description: "파일 경로" },
content: { type: "string", description: "파일 내용" },
},
required: ["path", "content"],
},
},
{
name: "list_directory",
description: "디렉토리의 파일 목록을 반환합니다",
input_schema: {
type: "object" as const,
properties: {
path: { type: "string", description: "디렉토리 경로" },
},
required: ["path"],
},
},
];
// --- 도구 실행 ---
async function executeTool(
name: string,
input: Record<string, string>
): Promise<string> {
switch (name) {
case "read_file": {
try {
return await fs.readFile(input.path, "utf-8");
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}
case "write_file": {
try {
await fs.writeFile(input.path, input.content, "utf-8");
return `파일 저장 완료: ${input.path}`;
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}
case "list_directory": {
try {
const entries = await fs.readdir(input.path, { withFileTypes: true });
return entries
.map((e) => `${e.isDirectory() ? "[dir]" : "[file]"} ${e.name}`)
.join("\n");
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}
default:
return `Error: 알 수 없는 도구 - ${name}`;
}
}
// --- 메인 Agent 루프 ---
async function runAgent(userMessage: string): Promise<void> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
let stepCount = 0;
const MAX_STEPS = 20;
while (stepCount < MAX_STEPS) {
stepCount++;
console.log(`\n[Step ${stepCount}]`);
const response = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
tools: TOOLS,
messages,
system:
"당신은 파일 시스템 작업을 도와주는 AI 어시스턴트입니다. 주어진 도구를 활용해 작업을 완료하세요.",
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
const text = response.content.find((b) => b.type === "text");
if (text?.type === "text") {
console.log("\n[최종 응답]", text.text);
}
break;
}
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "tool_use") {
console.log(` 도구 호출: ${block.name}(${JSON.stringify(block.input)})`);
const result = await executeTool(
block.name,
block.input as Record<string, string>
);
console.log(` 결과: ${result.slice(0, 100)}...`);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
}
if (toolResults.length > 0) {
messages.push({ role: "user", content: toolResults });
}
}
if (stepCount >= MAX_STEPS) {
console.log("최대 단계 수에 도달했습니다.");
}
}
// --- CLI 인터페이스 ---
async function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("Mini Agent 시작! (종료: Ctrl+C)");
const ask = () => {
rl.question("\n> ", async (input) => {
if (input.trim()) {
await runAgent(input.trim());
}
ask();
});
};
ask();
}
main().catch(console.error);
단일 Agent의 한계는 분명하다. 모든 도구를 한 Agent에 넣으면 컨텍스트가 커지고, 집중력이 분산된다. 이때 Multi-Agent 패턴을 쓴다.
Orchestrator Agent
├── Research Agent (웹 검색 전문)
├── Code Agent (코드 작성/실행 전문)
└── Writer Agent (문서 작성 전문)
interface AgentConfig {
name: string;
systemPrompt: string;
tools: Anthropic.Tool[];
}
class MultiAgentOrchestrator {
private agents: Map<string, AgentConfig> = new Map();
registerAgent(config: AgentConfig): void {
this.agents.set(config.name, config);
}
async delegateTo(agentName: string, task: string): Promise<string> {
const config = this.agents.get(agentName);
if (!config) throw new Error(`에이전트를 찾을 수 없음: ${agentName}`);
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: task },
];
// 간단한 단일 호출 (실제로는 루프 필요)
const response = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 2048,
system: config.systemPrompt,
tools: config.tools,
messages,
});
return response.content
.filter((b) => b.type === "text")
.map((b) => (b.type === "text" ? b.text : ""))
.join("\n");
}
}
// 사용 예시
const orchestrator = new MultiAgentOrchestrator();
orchestrator.registerAgent({
name: "researcher",
systemPrompt: "당신은 정보 수집 전문가입니다. 웹 검색과 문서 분석에 집중하세요.",
tools: [/* search tools */],
});
orchestrator.registerAgent({
name: "coder",
systemPrompt: "당신은 코드 작성 전문가입니다. 코드 구현과 테스트에 집중하세요.",
tools: [/* code execution tools */],
});
Agent는 강력하지만, 그만큼 위험하다. 몇 가지 필수 안전장치를 챙기자.
// 1. 허용된 경로만 접근
const ALLOWED_PATHS = ["/tmp/agent-workspace", "/home/user/projects"];
function isPathAllowed(path: string): boolean {
return ALLOWED_PATHS.some((allowed) => path.startsWith(allowed));
}
// 2. 위험한 명령어 차단
const BLOCKED_COMMANDS = ["rm -rf", "sudo", "chmod 777", "curl | bash"];
function isSafeCommand(command: string): boolean {
return !BLOCKED_COMMANDS.some((blocked) => command.includes(blocked));
}
// 3. 비용 제한
class CostGuard {
private totalTokens = 0;
private readonly MAX_TOKENS = 100_000; // ~$3 정도
checkAndAdd(tokens: number): boolean {
this.totalTokens += tokens;
if (this.totalTokens > this.MAX_TOKENS) {
console.error(`토큰 한도 초과: ${this.totalTokens}`);
return false;
}
return true;
}
}
// 4. Human-in-the-loop: 위험한 작업은 사람 확인 요청
async function confirmBeforeExecute(
action: string,
description: string
): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(
`\n[확인 필요] ${description}\n실행하시겠습니까? (y/n): `,
(answer) => {
rl.close();
resolve(answer.toLowerCase() === "y");
}
);
});
}
AI Agent 개발에서 가장 중요한 교훈은 이거다. 패턴을 먼저 이해하고 코드를 짜라.
Tool Use 없이는 Agent가 아무것도 못 한다. ReAct 없이는 복잡한 문제를 풀 수 없다. Chain of Thought 없이는 계획이 엉망이 된다.
세 패턴을 조합하면 꽤 강력한 Agent를 만들 수 있다. 그리고 이 패턴들은 특정 SDK나 프레임워크에 종속되지 않는다. Anthropic이든 OpenAI든, LangChain이든 직접 구현이든 동일하게 적용된다.
다음 단계? 이 패턴을 실제 프로젝트에 적용해보는 거다. 간단한 파일 관리 Agent부터 시작해서, 점점 복잡한 멀티 에이전트 시스템으로 나아가면 된다.