Spring AI Explainable Agents: LLM Tool Call Reasoning을 추적하는 방법

·8분 읽기

원문 출처: Baeldung, “Explainable AI Agents: Capture LLM Tool Call Reasoning with Spring AI”
원문 링크: https://www.baeldung.com/spring-ai-explainable-agents-capture-llm-tool-call-reasoning
작성 방식: 원문 내용을 빠짐없이 따라가되, 전체 번역이 아니라 한국어 독자를 위한 해설형 재구성으로 작성했다.

들어가며: 도구를 호출한 “이유”까지 보고 싶을 때

LLM 기반 에이전트를 만들다 보면, 애플리케이션 로그에는 대개 “어떤 도구가 호출됐는지”만 남는다. 예를 들어 모델이 retrievePatientHealthStatus라는 도구를 선택했다는 사실은 확인할 수 있다. 하지만 더 중요한 질문이 남는다.

왜 그 도구를 골랐을까?

이 정보가 없으면 디버깅이 어려워지고, 운영 관점의 관찰 가능성도 떨어진다. 특히 실제 서비스에 투입되는 AI 에이전트라면, 모델이 어떤 판단으로 특정 도구를 선택했는지 설명할 수 있어야 한다.

Spring AI의 Tool Argument Augmenter는 이 지점에서 유용하다. 핵심 아이디어는 간단하다. 기존 도구 메서드 자체는 그대로 두고, LLM에게 전달되는 도구 호출 스키마만 확장해서 “모델의 판단 근거” 같은 추가 메타데이터를 함께 받는 것이다.

이 글에서는 Baeldung의 Spring AI 예제를 바탕으로 다음 내용을 정리한다.

  • 일반적인 Spring AI tool calling 흐름
  • 도구 호출만으로는 부족한 이유
  • Tool Argument Augmenter가 해결하는 방식
  • 환자 건강 상태 조회 예제
  • 단일 도구 호출과 연쇄 도구 호출에서 reasoning을 캡처하는 방법
  • 운영 환경에서 이 데이터를 어떻게 활용할 수 있는지

1. Tool Calling이 필요한 상황

LLM은 학습 데이터만으로는 답하기 어려운 질문을 자주 만난다. 예를 들어 다음과 같은 경우다.

  • 현재 가격, 재고, 날씨처럼 실시간 데이터가 필요할 때
  • 사용자별 정보나 내부 DB 데이터를 조회해야 할 때
  • 사내 API, 외부 서비스, 파일 시스템 같은 애플리케이션 외부 시스템에 접근해야 할 때
  • 레코드 생성, 알림 발송, 예약 처리처럼 실제 행동을 수행해야 할 때

이럴 때 Spring AI의 tool calling을 사용하면, LLM은 사용자의 요청을 이해하고 어떤 작업이 필요한지 판단한다. 실제 데이터 조회나 액션 수행은 애플리케이션 코드가 맡는다.

예를 들어 환자 정보를 조회하는 시스템에 다음 두 가지 도구가 있다고 해보자.

1@Tool(description = "환자의 현재 건강 상태를 조회한다")2public String getPatientHealthStatus(String patientId) {3    return patientRepository.findStatusByPatientId(patientId);4}5 6@Tool(description = "환자의 건강 상태가 마지막으로 변경된 날짜를 조회한다")7public LocalDate getPatientStatusUpdatedDate(String patientId) {8    return patientRepository.findStatusUpdatedDate(patientId);9}

@Tool이 붙은 메서드는 LLM이 호출 가능한 도구로 노출된다. 사용자가 “P002 환자의 상태가 안정적인가?”라고 묻는다면, Spring AI는 도구 설명과 입력 스키마를 모델에게 전달한다. 모델은 요청을 분석하고 적절한 도구를 선택한 뒤, 필요한 인자와 함께 도구 호출을 요청한다.

일반적인 흐름은 다음과 같다.

  1. Spring AI가 사용 가능한 도구 목록과 입력 스키마를 LLM에게 보낸다.
  2. LLM이 사용자 요청을 분석한다.
  3. LLM이 호출할 도구를 결정한다.
  4. LLM이 도구 이름과 인자를 포함한 tool call 요청을 반환한다.
  5. Spring AI의 도구 관리 로직이 해당 애플리케이션 메서드를 실행한다.
  6. 도구 실행 결과가 다시 LLM에게 전달된다.
  7. LLM이 최종 사용자 응답을 생성한다.

