Spring AI 출력 컨버터
Spring AI 출력 컨버터 (Output Converters)
고수준 .entity(...) API 는 StructuredOutputConverter 추상화 위에 세워져 있어요. 대부분의 애플리케이션은 이걸 직접 만지지 않습니다. 다음과 같은 경우에 저수준 API가 필요해요:
- 내장 컨버터가 거부하는 출력을 파싱해야 할 때 (예: 마크다운 코드 펜스로 감싸인 JSON);
- YAML이나 CSV 같은 비-JSON 형식을 만들어야 할 때;
- 저수준
ChatModelAPI에 직접 컨버터를 사용해야 할 때.
Spring AI의 구조화 출력 컨버터는 LLM 출력을 구조화된 형식으로 변환합니다. 이 방식은 LLM 텍스트 완성 엔드포인트를 중심으로 동작해요.
구조화 출력 컨버터는 LLM 호출 전후에 역할을 합니다. 호출 전에는 컨버터가 형식 지침을 프롬프트에 추가해 모델이 원하는 출력 구조를 만들도록 유도하고, 호출 후에는 모델의 텍스트 출력을 파싱해 구조화 타입의 인스턴스로 매핑해요.
내장 BeanOutputConverter 는 엄격합니다. 모델 응답이 파싱 가능한 JSON이어야 한다고 단정하죠. 그런데 모델은 종종 JSON을 마크다운 코드 펜스로 감쌉니다:
Here's the filmography:
```json
{ "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }
`BeanOutputConverter` 는 "Here's"의 첫 글자 `H`에서 예외를 던져요. 흔한 해결책은 펜스를 제거하고 기본 파서에 위임하기 전에 JSON을 추출하는 커스텀 컨버터입니다. (아래 커스텀 컨버터 참고)
> 구조화 출력 컨버터는 모델 출력을 구조화 출력으로 변환하려는 최선의 노력이에요. AI 모델이 요청한 구조화 출력을 반환한다는 보장은 없습니다. [스키마 검증](https://docs.spring.io/spring-ai/reference/api/structured-output/validation.html)과 결합해 모델 출력이 예상대로인지 확인하는 걸 고려하세요.
> `StructuredOutputConverter` 는 LLM 도구 호출에는 사용되지 않습니다. 도구 호출은 기본적으로 구조화 출력을 제공하니까요.
## 구조화 출력 API
`StructuredOutputConverter` 인터페이스를 사용하면 텍스트 기반 AI 모델 출력에서 Java 클래스나 값 배열 같은 구조화 출력을 얻을 수 있습니다. 인터페이스 정의는 이렇습니다:
```java
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
/**
* Returns the JSON schema for the structured output of an LLM call,
* or NO_JSON_SCHEMA ("") if not available.
*/
default String getJsonSchema() {
return NO_JSON_SCHEMA;
}
}
이 인터페이스는 Spring의 Converter<String, T> 인터페이스와 FormatProvider 인터페이스를 결합한 것입니다:
public interface FormatProvider {
String getFormat();
}
FormatProvider 는 AI 모델에 특정 형식 지침을 제공해, Converter 로 지정된 대상 타입 T로 변환 가능한 텍스트 출력을 만들게 해요. 이런 형식 지침의 예는 다음과 같습니다:
Your response should be in JSON format.
The data structure for the JSON should match this Java class: java.util.HashMap
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
형식 지침은 대부분 PromptTemplate을 사용해 사용자 입력 끝에 추가됩니다:
StructuredOutputConverter outputConverter = ...
String userInputTemplate = """
... user text input ....
{format}
"""; // user input with a "format" placeholder.
Prompt prompt = new Prompt(
PromptTemplate.builder()
.template(this.userInputTemplate)
.variables(Map.of(..., "format", this.outputConverter.getFormat())) // replace the "format" placeholder with the converter's format.
.build().createMessage()
);
Converter<String, T> 는 모델의 출력 텍스트를 지정된 타입 T의 인스턴스로 변환하는 책임을 집니다.
getJsonSchema() 의 역할
2.0에서 StructuredOutputConverter의 기본 메서드로 추가된 getJsonSchema() 는 컨버터가 useProviderStructuredOutput() 과 validateSchema() 에 참여하게 해 주는 다리예요. 이 메서드를 구현해 스키마를 반환하면(보통 BeanOutputConverter에 위임) 두 스위치가 모두 동작하고, 기본으로 남겨두면 두 스위치 모두 그 컨버터에 대해 no-op이 됩니다.
사용 가능한 컨버터
Spring AI는 AbstractConversionServiceOutputConverter, AbstractMessageOutputConverter, BeanOutputConverter, MapOutputConverter, ListOutputConverter 구현을 제공합니다:
AbstractConversionServiceOutputConverter<T>— LLM 출력을 원하는 형식으로 변환하기 위한 사전 구성된GenericConversionService를 제공합니다. 기본FormatProvider구현은 없어요.AbstractMessageOutputConverter<T>— LLM 출력을 원하는 형식으로 변환하기 위한 사전 구성된MessageConverter를 제공합니다. 기본FormatProvider구현은 없어요.BeanOutputConverter<T>— 지정된 Java 클래스(Bean)나ParameterizedTypeReference로 구성된 컨버터입니다. 지정된 Java 클래스에서 파생된DRAFT_2020_12JSON 스키마를 준수하는 JSON 응답을 AI 모델이 만들도록 지시하는FormatProvider구현을 사용합니다. 이후JsonMapper로 JSON 출력을 대상 클래스의 Java 객체 인스턴스로 역직렬화해요.MapOutputConverter—AbstractMessageOutputConverter의 기능을 확장하면서 RFC8259 준수 JSON 응답을 AI 모델이 만들도록 안내하는FormatProvider구현을 포함합니다. 제공된MessageConverter로 JSON 페이로드를java.util.Map<String, Object>인스턴스로 변환하는 컨버터 구현도 통합해요.ListOutputConverter—AbstractConversionServiceOutputConverter를 확장하고 콤마로 구분된 리스트 출력에 맞춘FormatProvider구현을 포함합니다. 제공된ConversionService로 모델 텍스트 출력을java.util.List으로 변환해요.
컨버터 사용
Bean 출력 컨버터
배우의 필모그래피를 만드는 BeanOutputConverter 예시입니다. 대상 record:
record ActorsFilms(String actor, List<String> movies) {
}
고수준 플루언트 ChatClient API로 적용:
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
.user(u -> u.text("Generate the filmography of 5 movies for {actor}.")
.param("actor", "Tom Hanks"))
.call()
.entity(ActorsFilms.class);
또는 저수준 ChatModel API 직접 사용:
BeanOutputConverter<ActorsFilms> beanOutputConverter =
new BeanOutputConverter<>(ActorsFilms.class);
String format = this.beanOutputConverter.getFormat();
String actor = "Tom Hanks";
String template = """
Generate the filmography of 5 movies for {actor}.
{format}
""";
Generation generation = chatModel.call(
PromptTemplate.builder().template(this.template).variables(Map.of("actor", this.actor, "format", this.format)).build().create()).getResult();
ActorsFilms actorsFilms = this.beanOutputConverter.convert(this.generation.getOutput().getText());
생성된 스키마의 속성 순서
BeanOutputConverter 는 @JsonPropertyOrder 애노테이션으로 생성된 JSON 스키마의 커스텀 속성 순서를 지원합니다. 이 애노테이션으로 클래스나 record의 선언 순서와 무관하게 속성이 스키마에 나타날 정확한 순서를 지정할 수 있어요.
예를 들어 ActorsFilms record에서 특정 순서를 보장하려면:
@JsonPropertyOrder({"actor", "movies"})
record ActorsFilms(String actor, List<String> movies) {}
이 애노테이션은 record와 일반 Java 클래스 모두에서 동작합니다.
제네릭 Bean 타입
ParameterizedTypeReference 생성자로 더 복잡한 대상 클래스 구조를 지정할 수 있습니다. 예를 들어 배우들과 필모그래피의 리스트를 나타내려면:
List<ActorsFilms> actorsFilms = ChatClient.create(chatModel).prompt()
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});
또는 저수준 ChatModel API 직접 사용:
BeanOutputConverter<List<ActorsFilms>> outputConverter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilms>>() { });
String format = this.outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks and Bill Murray.
{format}
""";
Prompt prompt = PromptTemplate.builder().template(this.template).variables(Map.of("format", this.format)).build().create();
Generation generation = chatModel.call(this.prompt).getResult();
List<ActorsFilms> actorsFilms = this.outputConverter.convert(this.generation.getOutput().getText());
Map 출력 컨버터
모델 출력을 map 안의 숫자 리스트로 변환하는 MapOutputConverter 예시입니다:
Map<String, Object> result = ChatClient.create(chatModel).prompt()
.user(u -> u.text("Provide me a List of {subject}")
.param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'"))
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});
또는 저수준 ChatModel API 직접 사용:
MapOutputConverter mapOutputConverter = new MapOutputConverter();
String format = this.mapOutputConverter.getFormat();
String template = """
Provide me a List of {subject}
{format}
""";
Prompt prompt = PromptTemplate.builder().template(this.template)
.variables(Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", this.format)).build().create();
Generation generation = chatModel.call(this.prompt).getResult();
Map<String, Object> result = this.mapOutputConverter.convert(this.generation.getOutput().getText());
List 출력 컨버터
모델 출력을 아이스크림 맛 리스트로 변환하는 ListOutputConverter 예시입니다:
List<String> flavors = ChatClient.create(chatModel).prompt()
.user(u -> u.text("List five {subject}")
.param("subject", "ice cream flavors"))
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
또는 저수준 ChatModel API 직접 사용:
ListOutputConverter listOutputConverter = new ListOutputConverter(new DefaultConversionService());
String format = this.listOutputConverter.getFormat();
String template = """
List five {subject}
{format}
""";
Prompt prompt = PromptTemplate.builder().template(this.template).variables(Map.of("subject", "ice cream flavors", "format", this.format)).build().create();
Generation generation = this.chatModel.call(this.prompt).getResult();
List<String> list = this.listOutputConverter.convert(this.generation.getOutput().getText());
커스텀 컨버터
앞서 언급했듯 모델은 종종 JSON을 마크다운 코드 펜스로 감쌉니다. BeanOutputConverter 는 첫 문장에서 예외를 던져요. 흔한 해결책은 펜스를 제거하고 JSON을 추출한 뒤 기본 파서에 위임하는 커스텀 컨버터입니다:
public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {
private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");
private final BeanOutputConverter<T> delegate;
public LenientJsonOutputConverter(Class<T> targetType) {
this.delegate = new BeanOutputConverter<>(targetType);
}
@Override public String getFormat() { return delegate.getFormat(); }
@Override public String getJsonSchema() { return delegate.getJsonSchema(); }
@Override
public T convert(String source) {
var matcher = FENCE.matcher(source);
String json = matcher.find() ? matcher.group(1).trim() : source.trim();
return delegate.convert(json);
}
}
이것을 Class 대신 .entity(...) 에 넘깁니다:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(new LenientJsonOutputConverter<>(ActorsFilms.class));
이 컨버터는 getJsonSchema() 를 내부 BeanOutputConverter 에 위임하므로 두 신뢰성 스위치가 여전히 동작합니다 — validateSchema() 와 useProviderStructuredOutput() 이 기본 컨버터가 사용하는 것과 동일한 스키마를 대상으로 동작하죠.
비-JSON 형식
JSON의 범위를 벗어난 형식 — 설정 생성기의 YAML, 데이터 추출의 CSV 같은 — 의 경우 StructuredOutputConverter 를 처음부터 구현하세요. 자신만의 getFormat() 프롬프트와 자신만의 convert(...) 파서를 작성하면 됩니다. getJsonSchema() 는 기본값으로 두면 두 신뢰성 스위치가 빠지고, 내장 컨버터와 마찬가지로 프롬프트 기반 경로가 동작합니다.