Structured output: bắt LLM trả về JSON đúng schema với Spring AI

1. Tổng quan

Khi tích hợp LLM vào ứng dụng, ta thường không cần một đoạn văn. Ta cần dữ liệu có cấu trúc để lưu DB, gọi API hoặc ra quyết định. Bài này trình bày cách bắt LLM trả về JSON đúng schema trong Spring Boot 3.x bằng Spring AI. Đọc xong, bạn sẽ map được phản hồi của model thẳng vào một Java record, bật chế độ JSON Schema native của OpenAI, validate kết quả, retry khi model trả sai và viết test cho phần parse.

2. Vì sao “hãy trả về JSON” là chưa đủ

Cách đơn giản nhất là thêm vào prompt câu “Trả lời bằng JSON”. Cách này hỏng theo nhiều kiểu:

  • Model bọc JSON trong markdown fence, hoặc thêm câu “Đây là kết quả:” ở đầu.
  • Tên field thay đổi giữa các lần gọi, ví dụ lúc quantity, lúc qty.
  • Kiểu dữ liệu sai, chẳng hạn "quantity": "hai" thay vì 2.
  • Giá trị enum được tự bịa, ví dụ "speed": "nhanh lắm".

Muốn ổn định, bạn cần ba lớp bảo vệ:

  1. Schema rõ ràng gửi kèm prompt, hoặc tốt hơn là gửi qua API dưới dạng ràng buộc.
  2. Parse chặt chẽ vào kiểu Java.
  3. Validate nghiệp vụ sau khi parse, rồi retry nếu cần.

3. Cài đặt

Bài dùng Spring AI 1.0 với OpenAI. Trước hết, thêm BOM và 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>

Sau đó cấu hình API key và model:

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 giảm độ “sáng tạo” của model. Với bài toán trích xuất dữ liệu, đó là điều bạn muốn.

4. Map phản hồi vào record với ChatClient.entity()

Bài toán ví dụ: trích xuất thông tin đơn hàng từ email của khách. Bắt đầu bằng việc định nghĩa kiểu dữ liệu đích:

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("Họ tên khách hàng")
        String customerName,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Mã sản phẩm, dạng SP-xxxx")
        String productCode,

        @JsonProperty(required = true)
        @JsonPropertyDescription("Số lượng, số nguyên dương")
        int quantity,

        @JsonProperty(required = true)
        DeliverySpeed speed
) {}

Các annotation Jackson ở đây có hai tác dụng:

  • @JsonPropertyDescription được đưa vào JSON Schema. Model đọc mô tả này để hiểu ý nghĩa từng field.
  • @JsonProperty(required = true) đánh dấu field bắt buộc trong schema.
  • Enum DeliverySpeed sinh ra "enum": ["STANDARD", "EXPRESS"], nên model bị giới hạn trong hai giá trị này.

Tiếp theo là service gọi model:

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("Bạn trích xuất thông tin đơn hàng từ email của khách hàng.")
                .build();
    }

    public OrderRequest extract(String email) {
        return chatClient.prompt()
                .user(u -> u.text("Email:\n{email}").param("email", email))
                .call()
                .entity(OrderRequest.class);
    }
}

Lời gọi .entity(OrderRequest.class) làm ba việc:

  1. Sinh JSON Schema từ OrderRequest.
  2. Nối hướng dẫn định dạng cùng schema vào cuối prompt.
  3. Parse phản hồi bằng Jackson thành OrderRequest.

Thử với email sau:

Chào shop, mình là Trần Thị Lan. Cho mình đặt 3 cái SP-1024, giao hỏa tốc giúp mình nhé.

Kết quả nhận được:

OrderRequest[customerName=Trần Thị Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]

Model tự hiểu “hỏa tốc” ứng với EXPRESS, vì enum chỉ cho phép hai giá trị.

4.1. Bên trong: BeanOutputConverter

.entity() dùng BeanOutputConverter ở phía sau. Bạn có thể dùng trực tiếp lớp này để xem chính xác những gì được gửi đi:

var converter = new BeanOutputConverter<>(OrderRequest.class);
System.out.println(converter.getJsonSchema());

Đầu ra (rút gọn):

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "Họ tên khách hàng" },
    "productCode" : { "type" : "string", "description" : "Mã sản phẩm, dạng SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "Số lượng, số nguyên dương" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

Phương thức getFormat() trả về đoạn hướng dẫn được nối vào prompt. Đoạn này yêu cầu model chỉ trả JSON theo RFC 8259, không kèm giải thích, không bọc markdown, và đính kèm schema ở trên. Khi parse, convert() còn tự gỡ markdown fence nếu model vẫn cố thêm vào.

4.2. Kiểu generic: danh sách đơn hàng

Nếu một email chứa nhiều đơn, hãy dùng ParameterizedTypeReference:

public List<OrderRequest> extractAll(String email) {
    return chatClient.prompt()
            .user(u -> u.text("Liệt kê mọi đơn hàng trong email:\n{email}").param("email", email))
            .call()
            .entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}

Truyền List.class sẽ không hoạt động. Thông tin generic bị xóa lúc runtime, nên Spring AI không biết phần tử bên trong có kiểu gì.

5. Structured output native của OpenAI

Cách ở mục 4 chỉ là yêu cầu trong prompt. Model vẫn có thể bỏ qua. Các model OpenAI mới hỗ trợ response_format kiểu json_schema ở chế độ strict. Ở chế độ này, quá trình sinh token bị ràng buộc theo schema, nên JSON trả về luôn hợp lệ về mặt cú pháp và cấu trúc.

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);
}

Đoạn code này hoạt động như sau:

  • Vẫn dùng BeanOutputConverter, nhưng chỉ để lấy schema (getJsonSchema()) và parse kết quả (convert()).
  • Schema được gửi qua tham số API thay vì nhét vào prompt.
  • .content() trả về chuỗi JSON thô, sau đó ta tự convert.

