자바로 배우는 AI (2) — Tool use와 에이전트 루프
LLM은 코드를 직접 실행하지 못한다. 대신 “이 함수를 이 인자로 불러줘”라고 JSON으로 요청한다. 그 요청을 받아 실행하고 결과를 되돌려주는 반복 루프 — 그게 에이전트다. 전체 코드: github.com/dadaok/ai-playground
- tool use 원리
- 2단계 — SDK 헬퍼가 루프를 돌려줌
- “지금 몇 시? 그리고 (128×7)−19?” → 3턴이 나온 이유
- “어떻게 끝을 아는가” — 코드는 판단 안 한다, 모델이 한다
- 3단계 — 루프를 손으로 직접 구현
- agent란 결국 무엇인가
- 정리
tool use 원리
- 우리가 “이런 도구들이 있다”고 스키마(이름 + 설명 + 파라미터)를 넘긴다.
- 모델이 필요하면 “calc를 이 인자로 불러줘”라고 JSON으로 요청한다 → 응답의
stop_reason이"tool_use". - 우리 코드가 그 함수를 실제로 실행한다.
- 결과를
tool_result로 다시 모델에게 넘긴다. - 모델이 답을 마무리하거나(
end_turn) 또 다른 도구를 부른다. 2~4 반복.
이 2~4 반복 루프가 바로 에이전트다.
2단계 — SDK 헬퍼가 루프를 돌려줌
Anthropic 자바 SDK에는 BetaToolRunner 가 있어서 루프를 대신 돌려준다. 도구는 클래스로 정의한다. 필드 = 파라미터, get() = 실행 로직, 애노테이션 설명 = 모델이 읽는 프롬프트.
@JsonClassDescription("두 수의 사칙연산을 계산한다. 산수가 필요하면 반드시 이 도구를 사용할 것.")
static class Calculator implements Supplier<String> {
@JsonPropertyDescription("연산자: +, -, *, / 중 하나")
public String op;
@JsonPropertyDescription("첫 번째 피연산자")
public double a;
@JsonPropertyDescription("두 번째 피연산자")
public double b;
@Override
public String get() {
double result = switch (op) {
case "+" -> a + b; case "-" -> a - b;
case "*" -> a * b; case "/" -> a / b;
default -> throw new IllegalArgumentException("알 수 없는 연산자: " + op);
};
return String.valueOf(result);
}
}
BetaToolRunner runner = client.beta().messages().toolRunner(
MessageCreateParams.builder()
.model("claude-haiku-4-5")
.maxTokens(1024L)
.putAdditionalHeader("anthropic-beta", "structured-outputs-2025-11-13")
.addTool(Calculator.class)
.addTool(CurrentTime.class)
.addUserMessage("지금 서울은 몇 시야? 그리고 (128 * 7) 에서 19 를 뺀 값도 알려줘. 계산은 반드시 도구를 써서 해.")
.build());
// runner 를 순회하면 한 턴씩 진행된다. 모델 응답 -> 도구 실행 -> 다시 모델 ...
for (BetaMessage message : runner) {
System.out.println("stop_reason=" + message.stopReason());
System.out.println(message);
}
삽질 포인트: 도구는 파라미터가 최소 1개 있어야 한다. 파라미터 없는
CurrentTime을 만들었더니'properties' field is missing로 스키마 검증에서 터졌다.timezone같은 인자를 하나 받게 하면 된다.
“지금 몇 시? 그리고 (128×7)−19?” → 3턴이 나온 이유
| turn | 보낸 messages | 모델 응답 | 우리 코드 |
|---|---|---|---|
| 1 | [질문] | tool_use: CurrentTime + Calculator(128×7) | 시각 조회, 896 계산 |
| 2 | [질문, tool_use, 결과(시각·896)] | tool_use: Calculator(896 − 19) | 877 계산 |
| 3 | […, 결과(877)] | end_turn: “서울은 …, 답은 877” | 종료 |
2턴이 아니라 3턴인 이유: 896 − 19 는 첫 계산 결과(896)를 봐야 요청할 수 있다. 의존성이 있어 순차적이다. 독립적인 계산 두 개였다면 한 턴에서 도구 2개를 동시에 요청(parallel tool use)해서 2턴에 끝났을 것이다.
“어떻게 끝을 아는가” — 코드는 판단 안 한다, 모델이 한다
- 모델이 매 턴 둘 중 하나를 출력:
tool_use블록(정보 부족) / 텍스트만(답할 수 있음) - API 서버가 그걸 보고
stop_reason을 자동으로 정함 - 우리 코드는 기계적 체크만:
while (응답.stop_reason == "tool_use") { // 도구 요청 있으면 계속
도구 실행하고 결과 붙여서 다시 호출;
}
// tool_use 아니면 루프 탈출 = 끝
877이 정답인지 코드는 모른다. “모델이 도구를 더 요청했나?”만 본다. 안전장치로 max_iterations(예: 10턴) 상한을 둔다.
3단계 — 루프를 손으로 직접 구현
BetaToolRunner 없이 짜면 에이전트의 실체가 드러난다.
List<MessageParam> conversation = new ArrayList<>();
conversation.add(MessageParam.builder()
.role(MessageParam.Role.USER)
.content("(128 * 7) 에서 19 를 뺀 값이 뭐야? 계산은 반드시 도구를 써.")
.build());
for (int turn = 1; turn <= MAX_TURNS; turn++) {
Message response = client.messages().create(MessageCreateParams.builder()
.model("claude-haiku-4-5")
.maxTokens(1024L)
.addTool(CALCULATOR) // Tool.builder() 로 JSON 스키마 직접 작성
.messages(conversation) // 매번 전체 대화를 다시 보낸다 (stateless)
.build());
conversation.add(response.toParam()); // 방금 받은 assistant 턴을 대화에 추가
if (!response.stopReason().equals(Optional.of(StopReason.TOOL_USE))) {
break; // 도구 요청 없으면 끝
}
List<ContentBlockParam> toolResults = new ArrayList<>();
for (ContentBlock block : response.content()) {
if (block.toolUse().isEmpty()) continue;
ToolUseBlock call = block.toolUse().get();
String output = executeTool(call); // call.name() 보고 실제 실행
toolResults.add(ContentBlockParam.ofToolResult(ToolResultBlockParam.builder()
.toolUseId(call.id()) // id 로 요청↔응답 짝을 맞춘다
.content(output)
.build()));
}
// tool_result 들을 하나의 user 메시지로 묶어 대화에 추가
conversation.add(MessageParam.builder()
.role(MessageParam.Role.USER)
.contentOfBlockParams(toolResults)
.build());
}
모델이 채워 보낸 인자 JSON은 call._input().convert(CalcInput.class) 로 POJO 변환한다.
agent란 결국 무엇인가
“별거 없어 보인다”는 직감은 맞다. 메커니즘은 while + 도구가 전부다. 핵심은 누가 제어 흐름을 정하느냐다.
| 방식 | 다음 행동을 정하는 주체 |
|---|---|
| 단일 호출 | 없음 |
| 워크플로우 | 개발자 (코드로 순서를 못박음) |
| 에이전트 | 모델 (어떤 도구를, 몇 번, 어떤 순서로, 언제 멈출지) |
에이전트 = LLM이 목표를 받고, 도구를 써서, 스스로 다음 행동을 정하며, 끝났다고 판단할 때까지 반복하는 것.
진짜 난이도는 루프가 아니라 (1) 도구 설계 (2) 모델이 계획·자기수정을 할 만큼 똑똑한가 (3) 컨텍스트 관리 (4) 신뢰성(무한루프·인자 환각) (5) 권한/안전 에 있다.
정리
에이전트는 새 기술이 아니라 API 호출을 루프에 넣고 제어권을 모델에 넘긴 패턴이다. stop_reason 이 신호등(tool_use = 계속, end_turn = 끝)이고, tool_result 를 id 로 짝지어 되돌려준다. 다음 편은 RAG — 답하기 전에 문서를 검색해 프롬프트에 붙이는 방법.