Structured Outputs

Structured Outputs

LLM이 구조화된 포맷(보통 JSON)으로 출력을 생성하게 하고, 그 출력을 Java 객체로 매핑해서 애플리케이션의 다른 부분에서 쓰는 방법을 정리해요. "Structured Outputs"라는 용어는 두 가지를 가리킬 수 있어요:

  • LLM이 구조화된 포맷으로 출력을 생성하는 일반적인 능력(이 페이지에서 다룰 것)
  • OpenAI의 Structured Outputs 기능 (응답 포맷과 도구(함수 호출) 모두에 적용)

출처: 공식문서 - Structured Outputs

상황 설명

많은 LLM과 제공자가 구조화된 포맷(보통 JSON)의 출력 생성을 지원해요. 이 출력물은 Java 객체로 쉽게 매핑되어 애플리케이션의 다른 부분에서 쓰일 수 있죠.

Person 클래스가 있다고 가정해요:

record Person(String name, int age, double height, boolean married) {
}

가상의 인물을 묘사하는 비구조화 텍스트에서 Person 객체를 추출하려고 해요:

Eldwin Brightblade is 412 years old and serves as court wizard in the kingdom of Aelyria.
He stands 1.65 meters tall and is known for his flowing white beard.
Currently unmarried, he devotes his time to studying ancient runes.

LLM과 제공자에 따라 이걸 달성하는 방법이 세 가지 있어요(가장 신뢰할 수 있는 것부터):

  • JSON Schema
  • Prompting + JSON Mode
  • Prompting

JSON Schema

일부 LLM 제공자(현재 Amazon Bedrock, Azure OpenAI, Google AI Gemini, Mistral, Ollama, OpenAI)는 원하는 출력에 JSON schema를 지정할 수 있게 해줘요. 요청에 JSON 스키마가 지정되면 LLM은 이 스키마를 따르는 출력을 생성해야 해요.

:::note JSON 스키마는 LLM 제공자 API에 대한 요청의 전용 속성에 지정되며, 프롬프트(시스템·사용자 메시지)에 자유 형식 지시를 포함할 필요가 없어요. :::

LangChain4j는 저수준 ChatModel API와 고수준 AI Service API 둘 다에서 JSON Schema 기능을 지원해요.

ChatModel과 JSON Schema

저수준 ChatModel API에서는 ChatRequest를 만들 때 LLM 제공자에 무관한 ResponseFormatJsonSchema로 JSON 스키마를 지정할 수 있어요:

ResponseFormat responseFormat = ResponseFormat.builder()
        .type(JSON) // type can be either TEXT (default) or JSON
        .jsonSchema(JsonSchema.builder()
                .name("Person") // OpenAI requires specifying the name for the schema
                .rootElement(JsonObjectSchema.builder() // see [1] below
                        .addStringProperty("name")
                        .addIntegerProperty("age")
                        .addNumberProperty("height")
                        .addBooleanProperty("married")
                        .required("name", "age", "height", "married") // see [2] below
                        .build())
                .build())
        .build();

UserMessage userMessage = UserMessage.from("""
        Eldwin Brightblade is 412 years old and serves as court wizard in the kingdom of Aelyria.
        He stands 1.65 meters tall and is known for his flowing white beard.
        Currently unmarried, he devotes his time to studying ancient runes.
        """);

ChatRequest chatRequest = ChatRequest.builder()
        .responseFormat(responseFormat)
        .messages(userMessage)
        .build();

ChatModel chatModel = OpenAiChatModel.builder()
        .apiKey(System.getenv("OPENAI_API_KEY"))
        .modelName("gpt-4o-mini")
        .logRequests(true)
        .logResponses(true)
        .build();

ChatResponse chatResponse = chatModel.chat(chatRequest);

String output = chatResponse.aiMessage().text();
System.out.println(output); // {"name":"Eldwin Brightblade","age":412,"height":1.65,"married":false}

Person person = new ObjectMapper().readValue(output, Person.class);
System.out.println(person); // Person[name=Eldwin Brightblade, age=412, height=1.65, married=false]

참고:

  • [1] 대부분의 경우 루트 요소는 JsonObjectSchema 타입이어야 해요. 다만 Amazon Bedrock, Azure OpenAI, Mistral, Ollama, OpenAI, OpenAI Official은 JsonRawSchema를 루트 요소로 허용하고, Gemini는 JsonEnumSchemaJsonArraySchema를 루트 요소로 허용해요.
  • [2] 필수 속성은 명시적으로 지정해야 해요. 그렇지 않으면 선택으로 간주돼요.

JsonSchemaElement 타입들

JSON 스키마의 구조는 JsonSchemaElement 인터페이스로 정의되는데, 다음과 같은 하위 타입이 있어요:

  • JsonObjectSchema — 객체 타입
  • JsonStringSchemaString, char/Character 타입
  • JsonIntegerSchemaint/Integer, long/Long, BigInteger 타입
  • JsonNumberSchemafloat/Float, double/Double, BigDecimal 타입
  • JsonBooleanSchemaboolean/Boolean 타입
  • JsonEnumSchemaenum 타입
  • JsonArraySchema — 배열과 컬렉션(예: List, Set)
  • JsonReferenceSchema — 재귀 지원(예: PersonSet<Person> children 필드)
  • JsonAnyOfSchema — 다형성 지원(예: ShapeCircle 또는 Rectangle)
  • JsonNullSchema — nullable 타입 지원
  • JsonRawSchema — 완전히 정의한 커스텀 JSON 스키마 사용

