AI Services

AI Services (선언적 AI 서비스)

ChatModel, ChatMessage, ChatMemory 같은 저수준 컴포넌트를 직접 조합하려면 보일러플레이트가 많이 생겨요. LangChain4j 는 이를 숨기고 인터페이스만 선언하면 프록시 구현체를 만들어 주는 상위 개념, AI Services 를 제공해요. Spring Data JPA 나 Retrofit 방식과 비슷하다고 생각하면 돼요.

출처: 공식문서

AI Services 란

인터페이스에 원하는 API 를 선언하면 LangChain4j 가 그 인터페이스를 구현하는 프록시 객체를 만들어 줘요. 내부적으로 LLM 입력 포맷팅과 출력 파싱을 처리하고, 채팅 메모리·도구·RAG 같은 고급 기능도 지원해요.

가장 간단한 AI Service 먼저 볼게요. String 을 받아 String 을 반환하는 chat 메서드를 가진 인터페이스를 정의하고:

interface Assistant {

    String chat(String userMessage);
}

저수준 컴포넌트(ChatModel)를 만든 뒤:

ChatModel model = OpenAiChatModel.builder()
    .apiKey(System.getenv("OPENAI_API_KEY"))
    .modelName(GPT_4_O_MINI)
    .build();

AiServices 로 인스턴스를 만듭니다:

Assistant assistant = AiServices.create(Assistant.class, model);

:::note Quarkus 와 Spring Boot 애플리케이션에서는 자동 설정이 Assistant 빈 생성을 처리해요. AiServices.create(...) 를 호출할 필요 없이 필요한 곳에 주입/autowire 만 하면 돼요. :::

String answer = assistant.chat("Hello");
System.out.println(answer); // Hello, how can I help you?

동작 원리는 간단해요. 인터페이스 Class 와 저수준 컴포넌트를 AiServices 에 넘기면 프록시 객체를 만들고, 입력·출력 변환을 모두 처리해요. String 입력을 UserMessage 로 바꾸고, 반환 시 AiMessageString 으로 변환하죠.

@SystemMessage

시스템 프롬프트를 지정해야 한다면 @SystemMessage 어노테이션을 써요. 내부적으로 SystemMessage 로 변환되어 LLM 에 전달돼요.

interface Friend {

    @SystemMessage("You are a good friend of mine. Answer using slang.")
    String chat(String userMessage);
}

Friend friend = AiServices.create(Friend.class, model);

String answer = friend.chat("Hello"); // Hey! What's up?

@SystemMessage(fromResource = "my-prompt-template.txt") 로 리소스에서 프롬프트 템플릿을 불러올 수도 있고, 인터페이스 레벨에 선언하면 상속 메서드를 포함한 모든 메서드에 적용돼요. 메서드에 선언한 쪽이 인터페이스보다 우선해요.

시스템 메시지를 동적으로 주고 싶으면 systemMessageProvider(chatMemoryId -> "...") 로 채팅 메모리 ID(사용자·대화)마다 다른 메시지를 줄 수 있어요. systemMessageTransformer(...) 는 매 호출마다 시스템 메시지를 변형해요.

@UserMessage

시스템 메시지를 지원하지 않는 모델이거나, UserMessage 를 쓰고 싶다면 @UserMessage 로 프롬프트 템플릿을 지정해요. {{it}} 자리 표시자로 메서드 인자를 주입할 수 있어요. 예를 들어 감정 분석:

interface SentimentAnalyzer {

    @UserMessage("Does {{it}} has a positive sentiment?")
    boolean isPositive(String text);

}

boolean positive = sentimentAnalyzer.isPositive("It's wonderful!"); // true

반환 타입 (Return Types)

AI Service 메서드는 여러 타입을 반환할 수 있어요:

  • String — LLM 출력을 가공 없이 그대로 반환
  • Structured Outputs 가 지원하는 타입 — LLM 출력을 원하는 타입으로 파싱해 반환
  • TokenStream — 응답을 생성되는 대로 스트리밍
  • CompletableFuture<T>/CompletionStage<T>, Flow.Publisher<...> — 호출 스레드를 블로킹하지 않고 실행 (실험적)

Result<T> 로 감싸면 AI Service 호출에 대한 부가 메타데이터를 얻을 수 있어요: TokenUsage(도구 실행 등 여러 번 LLM 호출 시 합산), RAG 로 검색된 Content 소스, 실행된 도구 목록, 마지막 FinishReason, 중간·최종 ChatResponse 등.

Result<List<String>> result = assistant.generateOutlineFor("Java");

List<String> outline = result.content();
TokenUsage tokenUsage = result.tokenUsage();
List<Content> sources = result.sources();
List<ToolExecution> toolExecutions = result.toolExecutions();
FinishReason finishReason = result.finishReason();

Result<T>T 는 LLM 이 실제로 생성할 수 있는 타입이어야 해요. ChatResponse, ChatMessage, TextSegment, Embedding, TokenUsage 같은 LangChain4j 자체 타입은 T 로 쓸 수 없고, 그렇게 하면 IllegalConfigurationException 이 나요.

구조화 출력 (Structured Outputs)

복잡한 Java 객체를 LLM 에서 받고 싶다면 반환 타입을 바꾸면 돼요.

boolean:

interface SentimentAnalyzer {
    @UserMessage("Does {{it}} has a positive sentiment?")
    boolean isPositive(String text);
}

Enum:

enum Priority { CRITICAL, HIGH, LOW }

interface PriorityAnalyzer {
    @UserMessage("Analyze the priority of the following issue: {{it}}")
    Priority analyzePriority(String issueDescription);
}
// Priority priority = ...analyzePriority("The main payment gateway is down..."); // CRITICAL

POJO — @Description 로 LLM 이해를 돕는 설명을 붙일 수 있어요:

class Person {
    @Description("first name of a person")
    String firstName;
    String lastName;
    LocalDate birthDate;
    Address address;
}

@Description("an address")
class Address {
    String street;
    Integer streetNumber;
    String city;
}

interface PersonExtractor {
    @UserMessage("Extract information about a person from {{it}}")
    Person extractPersonFrom(String text);
}

스트리밍

TokenStream 반환 타입으로 응답을 토큰 단위로 스트리밍할 수 있어요. 콜백 체인으로 각 이벤트를 처리하고 .start() 로 시작해요:

TokenStream tokenStream = assistant.chat("Tell me a joke");

tokenStream
    .onPartialResponse((String partialResponse) -> System.out.println(partialResponse))
    .onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking))
    .onRetrieved((List<Content> contents) -> System.out.println(contents))
    .onIntermediateResponse((ChatResponse intermediateResponse) -> System.out.println(intermediateResponse))
    .onPartialToolCall((PartialToolCall partialToolCall) -> System.out.println(partialToolCall))
    .beforeToolExecution((BeforeToolExecution beforeToolExecution) -> System.out.println(beforeToolExecution))
    .onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
    .onCompleteResponse((ChatResponse response) -> futureResponse.complete(response))
    .onError((Throwable error) -> futureResponse.completeExceptionally(error))
    .start();

채팅 메모리 ID, 문서 로드, RAG, 벡터 저장소, Retrieval Augmenter 설정, Auto-Moderation(부적절 콘텐츠 탐지 시 ModerationException 발생), AI Service 간 연결(한 AI Service 를 다른 AI Service 의 도구로 사용) 등의 고급 기능도 같은 방식으로 구성해요. 자세한 내용은 원문을 참고하세요.

더 알아보기