Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 3 章

CH 03结构化输出与 Advisors

前面两章模型只会「说人话」,这一章要让它「交作业」。结构化输出解决「模型输出能不能被程序稳定消费」,Advisor 解决「请求前后的横切逻辑往哪放」。这两件事分别对应企业级应用里最要命的两个问题:接口稳定性 和 代码可维护性。本章所有代码基于 Spring AI 2.0.1 + Spring Boot 4.1.x + Java 17,模型默认走 DeepSeek(OpenAI 兼容协议),密钥使用 ${DEEPSEEK_API_KEY:} 占位符。

教学目标
掌握 entity() 与 Advisor 横切机制
教学重点
entity() 的 2.0 写法、校验五道防线、Advisor 洋葱模型
教学难点
自定义 Advisor 的 order 与上下文、JSON Schema 与 JSR-303 分工
建议学时
3 学时

教学设计说明(教师参考):本章分两段,第一段(3.1~3.4)是「让模型吐出干净对象」,第二段(3.5~3.7)是「Advisor 横切」。按「先跑通 entity() → 手写 converter 反向理解 → 加校验重试 → 抽 Advisor」的顺序推进,每个可运行节点都立即验证、立即对照坑位,不要等全章写完才跑。本章新发现的易掉队点(教师实施时重点盯):

  • ① 编译期不报错、运行期才炸:学生把 List<OrderInfo>.class 这种写法当合法,IDEA 标红却硬编,必须讲清「泛型擦除 + ParameterizedTypeReference」。
  • ② BigDecimal 与温度设 0:这两个是抽取任务的最常翻车点,前者导致反序列化失败、后者导致输出抖动,必须在 3.1/3.4 就钉死。
  • ③ Advisor 的 order 语义反直觉:学生几乎都会把「order 小=先执行」记反,用洋葱图在黑板上画一遍,并让他们改 order 后看日志验证。
  • ④ 验证要「看到失败长什么样」:验收时让学生贴一次「信息不全输入」的返回,确认 success=false 且不编造——能跑出这个结果,才说明五道防线真懂了。

3.1为什么输出必须是对象

先看一个真实场景。用户输入一段话,你要从中提取成订单数据:

// 用户输入(一段自由文本,可能来自邮件、聊天记录、OCR 结果)
"你好,我是张三,订单 2024001 在 3 月 5 日下的单,金额 899 元,
 买了一个机械键盘和一个鼠标垫,现在还没发货,麻烦催一下。联系电话 138****8888"

如果用纯文本返回,你拿到的是一段字符串,要写正则去抠订单号、抠金额——模型稍微换个说法(「总计 899 元」/「共 899.00 元」),你的正则就废了。这就是非结构化输出的致命问题:不可靠。

❌ 纯文本输出的问题

  • 字段顺序、措辞可能变化,解析逻辑脆
  • 无法用 Java 类型系统约束
  • 无法做校验,脏数据直接入库
  • 下游要写一堆容错代码

✅ 结构化输出的价值

  • 模型输出 JSON → 自动映射成 record/POJO
  • 字段类型、必填项由 Java 定义
  • 可直接接入 JSR-303 校验
  • 下游代码干净、可测试
为什么本章一上来就要先讲「为什么」?很多学生第一次见 entity() 觉得「不就是个 JSON 解析吗」。但真正上线后炸掉的,几乎都是「模型偶尔不按 JSON 输出」这种边界情况。理解了「非结构化不可靠」这个根因,你才会明白后面 3.4 的五道防线和 3.2 的 validateSchema() 不是在炫技,而是在填生产环境的坑。

动手:创建 OrderInfo 映射类

我们的目标类型用 record(简洁且不可变)。下面这步是本章第一个「可运行节点」——先把映射类建出来并编译通过,后面才能往上叠逻辑。

  1. 在 IDEA 左侧 Project 视图里,展开 src/main/java,找到包 com.example.springai。在 com.example.springai 上右键 → New → Package,输入 com.example.springai.ch03.model,回车。这是本章所有模型类的家。

  2. 在刚建好的 com.example.springai.ch03.model 包上右键 → New → Java Class,Name 填 OrderInfo,按回车(Kind 选 Class 即可,record 我们手动写)。

  3. 把 IDEA 自动生成的内容整段清空,粘贴下面这段「标准答案」(注意字段顺序、注解一字不差):

    package com.example.springai.ch03.model;
    
    import java.math.BigDecimal;
    import java.time.LocalDate;
    import java.util.List;
    
    /**
     * 订单信息提取结果。
     * 用 record 的好处:字段即构造参数,Jackson 反序列化开箱即用(JDK 17+ / Jackson 2.12+)。
     * 注意字段名要和提示词里的 JSON schema 对应,否则映射不上会得到 null。
     */
    public record OrderInfo(
            String orderNo,              // 订单号,如 2024001
            String customerName,         // 客户姓名
            BigDecimal amount,           // 金额。★ 必须用 BigDecimal,绝不能用 double
            String status,               // 状态:未发货/已发货/已完成/已取消
            LocalDate orderDate,         // 下单日期
            List<String> items,          // 商品列表
            String phone                 // 联系电话
    ) {}
  4. Ctrl + S 保存(或等 IDEA 自动保存)。

运行验证(编译期验收):在 IDEA 底部的 Terminal(或系统终端,先 cd 到工程根目录,即含 pom.xml 的那层)执行:

mvn -q compile

期望看到:命令安静退出、没有任何红色报错;若想确认,去掉 -q 再跑一次应出现 BUILD SUCCESS。

[INFO] BUILD SUCCESS
[INFO] Total time:  3.456 s

看到什么算通过:OrderInfo 这个 record 能被 javac 编译进 target/classes,说明包名、import、record 语法全部正确。编译不过就别往下走——后面所有逻辑都依赖它。

排查:编译报 cannot find symbol 或 record 不是合法的构造。按顺序查:① 包名是不是 com.example.springai.ch03.model?IDEA 里目录层级和 package 声明必须一致,否则找不到类;② BigDecimal/LocalDate/List 三个 import 是否都在(漏一个就标红);③ 你是不是在用 Java 8/11?record 是 Java 16 才正式定稿、17 才普遍可用,第 0 章要求 JDK 17,java -version 确认是 17;④ 文件 Kind 选了 Interface/Enum?record 要当 Class 写、再手动改 class 为 record。
金额字段为什么必须用 BigDecimal?用 double 会产生浮点误差(0.1+0.2=0.30000000000000004),在财务场景是灾难。另外模型有时会返回 "899元" 这种带单位的字符串,映射到 BigDecimal 会失败——解决办法是在字段上写明要求(3.4 节会讲怎么通过 JSON Schema 描述做到),或加一个自定义反序列化器清洗。

为什么选 record 而不是普通 class?三个理由:① 不可变,多线程下天然安全;② 构造参数顺序即字段顺序,Jackson 按名称匹配不受影响;③ 自动生成 equals/hashCode/toString,调试时打印对象一目了然。本课程不使用 Lombok(见第 0 章纪律),所以校验注解直接写在 record 的组件上即可,不需要额外加 @Data。

一句话记忆:模型输出先想「能不能当对象消费」,能当对象就不要拿字符串硬抠——record + BigDecimal 是结构化输出的起点,也是编译期就能保住的第一道关。

3.2entity():一行代码拿到对象

Spring AI 把「提示词工程 + 解析」这套流程封装得极其简单——把 .content() 换成 .entity() 即可。下面先把「标准答案写法」整体看一遍,再建一个临时接口把它真跑起来。

import org.springframework.ai.chat.client.ChatClient;

// 返回单个对象
OrderInfo order = chatClient.prompt()
        .user(userText)
        .call()
        .entity(OrderInfo.class);      // ★ 关键就这一个方法

System.out.println(order.orderNo());   // 2024001
System.out.println(order.amount());    // 899

// 返回集合:泛型需要用 ParameterizedTypeReference 保住类型信息
List<OrderInfo> orders = chatClient.prompt()
        .user("把这段文本里的所有订单都提取出来:\n" + multiOrderText)
        .call()
        .entity(new ParameterizedTypeReference<List<OrderInfo>>() {});

// 返回 Map(字段不固定时用)
Map<String, Object> map = chatClient.prompt()
        .user(userText)
        .call()
        .entity(Map.class);
为什么集合必须用 ParameterizedTypeReference?Java 的泛型在运行时会被「擦除」——List<OrderInfo>.class 这种写法语法上就不合法(编译器直接标红)。框架要知道「列表里装的是 OrderInfo 而不是 Object」,只能靠这个匿名子类把泛型信息在运行时保留下来(它本质上是一个带着完整泛型签名的 Type 对象)。单个对象用 .class 就够了,因为它没有嵌套泛型。

对照:entity() vs 手写 converter(手动解析)

