结构化输出:用 Spring AI 强制 LLM 返回符合 JSON Schema
1. 概述
在将 LLM 集成到应用中时,我们通常不需要一段文本。我们需要结构化的数据来存储到数据库、调用 API 或做出决策。本文介绍了在 Spring Boot 3.x 中如何使用 Spring AI 强制 LLM 返回符合 JSON Schema 的数据。读完后,你将能够把模型的响应直接映射到一个 Java record,启用 OpenAI 的原生 JSON Schema 模式,验证结果,在模型返回错误数据时重试,以及为解析部分编写测试。
2. 为什么“返回 JSON”还不够
最简单的方法是在 prompt 中添加“用 JSON 回答”这句话。这种方法容易出现很多问题:
- 模型将 JSON 包裹在 markdown 围栏中,或在开头添加“这是结果:“这样的句子。
- 字段名称在不同调用之间发生变化,例如有时是
quantity,有时是qty。 - 数据类型错误,例如
"quantity": "hai"而不是2。 - 枚举值被随意编造,例如
"speed": "nhanh lắm"。
要保证稳定性,你需要三层保护:
- 清晰的 Schema 随 prompt 发送,或最好通过 API 作为约束发送。
- 严格的解析 到 Java 类型。
- 业务验证 在解析后进行,如果需要则重试。
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 注解有两个作用:
@JsonPropertyDescription被包含在 JSON Schema 中。模型读取这个描述来理解每个字段的含义。@JsonProperty(required = true)标记 schema 中的必填字段。- enum
DeliverySpeed生成"enum": ["STANDARD", "EXPRESS"],因此模型被限制在这两个值中。
接下来是调用模型的 service:
@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("邮件:\n{email}").param("email", email))
.call()
.entity(OrderRequest.class);
}
}
调用 .entity(OrderRequest.class) 做三件事:
- 从
OrderRequest生成 JSON Schema。 - 将格式化指导和 schema 连接到 prompt 的末尾。
- 使用 Jackson 解析响应为
OrderRequest。
用以下电子邮件尝试:
你好,我是 Trần Thị Lan。请给我订 3 个 SP-1024,快速配送。
得到的结果:
OrderRequest[customerName=Trần Thị Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]
模型自动理解“快速”对应 EXPRESS,因为 enum 只允许两个值。
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() 返回连接到下一个 prompt 的指导片段。该片段要求模型仅按 RFC 8259 返回 JSON,不包含解释、不包装 markdown,并附上上面的 schema。解析时,convert() 还会自动删除 markdown 围栏,以防模型仍然尝试添加它。
4.2. 泛型类型:订单列表
如果一封电子邮件包含多个订单,请使用 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 部分的方法只是 prompt 中的请求。模型仍然可能忽略它。新的 OpenAI 模型支持严格模式下类型为 json_schema 的 response_format。在此模式下,token 生成过程受 schema 约束,因此返回的 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("邮件:\n{email}").param("email", email))
.options(options)
.call()
.content();
return converter.convert(json);
}
这段代码的工作原理如下:
- 仍然使用
BeanOutputConverter,但仅用于获取 schema(getJsonSchema())和解析结果(convert())。 - Schema 通过 API 参数发送,而不是嵌入到 prompt 中。
.content()返回原始 JSON 字符串,然后我们自己进行转换。
注意: OpenAI 的严格模式有自己的限制。每个字段必须在
required中,对象必须有additionalProperties: false。某些关键字如pattern或minimum可能不被支持,取决于模型。如果字段是可选的,请声明允许null的类型,而不是将其从required中省略。
原生 structured output 保证结构,不保证内容正确。模型仍然可能返回 quantity: 0 或编造 productCode。因此你仍然需要下一部分。
6. 验证和重试
在 record 中添加 Bean Validation 约束:
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)检查 schema 未完全表达的业务约束,例如产品代码的正则表达式。- 像
quantity must be greater than 0这样的错误消息被连接到下一个 prompt 中。 - 当模型知道错误在哪里时,它能更好地修正错误。
catch块处理损坏的 JSON 的情况。- 这个错误在严格模式下很少见,但在其他模型中可能发生。
- 限制尝试次数以避免浪费资金的循环。
注意: 如果电子邮件中确实没有数量,重试只会让模型编造一个数字来通过验证。在这种情况下,应该让字段接受
null,然后在业务层处理,例如询问客户。
7. 编写测试
在单元测试中不应该调用真实的 LLM,因为它很慢、花费大且结果不稳定。应该测试的是确定性逻辑:schema 生成正确、解析正确、验证正确。
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");
}
}
第三个测试清楚地展示了职责边界。这个 JSON 结构正确,所以解析成功,但在业务上仍然是错误的。Bean Validation 捕获了两个错误字段。
要使用真实模型检查 prompt 质量,应该分离出一个单独的集成测试套件。该测试套件按计划或手动运行,使用一组示例电子邮件并测量提取准确率。
8. 常见错误和何时不应使用
一些常见的错误:
- 对不能很好地遵循指令的模型使用
.entity()。 本地运行的小型模型通常返回缺少字段的 JSON。在这种情况下,如果可用,应使用 runtime 的 JSON mode 或 grammar。 - Schema 过于嵌套或过于宽泛。 数十个嵌套字段会增加错误率并消耗令牌。应该分成多个较小的调用。
- 缺少
@JsonPropertyDescription。 字段名称如code或type对模型来说很模糊。一行描述会显著提高准确性。 - 完全相信严格模式。 严格模式仅保证 JSON 的形状。它无法防止编造的数据。
- 保持
temperature高。 提取结果会在不同运行之间波动。 - 将用户数据输入到 prompt 中而没有明确分离。 电子邮件可能包含“忽略之前的指导”这样的句子。应该分离 system prompt 并始终验证输出。
不应使用 structured output 的情况:
- 输出本质上是自由文本,例如摘要或聊天回复。将文本强制放入 JSON 只会降低质量。
- 数据可以通过正则表达式或常规解析器提取。那样会更便宜、更快、更有确定性。
- 你需要绝对准确性,例如交易金额,但没有人工确认步骤。
9. 结论
本文介绍了在 Spring Boot 中强制 LLM 返回符合 JSON Schema 的几种方法:
ChatClient.entity()与 record 和 enum 一起使用,可以仅用一行代码就将响应映射到 Java 类型。BeanOutputConverter允许查看和控制生成的 schema。- OpenAI 的
ResponseFormat.Type.JSON_SCHEMA确保模型生成令牌时的结构。 - Bean Validation 与带有错误反馈的重试结合,帮助处理 schema 未涵盖的部分。
核心原则:schema 保持数据的形状,validation 保持数据的含义。生产应用程序需要两层保护,以及解析部分的单元测试和 prompt 质量的单独评估套件。