여기까지만 보면 충분해 보이지만, 운영 관점에서는 중요한 정보가 빠져 있다. 애플리케이션은 “어떤 도구가 호출됐는지”는 알 수 있지만, “왜 그 도구를 선택했는지”는 알 수 없다.

이 차이가 디버깅과 신뢰성에서 큰 차이를 만든다. 예를 들어 비슷한 이름의 도구가 여러 개 있을 때, 모델이 잘못된 도구를 고른 이유를 모르면 프롬프트를 고쳐야 하는지, 도구 설명을 바꿔야 하는지, 입력 스키마를 조정해야 하는지 판단하기 어렵다.

2. Tool Argument Augmenter의 핵심 아이디어

Tool Argument Augmenter는 기존 tool calling 위에 설명 가능성 계층을 얹는 방식이다. 중요한 점은 실제 도구 메서드의 시그니처를 바꾸지 않는다는 것이다.

대신 LLM에게 전달되는 JSON Schema를 동적으로 확장한다. 원래 도구에 필요한 인자 외에, 애플리케이션이 관찰하고 싶은 추가 인자를 붙인다. 예를 들어 다음과 같은 메타데이터를 받을 수 있다.

  • 이 도구를 선택한 단계별 이유
  • 이 도구가 요청 해결에 적합하다고 판단한 근거
  • 도구 선택에 대한 confidence
  • 위험 수준
  • fallback 전략
  • 의사결정 카테고리

Baeldung 예제에서는 innerThoughtconfidence라는 값을 사용한다. innerThought는 모델이 해당 도구를 호출하는 이유를 설명하고, confidence는 도구 선택에 대한 확신 정도를 나타낸다.

개념적으로는 다음과 같은 DTO를 둔다.

1public record AgentThinking(2    @ToolParam(3        description = "이 도구를 호출하는 이유와 기대하는 결과를 단계적으로 설명한다",4        required = true5    )6    String innerThought,7 8    @ToolParam(9        description = "도구 선택에 대한 확신 수준: low, medium, high",10        required = true11    )12    String confidence13) {}

이 DTO는 실제 비즈니스 도구의 인자가 아니다. 환자 상태 조회 메서드는 여전히 patientId만 받는다. 하지만 LLM에게 전달되는 도구 스키마에는 patientId뿐 아니라 innerThought, confidence 같은 추가 필드도 포함된다.

그 결과 흐름은 다음처럼 바뀐다.

  1. 사용자가 환자 상태를 묻는다.
  2. Spring AI가 도구 정의를 준비한다.
  3. Tool Argument Augmenter가 도구 정의를 가로챈다.
  4. Augmenter가 각 도구의 JSON Schema에 추가 reasoning 인자를 붙인다.
  5. 확장된 도구 스키마가 LLM에게 전달된다.
  6. LLM은 도구 호출 요청을 만들 때 원래 인자와 reasoning 메타데이터를 함께 반환한다.
  7. Augmenter가 reasoning 메타데이터를 분리해서 consumer로 전달한다.
  8. 실제 도구 메서드는 원래 필요한 인자만 받아 실행된다.
  9. 도구 결과를 바탕으로 LLM이 최종 응답을 만든다.

이 구조의 장점은 명확하다. 기존 도구는 깨끗하게 유지하면서도, 운영 로그나 분석 시스템에는 모델의 선택 이유를 남길 수 있다.

3. 예제: 환자 건강 상태 확인 에이전트

Baeldung 글의 중심 예제는 환자 건강 상태를 조회하는 간단한 Spring AI 애플리케이션이다. 이 애플리케이션은 몇 가지 환자 데이터를 가지고 있고, 사용자의 질문에 따라 LLM이 적절한 도구를 선택한다.

3.1 의존성

먼저 OpenAI 모델을 사용하기 위해 Spring AI OpenAI starter를 추가한다.