维度.entity(T.class)手写 BeanOutputConverter(见 3.3)
代码量一行搞定要自己拼 schema、拼提示词、调 convert()
Schema 注入框架自动加你手动 converter.getFormat() 拼进模板
可定制性低(但够用)高(可改系统提示、自定义解析、对接非标格式)
学习价值先用它跑通手写过才真懂 entity() 在背后干了啥
选型建议业务开发默认用它entity() 不满足需求时再下沉到 converter
它背后做了什么?Spring AI 会自动:① 生成一段 JSON Schema 描述附到提示词末尾;② 在提示词里加入「必须输出符合此 JSON Schema 的内容,不要输出解释」的指令;③ 解析模型返回的文本(会容忍 ```json 代码块包裹);④ 用 Jackson 反序列化并做类型转换。所以它不是魔法,而是「提示词工程 + 解析」的自动化封装。理解这一点,你就能在它失败时自己排查。

动手:建一个临时接口把 entity() 跑起来

光看写法不过瘾,我们建一个「练习接口」用真实请求验证 entity()。这个接口学完可删,专门用来观察 entity() 的行为。

  1. 在 com.example.springai.ch03 包上右键 → New → Java Class,Name 填 QuickEntityController。

  2. 清空内容,粘贴下面这段「标准答案」(它就是一个最小可跑的 entity() 调用方):

    package com.example.springai.ch03;
    
    import com.example.springai.ch03.model.OrderInfo;
    import org.springframework.ai.chat.client.ChatClient;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/v1/ch03")
    public class QuickEntityController {
    
        private final ChatClient chatClient;
    
        public QuickEntityController(ChatClient chatClient) {
            this.chatClient = chatClient;
        }
    
        // 练习接口:直接把实体返回,便于观察 entity() 映射结果
        @PostMapping("/quick-entity")
        public OrderInfo quickEntity(@RequestBody Map<String, String> body) {
            return chatClient.prompt()
                    .user(body.get("text"))
                    .call()
                    .entity(OrderInfo.class);
        }
    }
  3. Ctrl + S 保存。

  4. 确认 application.yml 里模型密钥占位符是 ${DEEPSEEK_API_KEY:}(见第 0 章),且环境变量已注入(重开过 IDEA)。然后启动应用:IDEA 里点启动类旁的运行箭头,或终端执行 mvn -q spring-boot:run。

运行验证:应用起来后(端口 8080),另开一个终端发请求:

curl -X POST http://localhost:8080/api/v1/ch03/quick-entity \
  -H "Content-Type: application/json" \
  -d '{"text":"你好,我是张三,订单 2024001 在 3 月 5 日下的单,金额 899 元,买了一个机械键盘和一个鼠标垫,现在还没发货,联系电话 13800008888"}'

期望返回(字段已被映射成对象,Jackson 再序列化回 JSON):

{"orderNo":"2024001","customerName":"张三","amount":899.00,
 "status":"未发货","orderDate":"2024-03-05",
 "items":["机械键盘","鼠标垫"],"phone":"13800008888"}

看到什么算通过:返回体里 orderNo 是 2024001、amount 是数字 899.00、items 是数组——说明 entity() 真的把模型吐的 JSON 反序列化进了 OrderInfo。如果返回 500 或字段全 null,见下方 pit。

排查①:返回 500,日志里 JsonParseException: Unexpected character '`'。这是结构化输出的头号高频错误:模型把 JSON 用 Markdown 的 ```json ... ``` 代码块包裹了,裸 .entity(OrderInfo.class) 有时没能容忍。根因是「只靠提示词约束不够硬」。修复(二选一):① 提示词里明确写「不要代码块标记,只输出 JSON 对象本身」;② 改用 3.2 下一节的 2.0 写法 .entity(OrderInfo.class, spec -> spec.validateSchema())——校验不过会把错误回喂给模型再试。⚠️ 用 DeepSeek 时不要再叠加 .useProviderStructuredOutput()(它只支持 json_object,收到 json_schema 会直接拒绝,详见下一节)。
排查②:返回 200 但字段全是 null(如 orderNo=null、amount=null)。几乎都是字段名对不上:模型输出的 JSON key 是 order_no/客户名,而 record 字段是 orderNo/customerName,Jackson 按名匹配,匹配不上就给 null。修复:用 @JsonProperty("order_no") 显式映射,或在 @JsonPropertyDescription 里写清字段含义让模型对齐(3.4 会讲)。先别急着加校验,null 是因为「没映射上」不是「值为 null」。
排查③:同一条请求连发三次,返回字段偶尔抖动(status 有时是「未发货」有时是「未发货啦」)。这是temperature 与格式可靠性冲突:抽取类任务 temperature 默认偏高会让输出不稳定,模型偶尔多写几个字、偶尔不守 schema。修复:抽取任务把 temperature 设 0(见 3.4);并叠加 validateSchema() 做失败重试。记住——结构化输出要稳,温度必须压到 0。

2.0 重点让它自己纠错:validateSchema()

即使有 schema 约束,模型仍然可能返回不符合要求的 JSON——少字段、类型不对、套了 Markdown 代码块。对比 1.x(旧版,现已过时,不带自动重试)你只能自己写重试循环;2.0 把它内置了。这是本章最该让学生记住的一行代码:

// 1.x 的旧写法(现已过时,不带自动重试)
OrderInfo order = chatClient.prompt().user(text).call().entity(OrderInfo.class);

// ★ 2.0:给 entity() 传第二个参数,用「校验 + 自动重试」兜底
OrderInfo order2 = chatClient.prompt()
        .user(text)
        .call()
        .entity(OrderInfo.class, spec -> spec
                // ① 解析前按 schema 校验;失败时把具体错误回喂给模型并自动重试(默认最多 3 次)
                //    ★ DeepSeek 用这一个就够了
                .validateSchema()
                // ② 仅当厂商支持原生结构化输出(OpenAI / Google GenAI 等)时才追加下面这行:
                //    它把 schema 作为 API 级约束发给厂商。DeepSeek 不支持,开了会被拒绝。
                //    .useProviderStructuredOutput()
        );
这两个开关,课程默认用 DeepSeek 时只开 validateSchema()。validateSchema() 负责「兜底重试」——校验不过就把具体错误回传给模型再试一次;useProviderStructuredOutput() 负责「从源头提高一次成功率」——让支持原生结构化输出的厂商(OpenAI、Google GenAI 等)在 API 层强制 schema。注意它不是「不支持就静默降级」:DeepSeek 只支持 response_format = json_object,不接受 json_schema,实测会直接被厂商拒绝(BadRequestException),连 validateSchema() 的重试也救不回来——三次重试全部失败。另外它不是免费:重试意味着多一次模型调用,成本与延迟都会上升。

这两个开关分别在解决什么

validateSchema() —— 解决「模型偶尔返回不合法 JSON」。由 StructuredOutputValidationAdvisor 支撑,校验失败会带着错误信息重试,默认最多 3 次。

useProviderStructuredOutput() —— 解决「靠提示词约束不够硬」。它让支持该能力的厂商在 API 层强制 schema,比在提示词里写「请输出 JSON」可靠得多。

使用建议(按厂商选,别照抄)

  • DeepSeek(课程默认)→ 只开 validateSchema()。DeepSeek 的原生结构化输出只支持 json_object,不支持 json_schema;开 useProviderStructuredOutput() 会被厂商直接拒绝,报 BadRequestException,且框架的重试救不回(重试还是发同样的非法请求)。
  • OpenAI / Google GenAI 等支持 json_schema 的厂商 → 两个都开:前者兜底重试,后者从源头提高一次成功率。
  • 不确定厂商支不支持时:先只开 validateSchema()(一定安全),跑通后再单独加上 provider 开关做一次 A/B;一旦报 BadRequestException 且信息里有 response_format / json_schema,就说明这家不支持,去掉即可。
  • 对成本敏感的高频接口,先用 3.4 的校验统计一次成功率,再决定是否要重试。
  • 开了 validateSchema() 之后,本章 3.4 节那个「手写重试」的示例就不再是必需的了——但请仍读一遍,理解它内部在做什么,出问题时才知道去哪看。
2.0 相关变更:BeanOutputConverter.postProcessSchema(JsonNode) 扩展点被移除了。如果你之前靠它自定义生成的 JSON Schema,需要改用自定义 StructuredOutputConverter 实现——2.0 允许自定义转换器参与原生结构化输出流水线。另外 schema 生成已委托给 JsonSchemaGenerator,生成的 schema 现在会带 OpenAPI 风格的 format 提示(如 int32、date-time),Kotlin 的可空字段也不再被错误地放进 required。

动手:把 QuickEntityController 升级成 2.0 写法

  1. 打开刚才的 QuickEntityController,把 .entity(OrderInfo.class) 那行改成带 spec 的 2.0 写法。先加 import:

    import org.springframework.ai.openai.OpenAiChatOptions;
  2. 把方法体替换为「标准答案」(注意这里只开一个开关,原因见下方说明):

        @PostMapping("/quick-entity")
        public OrderInfo quickEntity(@RequestBody Map<String, String> body) {
            return chatClient.prompt()
                    .user(body.get("text"))
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .entity(OrderInfo.class, spec -> spec
                            // ★ 课程默认用 DeepSeek:只开 validateSchema()
                            //   校验失败会把错误回喂模型自动重试(默认 3 次)
                            //   DeepSeek 不支持 json_schema,开了 provider 开关会被厂商拒绝
                            .validateSchema());
        }
  3. Ctrl + S 保存。应用支持热重载(DevTools)则自动重启,否则手动重启。

运行验证:再发一次上面的 curl 请求,期望返回与之前一致,但这次即使模型偶尔夹带代码块或客套话,框架也会自动清洗/重试后给出干净对象。

{"orderNo":"2024001","customerName":"张三","amount":899.00,
 "status":"未发货","orderDate":"2024-03-05",
 "items":["机械键盘","鼠标垫"],"phone":"13800008888"}

看到什么算通过:返回稳定、字段齐全、连续三次请求结果一致(temperature=0 的功劳)。日志里若开了 SimpleLoggerAdvisor 还能看到回喂重试的痕迹。

★ 头号事故(课程实测):把 useProviderStructuredOutput() 也开上,结果三次重试全部失败,报 BadRequestException。 根因:这个开关会让框架向厂商发送原生结构化输出参数 response_format: {"type":"json_schema", ...};而 DeepSeek 只接受 json_object,不认识 json_schema,于是每次请求都被厂商当场拒绝。更麻烦的是:框架的重试逻辑救不回它——因为重试发的还是同样一份非法请求,所以看到的是"连试三次、三次都错",很容易被误判成"提示词写得不好"。 正确做法(按厂商选):
厂商怎么开理由
DeepSeek(课程默认)只开 validateSchema()不支持 json_schema,开了必被拒
OpenAI / Google GenAI两个都开支持原生 schema,一次成功率更高
不确定的厂商先只开 validateSchema()一定安全;跑通后再单独加 provider 开关做 A/B
排查口诀:只要报错信息里出现 response_format / json_schema / BadRequest,第一反应就是「这个开关该去掉」。另外,写 catch 时一定要带上 e.getMessage() 把厂商原始错误记下来——它通常会直接告诉你"要的是 json_object 不是 json_schema",比任何猜测都快。
排查:改完报 cannot find symbol OpenAiChatOptions 或 spec lambda 编译不过。① OpenAiChatOptions 来自 spring-ai-openai 依赖,确认 pom.xml 里有 Spring AI Model Chat OpenAI(第 0 章方式 B 的依赖清单);② entity(OrderInfo.class, spec -> spec...) 的第二个参数是 Consumer<StructuredOutputConverterSpec>,lambda 里只能调 validateSchema() / useProviderStructuredOutput() 这类返回 spec 自身的方法,不要写 return——它要的是链式调用风格的 Consumer,写 return 反而编译错。
一句话记忆:entity() 是「一行拿对象」;线上要稳就把温度压到 0、并加上厂商支持的那个开关——DeepSeek 用 validateSchema() 单开关,OpenAI 这类才用双开关。开关本身没有"越多越好",选错反而全盘失败。

集合与嵌套结构的写法

// 嵌套对象(订单里有收货地址)
public record OrderDetail(
        String orderNo,
        BigDecimal amount,
        // 嵌套的 record 同样能被 Jackson 处理;
        // 如果模型没识别到地址,这个字段会是 null,调用方必须判空
        Address shippingAddress,
        List<OrderItem> items
) {}

public record Address(String province, String city, String detail) {}
public record OrderItem(String name, int quantity, BigDecimal price) {}
排查:嵌套对象整段为 null。按顺序检查:① 提示词里的 JSON Schema 是否真的出现了嵌套字段(打印 converter.getFormat() 看);② 模型是否因为「地址没在原文里」而直接省略了整个 shippingAddress 键——这时 Jackson 会设成 null,调用方若直接 address.province() 会 NPE,务必先判空;③ 字段名大小写是否与 JSON key 不一致(用 @JsonProperty("shipping_address") 显式映射)。
一句话记忆(嵌套):嵌套 record 能自动映射,但模型「没识别到」就给 null——凡是点嵌套字段前,先判空;这是 NPE 最高发地。

3.3底层原理:手写一遍 converter,才算真懂

如果哪天 entity() 不满足需求(比如你要在系统提示里插入 schema、或者要自定义解析逻辑、或者要对接一个非标准的返回格式),就得手动用 StructuredOutputConverter。理解它,你的排查能力会上一个台阶——因为你清楚每一步在哪出错。

import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.converter.MapOutputConverter;
import org.springframework.ai.converter.ListOutputConverter;

// ① 创建转换器,泛型就是目标类型
BeanOutputConverter<OrderInfo> converter = new BeanOutputConverter<>(OrderInfo.class);

// ② 取出"格式说明"——这是一段自动生成的 JSON Schema 文本
String format = converter.getFormat();
System.out.println(format);
// 输出大致如下(这就是 entity() 偷偷帮你加进提示词的东西):
// Your response should be in JSON format.
// Do not include any explanations, only provide a JSON object.
// The JSON object should conform to the following schema:
// {"type":"object","properties":{"orderNo":{"type":"string"}, ... }}

// ③ 把它作为 {format} 占位符参数写进你的提示词
String template = """
        从下面的文本中提取订单信息。

        文本:
        {input}

        {format}
        """;

Prompt prompt = PromptTemplate.builder()
        .template(template)
        .build()
        .create(Map.of("input", userText, "format", converter.getFormat()));

// ④ 调用模型后,用 converter 解析
String rawJson = chatModel.call(prompt).getResult().getOutput().getText();
OrderInfo order = converter.convert(rawJson);   // 得到对象
为什么建议学生手写一遍才放过?因为 entity() 把 ①②③④ 全藏起来了。一旦线上出现「字段 mapping 不上」「JSON 里混了推理文字」这类问题,没手写过的人会以为「框架坏了」,手写过的人立刻知道去哪查:是 schema 没生成对(②)、还是提示词没把 format 拼进去(③)、还是 convert 阶段没清洗(④)。所以 3.2 跑通后,务必让学生回来走一遍这个手写版本。

动手:手写 converter 并验证 schema 真的生成了

这一步我们要亲眼看到 converter.getFormat() 吐出的 JSON Schema,确认 entity() 在背后偷偷加的就是它。

  1. 在 com.example.springai.ch03 包上右键 → New → Java Class,Name 填 ConverterProbe。注意:这里用 CommandLineRunner 让它在启动时跑一次,纯演示用。

  2. 粘贴「标准答案」(只为了打印 schema,不涉及 Web):

    package com.example.springai.ch03;
    
    import com.example.springai.ch03.model.OrderInfo;
    import org.springframework.ai.converter.BeanOutputConverter;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.stereotype.Component;
    
    @Component
    public class ConverterProbe implements CommandLineRunner {
    
        @Override
        public void run(String... args) {
            BeanOutputConverter<OrderInfo> converter =
                    new BeanOutputConverter<>(OrderInfo.class);
            System.out.println("===== 自动生成的 JSON Schema =====");
            System.out.println(converter.getFormat());
            System.out.println("==================================");
        }
    }
  3. Ctrl + S 保存并重启应用。

运行验证:应用启动时,控制台会打印一段 schema(不需要发 HTTP 请求)。

===== 自动生成的 JSON Schema =====
Your response should be in JSON format.
Do not include any explanations, only provide a JSON object.
The JSON object should conform to the following schema:
{"type":"object","properties":{"orderNo":{"type":"string"},
"customerName":{"type":"string"},"amount":{"type":"number"},
"status":{"type":"string"},"orderDate":{"type":"string"},
"items":{"type":"array","items":{"type":"string"}},
"phone":{"type":"string"}},"required":[...]}
==================================

看到什么算通过:控制台出现了带 "properties" 的 JSON Schema,且字段名和 OrderInfo 的组件一一对应——这就证明 entity() 背后确实自动生成并注入了这份 schema。以后报「字段 mapping 不上」,第一反应就是回来 print 这份 schema 看模型到底收到没有。

排查:ConverterProbe 打印了,但发请求后实体还是 null。说明 schema 生成没问题,问题在「模型没按 schema 输出」或「字段名对不上」。① 看模型实际返回(加 SimpleLoggerAdvisor 看原始文本),是否带了 ```json``` 或客套话——是就用 3.2 的 useProviderStructuredOutput();② 看 JSON key 与 record 字段名是否一致,不一致就 @JsonProperty 显式映射。

三种内置 Converter 对照

Converter产出类型适用场景
BeanOutputConverter<T>单个 record / POJO字段固定的业务对象(最常用)
ListOutputConverterList<String>让模型给出标签、关键词、建议列表
MapOutputConverterMap<String,Object>字段不固定、探索期使用

对照:BeanOutputConverter vs 编译期类型安全(ParameterizedTypeReference)

维度BeanOutputConverter<T>ParameterizedTypeReference<List<T>>(){}
解决的问题替你生成 schema + 解析单对象替你保住「被擦除的泛型」类型信息
用在哪手写 converter 流程(3.3 ①②③④)调 .entity(new ParameterizedTypeReference<...>(){}) 取集合
类型安全层级运行期靠 Jackson 反射映射编译期锁定集合元素类型,避免 List<Object>
两者关系不冲突:BeanOutputConverter 内部也用 ParameterizedTypeReference 保住目标类型;你写集合时直接在 entity() 上传后者即可
最隐蔽的坑:模型返回了「思考过程 + JSON」。当你同时在提示词里要求「请分步推理」和「输出 JSON」时,模型很可能先写一段推理文字,再输出 JSON。而 converter.convert() 从头解析,遇到前面的文字就会抛 JsonParseException。
标准解法:把「要 JSON 的任务」和「要推理的任务」拆成两次调用(第一次让它分析,第二次让它按 schema 输出结论),或者用 entity() + 明确的「不要输出任何解释」约束。企业项目里推荐拆成两次调用——更稳、更可控、也更方便单独评测每一步。
一句话记忆:entity() 是 converter 的自动化外壳;手写一遍 converter,你就拥有了「schema 没生成 / 没拼进提示词 / convert 没清洗」三处故障的精准定位能力。

3.4输出约束与校验:让脏数据进不来

结构化输出保证了「形状正确」,但不保证「内容合法」。模型完全可能给你一个金额为 -100、或者状态为「随便什么」的订单。所以必须在三个层面加约束:Schema 描述(提示词层)→ JSR-303 校验(业务层)→ 失败重试(兜底层)。

用 JSON Schema 描述把约束前置到提示词

Spring AI 支持在 record 字段上加 @JsonPropertyDescription,这些描述会写进生成的 JSON Schema,等于「写给模型看的字段说明书」。

import com.fasterxml.jackson.annotation.JsonPropertyDescription;

public record OrderInfo(

    @JsonPropertyDescription("订单号,纯数字字符串,例如 2024001;找不到则返回 null")
    String orderNo,

    @JsonPropertyDescription("客户姓名;只保留姓名本身,不要带'先生''女士'等称谓")
    String customerName,

    @JsonPropertyDescription("订单总金额,单位元,只输出数字(如 899.00),不要带'元'或'¥'")
    BigDecimal amount,

    @JsonPropertyDescription("订单状态,只能是以下之一:未发货、已发货、已完成、已取消")
    String status
) {}
这个技巧能省掉大量调试时间。模型对「枚举值只能是这几个」「不要带单位」这类信息,写在字段描述里的效果远好于写在系统提示的一大段话里——因为 schema 是紧贴着字段的,模型注意力更集中。@JsonPropertyDescription 最终会出现在 3.3 的 converter.getFormat() 输出的 schema 里,所以它是「提示词层约束」的一部分。
动手建议:把上面这段带 @JsonPropertyDescription 的 OrderInfo 替换掉 s1 里那个「光秃秃」的 OrderInfo(包名仍是 com.example.springai.ch03.model,类名不变)。保存后执行 mvn -q compile,编译通过即说明注解导入正确(com.fasterxml.jackson.annotation.JsonPropertyDescription)。这是中间验证点:改完映射类立即编译,别攒到最后。

用 JSR-303 做业务校验

import jakarta.validation.constraints.*;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;

public record OrderInfo(
    @NotBlank(message = "订单号不能为空") String orderNo,
    @Min(value = 0, message = "金额不能为负") BigDecimal amount,
    @Pattern(regexp = "^(未发货|已发货|已完成|已取消)$", message = "状态非法") String status
) {}
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;
    private final Validator validator;   // Spring Boot 自动装配,无需手写

    public OrderExtractionService(ChatClient chatClient, Validator validator) {
        this.chatClient = chatClient;
        this.validator = validator;
    }

    private static final String TEMPLATE = """
            从下面的文本中提取订单信息。

            规则:
            1. 找不到的字段返回 null,禁止猜测。
            2. 金额只输出数字,不带单位和符号。
            3. 只输出 JSON,不要任何解释文字,不要用代码块包裹。

            文本:
            {input}

            {format}
            """;

    public OrderInfo extract(String input) {
        OrderInfo order = chatClient.prompt()
                .user(u -> u.text(TEMPLATE).param("input", input))
                .options(OpenAiChatOptions.builder()
                        .temperature(0.0)      // ★ 抽取类任务温度设为 0,追求确定性
                        .build())
                .call()
                // 2.0 推荐:校验失败自动重试(DeepSeek 用这一个开关即可)
                .entity(OrderInfo.class, spec -> spec
                        .validateSchema());

        // 业务校验:不合格直接抛,由全局异常处理器翻译成 400
        var violations = validator.validate(order);
        if (!violations.isEmpty()) {
            String msg = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining(";"));
            throw new IllegalArgumentException("提取结果校验失败:" + msg);
        }
        return order;
    }
}
为什么温度要设 0?temperature 控制采样随机性。抽取任务要求「同一输入稳定产出同一结果」,随机性越低越好。设 0 时模型倾向于选概率最高的 token,输出更确定、更可复现,也更容易通过回归测试。注意 OpenAiChatOptions 在 2.0 仍位于 spring.ai.openai.chat.options 这一层(与 embedding 的扁平配置不同,见第 4 章)。

