다단계 에이전트 — Claude 가 스스로 도구를 계획하고 잇는다 (Part 6 시작)
Article
1215강에서 우리는 Claude 에게 계산기와 npm 조회 도구를 쥐어줬다. 도구는 하나뿐이었고, 질문도 대부분 도구 하나면 풀렸다. 1620강에서는 문서를 검색하는 파이프라인을 만들었는데, 검색을 언제·어떻게 할지는 전부 우리 코드가 정했다. 20강 chat.ts를 다시 보면, searchStore(store, question, 3) 처럼 검색 시점과 검색어를 우리가 직접 짜 넣었다.
오늘 그 결정권을 Claude 에게 넘긴다. 검색을 도구로 만들어 쥐어주고, "이 질문에 답하려면 검색을 몇 번, 어떤 말로 해야 하는지"는 Claude 가 스스로 정하게 한다. 계산기 도구도 다시 꺼내 같이 준다. 문서에서 찾은 숫자로 계산까지 필요한 질문을 던져, Claude 가 검색 → 계산 → 답 순서를 스스로 설계하는지 실측으로 확인한다.
- 12~15강도구 하나. 언제 쓸지는 Claude 가 정했지만, 보통 한 번이면 끝났다
- 16~20강검색 시점·검색어를 전부 우리 코드가 정해서 넣었다
- 21강 — 오늘검색도 도구로 준다. 몇 번, 어떤 순서로 쓸지 Claude 가 계획한다
lesson-20 의 lib.ts, vectors.json, docs 를 그대로 가져와 lesson-21 을 만든다.
검색을 도구로 감싼다
20강의 검색 로직 그대로다. 달라진 건 함수를 직접 호출하는 대신, Claude 가 호출하도록 도구로 등록한다는 것뿐이다.
async function searchCompanyDocs(query: string): Promise<string> {
const chunks = await loadStore();
const queryVector = embed(query);
const hits = chunks
.map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, c.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
return JSON.stringify(
hits.map((h) => ({ source: h.chunk.source, score: Number(h.score.toFixed(4)), text: h.chunk.text }))
);
}
const tools: Anthropic.Tool[] = [
{
name: "search_company_docs",
description:
"사내 규정 문서(연차·휴가 정책, 원격근무 정책, 경비 처리 가이드)에서 관련 내용을 검색한다. " +
"질문에 서로 다른 주제가 섞여 있으면, 주제마다 이 도구를 따로따로 불러 각각 검색한다. " +
"한 번의 호출로 모든 주제를 다 커버하려 하지 말 것.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "검색할 내용. 짧은 키워드나 문장으로." },
},
required: ["query"],
},
},
{
name: "calculator",
description:
"사칙연산을 정확히 계산한다. 문서에서 찾은 숫자(한도·금액·일수 등)로 계산이 필요할 때, " +
"암산하지 말고 반드시 이 도구를 쓴다.",
input_schema: {
type: "object",
properties: {
operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] },
a: { type: "number" },
b: { type: "number" },
},
required: ["operation", "a", "b"],
},
},
];
description 을 눈여겨보자. "주제마다 따로 불러라", "암산하지 말고 이 도구를 써라"라는 문구가 바로 Claude 에게 주는 사용 설명서다. 도구 이름과 파라미터만으로는 언제 어떻게 써야 할지 모른다. 13강에서 배운 "좋은 도구 스키마는 좋은 설명에서 나온다"는 원칙이 오늘도 그대로 적용된다.
실행 루프는 12~15강과 똑같다
새로 짤 게 없다. stop_reason 이 tool_use 인 동안 반복하는 루프를 그대로 가져오고, 도구 이름에 따라 분기만 추가한다.
while (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
if (block.name === "search_company_docs") {
const input = block.input as { query: string };
const result = await searchCompanyDocs(input.query);
console.log(` [실행] search_company_docs(query: "${input.query}")`);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
} else if (block.name === "calculator") {
const input = block.input as { operation: string; a: number; b: number };
const result = runCalculator(input);
console.log(` [실행] calculator(${JSON.stringify(input)}) = ${result}`);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: String(result) });
}
}
messages.push({ role: "user", content: toolResults });
response = await client.messages.create({ model: "claude-haiku-4-5", max_tokens: 800, system: SYSTEM_PROMPT, tools, messages });
console.log(`[${++round}차 응답] stop_reason: ${response.stop_reason}`);
}
실행 — 검색 결과의 숫자를 계산기로 잇는다
가장 확실한 다단계 사례부터 본다. 문서에서 찾은 숫자를 계산에 써야 하는 질문이다.
npx tsx agent.ts "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?"
- 질문: "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?"
- [1차 응답] stop_reason: tool_use
- [실행] search_company_docs(query: "야근 식대 한도")
- [2차 응답] stop_reason: tool_use
- [실행] calculator({"operation":"multiply","a":20000,"b":5}) = 100000
- [3차 응답] stop_reason: end_turn
- [최종 답]
- 야근을 5번 했을 경우, 야근 식대 한도로 총 10만 원까지 정산받을 수 있습니다.
- (1회당 2만 원 × 5회 = 10만 원)
- 단, 경비 정산은 매월 25일이 마감일이므로, 25일 이후에 등록하면 다음 달 정산으로 넘어간다는 점을 참고하세요.
- [usage] input: 2697, output: 138
3번 응답이 오갔다. 1차에서 검색을 해 "야근 식대 2만 원"이라는 숫자를 찾고, 그 숫자를 2차 응답에서 계산기에 직접 넣었다. 우리는 "검색부터 하고 계산해라"라고 순서를 정해준 적이 없다. 시스템 프롬프트엔 "계산이 필요하면 문서에서 찾은 숫자를 calculator 에 넣으라"고만 적었다. 검색 결과 없이는 곱할 숫자(20000)를 몰랐을 테니, 이 순서는 Claude 가 스스로 필요에 따라 정한 것이다. 이게 12~20강까지 만든 도구 하나·검색 하나짜리 구조와 오늘의 결정적인 차이다.
실행 — 질문이 여러 주제면 검색을 그만큼 나눠 부른다
이번엔 계산 없이, 서로 다른 주제 세 개를 한 번에 물었다.
npx tsx agent.ts "연차 반차는 어떻게 쓰고, 경비 정산 마감은 언제고, 원격근무 코어타임은 몇 시부터 몇 시까지야?"
- [1차 응답] stop_reason: tool_use
- [실행] search_company_docs(query: "연차 반차 사용")
- [실행] search_company_docs(query: "경비 정산 마감")
- [실행] search_company_docs(query: "원격근무 코어타임")
- [2차 응답] stop_reason: end_turn
- [usage] input: 3352, output: 384
세 주제, 세 번의 검색이 1차 응답 안에서 한꺼번에 나갔다. 앞선 계산기 사례와 다르게, 이번엔 세 검색이 서로 의존하지 않으니 굳이 순서대로(3라운드) 부를 필요가 없었다. Claude 가 "이건 독립적인 세 질문이니 한 번에 병렬로 처리해도 된다"고 판단해 한 라운드에 몰아 부른 것이다. 그리고 각 검색어("연차 반차 사용", "경비 정산 마감", "원격근무 코어타임")도 우리가 준 원래 질문 문장을 그대로 쓰지 않고 Claude 가 직접 짧은 키워드로 다듬어서 넣었다. 20강까지는 이 검색어를 우리 코드가 정했는데, 오늘부터는 Claude 의 몫이다.
실행 — 답이 없는 주제가 섞여도 정직함은 유지된다
19강에서 만든 그라운딩 규칙(근거 없인 답하지 말라)이 도구를 여러 개 쓰는 오늘도 지켜지는지 확인한다. 답할 수 있는 주제와, 문서에 아예 없는 주제를 함께 물었다.
npx tsx agent.ts "연차 이월 규정 알려주고, 회사 주차장은 몇 층에 있는지도 알려줘."
- [1차 응답] stop_reason: tool_use
- [실행] search_company_docs(query: "연차 이월 규정")
- [실행] search_company_docs(query: "회사 주차장 위치 층수")
- [2차 응답] stop_reason: end_turn
- [최종 답]
- **연차 이월 규정:**
- 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사용하지 못한 일수에 한해 다음 해로 최대 5일까지 이월할 수 있습니다. 이월 신청은 매년 12월 첫째 주까지 인사팀에 제출해야 합니다.
- **회사 주차장:**
- 문서에서 찾을 수 없습니다.
- [usage] input: 2709, output: 274
한쪽은 정확히 답하고, 다른 한쪽은 정직하게 "찾을 수 없다"고 답했다. 도구를 여러 개 쓰는 에이전트 구조로 바뀌어도, 근거 없인 답하지 않는다는 규칙은 항목별로 따로 지켜진다. 답할 수 있는 부분이 있다고 해서 답할 수 없는 부분까지 얼버무리지 않았다.
질문이 단순하면 도구도 한 번만 쓴다
마지막으로, 굳이 여러 번 부를 필요 없는 단순한 질문도 확인해둔다. 안 그래도 되는데 자꾸 여러 번 검색하는 건 아닌지 보기 위해서다.
npx tsx agent.ts "야근 식대는 얼마까지 인정돼?"
- [1차 응답] stop_reason: tool_use
- [실행] search_company_docs(query: "야근 식대")
- [2차 응답] stop_reason: end_turn
- [최종 답] 야근 식대는 2만 원까지 인정됩니다. (일반 식대는 1인당 1만 5천 원)
- [usage] input: 1989, output: 47
질문 하나엔 검색 한 번만 나갔다. 앞의 세 사례와 비교하면, Claude 가 질문의 복잡도에 맞춰 도구 호출 횟수를 스스로 조절한다는 게 분명해진다.
| 검색 → 계산 (의존적) | 3 | search 1회 + calculator 1회 (순차) | 2697 | 138 |
| 3주제 (독립적) | 2 | search 3회 (병렬) | 3352 | 384 |
| 2주제, 하나는 답 없음 | 2 | search 2회 (병렬) | 2709 | 274 |
| 단일 주제 | 2 | search 1회 | 1989 | 47 |
| 2주제 (독립적) | 2 | search 2회 (병렬) | 2660 | 397 |
지금까지 만든 것
- my-ai-assistant
- docs/
- lib.ts
- vectors.json
- agent.ts
- .env.example
- package.json
정리하면
검색을 도구로 감싸 Claude 에게 넘기니, 질문의 모양에 따라 도구를 몇 번·어떤 순서로 부를지 스스로 계획했다. 서로 의존하는 작업(검색→계산)은 순차로, 독립적인 작업(주제 여러 개)은 병렬로 처리했고, 그 와중에도 근거 없인 답하지 않는 원칙은 유지됐다.
- 01
검색을 도구로 등록했다
20강의 검색 로직을 search_company_docs 도구로 감싸고, 계산기 도구도 함께 줬다.
- 02
12~15강의 실행 루프를 그대로 재사용했다
stop_reason 이 tool_use 인 동안 반복하는 구조는 그대로, 도구 이름별 분기만 추가했다.
- 03
순차 의존과 병렬 독립을 실측으로 구분했다
검색 결과 숫자가 필요한 계산은 3라운드(순차)로, 독립된 세 주제는 2라운드(병렬)로 처리했다.
npx tsx agent.ts "야근을 5번 했어. 야근 식대 한도로 총 얼마까지 정산받을 수 있어?" - 04
도구가 늘어도 그라운딩 원칙은 유지됐다
답할 수 있는 주제와 없는 주제가 섞여도, 항목별로 따로 정직하게 답했다.
다음 22강에서는 이 에이전트에 메모리를 붙인다. 지금 agent.ts 는 ask() 를 부를 때마다 대화가 처음부터 다시 시작된다. 20강 챗봇처럼 이전 턴을 기억하지 못한다. 도구를 쓰는 에이전트에 20강의 히스토리 관리를 결합해 상태를 유지하는 에이전트를 만들면, Part 6(에이전트)이 끝난다.