Spring AI 프로바이더 네이티브 구조화 출력

Spring AI 프로바이더 네이티브 구조화 출력

기본적으로 .entity(...) 는 JSON 스키마를 텍스트 지시로 프롬프트에 추가합니다. 이건 응답 측 접근 방식이에요. 모델이 준수하도록 요청받고, 그 응답이 나중에 파싱되죠.

보완적인 접근은 요청 측 제약입니다. 모델의 프로바이더에 API 레벨에서 "응답은 이 스키마를 따라야 한다"고 알려주는 거예요. 대부분의 현대 프로바이더가 이를 지원합니다 (OpenAI의 Structured Outputs, Anthropic의 구조화 출력 확장, Gemini의 responseSchema, Mistral의 response_format).

Spring AI는 EntityParamSpec 컨슈머의 스위치 하나로 이를 포터블하게 노출합니다:

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());

와이어 레벨에서 달라지는 점은 이렇습니다:

  • 시스템 프롬프트가 더 이상 JSON 형식 지시를 담지 않습니다 (더 깔끔하고 토큰도 적어요).
  • 스키마가 API 레벨 필드로 프로바이더에 전송됩니다.
  • 프로바이더 런타임이 준수를 강제해요 — 잘못된 응답은 애초에 방출될 수 없습니다.

이 방식이 주는 이점은 다음과 같습니다:

  • 더 높은 신뢰성: 모델이 스키마에 맞는 출력을 보장합니다.
  • 더 깔끔한 프롬프트: 형식 지시를 추가할 필요가 없어요.
  • 더 나은 성능: 모델이 내부적으로 구조화 출력을 위해 최적화할 수 있습니다.

Spring AI가 지원을 감지하는 방법

Spring AI는 모델의 채팅 옵션이 StructuredOutputChatOptions 인터페이스를 구현하는지 확인해 네이티브 지원을 감지합니다. 구현하지 않으면 플래그는 조용히 무시되고 호출은 프롬프트 기반 기본값으로 폴백해요.

지원 모델

다음 프로바이더들이 Spring AI 2.0 기준으로 네이티브 구조화 출력을 지원합니다. 어느 프로바이더가 연결돼 있든 동일한 .useProviderStructuredOutput() 호출이 동작합니다:

  • OpenAI: JSON Schema를 지원하는 GPT-4o 이후 모델.
  • Anthropic: Claude 3.5 Sonnet 이후 모델.
  • Google GenAI: Gemini 1.5 Pro 이후 모델.
  • Mistral AI: JSON Schema를 지원하는 Mistral Small 이후 모델.
  • Ollama: JSON Schema 지원 모델 (모델별 상이 — 알려진 한계 참고).

왜 기본으로 꺼져 있나

호환성 때문이에요. 오래되었거나 지원하지 않는 모델은 요청을 거부할 수 있고, 프롬프트 기반 기본값은 어디서나 동작하니까요.

네이티브 구조화 출력은 기본적으로 활성화되지 않습니다. 모델과 프로바이더마다 지원이 크게 다르기 때문이에요. 더 강한 API 레벨 스키마 강제가 필요할 때만 활성화하고, 항상 특정 모델 버전으로 테스트하세요.

알려진 한계

그 기능을 광고하는 프로바이더에서도 네이티브 구조화 출력 지원은 종종 부분적입니다 — 수용되는 JSON Schema 표면이 다양해요. $ref, 깊게 중첩된 배열, allOf/anyOf/oneOf, 정규식 패턴, 재귀 타입이 흔한 한계입니다. 이것이 일으킬 수 있는 형태 변화는 바로 validateSchema() 가 잘 잡아내는 지점이에요.

Ollama: 모델별 불안정성

모든 Ollama 모델이 구조화 출력 스키마 제약을 안정적으로 준수하는 건 아닙니다. 특히 내장 추론("thinking") 모드가 있는 모델(qwen3:8b, qwen3.5:9b, 그 외 새 Qwen 계열)은 구조화 JSON 대신 내부 추론 흔적을 평문으로 반환할 수 있어요. 그러면 BeanOutputConverter 에서 다음과 같은 역직렬화 오류가 납니다:

StreamReadException: Unrecognized token 'The': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')

Ollama에서 이 문제를 겪으면 다른 모델(예: llama3.1:latest)을 시도하거나 기본 프롬프트 기반 방식으로 폴백하세요. useProviderStructuredOutput()validateSchema() 와 결합해 잘못된 응답이 자동으로 재시도되게 할 수도 있습니다:

ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec
        .useProviderStructuredOutput()
        .validateSchema());

OpenAI: 최상위 배열 미지원

OpenAI Structured Outputs API는 응답 스키마로 최상위 JSON 배열을 받지 않습니다 (OpenAI 커뮤니티 토론 참고). 네이티브 구조화 출력을 활성화한 상태에서 List<T> 를 요청하면 API 오류가 발생합니다.

// Does NOT work with OpenAI native structured output:
List<ActorsFilms> films = chatClient.prompt()
    .user("Generate filmographies for Tom Hanks and Bill Murray.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {},
            spec -> spec.useProviderStructuredOutput()); // fails with OpenAI

대신 이런 대안을 사용하세요:

// Option 1: wrap the list in a container record
record FilmographyList(List<ActorsFilms> films) {}

FilmographyList result = chatClient.prompt()
    .user("Generate filmographies for Tom Hanks and Bill Murray.")
    .call()
    .entity(FilmographyList.class, spec -> spec.useProviderStructuredOutput());
List<ActorsFilms> films = result.films();

// Option 2: use the default prompt-based approach (no native output required)
List<ActorsFilms> films = chatClient.prompt()
    .user("Generate filmographies for Tom Hanks and Bill Murray.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});

기본 프롬프트 기반 흐름에는 이런 제약이 없습니다 — useProviderStructuredOutput() 없이는 최상위 배열이 잘 동작해요.

전역 활성화

useProviderStructuredOutput() 은 호출별 스위치입니다. ChatClient 의 모든 호출에서 네이티브 구조화 출력을 켜려면 AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT advisor 파라미터를 설정하세요 — 빌더의 기본값으로, 또는 요청별로:

// Per request
ActorsFilms films = chatClient.prompt()
    .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class);

// Globally on the builder
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
    return builder
        .defaultAdvisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
        .build();
}

프로바이더 내장 JSON 모드

useProviderStructuredOutput() 과 독립적으로, 일부 AI 모델은 구조화(보통 JSON) 출력을 직접 생성하는 전용 설정 옵션을 노출합니다:

  • OpenAI Structured Outputs — 제공된 JSON Schema를 엄격히 따르는 응답을 보장합니다. JSON_OBJECT(유효한 JSON) 또는 스키마를 제공한 JSON_SCHEMA 중 선택할 수 있어요 (spring.ai.openai.chat.response-format 옵션).
  • Ollamaspring.ai.ollama.chat.format 옵션으로 응답 형식을 지정합니다. 현재 유일하게 허용되는 값은 json 이에요.
  • Mistral AIspring.ai.mistralai.chat.response-format 옵션을 제공합니다. { "type": "json_object" } 로 설정하면 JSON 모드가 켜지고, 스키마와 함께 { "type": "json_schema" } 로 설정하면 스키마에 맞는 네이티브 구조화 출력이 활성화됩니다.