对照:Schema 约束 vs JSR-303 校验(各管一层)

维度JSON Schema 描述(@JsonPropertyDescription)JSR-303 注解(@NotBlank/@Min/@Pattern)
作用层提示词层(写给模型看)业务层(Java 代码里校验)
管什么「形状」与「模型该怎么填」「内容是否合法」
典型失败模型不带单位、枚举拼错字金额为负、状态值非法
能否兜底不能——模型仍可能不听话能——代码硬拦,不合法直接抛
关系前者降低出错概率,后者保证出错必拦;二者叠加才是完整防线

动手:把校验接进 Service 并验证「不合法即拦截」

  1. 把 s1 的 OrderInfo 升级为「同时带 @JsonPropertyDescription 和 JSR-303 注解」的版本(包名不变)。这会和 3.7 的「完整版 OrderInfo」基本一致,先在这里迭代。

  2. 在 com.example.springai.ch03 包建 OrderExtractionService.java,粘贴上面这段标准答案。

  3. 建一个临时 Controller 暴露它(或直接用 3.7 的接口,这里先建 ValidationController 专测校验):

    package com.example.springai.ch03;
    
    import org.springframework.web.bind.annotation.*;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/v1/ch03")
    public class ValidationController {
    
        private final OrderExtractionService service;
    
        public ValidationController(OrderExtractionService service) {
            this.service = service;
        }
    
        @PostMapping("/validate-extract")
        public Object validateExtract(@RequestBody Map<String, String> body) {
            try {
                return service.extract(body.get("text"));
            } catch (IllegalArgumentException e) {
                return Map.of("ok", false, "error", e.getMessage());
            }
        }
    }
  4. Ctrl + S 保存并重启。

