AI 번역

Spring AI에서 구조화된 출력 가이드

hanulbook 2025. 3. 27. 20:29

1. 소개

일반적으로 대형 언어 모델(LLM)을 사용할 때 구조화된 응답을 기대하지 않습니다. 또한, 예측할 수 없는 동작에 익숙해져 있으며, 이는 종종 기대에 부합하지 않는 출력을 초래하곤 합니다. 하지만, 100% 확실하지는 않더라도 구조화된 응답을 생성할 가능성을 높이는 방법이 있으며, 이러한 응답을 실제로 활용할 수 있는 코드 구조로 변환하는 것도 가능합니다.

이 튜토리얼에서는 Spring AI와 이 과정을 더 쉽고 간단하게 만들어주는 다양한 도구를 탐색하며, 보다 효율적인 활용 방법을 살펴보겠습니다.

 

2. 채팅 모델의 간략한 소개

AI 모델에 프롬프트를 전달할 수 있도록 해주는 기본 구조는 ChatModel 인터페이스입니다.

public interface ChatModel extends Model<Prompt, ChatResponse> {
    default String call(String message) {
        // implementation is skipped
    }

    @Override
    ChatResponse call(Prompt prompt);
}

call() 메서드와 프롬프트 구조

call() 메서드는 단순히 모델에 메시지를 보내고 응답을 받는 기능을 합니다. 따라서 프롬프트와 응답이 문자열(String) 형태일 것이라고 예상하는 것이 자연스럽습니다. 하지만, 최신 모델 구현에서는 보다 정교한 조정이 가능하도록 복잡한 구조를 제공하여 예측 가능성을 높이고 있습니다.

예를 들어, 문자열을 매개변수로 받는 기본 call() 메서드도 존재하지만, Prompt 객체를 활용하는 것이 더 실용적입니다. Prompt는 여러 개의 메시지를 포함할 수 있으며, temperature와 같은 옵션을 설정하여 모델의 창의성을 조절할 수도 있습니다.

ChatModel의 자동 주입 (Autowiring)

우리는 ChatModel을 자동 주입(AutoWiring) 하고 직접 호출할 수 있습니다. 예를 들어, OpenAI API를 사용하려면 spring-ai-openai-spring-boot-starter 의존성을 추가하면 OpenAiChatModel 구현체가 자동으로 주입(Autowired)됩니다.

 

3. 구조화된 출력 API

데이터 구조 형태의 출력을 얻기 위해 Spring AI는 ChatModel의 call() 메서드를 감싸는 Structured Output API를 제공합니다. 이 API의 핵심 인터페이스는 StructuredOutputConverter 입니다.

public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {}

 

이 인터페이스는 두 가지 다른 인터페이스를 결합하는데, 그중 첫 번째는 FormatProvider 입니다.

public interface FormatProvider {
    String getFormat();
}

 

ChatModel의 call() 메서드가 실행되기 전에, getFormat() 메서드는 프롬프트를 준비하고 필요한 데이터 스키마를 포함시켜 응답의 일관성을 유지할 수 있도록 형식을 명확히 지정합니다.

예를 들어, JSON 형식의 응답을 받으려면 다음과 같은 프롬프트를 사용할 수 있습니다:

public String getFormat() {
    String template = "Your response should be in JSON format.\n"
      + "Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\n"
      + "Do not include markdown code blocks in your response.\n
      + "Remove the ```json markdown from the output.\nHere is the JSON Schema instance your output must adhere to:\n```%s```\n";
    return String.format(template, this.jsonSchema);
}

 

이러한 지침들은 보통 사용자의 입력 뒤에 추가됩니다.

두 번째 인터페이스는 Converter 입니다.

@FunctionalInterface
public interface Converter<S, T> {
    @Nullable
    T convert(S source);
 
    // default method
}

 

call()이 응답을 반환한 후, **Converter**는 이를 필요한 데이터 구조 타입 T로 파싱합니다.