1<dependency>2    <groupId>org.springframework.ai</groupId>3    <artifactId>spring-ai-starter-model-openai</artifactId>4    <version>${spring-ai.version}</version>5</dependency>

이 의존성은 Spring AI의 핵심 클래스와 OpenAI 모델 연동 기능을 함께 제공한다. 예제에서는 OpenAI 기반 ChatClient를 구성하고, 여기에 augmented tool callback provider를 연결한다.

3.2 환자 정보 도구 정의

다음으로 LLM이 호출할 수 있는 도구 클래스를 만든다. 원문 예제에서는 PatientHealthInformationTools라는 클래스를 두고, 환자 ID에 따라 건강 상태와 변경일을 조회한다.

1public class PatientHealthInformationTools {2 3    private static final Map<String, HealthStatus> HEALTH_DATA = Map.of(4        "P001", new HealthStatus("Healthy", LocalDate.ofYearDay(2025, 100)),5        "P002", new HealthStatus("Has cough", LocalDate.ofYearDay(2025, 200)),6        "P003", new HealthStatus("Healthy", LocalDate.ofYearDay(2025, 300)),7        "P004", new HealthStatus("Has increased blood pressure", LocalDate.ofYearDay(2025, 350)),8        "P005", new HealthStatus("Healthy", LocalDate.ofYearDay(2026, 10))9    );10 11    @Tool(description = "환자의 현재 건강 상태를 조회한다")12    public String retrievePatientHealthStatus(String patientId) {13        return HEALTH_DATA.get(patientId).status();14    }15 16    @Tool(description = "환자의 건강 상태가 마지막으로 바뀐 날짜를 조회한다")17    public LocalDate retrievePatientHealthStatusChangeDate(String patientId) {18        return HEALTH_DATA.get(patientId).changeDate();19    }20}

여기에는 두 개의 도구가 있다.

  • retrievePatientHealthStatus()는 환자의 현재 상태를 반환한다.
  • retrievePatientHealthStatusChangeDate()는 그 상태가 마지막으로 업데이트된 날짜를 반환한다.

LLM 입장에서는 사용자의 질문을 보고 둘 중 어떤 도구가 필요한지 선택해야 한다.

3.3 모델의 생각을 담는 DTO

Tool Argument Augmenter를 쓰기 위해, 모델에게 추가로 요구할 메타데이터 타입을 정의한다. 원문에서는 AgentThinking이라는 record를 사용한다.

1public record AgentThinking(2    @ToolParam(3        description = "이 도구를 선택한 이유와 기대 결과를 단계별로 설명한다",4        required = true5    )6    String innerThought,7 8    @ToolParam(9        description = "이 도구 선택에 대한 확신 수준: low, medium, high",10        required = true11    )12    String confidence13) {}

이 DTO는 “도구 호출 자체에 필요한 데이터”가 아니라 “도구 선택 과정을 설명하기 위한 데이터”다. 따라서 실제 도구 메서드에는 전달되지 않고, Augmenter가 따로 꺼내서 로그나 분석 파이프라인으로 보낸다.

3.4 PatientHealthStatusService 구성

이제 서비스 계층에서 AugmentedToolCallbackProvider를 구성한다. 이 provider는 실제 도구 객체와 reasoning DTO 타입을 함께 알고 있다.

1@Service2public class PatientHealthStatusService {3 4    private static final Logger log = LoggerFactory.getLogger(PatientHealthStatusService.class);5    private final ChatClient chatClient;6 7    public PatientHealthStatusService(OpenAiChatModel model) {8        AugmentedToolCallbackProvider<AgentThinking> provider =9            AugmentedToolCallbackProvider.<AgentThinking>builder()10                .toolObject(new PatientHealthInformationTools())11                .argumentType(AgentThinking.class)12                .argumentConsumer(event -> {13                    AgentThinking thinking = event.arguments();14                    log.info(15                        "Chosen tool: {}\nLLM Reasoning: {}\nConfidence: {}",16                        event.toolDefinition().name(),17                        thinking.innerThought(),18                        thinking.confidence()19                    );20                })21                .build();22 23        this.chatClient = ChatClient.builder(model)24            .defaultToolCallbacks(provider)25            .build();26    }27 28    public String getPatientStatusInformation(String prompt) {29        log.info("Input request: {}", prompt);30        return chatClient.prompt(prompt)31            .call()32            .content();33    }34}