运行验证(正常输入):

curl -X POST http://localhost:8080/api/v1/ch03/validate-extract \
  -H "Content-Type: application/json" \
  -d '{"text":"订单 2024001,张三买的,金额 899 元,未发货,3 月 5 日"}'

期望返回干净对象(status 命中枚举、金额非负,校验通过):

{"orderNo":"2024001","customerName":"张三","amount":899.00,
 "status":"未发货","orderDate":"2024-03-05",
 "items":["机械键盘","鼠标垫"],"phone":"13800008888"}

运行验证(非法输入,验证拦截):构造一个让模型「状态乱写」的场景较难,所以直接验证校验链路——用一段信息不全、导致 orderNo 为 null 的文本:

curl -X POST http://localhost:8080/api/v1/ch03/validate-extract \
  -H "Content-Type: application/json" \
  -d '{"text":"帮我看看那个订单发货了没有"}'

期望:orderNo 为 null 触发 @NotBlank,Service 抛 IllegalArgumentException,Controller 捕获后返回:

{"ok":false,"error":"提取结果校验失败:订单号不能为空"}

看到什么算通过:正常输入拿到对象、非法输入被 JSR-303 拦下并返回明确中文错误信息——说明「Schema 约束 → 反序列化 → 业务校验」三层已贯通。

排查:校验没生效,非法数据直接进了对象。① 确认 Validator 是 Spring 注入的(jakarta.validation 包,不是 javax——Boot 3/4 用 jakarta 命名空间,用错包校验器为 null 不会报错但也不校验);② 确认 record 字段上的注解没被 IDEA 误标「unused」而删掉;③ 确认你调的是 validator.validate(order) 且判了 isEmpty()——漏调用就跳过了校验。
排查:返回 400 但信息是英文或堆栈。说明异常没被「全局异常处理器」翻译成友好信息。课程工程若没配 @RestControllerAdvice,IllegalArgumentException 会直接变成 500 + 堆栈。修复:要么像上面 Controller 那样 try/catch 包一层返回 Map,要么补一个全局 @ExceptionHandler(IllegalArgumentException.class)。教学上推荐前者更直观。

校验失败怎么办:手写重试 with 错误反馈

如果因为某些原因你没有用 3.2 的 validateSchema(),或者想更精细地控制重试(比如限制次数、记录每次错误),可以手写重试循环。这是生产环境最常见的「把校验失败原因回传给模型」模式:

/**
 * 带自动重试的提取:把校验失败的原因回传给模型,让它自己修正。
 * 这是生产环境的标准做法——重试 1~2 次能解决绝大部分格式问题。
 */