다음은 **StructuredOutputConverter**가 작동하는 간단한 다이어그램입니다:

4. 사용 가능한 변환기 (Converters)

이 섹션에서는 StructuredOutputConverter의 사용 가능한 구현체들과 그 예제들을 살펴보겠습니다. 이를 통해 Dungeons & Dragons 게임의 캐릭터 생성을 예시로 보여드리겠습니다.

이 예제에서는 StructuredOutputConverter를 활용하여 게임 캐릭터 정보를 구조화된 데이터 형식으로 생성하는 방법을 시연할 예정입니다.

public class Character {
    private String name;
    private int age;
    private String race;
    private String characterClass;
    private String cityOfOrigin;
    private String favoriteWeapon;
    private String bio;
    
    // constructor, getters, and setters
}

5. BeanOutputConverter for Beans

BeanOutputConverter는 모델의 응답을 기반으로 지정된 클래스의 인스턴스를 생성합니다. 이 변환기는 모델에게 RFC8259 표준을 준수하는 JSON을 생성하도록 지시하는 프롬프트를 구성합니다.

이를 ChatClient API를 사용하여 어떻게 활용할 수 있는지 살펴보겠습니다.

@Override
public Character generateCharacterChatClient(String race) {
    return ChatClient.create(chatModel).prompt()
      .user(spec -> spec.text("Generate a D&D character with race {race}")
        .param("race", race))
        .call()
        .entity(Character.class); // <-------- we call ChatModel.call() here, not on the line before
}

 

이 방법에서는 **ChatClient.create(chatModel)**이 ChatClient를 인스턴스화합니다. prompt() 메서드는 요청을 생성하는 빌더 체인을 시작하며, 이 경우 사용자의 텍스트만 추가됩니다. 요청이 생성되면 call() 메서드를 호출하여 새로운 CallResponseSpec을 반환하고, 이 안에는 ChatModel과 ChatClientRequest가 포함됩니다. 이후 entity() 메서드는 제공된 타입을 기반으로 변환기를 생성하고, 프롬프트를 완료한 후 AI 모델을 호출합니다.

우리는 BeanOutputConverter를 직접 사용하지 않은 것을 확인할 수 있습니다. 그 이유는 .entity() 메서드에 클래스를 매개변수로 전달했기 때문에, BeanOutputConverter가 프롬프트 처리와 변환을 자동으로 담당하기 때문입니다.

더 많은 제어가 필요한 경우 우리는 이 접근 방식을 낮은 수준에서 직접 구현할 수 있습니다. 예를 들어, 사전에 자동 주입된 ChatModel.call() 메서드를 직접 사용할 수 있습니다:

 

@Override
public Character generateCharacterChatModel(String race) {
    BeanOutputConverter<Character> beanOutputConverter = new BeanOutputConverter<>(Character.class);

    String format = beanOutputConverter.getFormat();

    String template = """
                Generate a D&D character with race {race}
                {format}
                """;

    PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("race", race, "format", format));
    Prompt prompt = new Prompt(promptTemplate.createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return beanOutputConverter.convert(generation.getOutput().getContent());
}

 

위 예시에서는 BeanOutputConverter를 생성하고, 모델에 대한 형식 지침을 추출한 후, 이를 사용자 정의 프롬프트에 추가했습니다. 최종 프롬프트는 PromptTemplate을 사용하여 생성되었습니다. PromptTemplate은 Spring AI의 핵심 프롬프트 템플레이팅 구성 요소로, 내부적으로 StringTemplate 엔진을 사용합니다. 그런 다음 모델을 호출하여 Generation을 결과로 얻습니다. Generation은 모델의 응답을 나타내며, 우리는 그 내용을 추출한 후 변환기를 사용하여 Java 객체로 변환합니다.

다음은 OpenAI를 사용하여 우리 변환기를 통해 얻은 실제 응답 예시입니다:

