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つの保護層が必要です:

  1. 明確なSchema をプロンプトに含める、またはより良い方法としてAPI経由の制約として送信する。
  2. 厳密なparse をJava型に行う。
  3. 業務ロジックの検証 を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つのことを行います:

  1. OrderRequestからJSON Schemaを生成する
  2. フォーマット指示とスキーマをプロンプトの最後に追加する
  3. 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と、プロンプト品質の独立した評価セットと共に。

llmspring-aijson-schemaspring-bootjava