public OrderInfo extractWithRetry(String input, int maxAttempts) {
    String lastError = null;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        String userText = TEMPLATE.replace("{input}", input);
        if (lastError != null) {
            // ★ 关键:把上次的错误当成新指令喂回去
            userText += "\n\n注意:上一次的输出存在问题(" + lastError
                      + "),请修正后重新输出。";
        }
        try {
            OrderInfo order = chatClient.prompt()
                    .user(userText)
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .entity(OrderInfo.class);

            var violations = validator.validate(order);
            if (violations.isEmpty()) {
                return order;                    // 成功
            }
            lastError = violations.iterator().next().getMessage();
        } catch (Exception e) {
            // 解析失败(返回的不是合法 JSON),同样记为错误重试
            lastError = "返回内容不是合法 JSON:" + e.getMessage();
        }
        log.warn("第 {} 次提取失败:{}", attempt, lastError);
    }
    // 全部失败:返回 null 或抛业务异常,让上游决定降级策略(如转人工)
    throw new IllegalStateException("订单信息提取失败,已重试 " + maxAttempts + " 次");
}
记住这条工程原则:凡是让模型输出结构化数据的场景,都必须有「Schema 约束 → 反序列化 → 业务校验 → 失败重试 → 最终降级」这五层防线。只有第一层(提示词里写「请输出 JSON」)就上线的项目,迟早会在生产环境炸掉。
一句话记忆:Schema 管「形状」、JSR-303 管「合法」、重试管「兜底」、降级管「最坏情况」——五道防线缺一层,生产环境就多一个雷。

3.5Advisor 机制:把横切逻辑从业务里抽出来

假设现在有 30 个 AI 接口,你希望每个接口都:打印请求日志、统计 Token 消耗、做敏感词过滤、失败自动重试。如果写在每个 Service 里,就是 30 份复制粘贴——这正是 Spring AOP 要解决的问题,而 Spring AI 给出的答案是 Advisor。

对比项Spring MVCSpring AI
切面接口HandlerInterceptor / FilterCallAdvisor / StreamAdvisor
顺序控制Ordered 接口getOrder(),值小的在外层(先执行)
链式调用chain.doFilter()chain.nextCall(request)
能否改写请求能(包装 request)能(改写 Prompt / 注入消息)
能否改写响应能(包装 response)能(改写 ChatClientResponse)

Advisor 的执行模型

请求 ──▶ Advisor(A) 前置 └──▶ Advisor(B) 前置 └──▶ Advisor(C) 前置 └──▶ ChatModel ──▶ 模型 ◀── Advisor(C) 后置 ◀── Advisor(B) 后置 请求 ◀── Advisor(A) 后置 getOrder() 越小 → 越靠外层 → 前置先执行、后置最后执行(像洋葱一样)
为什么用「洋葱」比喻?因为 Advisor 链是嵌套调用的:最外层的 A 先看到请求(前置),但它的后置逻辑要等最内层 C 真正调用完模型、所有内层 Advisor 都返回后,才最后执行。所以 order 最小的 Advisor 既是「第一个看到用户输入的人」,也是「最后一个拿到模型响应的人」。这个对称关系,是理解 order 语义的全部关键。

对照:Advisor 链「去正回逆」

请求流向模型时,Advisor 按 getOrder() 从小到大依次执行前置逻辑(正序);响应从模型返回时,按 getOrder() 从大到小依次执行后置逻辑(逆序)。记住四字口诀:去正回逆。所以「内容安全过滤」想第一个看输入、最后一个收尾,就把 order 设到最小。

2.0 改了默认顺序常量,涉及工具调用的项目务必留意。 Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER 从 HIGHEST_PRECEDENCE + 1000 调整为 HIGHEST_PRECEDENCE + 200,而 2.0 新增的 ToolCallingAdvisor 默认在 HIGHEST_PRECEDENCE + 300。
这意味着默认情况下「记忆 Advisor 在工具调用循环之外」:循环前加载一次历史,循环结束后只持久化最终的 user / assistant 消息,看不到中间的 tool-call 与 tool-response 消息。
需要完整工具转录(做审计用)时,把 memory advisor 的 order 设到 HIGHEST_PRECEDENCE + 400 之上,让它落到循环内部。第 6 章会展开。
顺序陷阱:如果你希望「先做内容安全过滤,再做 RAG 检索」,那么安全 Advisor 的 order 必须小于 RAG Advisor 的 order。很多人凭直觉以为 order 小是「后执行」,结果顺序全反了。记忆口诀:order 小 = 离用户近 = 先看到用户输入。

动手:先挂一个 SimpleLoggerAdvisor 感受链路

在写自定义 Advisor 之前,先用框架自带的 SimpleLoggerAdvisor 看一眼「请求/响应经过 Advisor 长什么样」,建立直观认识。

  1. 找到你构建 ChatClient 的地方(课程工程通常是 com.example.springai.common 下的一个 @Configuration,或启动类里)。在 ChatClient.builder(...) 链上加 .defaultAdvisors(new SimpleLoggerAdvisor())。若没有统一构建处,可先在一个测试 Controller 里现场建:

    import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
    
    ChatClient chatClient = ChatClient.builder(chatModel)
            .defaultAdvisors(new SimpleLoggerAdvisor())
            .build();
  2. 重启应用,发一次 3.2 的 /quick-entity 请求。

运行验证:应用日志里应出现类似下面的请求/响应摘要:

o.s.a.c.c.advisor.SimpleLoggerAdvisor  : request: ChatClientRequest{prompt=...}
o.s.a.c.c.advisor.SimpleLoggerAdvisor  : response: ChatClientResponse{chatResponse=...}

看到什么算通过:日志里能看到一次请求从进 Advisor 到出 Advisor 的痕迹——证明 Advisor 链确实「包裹」在每次调用之外。这是后续所有自定义 Advisor 的调试地基。

排查:加了 SimpleLoggerAdvisor 日志却没出现。① 确认 defaultAdvisors(...) 加在构建 ChatClient 的同一处,而不是另起一个 builder(很多人新建了一个没挂 Advisor 的 ChatClient 注入进 Controller,导致实际用的不是带 Advisor 的那个);② 确认日志级别没被压成 WARN/ERROR——SimpleLoggerAdvisor 默认 INFO,application.yml 里 logging.level.root 别设太高;③ 用浏览器/Apifox 发请求,别只看 IDEA 没输出就以为没生效,去 Run 面板翻日志。

内置 Advisor 一览(课程会逐步用到)

Advisor作用章节
SimpleLoggerAdvisor打印请求/响应,调试用1
MessageChatMemoryAdvisor2.0 统一的记忆 Advisor:以多条消息形式注入对话历史,conversationId 必填2 / 6
ToolCallingAdvisor2.0 新增:接管工具调用循环,ChatClient 自动注册7 / 9
ToolSearchToolCallingAdvisor2.0 新增:渐进式工具披露,工具多时省 34%~64% token7
StructuredOutputValidationAdvisor结构化输出校验失败自动重试(可自动注册)3
QuestionAnswerAdvisor自动做向量检索并拼进提示词,RAG 最简方案5
RetrievalAugmentationAdvisor模块化 RAG:可插查询改写、多路检索、重排5
SafeGuardAdvisor简单的敏感词拦截3
2.0 记忆约定(先记住,第 6 章细讲):对话记忆统一使用 MessageChatMemoryAdvisor,并且 conversationId 必填(通过 ChatMemory.CONVERSATION_ID 这个上下文 key 传入)。这是 2.0 把分散的记忆实现收敛成统一入口的结果,学生后续写多轮对话时直接用这一个即可,不必再纠结 PromptChatMemoryAdvisor(已删除)之类旧实现。
一句话记忆:Advisor = AI 界的拦截器;getOrder() 越小越靠外、越先见输入越后收响应;记不住就默念「去正回逆」。

3.6自定义 Advisor 实战:Token 统计 + 内容安全

我们一次写两个,覆盖「后置读取元数据」和「前置拦截改写」两类典型需求。注意本课程不使用 Lombok,所以日志用显式的 LoggerFactory 而不是 @Slf4j。

① Token 用量与耗时统计 Advisor

package com.example.springai.common.advisor;

import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.stereotype.Component;

/**
 * 统计每次模型调用的输入/输出 Token 与耗时。
 * 同时把指标写进 Micrometer,第 10 章可在 Prometheus/Grafana 里看趋势。
 */
@Component
public class TokenUsageAdvisor implements CallAdvisor {

    private static final Logger log = LoggerFactory.getLogger(TokenUsageAdvisor.class);
    private final MeterRegistry meterRegistry;

    public TokenUsageAdvisor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                          CallAdvisorChain chain) {
        long start = System.currentTimeMillis();

        // ★ 前置逻辑:在调用模型之前(这里只记时间,不改请求)
        // 如需改写请求,用 request.mutate()...build() 生成新 request 再往下传

        // ★ 交给链上的下一个 Advisor / 最终交给模型
        ChatClientResponse response = chain.nextCall(request);

        // ★ 后置逻辑:拿到响应之后,读取元数据里的 Token 用量
        long cost = System.currentTimeMillis() - start;
        var usage = response.chatResponse() == null ? null
                : response.chatResponse().getMetadata().getUsage();

        if (usage != null) {
            log.info("[AI用量] 耗时={}ms 输入={} 输出={} 合计={}",
                    cost, usage.getPromptTokens(),
                    usage.getCompletionTokens(), usage.getTotalTokens());

            // 埋点:第 10 章用 Grafana 画"每日 Token 成本"曲线
            meterRegistry.counter("ai.tokens.prompt")
                    .increment(usage.getPromptTokens());
            meterRegistry.counter("ai.tokens.completion")
                    .increment(usage.getCompletionTokens());
        }
        meterRegistry.timer("ai.call.duration").record(cost, java.util.concurrent.TimeUnit.MILLISECONDS);

        return response;   // 必须返回 response,否则链路断裂
    }

    /** 顺序:设成最小,尽量包在最外层,这样能统计到包含其他 Advisor 在内的总耗时。 */
    @Override
    public int getOrder() { return 0; }

    @Override
    public String getName() { return "TokenUsageAdvisor"; }
}