{
    name: "Thoren Ironbeard",
    age: 150,
    race: "Dwarf",
    characterClass: "Wizard",
    cityOfOrigin: "Sundabar",
    favoriteWeapon: "Magic Staff",
    bio: "Born and raised in the city of Sundabar, he is known for his skills in crafting and magic."
}

 

6. MapOutputConverterListOutputConverter for Collections

MapOutputConverterListOutputConverter는 각각 맵과 리스트 형태로 구조화된 응답을 생성할 수 있도록 도와줍니다. 이제 MapOutputConverter를 사용한 high-level 코드와 low-level 코드 예시를 살펴보겠습니다:

@Override
public Map<String, Object> generateMapOfCharactersChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("Generate {amount} D&D characters, where key is a character's name")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new ParameterizedTypeReference<Map<String, Object>>() {});
}
    
@Override
public Map<String, Object> generateMapOfCharactersChatModel(int amount) {
    MapOutputConverter outputConverter = new MapOutputConverter();
    String format = outputConverter.getFormat();
    String template = """
            "Generate {amount} of key-value pairs, where key is a "Dungeons and Dragons" character name and value (String) is his bio.
            {format}
            """;
    Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("amount", String.valueOf(amount), "format", format)).createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return outputConverter.convert(generation.getOutput().getContent());
}

 

우리가 **Map<String, Object>**를 사용한 이유는 현재 **MapOutputConverter**가 제네릭 값을 지원하지 않기 때문입니다. 하지만 걱정하지 마세요, 나중에 우리가 사용자 정의 변환기를 만들어 이를 지원할 수 있게 될 것입니다.

지금은 ListOutputConverter 예제를 살펴보겠습니다. **ListOutputConverter**에서는 제네릭을 자유롭게 사용할 수 있습니다:

 

@Override
public List<String> generateListOfCharacterNamesChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("List {amount} D&D character names")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new ListOutputConverter(new DefaultConversionService()));
}

@Override
public List<String> generateListOfCharacterNamesChatModel(int amount) {
    ListOutputConverter listOutputConverter = new ListOutputConverter(new DefaultConversionService());
    String format = listOutputConverter.getFormat();
    String userInputTemplate = """
            List {amount} D&D character names
            {format}
            """;
    PromptTemplate promptTemplate = new PromptTemplate(userInputTemplate,
      Map.of("amount", amount, "format", format));
    Prompt prompt = new Prompt(promptTemplate.createMessage());
    Generation generation = chatModel.call(prompt).getResult();
    return listOutputConverter.convert(generation.getOutput().getContent());
}

 

7. 변환기의 구조 또는 우리만의 변환기 만들기

이제 AI 모델에서 데이터를 Map<String, V> 형식으로 변환하는 변환기를 만들어 보겠습니다. 여기서 **V**는 제네릭 타입입니다. Spring에서 제공하는 변환기들과 마찬가지로, 우리의 변환기도 **StructuredOutputConverter<T>**를 구현할 것이며, 이를 위해 **convert()**와 getFormat() 메서드를 추가해야 합니다.

1. StructuredOutputConverter 구현하기

먼저, StructuredOutputConverter 인터페이스를 구현하는 기본 구조를 살펴보겠습니다.

 

public class GenericMapOutputConverter<V> implements StructuredOutputConverter<Map<String, V>> {
    private final ObjectMapper objectMapper; // to convert response
    private final String jsonSchema; // schema for the instructions in getFormat()
    private final TypeReference<Map<String, V>> typeRef; // type reference for object mapper

    public GenericMapOutputConverter(Class<V> valueType) {
        this.objectMapper = this.getObjectMapper();
        this.typeRef = new TypeReference<>() {};
        this.jsonSchema = generateJsonSchemaForValueType(valueType);
    }

    public Map<String, V> convert(@NonNull String text) {
        try {
            text = trimMarkdown(text);
            return objectMapper.readValue(text, typeRef);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to convert JSON to Map<String, V>", e);
        }
    }

