[AI 용어] Agent Loop — AI 코딩 도구에서 돌고 있는 그 표시의 정체
AI 코딩 도구에 "README 오타 고쳐 줘" 한마디만 하면, 혼자 파일을 열고 고치고 끝내 놓습니다. 그동안 화면에서는 무언가가 계속 돌고 있습니다.
이 글에서는 그 뒤에서 무슨 일이 일어나는지, Node.js 120줄짜리 데모 코드와 실제로 주고받은 JSON으로 확인해 봅니다.영상: YouTube에서 보기
Agent Loop(에이전트 루프)
LLM에 묻고 → LLM이 도구를 원하면 내 프로그램이 실행해서 결과를 붙이고 → 다시 묻기를, LLM이 "끝"이라고 할 때까지 반복하는 구조.
1. 한 번에 끝난 게 아닙니다
Claude Code 같은 AI 코딩 도구에 "README 오타 고쳐 줘"라고 입력하면, 도구는 파일을 읽고, 고치고, "다 고쳤습니다"라고 답합니다. 사용자 입장에서는 한 번의 요청, 한 번의 답처럼 보입니다.
하지만 실제로는 AI에게 묻고, 답을 받고, 또 묻고, 또 답을 받기를 여러 번 반복한 결과입니다. 화면에서 돌고 있는 표시는 이 반복이 진행 중이라는 뜻입니다. 이 반복 구조를 **에이전트 루프(Agent Loop)**라고 부릅니다.
2. 한 줄 정의: 목표를 이룰 때까지 도는 고리
- LLM(Claude 같은 AI)이 다음에 무엇을 할지 생각합니다.
- 필요하면 도구를 써서 일을 합니다.
- 그 결과를 보고 다시 생각합니다.
- 사람이 매번 "다음은?" 하고 시키지 않아도 이 고리를 스스로 돌다가, 목표를 이루면 빠져나옵니다.
여기서 꼭 짚고 넘어갈 점이 하나 있습니다. 도구를 실행하는 것은 LLM이 아니라 내 프로그램입니다. LLM은 "이 도구를 이렇게 불러 주세요"라고 JSON으로 부탁만 합니다. 파일을 실제로 열고 쓰는 것은 LLM을 호출한 쪽의 코드입니다. (도구 호출 자체는 「Tool Calling」 편에서 따로 다룹니다.)
3. 코드로 보기: while문 하나
원리만 보여 드리려고 만든 아주 간단한 데모입니다. 실제 AI 코딩 도구는 이 뼈대 위에 권한 확인, 오류 처리, 대화 정리 같은 기능을 훨씬 많이 쌓아 올립니다. 하지만 뼈대는 같습니다.
- 프레임워크 없음
- Claude API 공식 SDK(
@anthropic-ai/sdk) + 파일 하나, 약 120줄 - Node.js + TypeScript
준비
mkdir ep01-agent-loop && cd ep01-agent-loop
npm init -y
npm install @anthropic-ai/sdk
npm install -D tsx typescript @types/node
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env # 키는 절대 커밋하지 않습니다 (.gitignore에 .env 추가)
package.json에는 "type": "module"과 실행 스크립트를 넣습니다.
{
"type": "module",
"scripts": {
"start": "tsx --env-file-if-exists=.env agent.ts",
"step": "tsx --env-file-if-exists=.env --import ./step.ts agent.ts"
}
}
연습용 파일 demo/README.md에는 일부러 오타를 세 개 넣어 둡니다. (instal, strat, 지윈)
# 팔복 메모장
간단한 메모 앱입니다. 할 일을 적고, 다 하면 체크합니다.
## 설치
npm instal
npm run strat
## 기능
- 메모 추가, 삭제
- 완료한 메모 체크
- 다크 모드 지윈
① 도구 설명서
LLM에게 "이런 도구가 있다"고 알려 주는 부분입니다. 이름, 설명, 받는 값(JSON Schema)만 있습니다. LLM은 이것을 보고 어떤 도구를 어떤 값으로 부를지 정합니다.
import Anthropic from "@anthropic-ai/sdk";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const WORKDIR = path.join(here, "demo"); // 에이전트는 이 폴더 안만 만질 수 있다
const LOGS = path.join(here, "logs");
const client = new Anthropic(); // ANTHROPIC_API_KEY 환경변수를 읽는다
const TOOLS: Anthropic.Beta.BetaTool[] = [
{
name: "read_file",
description: "파일 내용을 읽는다.",
input_schema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
{
name: "edit_file",
description: "파일에서 old 문자열을 new 문자열로 바꾼다.",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
old: { type: "string" },
new: { type: "string" },
},
required: ["path", "old", "new"],
},
},
];
② 도구 실행
LLM이 부탁한 도구를 내 코드가 실제로 실행합니다. 데모라도 최소한의 안전장치로 demo 폴더 밖은 건드리지 못하게 막았습니다.
function runTool(name: string, args: any): string {
const file = path.resolve(here, args.path);
if (!file.startsWith(WORKDIR + path.sep)) return "오류: demo 폴더 밖은 만질 수 없습니다";
if (name === "read_file") return fs.readFileSync(file, "utf8");
if (name === "edit_file") {
const text = fs.readFileSync(file, "utf8");
if (!text.includes(args.old)) return `오류: '${args.old}'를 찾지 못했습니다`;
fs.writeFileSync(file, text.replace(args.old, args.new));
return "수정 완료";
}
return `오류: 모르는 도구 ${name}`;
}
// 매 턴 보낸 JSON과 받은 JSON을 logs/에 그대로 저장한다
function save(turn: number, kind: "request" | "response", data: unknown) {
fs.mkdirSync(LOGS, { recursive: true });
const file = path.join(LOGS, `${String(turn).padStart(2, "0")}_${kind}.json`);
fs.writeFileSync(file, JSON.stringify(data, null, 2));
}
③ 에이전트 루프 (핵심)
async function agent(task: string) {
const messages: Anthropic.Beta.BetaMessageParam[] = [{ role: "user", content: task }];
let turn = 0;
while (true) {
turn++;
const request = {
model: "claude-opus-5-5",
max_tokens: 16000,
output_config: { effort: "medium" as const },
tools: TOOLS,
messages,
};
save(turn, "request", request);
console.log(`\n[턴 ${turn}] → LLM에 보냄 (messages ${messages.length}개)`);
const res = await client.beta.messages.create({
...request,
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default", // 안전 필터가 거절하면 서버가 다른 모델로 다시 시도
});
save(turn, "response", res);
console.log(`[턴 ${turn}] ← 답 받음 (stop_reason: ${res.stop_reason}, 입력 토큰: ${res.usage.input_tokens})`);
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
// 도구를 안 쓰겠다 → 끝
for (const block of res.content) {
if (block.type === "text") console.log(`\n✓ 완료 (${turn}턴)\n${block.text}`);
}
return;
}
const results: Anthropic.Beta.BetaToolResultBlockParam[] = [];
for (const call of res.content) {
if (call.type !== "tool_use") continue;
console.log(` ⚙ ${call.name}(${JSON.stringify(call.input)})`);
let output: string;
let isError = false;
try {
output = runTool(call.name, call.input);
} catch (e) {
output = `오류: ${(e as Error).message}`; // 실패도 LLM에 알려 주면 스스로 고쳐서 다시 시도한다
isError = true;
}
results.push({
type: "tool_result",
tool_use_id: call.id, // 어느 호출의 결과인지 짝을 맞춘다
content: output,
is_error: isError,
});
}
messages.push({ role: "user", content: results });
}
}
agent(process.argv[2] ?? "demo 폴더의 README.md 오타를 고쳐 줘");
루프를 말로 풀면 이렇습니다.
messages라는 대화 목록에 내 부탁을 넣습니다.while (true)— 끝날 때까지 반복합니다.- 대화 목록 전체를 API로 보내고, 받은 답을 대화 목록에 붙입니다.
- 답의
stop_reason(답을 멈춘 이유)이tool_use가 아니면 끝입니다. tool_use면 내 코드가 도구를 실행하고, 결과(tool_result)를 대화 목록에 붙여서 처음으로 돌아갑니다.
flowchart TD
S[messages = 내 부탁] --> R[messages 전체를 API로 전송]
R --> P[답을 messages에 추가]
P --> Q{stop_reason}
Q -->|tool_use| T[내 코드가 도구 실행]
T --> U[tool_result를 messages에 추가]
U --> R
Q -->|end_turn| E((종료))
중요한 점: 끝낼지 말지는 LLM이 정합니다
stop_reason은 내 코드가 아니라 LLM이 판단해서 보내는 값입니다. 도구를 더 쓸지, 이제 끝낼지는 LLM이 결정하고, 내 코드는 그 판단을 따라 반복할 뿐입니다. 그래서 같은 루프 코드라도 어떤 LLM을 쓰느냐에 따라 에이전트의 실력이 달라집니다.
참고: 왜 beta가 붙어 있나요?
코드에 client.beta.messages.create, BetaTool 같은 이름이 보이는 것은 fallbacks 옵션 때문입니다. 요청이 서버 쪽 안전 필터에 걸려 거절되면 다른 모델로 자동으로 다시 시도하게 하는 옵션인데, 아직 베타 기능이라 betas 헤더와 함께 beta 네임스페이스로 호출합니다. 이 옵션을 빼면 client.messages.create와 Anthropic.Tool, Anthropic.MessageParam으로 바꿔 써도 루프는 똑같이 동작합니다.
4. 한 턴씩 실행해 보기
루프가 너무 빨리 끝나서 흐름을 보기 어렵기 때문에, API를 보내기 직전마다 Enter를 기다리는 "단계 실행 모드"를 따로 만들었습니다. agent.ts는 건드리지 않고, 실행할 때만 끼워 넣습니다.
// step.ts — LLM에 보내기 직전마다 Enter를 기다린다
import Anthropic from "@anthropic-ai/sdk";
import readline from "node:readline/promises";
const proto = Object.getPrototypeOf(new Anthropic({ apiKey: "step" }).beta.messages);
const send = proto.create;
proto.create = async function (...args: unknown[]) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
await rl.question(" ⏎ Enter를 누르면 보냅니다… ");
rl.close();
return send.apply(this, args);
};
npm run step
실행 결과(영상 녹화 때 실제 출력)는 이렇습니다.
[턴 1] → LLM에 보냄 (messages 1개)
[턴 1] ← 답 받음 (stop_reason: tool_use, 입력 토큰: 507)
⚙ read_file({"path":"demo/README.md"})
[턴 2] → LLM에 보냄 (messages 3개)
[턴 2] ← 답 받음 (stop_reason: tool_use, 입력 토큰: 671)
⚙ edit_file({"path":"demo/README.md","old":"npm instal\nnpm run strat","new":"npm install\nnpm run start"})
⚙ edit_file({"path":"demo/README.md","old":"다크 모드 지윈","new":"다크 모드 지원"})
[턴 3] → LLM에 보냄 (messages 5개)
[턴 3] ← 답 받음 (stop_reason: end_turn, 입력 토큰: 1016)
✓ 완료 (3턴)
`demo/README.md`에서 오타 세 곳을 고쳤습니다. ...
| 턴 | 보낸 messages | LLM의 답 | stop_reason | 입력 토큰 |
|---|---|---|---|---|
| 1 | 1개 (내 부탁) | 파일부터 읽어 주세요 (read_file) |
tool_use |
507 |
| 2 | 3개 | 두 군데 고쳐 주세요 (edit_file × 2) |
tool_use |
671 |
| 3 | 5개 | 다 고쳤습니다 | end_turn |
1,016 |
LLM의 판단에 따라 결과는 실행할 때마다 조금 다를 수 있습니다. 예를 들어 한 군데씩 고치면 턴이 네 번이 됩니다.
5. 실제로 주고받은 JSON
logs/ 폴더에 저장된 JSON을 그대로 보겠습니다. (보기 쉽도록 일부 필드는 줄였습니다.)
턴 1 응답: "파일부터 읽어 주세요"
{
"stop_reason": "tool_use",
"content": [
{
"type": "tool_use",
"id": "toolu_01Mkr9XDdHbm8ZnM1dp5kU9k",
"name": "read_file",
"input": { "path": "demo/README.md" }
}
],
"usage": { "input_tokens": 507, "output_tokens": 52 }
}
LLM은 파일을 직접 열지 않았습니다. read_file을 demo/README.md로 불러 달라고 부탁만 했습니다. 이 요청을 받은 내 코드가 파일을 읽고, 그 내용을 tool_result로 만들어 대화 목록에 붙입니다.
턴 3 요청: 대화 목록 전체
턴 3에서 보낸 messages입니다. 처음 부탁부터 지금까지의 대화가 전부 들어 있습니다.
[
{ "role": "user", "content": "demo 폴더의 README.md 오타를 고쳐 줘" },
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_01Mkr...", "name": "read_file",
"input": { "path": "demo/README.md" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_01Mkr...",
"content": "# 팔복 메모장\n\n간단한 메모 앱입니다. ...\nnpm instal\nnpm run strat\n...\n- 다크 모드 지윈\n",
"is_error": false }
]
},
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "", "signature": "CAQSwwUK..." },
{ "type": "tool_use", "id": "toolu_01N4C...", "name": "edit_file",
"input": { "path": "demo/README.md", "old": "npm instal\nnpm run strat", "new": "npm install\nnpm run start" } },
{ "type": "tool_use", "id": "toolu_01Dtp...", "name": "edit_file",
"input": { "path": "demo/README.md", "old": "다크 모드 지윈", "new": "다크 모드 지원" } }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_01N4C...", "content": "수정 완료", "is_error": false },
{ "type": "tool_result", "tool_use_id": "toolu_01Dtp...", "content": "수정 완료", "is_error": false }
]
}
]
몇 가지 눈여겨볼 부분이 있습니다.
- 역할이 번갈아 나옵니다.
user→assistant→user→ … 순서입니다. 도구 실행 결과(tool_result)도user역할로 보냅니다. LLM 입장에서는 "내 프로그램이 알려 준 정보"이기 때문입니다. tool_use_id로 짝을 맞춥니다. 턴 2처럼 한 번에 도구를 여러 개 부르면, 각 결과가 어느 호출의 결과인지 id로 연결합니다.- thinking 블록도 그대로 돌려보냅니다. 턴 2 답에는 모델의 생각 과정을 담은
thinking블록이 있었습니다. 받은content를 손대지 않고 그대로 대화 목록에 붙이기만 하면 이것도 자연스럽게 다시 전달됩니다.
턴 3 응답: "다 고쳤습니다"
{
"stop_reason": "end_turn",
"content": [
{
"type": "text",
"text": "`demo/README.md`에서 오타 세 곳을 고쳤습니다.\n\n- `npm instal` → `npm install`\n- `npm run strat` → `npm run start`\n- `다크 모드 지윈` → `다크 모드 지원`\n\n..."
}
],
"usage": { "input_tokens": 1016, "output_tokens": 200 }
}
도구 호출 없이 텍스트만 돌아왔고, stop_reason은 end_turn입니다. 루프는 여기서 끝납니다. Claude Code 화면에서 돌던 그 표시가 멈추는 것도 바로 이런 순간입니다.
6. 왜 매번 전부 보낼까: API는 대화를 기억하지 않습니다
턴 1에서는 1개, 턴 2에서는 3개, 턴 3에서는 5개. 대화 목록은 매번 처음부터 전부 보냅니다.
이유는 간단합니다. Messages API는 상태가 없습니다(stateless). 서버는 이전 요청을 기억하지 않기 때문에, 이어지는 대화처럼 동작하게 하려면 지금까지의 대화를 매번 다시 보내야 합니다.
sequenceDiagram
participant P as 내 프로그램
participant L as LLM 서버
P->>L: 턴 1: [부탁] (507 토큰)
L-->>P: read_file 해 주세요
P->>L: 턴 2: [부탁, 답1, 결과1] (671 토큰)
L-->>P: edit_file × 2 해 주세요
P->>L: 턴 3: [부탁, 답1, 결과1, 답2, 결과2] (1,016 토큰)
L-->>P: 다 고쳤습니다 (end_turn)
그래서 턴마다 LLM이 읽는 글의 양, 입력 토큰이 계속 늘어납니다.
| 턴 | 입력 토큰 | 증가 |
|---|---|---|
| 1 | 507 | — |
| 2 | 671 | +164 |
| 3 | 1,016 | +345 |
턴 1의 507 토큰에는 내 부탁뿐 아니라 **도구 설명서(TOOLS)**도 포함되어 있습니다. 도구 설명서 역시 매 요청마다 함께 보내기 때문입니다.
토큰은 요금의 기준입니다. 에이전트가 오래 일할수록 비용이 늘어나는 이유가 바로 이것입니다. 이번 데모는 세 턴이라 숫자가 작지만, 실제 AI 코딩 도구는 수십 번 파일을 읽고 명령을 실행합니다. 그 결과가 모두 대화 목록에 쌓이면서 매 턴 다시 전송됩니다.
실제 도구들은 이 비용을 줄이기 위해 이미 보낸 앞부분을 서버에 캐시해 두는 프롬프트 캐싱을 함께 씁니다. 하지만 "매번 전체를 보낸다"는 구조 자체는 같습니다.
그럼 대화가 아주 길어져서 LLM이 한 번에 읽을 수 있는 양을 넘으면 어떻게 될까요? 이것이 다음 편의 주제인 Context Window입니다.
정리: 세 줄 요약
- 에이전트는 LLM에 묻고, 도구를 실행하고, 다시 묻기를 반복합니다. 도구를 실행하는 것은 LLM이 아니라 내 프로그램입니다.
- 반복할 때마다 대화 전체를 다시 보냅니다. API는 대화를 기억하지 않기 때문이고, 그래서 입력 토큰(=비용)이 늘어납니다.
- LLM이
end_turn으로 "끝"이라고 할 때까지 돕니다. 코드로는 while문 하나입니다.
화려해 보이는 AI 에이전트도 뼈대는 이 while문 하나입니다. 그 위에 어떤 도구를 주고, 얼마나 똑똑한 LLM을 쓰고, 길어진 대화를 어떻게 관리하느냐가 실력 차이를 만듭니다.
다음 편 「Context Window」에서 뵙겠습니다.
원리를 코드로, 김팔복
아직 댓글이 없습니다.