Claude Code 같은 대화형 AI 서비스, 어떻게 만들어질까
Claude Code나 ChatGPT 같은 대화형 AI 서비스를 쓰다 보면 문득 궁금해진다. 이게 도대체 어떻게 만들어진 걸까?
겉으로 보기엔 마법처럼 보이지만, 사실 핵심 구조는 생각보다 단순하다. LLM API만 있으면 누구나 비슷한 걸 만들 수 있다. 물론 "잘" 만드는 건 별개 문제지만 말이다.
핵심은 세 가지: LLM + Tool Use + Agent Loop
대화형 AI 서비스의 뼈대는 이렇게 생겼다.
- LLM: 사용자 메시지를 받아서 응답을 생성하는 언어 모델
- Tool Use (Function Calling): LLM이 필요할 때 외부 도구를 호출할 수 있게 해주는 기능
- Agent Loop: "생각 → 행동 → 관찰 → 다시 생각"을 반복하는 루프
이 세 가지가 조합되면 단순히 텍스트만 뱉는 챗봇이 아니라, 파일을 읽고 쓰고, 웹을 검색하고, 코드를 실행하는 "에이전트"가 탄생한다.
Tool Use: LLM에게 손과 발을 달아주기
LLM은 기본적으로 텍스트만 출력할 수 있다. 하지만 Tool Use를 쓰면 LLM이 "파일을 읽고 싶다", "코드를 실행하고 싶다" 같은 의도를 표현할 수 있게 된다.
작동 방식은 이렇다. LLM API를 호출할 때 사용 가능한 도구 목록을 함께 보낸다.
const response = await anthropic.messages.create({
model: "claude-sonnet-4-5-20250929",
messages: [
{ role: "user", content: "README.md 파일을 읽어줘" }
],
tools: [
{
name: "read_file",
description: "파일 내용을 읽는 도구",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "파일 경로" }
},
required: ["path"]
}
}
]
});
그러면 LLM이 응답할 때 일반 텍스트 대신 이런 식으로 tool_use 블록을 보낸다.
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "read_file",
"input": {
"path": "README.md"
}
}
LLM은 도구를 직접 실행하지 않는다. 대신 "나 이 도구를 이렇게 쓰고 싶어"라고 JSON으로 알려주는 것이다. 실제로 도구를 실행하는 건 개발자의 몫이다.
Agent Loop: 결과를 다시 LLM에게 돌려주기
도구를 실행했으면 그 결과를 LLM에게 다시 알려줘야 한다. 이게 Agent Loop의 핵심이다.
// 1. LLM이 tool_use를 요청함
const response1 = await anthropic.messages.create({
model: "claude-sonnet-4-5-20250929",
messages: [
{ role: "user", content: "README.md 파일을 읽어줘" }
],
tools: [...]
});
// 2. 개발자가 도구를 실행
const fileContent = fs.readFileSync("README.md", "utf-8");
// 3. 실행 결과를 다시 messages에 추가해서 LLM 호출
const response2 = await anthropic.messages.create({
model: "claude-sonnet-4-5-20250929",
messages: [
{ role: "user", content: "README.md 파일을 읽어줘" },
{ role: "assistant", content: response1.content }, // tool_use 블록 포함
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01A09q90qw90lq917835lq9",
content: fileContent
}
]
}
],
tools: [...]
});
LLM API는 stateless다. 이전 대화를 기억하지 못한다. 그래서 매번 전체 대화 히스토리를 다 보내줘야 한다.
messages 배열에 사용자 메시지, LLM 응답, 도구 실행 결과를 계속 쌓아가면서 API를 반복 호출하는 것이다. 이게 바로 Agent Loop다.
ReAct 패턴: 생각하고 행동하고 관찰하고
이런 루프 방식을 "ReAct 패턴"이라고 부른다. Reasoning(추론)과 Acting(행동)을 반복한다는 의미다.
- Thought (생각): LLM이 다음에 무엇을 할지 계획
- Action (행동): 도구 호출 요청
- Observation (관찰): 도구 실행 결과 확인
- 다시 Thought: 결과를 바탕으로 다음 행동 결정
이 과정을 계속 반복하다가 stop_reason이 "end_turn"이 되면 루프를 종료한다.
let messages = [{ role: "user", content: "README.md를 읽고 요약해줘" }];
while (true) {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-5-20250929",
messages,
tools: [...]
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
// 최종 응답 도달
break;
}
if (response.stop_reason === "tool_use") {
// tool_use 블록 찾아서 실행
const toolUse = response.content.find(block => block.type === "tool_use");
const result = executeTool(toolUse.name, toolUse.input);
messages.push({
role: "user",
content: [{
type: "tool_result",
tool_use_id: toolUse.id,
content: result
}]
});
}
}
이렇게 하면 LLM이 알아서 필요한 도구를 호출하고, 결과를 보고 다음 행동을 결정하는 자율적인 에이전트가 된다.
스트리밍: 실시간으로 응답 보여주기
실제 서비스에서는 LLM 응답을 실시간으로 보여주는 스트리밍 기능이 필수다. 응답이 완전히 끝날 때까지 기다리면 사용자 경험이 너무 안 좋다.
스트리밍 구현은 두 단계로 나뉜다.
1단계: LLM API → 서버 (토큰 스트리밍)
const stream = await anthropic.messages.stream({
model: "claude-sonnet-4-5-20250929",
messages,
tools: [...]
});
for await (const event of stream) {
if (event.type === "content_block_delta") {
if (event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
if (event.type === "content_block_start") {
if (event.content_block.type === "tool_use") {
console.log(`\n[도구 호출: ${event.content_block.name}]`);
}
}
}
LLM API는 토큰 단위로 이벤트를 보내준다. 텍스트와 tool_use가 섞여서 날아온다.
2단계: 서버 → 클라이언트 (SSE)
서버는 LLM에서 받은 스트림을 그대로 브라우저에 전달한다. 보통 SSE(Server-Sent Events)를 쓴다.
// 서버 (Next.js API Route)
export async function POST(req) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const llmStream = await anthropic.messages.stream({...});
for await (const event of llmStream) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
);
}
controller.close();
}
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" }
});
}
// 클라이언트
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message: "README.md를 읽어줘" })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const events = chunk.split("\n\n").filter(line => line.startsWith("data: "));
for (const event of events) {
const data = JSON.parse(event.slice(6));
if (data.type === "content_block_delta") {
// UI 업데이트
appendToChat(data.delta.text);
}
}
}
이렇게 하면 사용자가 타이핑하는 것처럼 응답이 실시간으로 화면에 나타난다.
여러 번의 Agent Loop를 하나의 스트림으로
여기서 핵심은 하나의 SSE 연결 안에서 여러 번의 LLM 호출을 이어붙이는 것이다.
const stream = new ReadableStream({
async start(controller) {
let messages = [{ role: "user", content: userMessage }];
while (true) {
const llmStream = await anthropic.messages.stream({
messages,
tools: [...]
});
// LLM 스트림을 클라이언트로 전달
for await (const event of llmStream) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
const finalMessage = await llmStream.finalMessage();
messages.push({ role: "assistant", content: finalMessage.content });
if (finalMessage.stop_reason === "end_turn") break;
if (finalMessage.stop_reason === "tool_use") {
// 도구 실행
const toolResults = await executeTools(finalMessage.content);
messages.push({ role: "user", content: toolResults });
// 도구 실행 결과도 클라이언트에 알림
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "tool_result",
results: toolResults
})}\n\n`));
}
}
controller.close();
}
});
사용자 입장에서는 하나의 긴 대화처럼 보이지만, 실제로는 내부에서 LLM API를 여러 번 호출하면서 도구를 실행하고 결과를 받아오는 것이다.
다른 LLM도 똑같다
이 패턴은 Claude뿐만 아니라 OpenAI, Google Gemini, 오픈소스 모델까지 전부 동일하다.
// OpenAI
const response = await openai.chat.completions.create({
model: "gpt-4",
messages: [...],
tools: [
{
type: "function",
function: {
name: "read_file",
parameters: { ... }
}
}
]
});
// Google Gemini
const response = await model.generateContent({
contents: [...],
tools: [
{
functionDeclarations: [
{
name: "read_file",
parameters: { ... }
}
]
}
]
});
필드명만 조금씩 다를 뿐, 구조는 똑같다. Tool Use를 지원하는 LLM이라면 전부 이 방식으로 에이전트를 만들 수 있다.
Vercel AI SDK 같은 라이브러리를 쓰면 이런 차이도 추상화해준다. 모델만 바꾸면 동일한 코드로 다양한 LLM을 쓸 수 있다.
컨텍스트 관리: 에이전트의 진짜 어려운 문제
여기서 한 가지 의문이 생긴다. Claude Code 같은 코딩 에이전트는 프로젝트의 여러 파일을 이해해야 하는데, 모든 파일 내용을 한 번에 LLM에 넣는 걸까?
아니다. 에이전트는 필요한 파일을 그때그때 읽어온다.
Turn 1: LLM → "프로젝트 구조를 파악하겠습니다" → list_directory 호출
Turn 2: LLM → (구조를 보고) "핵심 파일을 읽겠습니다" → read_file("src/index.ts")
Turn 3: LLM → "관련 파일도 확인합니다" → read_file("src/utils.ts")
Turn 4: LLM → "이제 수정합니다" → write_file(...)
LLM이 스스로 어떤 파일을 읽을지 판단하고, 도구를 통해 점진적으로 컨텍스트를 쌓아간다.
messages 배열이 계속 커지는 문제
그런데 파일을 읽을 때마다 그 내용이 messages 배열에 쌓인다. LLM API는 stateless라서 매번 전체를 보내야 하니까 이런 상황이 된다.
messages = [
// user: "리팩토링해줘" → 50 토큰
// assistant: tool_use(list_directory) → 100 토큰
// user: tool_result(디렉토리 구조) → 2,000 토큰
// assistant: tool_use(read_file index.ts) → 100 토큰
// user: tool_result(파일 내용) → 5,000 토큰
// assistant: tool_use(read_file utils.ts) → 100 토큰
// user: tool_result(파일 내용) → 3,000 토큰
// ...계속 쌓인다
]
파일을 읽을수록 토큰이 누적되고, 결국 컨텍스트 윈도우 한도 에 부딪힌다. Claude는 200K 토큰, GPT-4o는 128K 토큰이 한도다. 큰 것 같지만 파일 수십 개 읽으면 금방 찬다.
해결 방법 1: 요약
오래된 tool_result를 요약본으로 교체하는 방법이다.
// 원본: 파일 내용 전체 (5,000 토큰)
{ type: "tool_result", content: "import React from..." /* 전체 내용 */ }
// 요약으로 교체 (200 토큰)
{ type: "tool_result", content: "[요약] index.ts: React 앱 진입점. App 컴포넌트를 렌더링하고 라우터를 설정하는 파일." }
해결 방법 2: 슬라이딩 윈도우
오래된 메시지를 잘라내고 최근 N개의 메시지만 유지한다.
if (messages.length > MAX_MESSAGES) {
const systemSummary = await summarize(messages.slice(0, -MAX_MESSAGES));
messages = [
{ role: "user", content: `[이전 대화 요약] ${systemSummary}` },
...messages.slice(-MAX_MESSAGES)
];
}
해결 방법 3: 필요한 부분만 읽기
파일 전체를 읽는 대신 특정 줄 범위나 검색 결과만 가져오는 도구를 만든다.
tools: [
{ name: "read_file" }, // 파일 전체
{ name: "read_lines" }, // 특정 줄 범위만
{ name: "grep" }, // 패턴 매칭 결과만
{ name: "search_codebase" }, // 키워드 검색
]
Claude Code가 실제로 이렇게 한다. Grep으로 먼저 검색해서 관련 파일을 찾고, 필요한 부분만 Read로 읽는다. 파일 전체를 무조건 읽지 않는다.
해결 방법 4: 서브 에이전트
Claude Code의 Task 도구처럼, 별도의 에이전트를 띄워서 작업을 나누는 방법이다.
메인 에이전트 (컨텍스트: 전체 대화)
├── 서브 에이전트 A: "src/auth/ 분석해" → 독립 컨텍스트
├── 서브 에이전트 B: "src/api/ 분석해" → 독립 컨텍스트
└── 서브 에이전트 C: "테스트 분석해" → 독립 컨텍스트
각 서브 에이전트는 자기만의 messages 배열을 가진다. 작업이 끝나면 결과만 요약해서 메인 에이전트에 돌려준다. 이렇게 하면 하나의 컨텍스트 윈도우에 모든 걸 담을 필요가 없다.
이 컨텍스트 관리가 에이전트를 "잘" 만드는 데 있어 가장 어려운 부분 중 하나다. Agent Loop 자체는 단순한데, 대화가 길어졌을 때 어떤 정보를 유지하고 어떤 걸 버릴지 결정하는 게 서비스 품질을 좌우한다.
결국 차별화는 어디서 나올까
기본 구조가 비슷하다면 서비스 간 차이는 어디서 나올까? 몇 가지 핵심 요소가 있다.
모델 성능 이 가장 중요하다. 같은 도구를 줘도 어떤 모델은 적재적소에 잘 쓰고, 어떤 모델은 엉뚱하게 쓴다. 최근 Claude Sonnet 4.5나 GPT-4가 코딩 에이전트에서 좋은 평가를 받는 이유다.
시스템 프롬프트 설계도 중요하다. LLM에게 "너는 코딩 전문가야", "파일을 수정하기 전에 반드시 읽어", "에러가 나면 다시 시도해" 같은 지침을 어떻게 주느냐에 따라 행동 패턴이 완전히 달라진다.
도구 설계 도 차별화 포인트다. 똑같이 파일을 읽는 도구라도 에러 처리, 권한 관리, 성능 최적화를 어떻게 하느냐에 따라 사용자 경험이 달라진다.
컨텍스트 관리 역시 중요하다. 대화가 길어지면 messages 배열도 길어진다. 어떤 내용을 요약하고 어떤 내용을 유지할지, 언제 새로운 컨텍스트를 시작할지 결정해야 한다.
마지막으로 UX 다. 똑같은 기능이라도 UI/UX를 어떻게 설계하느냐에 따라 사용자가 느끼는 품질이 완전히 다르다.
진입장벽은 낮아졌지만
LLM API가 발전하면서 대화형 AI 서비스를 만드는 진입장벽은 크게 낮아졌다. 핵심 로직은 몇백 줄이면 구현할 수 있다.
하지만 "잘" 만드는 건 여전히 어렵다. 좋은 도구를 설계하고, 적절한 프롬프트를 작성하고, 안정적인 루프를 구현하는 건 경험과 노하우가 필요한 일이다.
그래도 한 가지는 확실하다. 이제 누구나 자신만의 AI 에이전트를 만들 수 있는 시대가 왔다는 것이다. LLM API 하나면 충분하다.