    public String getFormat() {
        String raw = "Your response should be in JSON format.\nThe data structure for the JSON should match this Java class: %s\n" +
                "For the map values, here is the JSON Schema instance your output must adhere to:\n```%s```\n" +
                "Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\n";
        return String.format(raw, HashMap.class.getName(), this.jsonSchema);
    }

    private ObjectMapper getObjectMapper() {
        return JsonMapper.builder()
          .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
          .build();
    }

    private String trimMarkdown(String text) {
        if (text.startsWith("```json") && text.endsWith("```")) {
            text = text.substring(7, text.length() - 3);
        }
        return text;
    }

    private String generateJsonSchemaForValueType(Class<V> valueType) {
        try {
            JacksonModule jacksonModule = new JacksonModule();
            SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
              .with(jacksonModule)
              .build();
            SchemaGenerator generator = new SchemaGenerator(config);

            JsonNode jsonNode = generator.generateSchema(valueType);
            ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter()
              .withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator())));

            return objectWriter.writeValueAsString(jsonNode);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Could not generate JSON schema for value type: " + valueType.getName(), e);
        }
    }
}

 

우리가 알고 있는 것처럼, getFormat() 메서드는 AI 모델에 지침을 제공합니다. 이 지침은 사용자 프롬프트를 최종 요청에 반영하며, 맵 구조를 지정하고 값에 대한 사용자 정의 객체의 스키마를 제공합니다. 우리는 com.github.victools.jsonschema 라이브러리를 사용하여 스키마를 생성했습니다. Spring AI는 이미 이 라이브러리를 내부적으로 사용하고 있기 때문에, 우리가 직접 해당 라이브러리를 가져올 필요는 없습니다.

우리는 응답을 JSON 형식으로 요청하므로, convert() 메서드에서는 Jackson의 **ObjectMapper**를 사용하여 파싱합니다. Spring의 BeanOutputConverter 구현처럼 마크다운을 제거하는 이유는, AI 모델이 종종 코드 스니펫을 마크다운으로 감싸기 때문입니다. 이를 제거하여 **ObjectMapper**에서 발생할 수 있는 예외를 방지합니다.

이제, 우리가 만든 구현을 다음과 같이 사용할 수 있습니다:

 

@Override
public Map<String, Character> generateMapOfCharactersCustomConverter(int amount) {
    GenericMapOutputConverter<Character> outputConverter = new GenericMapOutputConverter<>(Character.class);
    String format = outputConverter.getFormat();
    String template = """
            "Generate {amount} of key-value pairs, where key is a "Dungeons and Dragons" character name and value is character object.
            {format}
            """;
    Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("amount", String.valueOf(amount), "format", format)).createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return outputConverter.convert(generation.getOutput().getContent());
}

@Override
public Map<String, Character> generateMapOfCharactersCustomConverterChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("Generate {amount} D&D characters, where key is a character's name")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new GenericMapOutputConverter<>(Character.class));
}

8. 결론

이 글에서는 **대형 언어 모델(LLMs)**을 사용하여 구조화된 응답을 생성하는 방법을 살펴보았습니다. StructuredOutputConverter를 활용함으로써 모델의 출력을 효율적으로 사용 가능한 데이터 구조로 변환할 수 있습니다. 그 후, BeanOutputConverter, MapOutputConverter, ListOutputConverter의 사용 사례를 논의하고, 각각에 대한 실용적인 예제를 제공했습니다. 또한, 더 복잡한 데이터 타입을 처리하기 위해 사용자 정의 변환기를 만드는 방법도 다루었습니다.

이 도구들을 활용하면 AI 기반 구조화된 출력을 Java 애플리케이션에 통합하는 것이 더 쉬워지고 관리 가능해지며, LLM 응답의 신뢰성과 예측 가능성을 향상시킬 수 있습니다.