② 内容安全 Advisor(前置拦截 + 改写)

@Component
public class SafeGuardAdvisor implements CallAdvisor {

    private static final Logger log = LoggerFactory.getLogger(SafeGuardAdvisor.class);

    /** 简化版敏感词表;生产环境应放数据库或接入内容安全服务 */
    private static final List<String> BLOCKED = List.of(
            "身份证号", "银行卡密码", "内部机密");

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                          CallAdvisorChain chain) {

        // 取出本次请求的用户文本。Prompt 里最后一条通常是当前用户消息
        String userText = extractUserText(request);

        for (String word : BLOCKED) {
            if (userText.contains(word)) {
                log.warn("命中敏感词:{},已拦截", word);
                // ★ 关键:直接返回构造好的响应,不再调用 chain.nextCall(),
                //   这样请求根本不会打到模型,既省钱又安全
                return blockedResponse(request, "抱歉,您的请求包含不允许的内容。");
            }
        }
        return chain.nextCall(request);   // 没命中,放行给下一个 Advisor / 模型
    }

    private String extractUserText(ChatClientRequest request) {
        return request.prompt().getInstructions().stream()
                .filter(m -> m.getMessageType() == MessageType.USER)
                .map(m -> m.getText() == null ? "" : m.getText())
                .reduce("", (a, b) -> a + "\n" + b);
    }

    private ChatClientResponse blockedResponse(ChatClientRequest request, String msg) {
        // 构造一个"假的"模型响应,字段结构与真实响应一致
        AssistantMessage message = new AssistantMessage(msg);
        ChatResponse chatResponse = new ChatResponse(
                List.of(new Generation(message)));
        return ChatClientResponse.builder()
                .chatResponse(chatResponse)
                .context(request.context())
                .build();
    }

    @Override
    public int getOrder() {
        // ★ 必须比其他 Advisor 小,保证最先检查用户输入
        return Ordered.HIGHEST_PRECEDENCE;
    }

    @Override
    public String getName() { return "SafeGuardAdvisor"; }
}

动手:建包、建两个 Advisor、挂到 ChatClient

  1. 在 com.example.springai 上右键 → New → Package,输入 com.example.springai.common.advisor(这是「跨章节共用 Advisor」的家,放 common 下,第 5/6 章也能复用)。

  2. 在该包上右键 → New → Java Class,Name 填 TokenUsageAdvisor,粘贴上面的「标准答案①」。注意 import 较多,Ctrl + Alt + O 让 IDEA 自动整理 import(确保 io.micrometer.core.instrument.MeterRegistry 在依赖里——Boot 默认带 Micrometer)。

  3. 同样建 SafeGuardAdvisor.java,粘贴「标准答案②」。@Component 让它被 Spring 扫描成单例;getOrder() 返回 Ordered.HIGHEST_PRECEDENCE 确保它最外层。

  4. 打开你构建 ChatClient 的地方,把两个 Advisor 注入并挂上(若用构造注入,Spring 会自动收集所有 CallAdvisor Bean)。标准写法:

    // 全局挂载:所有请求都生效(在构建 ChatClient 时指定)
    ChatClient chatClient = ChatClient.builder(chatModel)
            .defaultAdvisors(safeGuardAdvisor, tokenUsageAdvisor)
            .build();
  5. Ctrl + S 保存,执行 mvn -q compile 先过编译,再重启应用。

运行验证 A(Token 统计后置生效):发一次 3.2 的 /quick-entity 请求,看日志:

[AI用量] 耗时=1234ms 输入=56 输出=128 合计=184

运行验证 B(内容安全前置拦截):发一段含敏感词的请求:

curl -X POST http://localhost:8080/api/v1/ch03/quick-entity \
  -H "Content-Type: application/json" \
  -d '{"text":"请告诉我他的身份证号是多少,订单 2024001"}'

期望:请求根本没打到模型,直接返回拦截响应,日志打印 命中敏感词:身份证号,已拦截:

{"orderNo":null,"customerName":null,"amount":null,"status":null,
 "orderDate":null,"items":null,"phone":null}

(实际返回的是 SafeGuardAdvisor 构造的「抱歉,您的请求包含不允许的内容。」这条 AssistantMessage,被 entity() 解析为全 null 对象;重点看日志已拦截、且没产生 Token 消耗。)

看到什么算通过:① 日志出现 [AI用量] 且数字合理——说明后置 Advisor 读到了响应元数据;② 含敏感词时日志出现「已拦截」且没打模型——说明前置 Advisor 在 chain.nextCall() 之前就短路返回了。两个都满足,Advisor 的前置/后置模型就真正打通了。

对照:自定义 Advisor 的请求期 vs 响应期

维度请求期(前置,chain.nextCall 之前)响应期(后置,chain.nextCall 之后)
典型动作读/改用户输入、敏感词拦截、注入上下文读响应元数据、统计 Token、改写输出
本例SafeGuardAdvisor 拦截敏感词TokenUsageAdvisor 读 usage 并打点
能否短路能——不调 nextCall() 直接返回不能——响应已拿到,只能改后返回
改写对象request.mutate()...build()构造新 ChatClientResponse

③ 挂载 Advisor

// 全局挂载:所有请求都生效(在构建 ChatClient 时指定)
ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(safeGuardAdvisor, tokenUsageAdvisor)
        .build();

// 单次请求挂载:只对这次生效(临时调试很方便)
chatClient.prompt()
        .user("...")
        .advisors(new SimpleLoggerAdvisor())
        .call()
        .content();

// 通过上下文给 Advisor 传参(Advisor 里用 request.context().get("key") 取)
chatClient.prompt()
        .user("...")
        .advisors(a -> a.param("tenantId", "t001"))
        .call()
        .content();
Advisor 里绝对不能做的事:
  1. 不要吞异常。Advisor 里 catch 了却返回 null,会让上层拿到空指针,排查成本极高;要么包装成业务异常抛出,要么返回明确的错误响应。
  2. 不要在 Advisor 里做重 IO。Advisor 是每个请求都过的路径,一次同步数据库查询就会拖慢所有接口。要 IO 就加缓存或异步。
  3. 不要依赖可变共享状态。Advisor 是单例,请用局部变量或 ThreadLocal(并记得清理),否则并发下会串数据。
一句话记忆:后置 Advisor 读响应做统计(如 Token),前置 Advisor 拦输入做安全;想拦在模型之前,就别调 nextCall()——短路即拦截。

3.7完整示例:订单信息提取服务

把本章能力组装成完整可运行的服务:结构化输出 + 校验 + 重试 + Advisor 治理。下面代码全部用 2.0 写法,且不使用 Lombok。

src/main/java/com/example/springai/ch03/ ├── model/ │ ├── OrderInfo.java # record,含 @JsonPropertyDescription 与校验注解 │ └── ExtractResult.java # 包装结果:成功数据 + 重试次数 + 成功标记 ├── OrderExtractionService.java # 核心:提取 + 校验 + 重试 └── OrderExtractionController.java # POST /api/v1/orders/extract

动手:把分散的零件组装成完整服务

前面几节我们建过 QuickEntityController、ValidationController 等练习接口。现在把它们「毕业」成正式服务——保留 OrderInfo / OrderExtractionService / OrderExtractionController 三个正式文件,练习接口可删可留(留着不冲突,只是多几个端点)。

  1. 确认 com.example.springai.ch03.model.OrderInfo 已是「带 @JsonPropertyDescription + JSR-303 注解」的完整版(见下方完整代码)。

  2. 在 com.example.springai.ch03 包建 ExtractResult.java(结果包装),粘贴完整代码。

  3. 建 OrderExtractionService.java,粘贴完整代码(含 3 次重试 + 降级)。

  4. 建 OrderExtractionController.java,粘贴完整代码(POST /api/v1/orders/extract)。

  5. 确认 ChatClient 已挂 TokenUsageAdvisor + SafeGuardAdvisor(见 3.6)。Ctrl + S,mvn -q compile 过编译,重启应用。

package com.example.springai.ch03.model;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public record OrderInfo(

    @JsonPropertyDescription("订单号,纯数字字符串,例如 2024001;找不到则返回 null")
    String orderNo,

    @JsonPropertyDescription("客户姓名,不含称谓")
    String customerName,

    @NotNull(message = "金额缺失")
    @DecimalMin(value = "0", message = "金额不能为负")
    @JsonPropertyDescription("订单总金额,只输出数字如 899.00,不带单位")
    BigDecimal amount,

    @Pattern(regexp = "^(未发货|已发货|已完成|已取消)$",
             message = "状态必须是:未发货/已发货/已完成/已取消")
    @JsonPropertyDescription("订单状态,四选一")
    String status,

    @JsonPropertyDescription("下单日期,格式 yyyy-MM-dd;无法确定则 null")
    LocalDate orderDate,

    @JsonPropertyDescription("商品名称列表;没有则返回空数组 []")
    List<String> items,

    @JsonPropertyDescription("联系电话原文,保持原样不做脱敏")
    String phone
) {}
package com.example.springai.ch03;