Lưu ý: Chế độ strict của OpenAI có giới hạn riêng. Mọi field phải nằm trong required, và object phải có additionalProperties: false. Một số từ khóa như pattern hay minimum có thể không được hỗ trợ, tùy model. Nếu một field là tùy chọn, hãy khai báo kiểu cho phép null thay vì bỏ nó khỏi required.

Native structured output đảm bảo cấu trúc, không đảm bảo nội dung đúng. Model vẫn có thể trả quantity: 0 hoặc bịa ra một productCode. Vì vậy bạn vẫn cần mục tiếp theo.

6. Validate và retry

Bổ sung ràng buộc Bean Validation vào 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
) {}

Sau đó validate kết quả. Nếu dữ liệu sai, gửi lại lỗi cho model và yêu cầu sửa:

@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 = "Trích xuất đơn hàng từ email:\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\nLần trước bạn trả về dữ liệu sai: " + describe(violations) + ". Hãy sửa lại.";
            } catch (RuntimeException e) {
                feedback = "\n\nLần trước phản hồi không parse được: " + e.getMessage();
            }
        }
        throw new IllegalStateException("Không trích xuất được đơn hàng sau " + MAX_ATTEMPTS + " lần");
    }

    private String describe(Set<ConstraintViolation<OrderRequest>> violations) {
        return violations.stream()
                .map(v -> v.getPropertyPath() + " " + v.getMessage())
                .collect(Collectors.joining("; "));
    }
}

Những điểm đáng chú ý:

  • validator.validate(order) kiểm tra các ràng buộc nghiệp vụ mà schema không diễn tả hết, ví dụ regex cho mã sản phẩm.
  • Thông điệp lỗi như quantity must be greater than 0 được nối vào prompt kế tiếp. Model sửa lỗi tốt hơn hẳn khi biết sai ở đâu.
  • Khối catch xử lý trường hợp JSON hỏng. Lỗi này hiếm gặp với chế độ strict, nhưng có thể xảy ra với model khác.
  • Giới hạn số lần thử để tránh vòng lặp tốn tiền.

Lưu ý: Nếu email thực sự không chứa số lượng, retry chỉ khiến model bịa ra một con số cho qua validate. Trường hợp này nên để field nhận null, rồi xử lý ở tầng nghiệp vụ, ví dụ hỏi lại khách.

7. Viết test

Bạn không nên gọi LLM thật trong unit test, vì chậm, tốn phí và kết quả không ổn định. Phần nên test là logic xác định: schema được sinh đúng, parse đúng, validate đúng.

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");
    }
}

Test thứ ba cho thấy rõ ranh giới trách nhiệm. JSON này đúng cấu trúc nên parse thành công, nhưng vẫn sai về mặt nghiệp vụ. Bean Validation bắt được cả hai field lỗi.

Để kiểm tra chất lượng prompt với model thật, hãy tách ra một bộ integration test riêng. Bộ test này chạy theo lịch hoặc thủ công, dùng một tập email mẫu và đo tỷ lệ trích xuất đúng.

8. Lỗi hay gặp và khi nào không nên dùng

Một số lỗi thường gặp:

  • Dùng .entity() với model không hỗ trợ tốt việc làm theo chỉ dẫn. Các model nhỏ chạy local thường trả JSON thiếu field. Khi đó, hãy dùng tính năng JSON mode hoặc grammar của runtime nếu có.
  • Schema quá sâu hoặc quá rộng. Hàng chục field lồng nhau làm tăng tỷ lệ lỗi và tốn token. Nên chia thành nhiều lời gọi nhỏ.
  • Thiếu @JsonPropertyDescription. Tên field như code hay type rất mơ hồ với model. Một dòng mô tả cải thiện độ chính xác đáng kể.
  • Tin hoàn toàn vào chế độ strict. Strict chỉ đảm bảo hình dạng của JSON. Nó không ngăn được dữ liệu bịa.
  • Để temperature cao. Kết quả trích xuất sẽ dao động giữa các lần chạy.
  • Đưa dữ liệu người dùng vào prompt mà không phân tách rõ. Email có thể chứa câu như “bỏ qua hướng dẫn trước”. Hãy tách system prompt và luôn validate đầu ra.

Không nên dùng structured output khi:

  • Đầu ra bản chất là văn bản tự do, chẳng hạn bài tóm tắt hoặc câu trả lời chat. Ép văn bản vào JSON chỉ làm chất lượng giảm.
  • Dữ liệu có thể trích xuất bằng regex hoặc parser thông thường. Cách đó rẻ hơn, nhanh hơn và xác định hơn.
  • Bạn cần độ chính xác tuyệt đối, ví dụ số tiền giao dịch, mà không có bước con người xác nhận.

9. Kết luận

Bài viết đã đi qua các cách bắt LLM trả về JSON đúng schema trong Spring Boot:

  • ChatClient.entity() với record và enum giúp map phản hồi vào kiểu Java chỉ với một dòng code.
  • BeanOutputConverter cho phép xem và kiểm soát schema được sinh ra.
  • ResponseFormat.Type.JSON_SCHEMA của OpenAI đảm bảo cấu trúc ngay khi model sinh token.
  • Bean Validation kết hợp retry có phản hồi lỗi giúp xử lý phần mà schema không bao quát được.

Nguyên tắc cốt lõi: schema giữ hình dạng của dữ liệu, validation giữ ý nghĩa của dữ liệu. Ứng dụng production cần cả hai lớp, cùng với unit test cho phần parse và bộ đánh giá riêng cho chất lượng prompt.

llmspring-aijson-schemaspring-bootjava