Structured output: Spring AIでLLMから正しいスキーマのJSONを返させる
1. 概要
LLMをアプリケーションに統合する際、単なるテキストではなく、構造化されたデータが必要です。データベースへの保存、API呼び出し、または意思決定に使用するためです。本記事では、Spring AIを使ってSpring Boot 3.xでLLMから正しいスキーマのJSONを返させる方法を説明します。読み終わるまでに、モデルの応答をJava Recordに直接マップできる、OpenAIのネイティブJSON Schemaモードを有効にできる、結果をバリデーションできる、モデルが間違った場合にリトライできる、parseロジックのテストを書けるようになります。
2. 「JSONを返して」だけでは不十分な理由
最も簡単な方法は、プロンプトに「JSON形式で答えて」という指示を追加することです。しかし、この方法にはいくつかの問題があります:
- モデルはJSONをマークダウンのフェンスで囲んだり、先頭に「これが結果です:」という文を追加したりします。
- APIの呼び出しごとにフィールド名が変わる場合があります。例えば、時には
quantity、時にはqtyのように。 - データ型が間違うことがあります。例えば、
2ではなく"quantity": "hai"のように。 - Enumの値が勝手に作成されることがあります。例えば、
"speed": "nhanh lắm"のように。
安定性を実現するには、3つの保護層が必要です:
- 明確なSchema をプロンプトに含める、またはより良い方法としてAPI経由の制約として送信する。
- 厳密なparse をJava型に行う。
- 業務ロジックの検証 をparse後に実行し、必要ならリトライする。
3. セットアップ
本記事ではSpring AI 1.0とOpenAIを使用します。まず、BOMとstarterを追加します:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
次に、APIキーとモデルを設定します:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0
temperature=0はモデルの「創造性」を低減させます。データ抽出タスクでは、これが望ましい動作です。
4. ChatClient.entity()でレスポンスをRecordにマップする
例題:顧客のメールから注文情報を抽出します。対象データ型の定義から始めます:
public enum DeliverySpeed { STANDARD, EXPRESS }
public record OrderRequest(
@JsonProperty(required = true)
@JsonPropertyDescription("顧客の名前")
String customerName,
@JsonProperty(required = true)
@JsonPropertyDescription("製品コード、形式SP-xxxx")
String productCode,
@JsonProperty(required = true)
@JsonPropertyDescription("数量、正の整数")
int quantity,
@JsonProperty(required = true)
DeliverySpeed speed
) {}
ここでのJacksonアノテーションは、2つの役割があります:
@JsonPropertyDescriptionはJSON Schemaに含まれます。モデルはこの説明を読んで、各フィールドの意味を理解します。@JsonProperty(required = true)はスキーマ内の必須フィールドをマークします。- Enum
DeliverySpeedは"enum": ["STANDARD", "EXPRESS"]を生成し、モデルはこれら2つの値に限定されます。
次は、モデルを呼び出すサービスです:
@Service
public class OrderExtractionService {
private final ChatClient chatClient;
public OrderExtractionService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("顧客のメールから注文情報を抽出してください。")
.build();
}
public OrderRequest extract(String email) {
return chatClient.prompt()
.user(u -> u.text("Email:\n{email}").param("email", email))
.call()
.entity(OrderRequest.class);
}
}
.entity(OrderRequest.class)の呼び出しは、3つのことを行います:
OrderRequestからJSON Schemaを生成する- フォーマット指示とスキーマをプロンプトの最後に追加する
- Jacksonを使ってレスポンスを
OrderRequestにパースする
次のメールで試してみます:
こんにちは、私はTrần Thị Lanです。3個のSP-1024を注文したいのですが、速配でお願いします。
受け取られた結果:
OrderRequest[customerName=Trần Thị Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]
モデルは「速配」がEXPRESSに対応することを自動的に理解します。列挙型は2つの値のみを許可しているためです。
4.1. 内部:BeanOutputConverter
.entity()は、バックグラウンドでBeanOutputConverterを使用します。このクラスを直接使用して、何が送信されるかを正確に確認できます:
var converter = new BeanOutputConverter<>(OrderRequest.class);
System.out.println(converter.getJsonSchema());
出力(簡略化):
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"customerName" : { "type" : "string", "description" : "顧客の名前" },
"productCode" : { "type" : "string", "description" : "製品コード、形式SP-xxxx" },
"quantity" : { "type" : "integer", "description" : "数量、正の整数" },
"speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
},
"required" : [ "customerName", "productCode", "quantity", "speed" ],
"additionalProperties" : false
}
メソッドgetFormat()は、プロンプトに追加される指示文字列を返します。この指示は、モデルにRFC 8259に従ったJSONのみを返すよう要求し、説明やマークダウンなしで、上記のスキーマを含みます。パース時に、convert()はモデルがまだマークダウンのフェンスを追加しようとした場合、それを自動的に削除します。
4.2. ジェネリック型:注文リスト
1つのメールに複数の注文が含まれている場合、ParameterizedTypeReferenceを使用してください:
public List<OrderRequest> extractAll(String email) {
return chatClient.prompt()
.user(u -> u.text("メールのすべての注文をリストアップしてください:\n{email}").param("email", email))
.call()
.entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}
List.classを渡すことは機能しません。ジェネリック情報はランタイムで削除されるため、Spring AIは内部要素の型を知ることができません。
5. OpenAIのネイティブStructured Output
セクション4の方法は、プロンプト内のリクエストに過ぎません。モデルはそれを無視できます。新しいOpenAIモデルは、strict モードでjson_schemaタイプのresponse_formatをサポートしています。このモードでは、トークン生成プロセスはスキーマに制限されるため、返されるJSONは構文と構造に関して常に有効です。
public OrderRequest extractStrict(String email) {
var converter = new BeanOutputConverter<>(OrderRequest.class);
var options = OpenAiChatOptions.builder()
.responseFormat(new ResponseFormat(
ResponseFormat.Type.JSON_SCHEMA, converter.getJsonSchema()))
.build();
String json = chatClient.prompt()
.user(u -> u.text("Email:\n{email}").param("email", email))
.options(options)
.call()
.content();
return converter.convert(json);
}
このコードの動作:
BeanOutputConverterを使用していますが、スキーマを取得する(getJsonSchema())と結果をパースする(convert())の目的のみです。- スキーマはAPIパラメータで送信され、プロンプトに組み込む代わりに。
.content()は生のJSON文字列を返し、その後に手動でconvertを行います。
注意: OpenAIのstrictモードには独自の制限があります。すべてのフィールドが
requiredに含まれている必要があり、オブジェクトはadditionalProperties: falseを持つ必要があります。patternやminimumなどの一部のキーワードは、モデルによってサポートされない場合があります。フィールドがオプションの場合、requiredから除外するのではなく、nullを許可する型を宣言してください。
ネイティブ構造化出力は、構造を保証しますが、正しいコンテンツは保証しません。モデルはまだquantity: 0を返すか、偽のproductCodeを作成できます。次のセクションが必要な理由はこれです。
6. バリデーションとリトライ
Bean Validationの制約をRecordに追加します:
public record OrderRequest(
@JsonProperty(required = true) @NotBlank
String customerName,
@JsonProperty(required = true) @Pattern(regexp = "SP-\\d{4}")
String productCode,
@JsonProperty(required = true) @Positive
int quantity,
@JsonProperty(required = true) @NotNull
DeliverySpeed speed
) {}
次に、結果をバリデーションします。データが間違っていた場合、エラーをモデルに送り返し、修正を要求します:
@Service
public class ValidatedOrderExtractor {
private static final int MAX_ATTEMPTS = 3;
private final ChatClient chatClient;
private final Validator validator;
public ValidatedOrderExtractor(ChatClient.Builder builder, Validator validator) {
this.chatClient = builder.build();
this.validator = validator;
}
public OrderRequest extract(String email) {
String feedback = "";
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
String prompt = "メールから注文情報を抽出してください:\n" + email + feedback;
try {
OrderRequest order = chatClient.prompt().user(prompt).call().entity(OrderRequest.class);
Set<ConstraintViolation<OrderRequest>> violations = validator.validate(order);
if (violations.isEmpty()) {
return order;
}
feedback = "\n\n前回は無効なデータが返されました: " + describe(violations) + ". 修正してください。";
} catch (RuntimeException e) {
feedback = "\n\n前回のレスポンスはパースできませんでした: " + e.getMessage();
}
}
throw new IllegalStateException(MAX_ATTEMPTS + " 回の試行後、注文情報を抽出できませんでした");
}
private String describe(Set<ConstraintViolation<OrderRequest>> violations) {
return violations.stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining("; "));
}
}
注目すべき点:
validator.validate(order)は、スキーマが完全には説明していない業務上の制約(例えば、製品コードのregex)をチェックします。quantity must be greater than 0のようなエラーメッセージは、次のプロンプトに追加されます。モデルはどこが間違っているかを知ることで、より良く修正できます。catchブロックは、JSONが破損している場合を処理します。このエラーはstrictモードでは稀ですが、他のモデルでは発生する可能性があります。- コストを無駄にしないようにループのリトライ回数を制限します。
注意: もしメールに実際に数量が含まれていない場合、単にフィールドが
nullを受け入れるようにして、業務層で処理する必要があります。例えば、顧客に再度問い合わせる等です。
7. テストを書く
Unit testで実際のLLMを呼び出さない方がよいです。遅く、コストがかかり、結果が不安定だからです。テストすべき部分は、決定的なロジック:スキーマが正しく生成され、パースが正しく、バリデーションが正しく行われること。
class OrderRequestConversionTest {
private final BeanOutputConverter<OrderRequest> converter =
new BeanOutputConverter<>(OrderRequest.class);
private final Validator validator =
Validation.buildDefaultValidatorFactory().getValidator();
@Test
void schemaContainsAllFieldsAndEnumValues() {
String schema = converter.getJsonSchema();
assertThat(schema)
.contains("\"customerName\"", "\"productCode\"", "\"quantity\"")
.contains("\"STANDARD\"", "\"EXPRESS\"");
}
@Test
void convertsValidJsonToRecord() {
String json = """
{"customerName":"Trần Thị Lan","productCode":"SP-1024","quantity":3,"speed":"EXPRESS"}
""";
OrderRequest order = converter.convert(json);
assertThat(order.quantity()).isEqualTo(3);
assertThat(order.speed()).isEqualTo(DeliverySpeed.EXPRESS);
assertThat(validator.validate(order)).isEmpty();
}
@Test
void flagsInvalidBusinessValues() {
String json = """
{"customerName":"Lan","productCode":"ABC","quantity":0,"speed":"STANDARD"}
""";
OrderRequest order = converter.convert(json);
assertThat(validator.validate(order))
.extracting(v -> v.getPropertyPath().toString())
.containsExactlyInAnyOrder("productCode", "quantity");
}
}
3番目のテストは責任の境界を明確に示しています。このJSONは構造的に正しいため解析に成功しますが、業務上は間違っています。Bean Validationは両方のエラーフィールドをキャッチします。
実際のモデルでプロンプト品質を確認するには、統合テストを別に分離してください。このテストセットは、スケジュール或いは手動で実行され、サンプルメールセットを使用して、正しい抽出率を測定します。
8. 一般的なエラーと使用すべきではない場合
一般的なエラーのいくつか:
- 指示に従うのが得意でないモデルで
.entity()を使用する。 ローカルで実行される小さいモデルは、しばしばフィールドが不足したJSONを返します。この場合、ランタイムのJSON modeまたはgrammar機能を使用してください。 - スキーマが深すぎるか広すぎる。 数十個の入れ子化されたフィールドは、エラー率を増加させトークンを消費します。複数の小さな呼び出しに分割する方がよいです。
@JsonPropertyDescriptionが不足している。codeやtypeなどのフィールド名はモデルにとって非常に曖昧です。1行の説明は精度を大幅に向上させます。- strictモードを完全に信頼する。 Strictは単にJSONの形状を保証するだけです。偽造データを防ぐことはできません。
temperatureを高く設定する。 抽出結果が実行ごとに変動します。- ユーザーデータを明確に分離せずにプロンプトに含める。 メールには「前の指示を無視して」のような文が含まれる可能性があります。システムプロンプトを分離し、常に出力をバリデーションしてください。
次の場合、構造化出力を使用すべきではありません:
- 出力が本質的に自由形式のテキスト(要約やチャット応答など)である場合。テキストをJSONに強制することは、品質を低下させるだけです。
- データをregexまたは通常のパーサーで抽出できる場合。その方法がより安く、速く、確定的です。
- 絶対的な精度が必要で、人間による確認ステップがない場合(例えば、取引金額など)。
9. 結論
本記事は、Spring BootでLLMから正しいスキーマのJSONを返させるいくつかの方法を説明しました:
- RecordとEnumを使用した
ChatClient.entity()は、1行のコードでレスポンスをJava型にマップできます。 BeanOutputConverterは、生成されたスキーマを確認し制御できます。- OpenAIの
ResponseFormat.Type.JSON_SCHEMAは、モデルがトークンを生成する際に構造を保証します。 - Bean Validationとエラーフィードバック付きリトライは、スキーマがカバーしていない部分を処理するのに役立ちます。
コア原則:スキーマはデータの形状を保ち、バリデーションはデータの意味を保ちます。本番アプリケーションは両方のレイヤーが必要です。parseロジックのUnit testと、プロンプト品質の独立した評価セットと共に。