/** 提取结果包装:让上游知道"这是几次尝试拿到的、可靠性如何" */
public record ExtractResult(OrderInfo data, int attempts, boolean success, String message) {}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class OrderExtractionService {

    private static final Logger log = LoggerFactory.getLogger(OrderExtractionService.class);
    private final ChatClient chatClient;
    private final Validator validator;

    public OrderExtractionService(ChatClient chatClient, Validator validator) {
        this.chatClient = chatClient;
        this.validator = validator;
    }

    private static final String TEMPLATE = """
            你是一名订单信息抽取助手。请从下面的文本中提取订单结构。

            抽取规则:
            1. 找不到的字段返回 null(列表返回 []),严禁编造或推测。
            2. 金额只输出数字,不带"元""¥"等符号。
            3. 状态只能从四个值里选,无法判断时返回 null。
            4. 只输出 JSON 对象本身,不要解释、不要代码块标记。

            待抽取文本:
            {input}

            {format}
            """;

    public ExtractResult extract(String input) {
        final int MAX = 3;
        String lastError = null;

        for (int attempt = 1; attempt <= MAX; attempt++) {
            String userText = TEMPLATE
                    .replace("{input}", input)
                    .replace("{format}", "");  // format 由 entity() 自动注入

            if (lastError != null) {
                userText += "\n\n【重要】上次输出有问题:" + lastError + ",请修正。";
            }

            try {
                OrderInfo order = chatClient.prompt()
                        .user(userText)
                        .options(OpenAiChatOptions.builder().temperature(0.0).build())
                        .call()
                        // 2.0 写法:校验失败自动重试(DeepSeek 只开这一个;换 OpenAI 才加 provider 开关)
                        .entity(OrderInfo.class, spec -> spec
                                .validateSchema());

                var violations = validator.validate(order);
                if (violations.isEmpty()) {
                    return new ExtractResult(order, attempt, true, "OK");
                }
                lastError = violations.stream()
                        .map(ConstraintViolation::getMessage)
                        .collect(Collectors.joining(";"));

            } catch (Exception e) {
                // 常见:模型输出了非 JSON(含解释文字 / 代码块包裹)
                lastError = "输出不是合法 JSON(" + e.getClass().getSimpleName() + ")";
                log.warn("第 {} 次提取失败:{}", attempt, lastError);
            }
        }
        // 三次都失败:不抛异常,返回失败标记,让上游走人工/降级流程
        return new ExtractResult(null, MAX, false,
                "自动提取失败,请转人工处理。最后错误:" + lastError);
    }
}
@RestController
@RequestMapping("/api/v1/orders")
public class OrderExtractionController {

    private final OrderExtractionService service;

    public OrderExtractionController(OrderExtractionService service) {
        this.service = service;
    }

    @PostMapping("/extract")
    public ExtractResult extract(@RequestBody Map<String, String> body) {
        return service.extract(body.get("text"));
    }
}

验证(三步跑通闭环)

  1. 确认应用已启动(端口 8080,密钥已注入 ${DEEPSEEK_API_KEY:})。先做一次正常输入:

    curl -X POST http://localhost:8080/api/v1/orders/extract \
      -H "Content-Type: application/json" \
      -d '{"text":"你好,我是张三,订单 2024001 在 3 月 5 日下的单,金额 899 元,买了一个机械键盘和一个鼠标垫,现在还没发货,联系电话 13800008888"}'

    预期输出:

    {"data":{"orderNo":"2024001","customerName":"张三","amount":899.00,
             "status":"未发货","orderDate":"2024-03-05",
             "items":["机械键盘","鼠标垫"],"phone":"13800008888"},
     "attempts":1,"success":true,"message":"OK"}
  2. 再做一次信息不全的输入,验证「不编造」规则:

    curl -X POST http://localhost:8080/api/v1/orders/extract \
      -H "Content-Type: application/json" \
      -d '{"text":"帮我看看那个订单发货了没有"}'

    预期输出:orderNo/amount 为 null,因 @NotNull 校验不过,success=false,最终返回「请转人工处理」——这正是「不瞎编」的正确表现。

    {"data":null,"attempts":3,"success":false,
     "message":"自动提取失败,请转人工处理。最后错误:金额缺失;订单号不能为空"}
  3. 打开应用日志,确认 TokenUsageAdvisor 打印出了 [AI用量] 耗时=... 输入=... 输出=... 合计=...,说明 Advisor 已生效。

看到什么算通过(闭环验收):① 正常输入返回 success=true 且字段齐全;② 信息不全输入返回 success=false 且 data=null、绝不编造订单号;③ 日志有 [AI用量] 打点。三点全中,本章主干能力即贯通。

排查:返回 400 或提取结果全是 null。① 先看日志里 TokenUsageAdvisor 有没有打印——没打印说明 Advisor 没挂上(检查 defaultAdvisors 或是否被 Spring 扫描到);② 字段全 null 通常是 JSON key 与 record 字段名大小写不一致,用 @JsonProperty("order_no") 显式映射;③ 金额映射失败是模型带上了「元」字,回到 3.4 在 @JsonPropertyDescription 里强调「只输出数字」。④ 若 success 一直 false 且最后错误是「输出不是合法 JSON」,说明 DeepSeek 偶发用代码块包裹——确认已开 useProviderStructuredOutput() 与 validateSchema()。
一句话记忆:完整服务 = 实体(record + 注解)+ Service(温度 0 + validateSchema() 校验重试 + 降级)+ Controller(暴露端点)+ Advisor(全局治理);五块拼齐,订单提取才是生产级。

3.8常见坑排错

