CH 03结构化输出与 Advisors
前面两章模型只会「说人话」,这一章要让它「交作业」。结构化输出解决「模型输出能不能被程序稳定消费」,Advisor 解决「请求前后的横切逻辑往哪放」。这两件事分别对应企业级应用里最要命的两个问题:接口稳定性 和 代码可维护性。本章所有代码基于 Spring AI 2.0.1 + Spring Boot 4.1.x + Java 17,模型默认走 DeepSeek(OpenAI 兼容协议),密钥使用 ${DEEPSEEK_API_KEY:} 占位符。
教学设计说明(教师参考):本章分两段,第一段(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(简洁且不可变)。下面这步是本章第一个「可运行节点」——先把映射类建出来并编译通过,后面才能往上叠逻辑。
-
在 IDEA 左侧 Project 视图里,展开
src/main/java,找到包com.example.springai。在com.example.springai上右键 → New → Package,输入com.example.springai.ch03.model,回车。这是本章所有模型类的家。 -
在刚建好的
com.example.springai.ch03.model包上右键 → New → Java Class,Name 填OrderInfo,按回车(Kind 选 Class 即可,record 我们手动写)。 -
把 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 // 联系电话 ) {} -
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 |
```json 代码块包裹);④ 用 Jackson 反序列化并做类型转换。所以它不是魔法,而是「提示词工程 + 解析」的自动化封装。理解这一点,你就能在它失败时自己排查。
动手:建一个临时接口把 entity() 跑起来
光看写法不过瘾,我们建一个「练习接口」用真实请求验证 entity()。这个接口学完可删,专门用来观察 entity() 的行为。
-
在
com.example.springai.ch03包上右键 → New → Java Class,Name 填QuickEntityController。 -
清空内容,粘贴下面这段「标准答案」(它就是一个最小可跑的 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); } } -
Ctrl + S 保存。
-
确认
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。
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 会直接拒绝,详见下一节)。
order_no/客户名,而 record 字段是 orderNo/customerName,Jackson 按名匹配,匹配不上就给 null。修复:用 @JsonProperty("order_no") 显式映射,或在 @JsonPropertyDescription 里写清字段含义让模型对齐(3.4 会讲)。先别急着加校验,null 是因为「没映射上」不是「值为 null」。
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()
);
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 节那个「手写重试」的示例就不再是必需的了——但请仍读一遍,理解它内部在做什么,出问题时才知道去哪看。
BeanOutputConverter.postProcessSchema(JsonNode) 扩展点被移除了。如果你之前靠它自定义生成的 JSON Schema,需要改用自定义 StructuredOutputConverter 实现——2.0 允许自定义转换器参与原生结构化输出流水线。另外 schema 生成已委托给 JsonSchemaGenerator,生成的 schema 现在会带 OpenAPI 风格的 format 提示(如 int32、date-time),Kotlin 的可空字段也不再被错误地放进 required。
动手:把 QuickEntityController 升级成 2.0 写法
-
打开刚才的
QuickEntityController,把.entity(OrderInfo.class)那行改成带 spec 的 2.0 写法。先加 import:import org.springframework.ai.openai.OpenAiChatOptions; -
把方法体替换为「标准答案」(注意这里只开一个开关,原因见下方说明):
@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()); } -
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) {}
converter.getFormat() 看);② 模型是否因为「地址没在原文里」而直接省略了整个 shippingAddress 键——这时 Jackson 会设成 null,调用方若直接 address.province() 会 NPE,务必先判空;③ 字段名大小写是否与 JSON key 不一致(用 @JsonProperty("shipping_address") 显式映射)。
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() 在背后偷偷加的就是它。
-
在
com.example.springai.ch03包上右键 → New → Java Class,Name 填ConverterProbe。注意:这里用CommandLineRunner让它在启动时跑一次,纯演示用。 -
粘贴「标准答案」(只为了打印 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("=================================="); } } -
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 | 字段固定的业务对象(最常用) |
ListOutputConverter | List<String> | 让模型给出标签、关键词、建议列表 |
MapOutputConverter | Map<String,Object> | 字段不固定、探索期使用 |
对照:BeanOutputConverter vs 编译期类型安全(ParameterizedTypeReference)
| 维度 | BeanOutputConverter<T> | ParameterizedTypeReference<List<T>>(){} |
|---|---|---|
| 解决的问题 | 替你生成 schema + 解析单对象 | 替你保住「被擦除的泛型」类型信息 |
| 用在哪 | 手写 converter 流程(3.3 ①②③④) | 调 .entity(new ParameterizedTypeReference<...>(){}) 取集合 |
| 类型安全层级 | 运行期靠 Jackson 反射映射 | 编译期锁定集合元素类型,避免 List<Object> |
| 两者关系 | 不冲突:BeanOutputConverter 内部也用 ParameterizedTypeReference 保住目标类型;你写集合时直接在 entity() 上传后者即可 | |
converter.convert() 从头解析,遇到前面的文字就会抛 JsonParseException。标准解法:把「要 JSON 的任务」和「要推理的任务」拆成两次调用(第一次让它分析,第二次让它按 schema 输出结论),或者用
entity() + 明确的「不要输出任何解释」约束。企业项目里推荐拆成两次调用——更稳、更可控、也更方便单独评测每一步。
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
) {}
@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;
}
}
OpenAiChatOptions 在 2.0 仍位于 spring.ai.openai.chat.options 这一层(与 embedding 的扁平配置不同,见第 4 章)。
对照:Schema 约束 vs JSR-303 校验(各管一层)
| 维度 | JSON Schema 描述(@JsonPropertyDescription) | JSR-303 注解(@NotBlank/@Min/@Pattern) |
|---|---|---|
| 作用层 | 提示词层(写给模型看) | 业务层(Java 代码里校验) |
| 管什么 | 「形状」与「模型该怎么填」 | 「内容是否合法」 |
| 典型失败 | 模型不带单位、枚举拼错字 | 金额为负、状态值非法 |
| 能否兜底 | 不能——模型仍可能不听话 | 能——代码硬拦,不合法直接抛 |
| 关系 | 前者降低出错概率,后者保证出错必拦;二者叠加才是完整防线 | |
动手:把校验接进 Service 并验证「不合法即拦截」
-
把 s1 的
OrderInfo升级为「同时带@JsonPropertyDescription和 JSR-303 注解」的版本(包名不变)。这会和 3.7 的「完整版 OrderInfo」基本一致,先在这里迭代。 -
在
com.example.springai.ch03包建OrderExtractionService.java,粘贴上面这段标准答案。 -
建一个临时 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()); } } } -
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()——漏调用就跳过了校验。
@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 + " 次");
}
3.5Advisor 机制:把横切逻辑从业务里抽出来
假设现在有 30 个 AI 接口,你希望每个接口都:打印请求日志、统计 Token 消耗、做敏感词过滤、失败自动重试。如果写在每个 Service 里,就是 30 份复制粘贴——这正是 Spring AOP 要解决的问题,而 Spring AI 给出的答案是 Advisor。
| 对比项 | Spring MVC | Spring AI |
|---|---|---|
| 切面接口 | HandlerInterceptor / Filter | CallAdvisor / StreamAdvisor |
| 顺序控制 | Ordered 接口 | getOrder(),值小的在外层(先执行) |
| 链式调用 | chain.doFilter() | chain.nextCall(request) |
| 能否改写请求 | 能(包装 request) | 能(改写 Prompt / 注入消息) |
| 能否改写响应 | 能(包装 response) | 能(改写 ChatClientResponse) |
Advisor 的执行模型
对照:Advisor 链「去正回逆」
请求流向模型时,Advisor 按 getOrder() 从小到大依次执行前置逻辑(正序);响应从模型返回时,按 getOrder() 从大到小依次执行后置逻辑(逆序)。记住四字口诀:去正回逆。所以「内容安全过滤」想第一个看输入、最后一个收尾,就把 order 设到最小。
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 章会展开。
动手:先挂一个 SimpleLoggerAdvisor 感受链路
在写自定义 Advisor 之前,先用框架自带的 SimpleLoggerAdvisor 看一眼「请求/响应经过 Advisor 长什么样」,建立直观认识。
-
找到你构建
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(); -
重启应用,发一次 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 的调试地基。
defaultAdvisors(...) 加在构建 ChatClient 的同一处,而不是另起一个 builder(很多人新建了一个没挂 Advisor 的 ChatClient 注入进 Controller,导致实际用的不是带 Advisor 的那个);② 确认日志级别没被压成 WARN/ERROR——SimpleLoggerAdvisor 默认 INFO,application.yml 里 logging.level.root 别设太高;③ 用浏览器/Apifox 发请求,别只看 IDEA 没输出就以为没生效,去 Run 面板翻日志。
内置 Advisor 一览(课程会逐步用到)
| Advisor | 作用 | 章节 |
|---|---|---|
SimpleLoggerAdvisor | 打印请求/响应,调试用 | 1 |
MessageChatMemoryAdvisor | 2.0 统一的记忆 Advisor:以多条消息形式注入对话历史,conversationId 必填 | 2 / 6 |
ToolCallingAdvisor | 2.0 新增:接管工具调用循环,ChatClient 自动注册 | 7 / 9 |
ToolSearchToolCallingAdvisor | 2.0 新增:渐进式工具披露,工具多时省 34%~64% token | 7 |
StructuredOutputValidationAdvisor | 结构化输出校验失败自动重试(可自动注册) | 3 |
QuestionAnswerAdvisor | 自动做向量检索并拼进提示词,RAG 最简方案 | 5 |
RetrievalAugmentationAdvisor | 模块化 RAG:可插查询改写、多路检索、重排 | 5 |
SafeGuardAdvisor | 简单的敏感词拦截 | 3 |
MessageChatMemoryAdvisor,并且 conversationId 必填(通过 ChatMemory.CONVERSATION_ID 这个上下文 key 传入)。这是 2.0 把分散的记忆实现收敛成统一入口的结果,学生后续写多轮对话时直接用这一个即可,不必再纠结 PromptChatMemoryAdvisor(已删除)之类旧实现。
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
-
在
com.example.springai上右键 → New → Package,输入com.example.springai.common.advisor(这是「跨章节共用 Advisor」的家,放 common 下,第 5/6 章也能复用)。 -
在该包上右键 → New → Java Class,Name 填
TokenUsageAdvisor,粘贴上面的「标准答案①」。注意 import 较多,Ctrl + Alt + O 让 IDEA 自动整理 import(确保io.micrometer.core.instrument.MeterRegistry在依赖里——Boot 默认带 Micrometer)。 -
同样建
SafeGuardAdvisor.java,粘贴「标准答案②」。@Component让它被 Spring 扫描成单例;getOrder()返回Ordered.HIGHEST_PRECEDENCE确保它最外层。 -
打开你构建
ChatClient的地方,把两个 Advisor 注入并挂上(若用构造注入,Spring 会自动收集所有CallAdvisorBean)。标准写法:// 全局挂载:所有请求都生效(在构建 ChatClient 时指定) ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(safeGuardAdvisor, tokenUsageAdvisor) .build(); -
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 里
catch了却返回null,会让上层拿到空指针,排查成本极高;要么包装成业务异常抛出,要么返回明确的错误响应。 - 不要在 Advisor 里做重 IO。Advisor 是每个请求都过的路径,一次同步数据库查询就会拖慢所有接口。要 IO 就加缓存或异步。
- 不要依赖可变共享状态。Advisor 是单例,请用局部变量或 ThreadLocal(并记得清理),否则并发下会串数据。
nextCall()——短路即拦截。
3.7完整示例:订单信息提取服务
把本章能力组装成完整可运行的服务:结构化输出 + 校验 + 重试 + Advisor 治理。下面代码全部用 2.0 写法,且不使用 Lombok。
动手:把分散的零件组装成完整服务
前面几节我们建过 QuickEntityController、ValidationController 等练习接口。现在把它们「毕业」成正式服务——保留 OrderInfo / OrderExtractionService / OrderExtractionController 三个正式文件,练习接口可删可留(留着不冲突,只是多几个端点)。
-
确认
com.example.springai.ch03.model.OrderInfo已是「带 @JsonPropertyDescription + JSR-303 注解」的完整版(见下方完整代码)。 -
在
com.example.springai.ch03包建ExtractResult.java(结果包装),粘贴完整代码。 -
建
OrderExtractionService.java,粘贴完整代码(含 3 次重试 + 降级)。 -
建
OrderExtractionController.java,粘贴完整代码(POST /api/v1/orders/extract)。 -
确认
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"));
}
}
验证(三步跑通闭环)
-
确认应用已启动(端口 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"} -
再做一次信息不全的输入,验证「不编造」规则:
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":"自动提取失败,请转人工处理。最后错误:金额缺失;订单号不能为空"} -
打开应用日志,确认
TokenUsageAdvisor打印出了[AI用量] 耗时=... 输入=... 输出=... 合计=...,说明 Advisor 已生效。
看到什么算通过(闭环验收):① 正常输入返回 success=true 且字段齐全;② 信息不全输入返回 success=false 且 data=null、绝不编造订单号;③ 日志有 [AI用量] 打点。三点全中,本章主干能力即贯通。
TokenUsageAdvisor 有没有打印——没打印说明 Advisor 没挂上(检查 defaultAdvisors 或是否被 Spring 扫描到);② 字段全 null 通常是 JSON key 与 record 字段名大小写不一致,用 @JsonProperty("order_no") 显式映射;③ 金额映射失败是模型带上了「元」字,回到 3.4 在 @JsonPropertyDescription 里强调「只输出数字」。④ 若 success 一直 false 且最后错误是「输出不是合法 JSON」,说明 DeepSeek 偶发用代码块包裹——确认已开 useProviderStructuredOutput() 与 validateSchema()。
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 |
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。这是「审计与可观测性」最常用的异步化模式。
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 确实生成了。
- 能说清
entity()的四个内部步骤,并能手写 converter 版本等价实现。 - 订单提取接口在正常输入下返回完整的
OrderInfo,在信息不足时不编造(success=false)。 - 能解释为什么金额用
BigDecimal、为什么温度设 0、为什么还要 JSR-303 校验。 - 自定义的 Token 统计 Advisor 能在日志里打印出准确的 Token 数,且能画出 Advisor 的洋葱模型并说明 order 语义。
3.11动手清单与自查表(照着核对)
本章文件最多(模型类 + 两个实验室 + 两个生产 Advisor),最容易"建了一半、不知道还差哪个"。下面把文件清单、分步验收、症状索引三件事固定下来。
① 你要建的文件(建一个,勾一个)
| 文件(相对工程根) | 对应小节 | 怎么确认建对了 |
|---|---|---|
.../ch03/model/OrderInfo.java | 3.1 | 是 record;金额字段类型是 BigDecimal(不是 double);字段名与提示词里的 JSON key 一致 |
.../ch03/model/OrderDetail.java | 3.2 | 含嵌套 record(地址、商品明细) |
.../ch03/model/ExtractResult.java | 3.7 | 四个组件:data / attempts / success / message |
.../ch03/OrderExtractService.java | 3.2 / 3.4 / 3.7 | 含 entity() 家族与整合版 extract();温度显式设为 0.0 |
.../ch03/OrderExtractController.java | 3.2 / 3.7 | 路径以 /api/v1/orders/ 开头;有一个 GET /format 调试端点 |
.../ch03/ConverterLabService.java + ConverterLabController.java | 3.3 | 3.3 的对照实验室(/api/v1/converter/*),与主力并存而不是替代它 |
.../ch03/advisor/TokenUsageLogAdvisor.java | 3.5 | 实现 CallAdvisor;不是 @Component(教学版,每次 new) |
.../ch03/AdvisorLabService.java + AdvisorLabController.java | 3.5 | /api/v1/advisor/chat 能演示敏感词短路 |
.../common/advisor/SafeGuardAdvisor.java | 3.6 | 带 @Component;命中敏感词时直接返回、不调用 chain.nextCall() |
.../common/advisor/TokenUsageAdvisor.java | 3.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)。前者是"生产该长什么样",后者是"拆开看里面怎么转"。学习阶段两套都留;真要精简,实验室类删掉不影响主链路跑通。
② 分步验收:从零到跑通(每步都有预期输出)
-
编译并启动:
mvn -q compile→BUILD SUCCESS;启动后日志出现Started SpringaiLabApplication。顺带确认 Advisor 挂上了:启动日志里应能看到 SafeGuard / TokenUsage 相关的初始化痕迹,或至少没有
NoSuchBeanDefinitionException: MeterRegistry(有就是漏了 actuator)。 -
先看"模型到底收到了什么"——这一条最能建立结构化输出的直觉:
curl -s http://localhost:8080/api/v1/orders/format预期:一段带
"properties"的 JSON Schema,字段名与OrderInfo逐一对应。看到什么算通过:能在这段 schema 里找到
orderNo/amount/status等字段。以后凡是"字段映射不上",第一反应就是回来打印这份 schema——先排除"模型压根没收到这个字段"这一层。 -
正常文本提取(把中文报文放进 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。 -
信息不全的文本(反例,验证"不编造"):换一段没有任何订单信息的文本发同一个接口,例如
{"text":"今天天气不错"}。预期:三次重试后返回
{"success":false, "attempts":3, ...},不抛 500、不编造订单号。看到什么算通过:
success=false且message里有失败原因。这是设计行为,不是 bug——真实系统里这一支应该走"转人工"而不是给用户甩 500。 -
验证内容安全(前置拦截):向
/api/v1/advisor/chat发一条含敏感词的请求。预期:返回被拦截的提示文案,且日志里没有该次模型调用的 Token 统计(说明请求根本没到模型)。
看到什么算通过:响应正常、不是 500,且"没花钱"——这两点同时成立才说明短路拦截写对了。
-
看 Token 统计(后置读取):再发一次正常的对话请求,翻应用日志。
预期:日志出现形如
promptTokens=... completionTokens=... totalTokens=...的记录,数字非 0。看不到怎么查:① Advisor 有没有挂进
defaultAdvisors;② 有没有被 Spring 扫到(@Component且包在扫描范围内);③ 有没有MeterRegistry(缺 actuator 会直接启动失败,反而不是静默失败)。 -
做一个温度对照实验(一眼看懂"为什么抽取任务温度必须 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() |
| 对象字段全是 null | 3.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 values | 3.8 排错表 | entity() 场景模板不要写 {format}(框架自己会加) |
| JSR-303 校验不生效 | 3.4 节 | 反序列化不会自动跑校验,要手动 validator.validate() |
| 自定义 Advisor 没生效 | 3.6 节 + 3.8 | 检查是否挂到 defaultAdvisors;getOrder() 是否影响预期顺序 |
| 敏感词拦截后返回 500 | 3.8 排错表 | 短路时必须构造并返回一个响应,不能返回 null |
/format 能看到 schema、正常文本 success=true 且七字段正确、异常文本 success=false 不编造;② 能画出 Advisor 洋葱模型并解释「order 小 = 离用户近」;③ 能说清结构化输出的五层防线顺序(Schema 约束 → 反序列化 → 业务校验 → 失败重试 → 最终降级),并知道自己的模型该开哪个开关。