자바로 배우는 AI (2) — Tool use와 에이전트 루프

LLM은 코드를 직접 실행하지 못한다. 대신 “이 함수를 이 인자로 불러줘”라고 JSON으로 요청한다. 그 요청을 받아 실행하고 결과를 되돌려주는 반복 루프 — 그게 에이전트다. 전체 코드: github.com/dadaok/ai-playground

tool use 원리

  1. 우리가 “이런 도구들이 있다”고 스키마(이름 + 설명 + 파라미터)를 넘긴다.
  2. 모델이 필요하면 “calc를 이 인자로 불러줘”라고 JSON으로 요청한다 → 응답의 stop_reason"tool_use".
  3. 우리 코드가 그 함수를 실제로 실행한다.
  4. 결과를 tool_result 로 다시 모델에게 넘긴다.
  5. 모델이 답을 마무리하거나(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_resultid 로 짝지어 되돌려준다. 다음 편은 RAG — 답하기 전에 문서를 검색해 프롬프트에 붙이는 방법.


© 2023 Lee. All rights reserved.