现象原因解决
JsonParseException: Unexpected character '`'模型用 ```json 代码块包裹了输出提示词里明确「不要代码块标记」;或加 validateSchema() 让校验失败自动回喂重试
BadRequestException,且连试 3 次全失败开了 useProviderStructuredOutput(),但厂商不支持 response_format: json_schema(DeepSeek 只支持 json_object)去掉该开关,只留 validateSchema();catch 里打印 e.getMessage() 看厂商原始错误,最快定位
报错里出现 response_format / json_schema同上:厂商不接受原生结构化输出参数按厂商选开关(见 3.2 节决策表):DeepSeek 单开 validateSchema(),OpenAI/Google 才双开
JsonParseException: Unrecognized token '好的'模型在 JSON 前写了客套话/推理过程提示词加「只输出 JSON」;把推理与输出拆成两次调用
对象字段全是 null字段名与 JSON key 不一致(大小写/下划线)用 @JsonProperty("order_no") 显式映射;或统一命名风格
金额映射失败 Cannot deserialize value of type BigDecimal from String "899元"模型带上了单位字段描述里写「只输出数字不带单位」;或在 record 里定义紧凑构造器做清洗
日期解析失败模型返回 2024/3/5 或「3月5日」字段描述里写死格式 yyyy-MM-dd;或改用 String 接收后自己解析
entity() 偶尔成功偶尔失败temperature 太高,输出不稳定抽取类任务 temperature 设 0;并加 validateSchema() 重试
自定义 Advisor 没生效没注册进 defaultAdvisors,或 order 冲突检查是否被 Spring 扫描到;打印 getName() 确认;检查 order 值
敏感词拦截后返回 500构造的 ChatResponse 结构不完整确保 getResult() 非空;必要时改成抛业务异常,由全局处理器返回 400
一句话记忆:排错先看「是谁在干活」——null 是映射问题、JSON 报错是包裹/客套话问题、抖动是温度问题、Advisor 不生效是注册/order 问题。

3.9自测题

Q1. .entity(OrderInfo.class) 背后到底做了什么?

四步:① 用 BeanOutputConverter 反射生成 JSON Schema;② 把 schema + 「必须输出符合此 schema 的 JSON」指令追加到提示词;③ 调用模型后取出文本,清洗(容忍代码块包裹);④ 用 Jackson 反序列化成目标类型。2.0 还可加 validateSchema() 与 useProviderStructuredOutput()。

Q2. 为什么抽取类任务的 temperature 要设成 0?

temperature 控制采样随机性。抽取任务要求「同一输入稳定产出同一结果」,随机性越低越好。设 0 时模型倾向于选概率最高的 token,输出更确定、更可复现,也更容易通过回归测试。

Q3. Advisor 的 order 值是 0 和 100,哪个先执行前置逻辑?

0 先执行。order 小的在外层,前置逻辑先跑、后置逻辑最后跑。所以「内容安全」这类要先看到用户输入的 Advisor,应该用很小的 order(甚至 Ordered.HIGHEST_PRECEDENCE)。

Q4. 结构化输出已经有 schema 约束了,为什么还要 jakarta validation 校验?

Schema 只约束形状(字段有没有、类型对不对),不约束业务语义。模型完全可能给出格式合法但业务荒谬的数据(金额为负、状态值为「已发货啦」)。Schema 是第一道防线,JSR-303 是第二道,重试是第三道,降级是第四道。

Q5.【动手题】写一个 Advisor:每次调用后把「用户问题 + 模型回答 + Token 数」写入一张 MySQL 审计表。要求不能拖慢主流程。

提示:Advisor 里把记录扔进一个 BlockingQueue 或 Spring 的 ApplicationEventPublisher,由独立的消费线程/监听器批量写库;绝对不要在 Advisor 里同步执行 insert。这是「审计与可观测性」最常用的异步化模式。

一句话记忆:结构化输出保「形状」、Advisor 管「横切」;前者靠 entity()+校验+重试,后者靠 order 编排链——这是企业级 AI 应用的两根支柱。

3.10实操作业与本章验收

课堂练习(课上完成,约 40 分钟):

  • 照 3.7 把订单提取服务敲出来并跑通:用一段正常文本和一段信息不全的文本分别调用,截图两次返回,确认第二次 success=false 且不编造。
  • 故意把 OpenAiChatOptions 的 temperature 改成 1.0,连发 3 次相同请求,观察返回字段是否抖动;再改回 0 对比,直观感受温度的作用。
  • 把 TokenUsageAdvisor 的 getOrder() 改成 100,看日志是否仍能统计到包含其他 Advisor 的总耗时(验证洋葱模型)。

课后作业:

  • 扩展 OrderInfo:加一个 List<OrderItem> 嵌套字段(商品名/数量/单价),重新跑通提取,并写 200 字说明「嵌套对象为 null 时下游该怎么防御」。
  • 手写一遍 3.3 的 BeanOutputConverter 版本(不借助 entity()),打印 converter.getFormat(),交截图证明 schema 确实生成了。

3.11动手清单与自查表(照着核对)

本章文件最多(模型类 + 两个实验室 + 两个生产 Advisor),最容易"建了一半、不知道还差哪个"。下面把文件清单、分步验收、症状索引三件事固定下来。

① 你要建的文件(建一个,勾一个)

文件(相对工程根)对应小节怎么确认建对了
.../ch03/model/OrderInfo.java3.1是 record;金额字段类型是 BigDecimal(不是 double);字段名与提示词里的 JSON key 一致
.../ch03/model/OrderDetail.java3.2含嵌套 record(地址、商品明细)
.../ch03/model/ExtractResult.java3.7四个组件:data / attempts / success / message
.../ch03/OrderExtractService.java3.2 / 3.4 / 3.7含 entity() 家族与整合版 extract();温度显式设为 0.0
.../ch03/OrderExtractController.java3.2 / 3.7路径以 /api/v1/orders/ 开头;有一个 GET /format 调试端点
.../ch03/ConverterLabService.java + ConverterLabController.java3.33.3 的对照实验室(/api/v1/converter/*),与主力并存而不是替代它
.../ch03/advisor/TokenUsageLogAdvisor.java3.5实现 CallAdvisor;不是 @Component(教学版,每次 new)
.../ch03/AdvisorLabService.java + AdvisorLabController.java3.5/api/v1/advisor/chat 能演示敏感词短路
.../common/advisor/SafeGuardAdvisor.java3.6带 @Component;命中敏感词时直接返回、不调用 chain.nextCall()
.../common/advisor/TokenUsageAdvisor.java3.6带 @Component;在 chain.nextCall() 前后掐表,读 getMetadata().getUsage()
.../common/ChatClientConfig.java(改动)3.6全局挂载三个 Advisor:safeGuard + tokenUsage + simpleLogger
pom.xml(改动)3.6新增 spring-boot-starter-actuator(TokenUsageAdvisor 用 MeterRegistry)
关于「实验室类要不要留」:本章刻意保留两套并行结构——主力服务(OrderExtractService,3.2/3.4/3.7)与对照实验室(ConverterLabService 3.3、AdvisorLabService 3.5)。前者是"生产该长什么样",后者是"拆开看里面怎么转"。学习阶段两套都留;真要精简,实验室类删掉不影响主链路跑通。

② 分步验收:从零到跑通(每步都有预期输出)

  1. 编译并启动:mvn -q compile → BUILD SUCCESS;启动后日志出现 Started SpringaiLabApplication。

    顺带确认 Advisor 挂上了:启动日志里应能看到 SafeGuard / TokenUsage 相关的初始化痕迹,或至少没有 NoSuchBeanDefinitionException: MeterRegistry(有就是漏了 actuator)。

  2. 先看"模型到底收到了什么"——这一条最能建立结构化输出的直觉:

    curl -s http://localhost:8080/api/v1/orders/format

    预期:一段带 "properties" 的 JSON Schema,字段名与 OrderInfo 逐一对应。

    看到什么算通过:能在这段 schema 里找到 orderNo / amount / status 等字段。以后凡是"字段映射不上",第一反应就是回来打印这份 schema——先排除"模型压根没收到这个字段"这一层。

  3. 正常文本提取(把中文报文放进 UTF-8 JSON 文件再打,避开终端编码坑):

    :: body.json 内容(UTF-8 保存)
    {"text":"张三千了 2024001 订单,金额 899 元,买了一个机械键盘和一个鼠标垫,还没发货,电话 13800008888"}
    
    :: 用 --data-binary 读文件发送
    curl -s -X POST http://localhost:8080/api/v1/orders/extract \
      -H "Content-Type: application/json" \
      --data-binary "@body.json"

    预期:返回 ExtractResult,其中 success=true、attempts=1,data 里七个字段全部填充。

    看到什么算通过(可精确断言):orderNo 必须等于 2024001;customerName 必须等于 张三;amount 必须是数字 899.00 而不是字符串 "899元";phone 必须等于 13800008888。

  4. 信息不全的文本(反例,验证"不编造"):换一段没有任何订单信息的文本发同一个接口,例如 {"text":"今天天气不错"}。

    预期:三次重试后返回 {"success":false, "attempts":3, ...},不抛 500、不编造订单号。

    看到什么算通过:success=false 且 message 里有失败原因。这是设计行为,不是 bug——真实系统里这一支应该走"转人工"而不是给用户甩 500。

  5. 验证内容安全(前置拦截):向 /api/v1/advisor/chat 发一条含敏感词的请求。

    预期:返回被拦截的提示文案,且日志里没有该次模型调用的 Token 统计(说明请求根本没到模型)。

    看到什么算通过:响应正常、不是 500,且"没花钱"——这两点同时成立才说明短路拦截写对了。

  6. 看 Token 统计(后置读取):再发一次正常的对话请求,翻应用日志。

    预期:日志出现形如 promptTokens=... completionTokens=... totalTokens=... 的记录,数字非 0。

    看不到怎么查:① Advisor 有没有挂进 defaultAdvisors;② 有没有被 Spring 扫到(@Component 且包在扫描范围内);③ 有没有 MeterRegistry(缺 actuator 会直接启动失败,反而不是静默失败)。

  7. 做一个温度对照实验(一眼看懂"为什么抽取任务温度必须 0"):

    把 OpenAiChatOptions.builder().temperature(0.0) 临时改成 1.0,同一个请求连发三次,比较三次返回的字段;再改回 0.0 连发三次。

    预期:温度 1.0 时字段措辞/取值可能出现抖动;温度 0.0 时三次完全一致。这一条做完,你就明白为什么抽取类接口必须压温度。

    ⚠️ 顺手记住一件事:如果你在这步尝试加 .useProviderStructuredOutput(),会遇到 BadRequestException——这不是你的代码错,是 DeepSeek 不支持 json_schema,详见 3.2 节的厂商决策表。

③ 卡住了去哪查(症状 → 定位)

症状先去哪一节一句话提示
BadRequestException,连试 3 次全失败3.2 节厂商决策表 + 3.8开了 useProviderStructuredOutput() 但厂商不支持,去掉它
JsonParseException: Unexpected character '`'3.8 排错表模型用 ```json 包裹了,提示词约束 + validateSchema()
对象字段全是 null3.1 / 3.8字段名与 JSON key 不一致,用 @JsonProperty 映射
金额映射失败 "899元"3.8 排错表字段描述里写"只输出数字不带单位"
编译错 .options() 类型不符3.8 排错表本版本 .options() 收 Builder,不要 .build()
编译错 ListOutputConverter 构造器不匹配3.3 节三种 Converter要传 new DefaultConversionService()
entity() 报 Not enough variable values3.8 排错表entity() 场景模板不要写 {format}(框架自己会加)
JSR-303 校验不生效3.4 节反序列化不会自动跑校验,要手动 validator.validate()
自定义 Advisor 没生效3.6 节 + 3.8检查是否挂到 defaultAdvisors;getOrder() 是否影响预期顺序
敏感词拦截后返回 5003.8 排错表短路时必须构造并返回一个响应,不能返回 null
本章「做完」的判据(三条同时成立):① /format 能看到 schema、正常文本 success=true 且七字段正确、异常文本 success=false 不编造;② 能画出 Advisor 洋葱模型并解释「order 小 = 离用户近」;③ 能说清结构化输出的五层防线顺序(Schema 约束 → 反序列化 → 业务校验 → 失败重试 → 最终降级),并知道自己的模型该开哪个开关。