핵심은 세 가지다.

  1. toolObject()로 LLM에게 노출할 도구 객체를 등록한다.
  2. argumentType()으로 reasoning 메타데이터 타입을 지정한다.
  3. argumentConsumer()에서 모델이 반환한 reasoning 데이터를 받아 로그로 남긴다.

이렇게 구성하면 getPatientStatusInformation() 메서드는 별도의 부가 로직 없이 평소처럼 ChatClient를 호출하면 된다. 도구 스키마 확장, reasoning 추출, 실제 도구 실행은 provider가 처리한다.

4. 단일 도구 호출에서 reasoning 확인하기

첫 번째 테스트는 환자 ID를 직접 알고 있는 경우다. 예를 들어 사용자가 다음처럼 묻는다.

P002 환자의 건강 상태는 어떤가?

이 경우 LLM은 환자의 상태를 조회하는 도구를 선택해야 한다. 테스트는 응답에 cough 같은 기대 값이 들어 있는지 확인한다.

1@Test2void shouldReturnPatientHealthStatusAndUpdatedDate() {3    String status = statusService.getPatientStatusInformation(4        "What is the health status of the patient P002?"5    );6 7    assertThat(status).contains("cough");8 9    String changedAt = statusService.getPatientStatusInformation(10        "When was the health status of patient P002 changed?"11    );12 13    assertThat(changedAt).contains("July 19, 2025");14}

이때 로그에는 단순히 도구 이름만 남지 않는다. 모델이 어떤 판단으로 도구를 골랐는지도 함께 남는다.

예를 들어 첫 번째 질문에서는 환자 P002의 현재 건강 상태가 필요하므로 건강 상태 조회 도구를 선택했다고 기록된다. 두 번째 질문에서는 “상태 변경일”이 필요하므로 변경 날짜 조회 도구를 선택했다고 기록된다.

로그에서 확인할 수 있는 정보는 다음 세 가지다.

  • 어떤 도구가 호출됐는가
  • 모델이 그 도구를 선택한 이유는 무엇인가
  • 모델이 그 선택에 대해 어느 정도 확신했는가

이 정보는 운영 중 문제를 분석할 때 특히 유용하다. 모델이 잘못된 도구를 골랐다면, 로그를 통해 “도구 설명을 잘못 이해했는지”, “사용자 질문이 모호했는지”, “도구 이름이나 설명이 혼동을 유발했는지”를 추적할 수 있다.

5. 연쇄 도구 호출에서 reasoning 추적하기

두 번째 예제는 도구를 하나만 호출해서는 답을 낼 수 없는 경우다. 사용자가 환자 ID가 아니라 환자 이름만 제공한다고 해보자.

John Snow 환자의 건강 상태는 어떤가?

현재 건강 상태 조회 도구는 patientId를 필요로 한다. 하지만 사용자는 이름만 줬다. 따라서 먼저 이름으로 ID를 찾고, 그 ID로 건강 상태를 조회해야 한다.

이를 위해 환자 이름을 ID로 바꾸는 도구를 추가한다.

1public class PatientHealthInformationTools {2 3    private static final Map<String, String> PATIENT_IDS = Map.of(4        "John Snow", "P001",5        "Emily Carter", "P002",6        "Michael Brown", "P003",7        "Sophia Williams", "P004",8        "Daniel Johnson", "P005"9    );10 11    @Tool(description = "환자 이름으로 환자 ID를 조회한다")12    public String retrievePatientId(String patientName) {13        return PATIENT_IDS.get(patientName);14    }15}

이제 테스트는 환자 이름으로 질문하고, 최종 응답에 건강 상태가 포함되는지 확인한다.

1@Test2void shouldResolvePatientIdAndThenReturnHealthStatus() {3    String response = statusService.getPatientStatusInformation(4        "What is the health status of the patient? Patient name: John Snow"5    );6 7    assertThat(response).containsIgnoringCase("healthy");8}