JsonObjectSchema에 속성을 추가하는 방법은 여러 가지가 있어요:

  1. properties(Map<String, JsonSchemaElement> properties) 메서드로 모든 속성을 한 번에:
JsonSchemaElement citySchema = JsonStringSchema.builder()
        .description("The city for which the weather forecast should be returned")
        .build();

JsonSchemaElement temperatureUnitSchema = JsonEnumSchema.builder()
        .enumValues("CELSIUS", "FAHRENHEIT")
        .build();

Map<String, JsonSchemaElement> properties = Map.of(
        "city", citySchema,
        "temperatureUnit", temperatureUnitSchema
);

JsonSchemaElement rootElement = JsonObjectSchema.builder()
        .addProperties(properties)
        .required("city") // required properties should be specified explicitly
        .build();
  1. addProperty(String name, JsonSchemaElement jsonSchemaElement) 메서드로 개별 추가:
JsonSchemaElement rootElement = JsonObjectSchema.builder()
        .addProperty("city", citySchema)
        .addProperty("temperatureUnit", temperatureUnitSchema)
        .required("city")
        .build();
  1. add{Type}Property(String name) / add{Type}Property(String name, String description) 메서드 중 하나로 개별 추가:
JsonSchemaElement rootElement = JsonObjectSchema.builder()
        .addStringProperty("city", "The city for which the weather forecast should be returned")
        .addEnumProperty("temperatureUnit", List.of("CELSIUS", "FAHRENHEIT"))
        .required("city")
        .build();

JsonReferenceSchema로 재귀를 지원할 수 있어요:

String reference = "person"; // reference should be unique withing the schema

JsonObjectSchema jsonObjectSchema = JsonObjectSchema.builder()
        .addStringProperty("name")
        .addProperty("children", JsonArraySchema.builder()
                .items(JsonReferenceSchema.builder()
                        .reference(reference)
                        .build())
                .build())
        .required("name", "children")
        .definitions(Map.of(reference, JsonObjectSchema.builder()
                .addStringProperty("name")
                .addProperty("children", JsonArraySchema.builder()
                        .items(JsonReferenceSchema.builder()
                                .reference(reference)
                                .build())
                        .build())
                ...

AI Services와 JSON Schema

다음 조건이 모두 충족되면:

  • AI Service 메서드가 POJO를 반환하고
  • 사용하는 ChatModel이 JSON Schema 기능을 지원하며
  • 사용하는 ChatModel에서 JSON Schema 기능이 활성화되어 있으면

생성된 JsonSchemaname은 반환 타입의 단순 이름(getClass().getSimpleName())예요. 이 경우엔 "Person"이죠. LLM이 응답하면 출력이 객체로 파싱되어 AI Service 메서드에서 반환돼요.

다형성 타입

기본 타입 및/또는 하위 타입에 @Description을 붙여서 LLM을 안내할 수 있어요. 기본 타입 설명은 anyOf 요소에, 각 하위 타입 설명은 개별 옵션에 붙어요.

Prompting + JSON Mode

더 많은 정보가 곧 제공될 예정이에요. 그동안은 AI Services의 JSON mode 섹션이 글을 읽어보세요.

Prompting

Prompting(JSON 스키마 지원이 활성화되지 않은 한 기본 선택)을 쓸 때, AI Service는 자동으로 포맷 지시를 생성해서 UserMessage 끝에 붙여 LLM이 어떤 포맷으로 응답해야 하는지 알려줘요. 메서드가 반환되기 전에 AI Service가 LLM의 출력을 원하는 타입으로 파싱해요.

:::note 이 접근법은 꽤 신뢰할 수 없어요. LLM과 제공자가 위에서 설명한 방법을 지원한다면 그것들을 쓰는 게 더 나아요. :::

지원 타입

Type JSON Schema Prompting
POJO
List<POJO>, Set<POJO>
Enum
List<Enum>, Set<Enum>
List<String>, Set<String>
Polymorphic (sealed / @JsonSubTypes), incl. List/Set
boolean, Boolean / int, Integer / long, Long / float, Float / double, Double
byte, Byte / short, Short / BigInteger / BigDecimal
Date / LocalDate / LocalTime / LocalDateTime / Map<?, ?>

몇 가지 예:

record Person(String firstName, String lastName) {}

enum Sentiment {
    POSITIVE, NEGATIVE, NEUTRAL
}

interface Assistant {

    Person extractPersonFrom(String text);

    Set<Person> extractPeopleFrom(String text);

    Sentiment extractSentimentFrom(String text);

    List<Sentiment> extractSentimentsFrom(String text);

    List<String> generateOutline(String topic);

    boolean isSentimentPositive(String text);

    Integer extractNumberOfPeopleMentionedIn(String text);
}

더 알아보기