Appearance
结构化输出:让模型返回能直接反序列化的 Java 对象
1. 本节产出
一个能把自然语言转成 Java 对象的接口:输入一段工单描述,直接得到 Ticket 对象(含枚举、嵌套对象、列表),并且自带一次重试与校验兜底,解析失败不会把异常抛给用户。
2. 前置依赖
- 01-02 第一个 ChatClient:已有 ChatClient Bean
- 01-01 LLM 基础概念与选型:理解 temperature 对稳定性的影响
- 了解 Jackson 基本注解
3. 为什么不能只靠「请返回 JSON」
最常见的做法是:在 prompt 里写「请返回 JSON 格式」,然后自己 objectMapper.readValue()。生产上你会遇到:
| 现象 | 出现频率 | 后果 |
|---|---|---|
| 模型在 JSON 前后加了 ```json 代码块标记 | 极高 | 解析直接抛异常 |
| 模型加了「好的,这是结果:」这类前缀 | 高 | 同上 |
| 字段名用了中文或驼峰不一致 | 中 | 反序列化为 null |
| 枚举值不符合预期 | 中 | 下游处理失败 |
| 少字段、多字段 | 中 | NPE 或忽略 |
根因:普通 prompt 只是「请求」模型输出 JSON,模型可以选择不遵守。真正的结构化输出需要在协议层约束模型的输出空间——让它在采样时只能产出合法 JSON 的 Token。
4. 核心原理
4.1 两种约束强度
| 方式 | 机制 | 兼容性 | 稳定性 |
|---|---|---|---|
| Prompt 约束(Converter 生成格式说明) | 把 JSON Schema 写进 prompt,模型照着写 | 所有模型 | 中,依赖模型遵循能力 |
| 原生结构化输出(response_format / json_schema) | 厂商在采样层强制约束 | 主流厂商支持 | 高 |
Spring AI 的 BeanOutputConverter 做的是前者,并且它额外做了一件很有用的事——自动清洗:
模型原始输出:```json\n{"title":"...","level":"HIGH"}\n```
│
▼ BeanOutputConverter.convert()
{"title":"...","level":"HIGH"}
│
▼ Jackson
Ticket 对象它能剥掉代码块标记、前后缀说明文本等常见噪声。这就是为什么应该用它而不是自己 readValue。
4.2 Converter 家族
| 类型 | 用途 | 什么时候用 |
|---|---|---|
BeanOutputConverter<T> | 转成指定 Java Bean | 90% 的场景,推荐 |
MapOutputConverter | 转成 Map<String,Object> | 结构不固定、动态字段 |
ListOutputConverter | 转成 List<String> | 抽取关键词、标签列表 |
优先用 BeanOutputConverter:类型安全、IDE 能补全、下游代码不用做类型转换。用 Map 的场景是「字段本身由用户配置决定」,这时没法定义固定 Bean。
4.3 影响成功率的三个因素
按影响力排序:
- temperature:这是第一因素。抽取/结构化任务必须设 0~0.2。设 0.7 时同样的输入可能得到不同结构。
- Bean 的字段设计:字段名要自解释(
priority优于p),每个字段加@JsonPropertyDescription说明取值范围。 - 模型能力:轻量模型遵循复杂 Schema 的能力明显弱于旗舰模型。
排序很重要:很多人一遇到问题就换更贵的模型,其实先把 temperature 调 0 能解决大半。
5. 代码走查
5.1 目标 Bean:带描述
java
// ch01-basics/src/main/java/com/aitech/basics/dto/Ticket.java
public record Ticket(
@JsonPropertyDescription("工单标题,15 字以内,概括核心问题")
String title,
@JsonPropertyDescription("紧急程度,只能取 LOW / MEDIUM / HIGH 之一")
Priority priority,
@JsonPropertyDescription("受影响的系统模块名,如:订单中心、支付网关")
String module,
@JsonPropertyDescription("从描述中提取的关键信息列表,最多 3 条,没有则为空列表")
List<String> keyPoints,
@JsonPropertyDescription("是否为线上故障,true 表示已影响线上用户")
boolean onlineIssue
) {
public enum Priority { LOW, MEDIUM, HIGH }
}@JsonPropertyDescription 不是可选的装饰——它会被写进给模型看的 Schema,直接决定模型填得对不对。不写描述的枚举字段,模型会自由发挥。
5.2 Controller 实现
java
// ch01-basics/src/main/java/com/aitech/basics/controller/StructuredOutputController.java
@RestController
@RequestMapping("/api")
public class StructuredOutputController {
private final ChatClient chatClient;
@PostMapping("/extract/ticket")
public Ticket extract(@RequestBody String description) {
BeanOutputConverter<Ticket> converter =
new BeanOutputConverter<>(Ticket.class);
// 把 JSON Schema 作为格式要求注入 prompt
String format = converter.getFormat();
String raw = chatClient.prompt()
.user(u -> u.text("""
从下面的工单描述中提取结构化信息。
{format}
工单描述:
{description}
""")
.param("format", format)
.param("description", description))
.options(OpenAiChatOptions.builder()
.temperature(0.0) // 结构化任务必须低温
.build())
.call()
.content();
return converter.convert(raw);
}
}5.3 带兜底的生产写法
java
// ch01-basics/src/main/java/com/aitech/basics/service/TicketExtractor.java
@Service
public class TicketExtractor {
private final ChatClient chatClient;
public Ticket extract(String description) {
for (int attempt = 1; attempt <= 2; attempt++) {
try {
Ticket t = doExtract(description);
if (validate(t)) return t;
log.warn("第 {} 次抽取结果未通过校验", attempt);
} catch (Exception e) {
log.warn("第 {} 次抽取失败", attempt, e);
}
}
// 兜底:返回人工待处理状态,绝不让异常穿透到用户
return Ticket.fallback(description);
}
private boolean validate(Ticket t) {
return t != null
&& t.title() != null && !t.title().isBlank()
&& t.priority() != null;
}
}
// Ticket 里的兜底工厂
static Ticket fallback(String raw) {
return new Ticket(
raw.length() > 15 ? raw.substring(0, 15) : raw,
Priority.MEDIUM, "待人工确认", List.of(), false);
}这段兜底逻辑比转换本身更重要。生产上「抽取失败」是必然会发生的,问题是失败后系统怎么办。返回 fallback 让流程继续(进人工队列),比抛异常中断整个工单流程要好得多。
5.4 使用原生结构化输出(能力更强的模型)
java
.options(OpenAiChatOptions.builder()
.model(model)
.temperature(0.0)
.responseFormat(ResponseFormat.JSON) // 厂商侧强制 JSON
.build())提示:
responseFormat的支持度因厂商而异,兼容端点上未必生效。判断方法:设了之后如果模型仍输出代码块标记,说明没生效,此时靠 Converter 的清洗兜底。两者叠加使用最稳。
6. 跑起来
bash
git checkout ch01-08-structured-output
mvn spring-boot:runbash
curl -X POST http://localhost:8080/api/extract/ticket \
-H "Content-Type: text/plain" \
-d "支付网关今天下午三点开始大量超时,用户下单付不了款,已经有二十多个客诉了,订单中心也跟着积压"期望输出:
json
{
"title": "支付网关大量超时",
"priority": "HIGH",
"module": "支付网关",
"keyPoints": ["15:00 开始超时", "用户无法付款", "20+ 客诉", "订单中心积压"],
"onlineIssue": true
}验证清单:
| 检查项 | 通过标准 |
|---|---|
| 字段完整 | 五个字段全部有值,没有 null |
| 枚举合法 | priority 恰好是三个值之一 |
| 列表长度 | keyPoints 不超过 3 条(遵守了描述里的约束) |
| 稳定性 | 同一输入连跑 5 次,结果一致或高度接近 |
| 脏输入 | 输入「随便写点什么」时返回 fallback 而不是报错 |
连跑 5 次这一步不能省:单次成功说明不了问题。temperature 设 0 之后仍不稳定的话,说明模型对 Schema 遵循能力弱,要换模型或简化 Schema。
7. 生产避坑
- temperature 不设 0 是结构化抽取失败的第一原因。这不是玄学:temperature 越高,采样越倾向长尾 Token,越容易偏离 Schema。抽取类任务统一设 0~0.2,并在代码里显式写出来(不要依赖默认值)。
- 不要相信单次解析成功,必须有校验和兜底。模型可能返回语法合法但语义荒谬的结果(比如 priority 永远是 HIGH)。做法是:反序列化后跑一遍业务校验,不通过就重试一次,再不通过走人工。永远不要让解析异常穿透到用户面前。
- 嵌套过深的 Bean 成功率显著下降。超过两层嵌套或字段超过 10 个时,考虑拆成多次调用(先抽主体,再抽细节)。一个经验值:单次抽取字段控制在 8 个以内,嵌套不超过 2 层。
8. 延伸与锚点
- 思考题:模型返回的 JSON 语法完全正确,但内容全是编的(比如不存在的模块名),你的校验能发现吗?(提示:需要业务侧枚举校验或引用检查——答案在 02-11 引用溯源与拒答)
- 代码锚点:
git checkout ch01-08-structured-output - 下一课时:01-09 PromptTemplate 与外部化
- 对应课件:L01-08 结构化输出