Skip to content

结构化输出:让模型返回能直接反序列化的 Java 对象 ​

1. 本节产出 ​

一个能把自然语言转成 Java 对象的接口:输入一段工单描述,直接得到 Ticket 对象(含枚举、嵌套对象、列表),并且自带一次重试与校验兜底,解析失败不会把异常抛给用户。

2. 前置依赖 ​

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 Bean90% 的场景,推荐
MapOutputConverter转成 Map<String,Object>结构不固定、动态字段
ListOutputConverter转成 List<String>抽取关键词、标签列表

优先用 BeanOutputConverter:类型安全、IDE 能补全、下游代码不用做类型转换。用 Map 的场景是「字段本身由用户配置决定」,这时没法定义固定 Bean。

4.3 影响成功率的三个因素 ​

按影响力排序:

  1. temperature:这是第一因素。抽取/结构化任务必须设 0~0.2。设 0.7 时同样的输入可能得到不同结构。
  2. Bean 的字段设计:字段名要自解释(priority 优于 p),每个字段加 @JsonPropertyDescription 说明取值范围。
  3. 模型能力:轻量模型遵循复杂 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:run
bash
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. 生产避坑 ​

  1. temperature 不设 0 是结构化抽取失败的第一原因。这不是玄学:temperature 越高,采样越倾向长尾 Token,越容易偏离 Schema。抽取类任务统一设 0~0.2,并在代码里显式写出来(不要依赖默认值)。
  2. 不要相信单次解析成功,必须有校验和兜底。模型可能返回语法合法但语义荒谬的结果(比如 priority 永远是 HIGH)。做法是:反序列化后跑一遍业务校验,不通过就重试一次,再不通过走人工。永远不要让解析异常穿透到用户面前。
  3. 嵌套过深的 Bean 成功率显著下降。超过两层嵌套或字段超过 10 个时,考虑拆成多次调用(先抽主体,再抽细节)。一个经验值:单次抽取字段控制在 8 个以内,嵌套不超过 2 层。

8. 延伸与锚点 ​

  • 思考题:模型返回的 JSON 语法完全正确,但内容全是编的(比如不存在的模块名),你的校验能发现吗?(提示:需要业务侧枚举校验或引用检查——答案在 02-11 引用溯源与拒答)
  • 代码锚点:git checkout ch01-08-structured-output
  • 下一课时:01-09 PromptTemplate 与外部化
  • 对应课件:L01-08 结构化输出