이 흐름에서 특히 흥미로운 점은 LLM이 여러 도구를 순차적으로 호출할 수 있다는 것이다. 원문 예제의 로그에서는 모델이 처음에는 건강 상태 조회를 시도하고, 문제가 생기자 이름으로 환자 ID를 조회한 뒤, 다시 그 ID로 건강 상태 조회 도구를 호출하는 흐름을 보여준다.

여기서 reasoning 로그는 단순한 디버깅 정보를 넘어선다. 모델이 어떤 순서로 문제를 풀려고 했는지, 중간에 왜 전략을 바꿨는지, 최종적으로 어떤 도구 조합을 사용했는지까지 확인할 수 있다.

이 정보는 다음과 같은 개선에 연결된다.

  • 프롬프트를 더 구체적으로 작성해 불필요한 도구 호출을 줄인다.
  • 도구 설명을 개선해 모델이 처음부터 올바른 도구를 고르게 한다.
  • 이름 기반 조회와 ID 기반 조회를 더 자연스럽게 연결하는 별도 도구를 설계한다.
  • 도구 호출 체인의 실패 지점을 관찰해 fallback 전략을 만든다.

즉 Tool Argument Augmenter는 “어떤 도구가 호출됐는가”뿐 아니라 “모델이 어떻게 생각의 경로를 이동했는가”를 보여준다.

6. 이 방식이 주는 운영상의 가치

Tool Argument Augmenter의 가장 큰 장점은 기존 도구 구현을 오염시키지 않는다는 점이다. 비즈니스 도구는 여전히 비즈니스 인자만 받는다. 예를 들어 환자 상태 조회 메서드는 patientId만 받으면 된다.

반면 관찰 가능성에 필요한 정보는 augmented argument로 별도 수집한다. 이 정보는 여러 방식으로 사용할 수 있다.

  • 애플리케이션 로그에 남겨 디버깅에 활용
  • 장기 메모리나 감사 로그에 저장
  • 모니터링 시스템으로 전달
  • 도구 선택 품질 분석에 사용
  • 프롬프트 개선 피드백으로 활용
  • 위험 수준이나 fallback 전략 같은 운영 메타데이터로 확장

또한 reasoning 데이터는 시간이 지날수록 중요한 학습 자료가 된다. 예를 들어 특정 도구가 자주 잘못 선택된다면 도구 설명이 모호할 수 있다. 특정 질문 유형에서 불필요한 연쇄 호출이 반복된다면 프롬프트나 도구 설계를 조정할 수 있다.

마무리

Spring AI의 Tool Argument Augmenter는 LLM tool calling의 투명성을 높이는 실용적인 방법이다. 일반적인 tool calling은 모델이 어떤 도구를 골랐는지만 알려준다. 하지만 실제 서비스에서는 “왜 골랐는지”가 더 중요할 때가 많다.

Baeldung 예제는 환자 건강 상태 조회라는 단순한 시나리오를 통해 이 문제를 잘 보여준다. AgentThinking 같은 DTO를 추가하고, AugmentedToolCallbackProvider를 사용하면 모델이 도구를 선택할 때의 reasoning과 confidence를 함께 수집할 수 있다.

정리하면 핵심은 다음과 같다.

  • tool calling은 LLM이 외부 데이터와 애플리케이션 기능을 사용할 수 있게 한다.
  • 기본 tool calling만으로는 도구 선택 이유를 알기 어렵다.
  • Tool Argument Augmenter는 도구 스키마를 동적으로 확장해 reasoning 메타데이터를 받는다.
  • 실제 도구 메서드는 변경하지 않아도 된다.
  • 수집한 reasoning은 로그, 감사, 분석, 프롬프트 개선, 운영 모니터링에 활용할 수 있다.
  • 단일 도구 호출뿐 아니라 연쇄 도구 호출에서도 모델의 판단 경로를 확인할 수 있다.

AI 에이전트를 프로덕션에 올릴수록 설명 가능성은 선택 사항이 아니라 운영 필수 요소에 가까워진다. Tool Argument Augmenter는 Spring AI 애플리케이션에서 그 출발점을 비교적 간단하게 마련해주는 도구라고 볼 수 있다.