作者:鱼宵 | Spring AI 实战精通营 · 第 3 篇
前两课你让模型输出的是"一坨文本"——一段英文译文、一句自我介绍,人看得挺爽。直到上周接了个需求:客服聊天记录自动生成订单。用户甩过来一句"我要买 2 个机械键盘和 1 个鼠标垫",你想拿里面的商品名、数量、总价去算钱、入库,纯文本你怎么算?总不能写个正则去抠"2 个"“399"吧——用户下次说"整两台键盘呗”,你的正则当场报废。
这就是第 3 课要收拾的事:.entity(Order.class),一句话让模型别自由作文了,直接吐一个强类型 Java 对象出来。代码全在仓库lesson-03/目录,clone 下来照着命令重跑,你会亲眼看到总价 847 这个数——模型不光抽对了商品和数量,连乘法加法都帮你算对了。
一、核心原理:让模型别口头回答,改成填表格
1. 问题:自由文本人能读,程序没法用
模型默认像在口头回答,话很顺但你没法直接记账;业务要的不是一段话,是能算钱、能入库的数据——订单里的商品列表、数量、总价。你总不能用正则从"我给你算了算,两个键盘大概七百多"里抠数字吧?
结构化输出就是让它改成填表格:每个格子对应一个字段,填完你直接拿表格做账。这是 LLM 从"聊天玩具"变成"生产组件"的关键一跃。
2. entity(Class):一键拿对象,内部干了什么
.entity(Order.class)表面就一行,内部流水线面试能背:
① new BeanOutputConverter(Order.class) ② 反射 Order 字段 → 生成 JSON Schema(给模型的"输出说明书") ③ 把说明书拼进提示词 → 要求模型按结构输出 JSON ④ call() 拿到模型文本 ⑤ 剥掉可能的三反引号代码块包裹 ⑥ JsonParser.fromJson(text, Order.class) → Order 对象类比自动报账机:你把"业务对象模板"塞进去,它自己填单子、自己念、自己把念出来的内容誊成正式账本。你全程只递了一张空模板。
这里有个名词链要理清(面试高频追问):
| 名词 | 是什么 |
|---|---|
| StructuredOutputConverter | 接口,两个方法:getFormat() 出说明书 + convert() 文本转对象 |
| BeanOutputConverter | 上面接口的 POJO 版实现,反射字段生成 Schema,.entity()用的就是它 |
| JsonParser | Spring AI 的 JSON 工具类(fromJson/toJson),底层封装 Jackson——它是工具类不是注解,面试别说错 |
3. 手写版 vs 一键版:对比才知道 entity() 替你省了啥
| 对比点 | /extract(entity 一键) | /extract-manual(手写) |
|---|---|---|
| JSON 说明书 | 自动反射 Order 生成 | 自己在 system 里手写字段 |
| 拿到的返回 | 直接是 Order 对象 | 是 String,要自己解析 |
| 剥代码块 | 框架内部做了 | 自己写 stripCodeFence |
| 出错兜底 | 框架内建(外层仍要 try) | 自己 try-catch |
| 代码量 | 一行.entity() | 4~5 行,还容易漏 |
还有个细节:Order 这个类里没有任何 AI 注解、没有 Spring AI 依赖,就是个普通 POJO。entity() 是反射它的字段结构生成说明书——你的业务对象长什么样,模型就被迫输出成什么样。这是结构化输出的精髓。
二、动手:十分钟跑通订单抽取
环境:Windows + JDK 17 + Maven 3.9+,知道 Jackson 的
readValue就行。
第 1 步:检查环境。
Test-PathC:\jdk\jdk-17.0.12\bin\java.exe# True[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')# TrueGet-NetTCPConnection-LocalPort 8095-State Listen-ErrorAction SilentlyContinue# 无输出=空闲第 2 步:编译 + 启动。
cd C:\Users\haoji\Desktop\课程\spring-ai-journey\lesson-03$env:JAVA_HOME="C:\jdk\jdk-17.0.12"mvn clean install-DskipTests mvn spring-boot:run# 看到 Tomcat started on port 8095 即成功第 3 步:调两个接口(中文输出用 UTF-8 解码,防乱码)。
# PowerShell 5.1 的 Invoke-RestMethod 默认不按 UTF-8 解中文会乱码,本课用 WebClient 抓[Console]::OutputEncoding=[System.Text.Encoding]::UTF8$wc=New-ObjectSystem.Net.WebClient;$wc.Encoding=[System.Text.Encoding]::UTF8# 主菜:entity() 一键$t=[uri]::EscapeDataString('我要买2个机械键盘和1个鼠标垫')$wc.DownloadString("http://localhost:8095/extract?text=$t")# 对照:手写解析版$wc.DownloadString("http://localhost:8095/extract-manual?text=$t")三、关键代码:POJO 零注解,控制器两个接口
第一段:Order.java + OrderItem.java——纯 POJO,零 AI 注解。
packagecom.springai.lesson03;importjava.math.BigDecimal;importjava.util.List;/** * 订单 JavaBean:本课要从大模型输出里解析出来的目标对象。 * 注意:这个类里没有任何 AI 注解、没有 Spring AI 依赖——它就是个普通 POJO。 * Spring AI 反射它的字段结构自动生成 JSON 说明书,让模型照着填。 */publicclassOrder{/** 商品行列表(一个订单可能买多样东西) */privateList<OrderItem>items;/** 订单总价(元) */privateBigDecimaltotalAmount;publicList<OrderItem>getItems(){returnitems;}publicvoidsetItems(List<OrderItem>items){this.items=items;}publicBigDecimalgetTotalAmount(){returntotalAmount;}publicvoidsetTotalAmount(BigDecimaltotalAmount){this.totalAmount=totalAmount;}}packagecom.springai.lesson03;importjava.math.BigDecimal;/** * 订单里的一行商品(Order 的子对象)。 * 你写了什么字段名,模型就输出什么 key;字段类型 Integer/BigDecimal 也会写进给模型的 Schema。 */publicclassOrderItem{/** 商品名,如 "机械键盘" */privateStringproductName;/** 购买数量,如 2 */privateIntegerquantity;/** 单价(元),如 399 */privateBigDecimalunitPrice;/** 该行小计 = 单价 × 数量,如 798 */privateBigDecimalsubtotal;publicStringgetProductName(){returnproductName;}publicvoidsetProductName(StringproductName){this.productName=productName;}publicIntegergetQuantity(){returnquantity;}publicvoidsetQuantity(Integerquantity){this.quantity=quantity;}publicBigDecimalgetUnitPrice(){returnunitPrice;}publicvoidsetUnitPrice(BigDecimalunitPrice){this.unitPrice=unitPrice;}publicBigDecimalgetSubtotal(){returnsubtotal;}publicvoidsetSubtotal(BigDecimalsubtotal){this.subtotal=subtotal;}}第二段:OrderExtractController.java——两个接口对照,完整可运行。
packagecom.springai.lesson03;importcom.fasterxml.jackson.databind.ObjectMapper;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;/** * 订单信息抽取控制器:一段下单聊天 → 结构化 Order JavaBean。 * 两个接口对照: * /extract —— entity() 一键:.call().entity(Order.class),模型输出自动解析成 Order * /extract-manual —— 手写解析:.call().content() 先拿原始 JSON 字符串,再自己 ObjectMapper 解析 */@RestControllerpublicclassOrderExtractController{privatefinalChatClientchatClient;/** Spring Boot 自带的 Jackson(web starter 自动装配),手写解析版要用它 */privatefinalObjectMapperobjectMapper;publicOrderExtractController(ChatClient.Builderbuilder,ObjectMapperobjectMapper){this.chatClient=builder.build();this.objectMapper=objectMapper;}/** * 接口一:entity() 一键结构化(本课主菜)。 * GET /extract?text=我要买2个机械键盘和1个鼠标垫 * 关键就是收尾那步 .entity(Order.class): * 框架反射 Order 字段生成 JSON Schema → 拼进提示词 → 模型输出 JSON * → 自动剥掉代码块 → Jackson 解析成 Order 对象。你只管要 Order.class。 */@GetMapping("/extract")publicOrderextract(@RequestParam("text")Stringtext){returnchatClient.prompt()// system:业务背景 + 写死的价目表,让模型会算钱(别给它瞎编价格的空间).system("你是电商订单信息抽取器。已知商品单价(人民币元):机械键盘=399、鼠标垫=49、显示器=1299。"+"从用户的下单聊天语句中抽取要购买的商品清单;"+"每个商品给出商品名、数量、单价、该行小计(单价×数量);最后给出订单总价(所有小计之和)。")// user:这一次要解析的原始语句.user("用户下单语句:"+text).call()// 一键结构化:直接把回答文本解析成 Order JavaBean.entity(Order.class);}/** * 接口二:手写解析版(对照用,看 entity() 内部替你做了什么)。 * .content() 拿到的是原始字符串,不是对象;先剥代码块,再 ObjectMapper 解析。 */@GetMapping("/extract-manual")publicOrderextractManual(@RequestParam("text")Stringtext)throwsException{// ① 手写 JSON 输出要求(对比:entity() 是自动根据 Order.class 生成这段)Stringjson=chatClient.prompt().system("你是电商订单抽取器。已知单价:机械键盘399、鼠标垫49、显示器1299。"+"从用户下单语句抽取订单,严格只输出一个 JSON 对象,结构为:"+"{\"items\":[{\"productName\":商品名,\"quantity\":数量,\"unitPrice\":单价,\"subtotal\":小计}],\"totalAmount\":总价},"+"不要输出 JSON 以外的任何解释文字。").user("用户下单语句:"+text).call().content();// ② 模型有时把 JSON 包在三反引号代码块里,先剥掉(entity() 内部也做了这件事)json=stripCodeFence(json);// ③ 手写解析:字符串 → Order 对象。生产里这步最容易抛 JsonProcessingExceptionreturnobjectMapper.readValue(json,Order.class);}/** * 剥掉模型可能输出的 markdown 代码块包裹,只留中间的 JSON。 * 模型的自由文本输出永远不能百分百干净,框架/你得帮它擦屁股。 */privateStringstripCodeFence(Stringraw){Strings=raw.trim();if(s.startsWith("```")){s=s.replaceFirst("^```[a-zA-Z]*\\s*","");intidx=s.lastIndexOf("```");if(idx>=0){s=s.substring(0,idx);}}returns.trim();}}这段代码讲清一件事:/extract和/extract-manual喂给模型的业务背景几乎一样,差别就在收尾那一步——一键版.entity(Order.class),手写版.content()+stripCodeFence+readValue。第四节跑出来两个结果一模一样,你就信了 entity() 内部干的就是这几步。
第三段:Lesson03Application.java——启动类。
packagecom.springai.lesson03;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * 第 3 课启动类。启动后访问: * GET http://localhost:8095/extract?text=我要买2个机械键盘和1个鼠标垫 * GET http://localhost:8095/extract-manual?text=我要买2个机械键盘和1个鼠标垫 */@SpringBootApplicationpublicclassLesson03Application{publicstaticvoidmain(String[]args){SpringApplication.run(Lesson03Application.class,args);}}第四段:application.yml——抽取得降温。
server:port:8095# 端口按课程分配表:spring-ai 系列 lesson-03 = 8095spring:ai:openai:base-url:${LLM_BASE_URL:https://api.deepseek.com}api-key:${DEEPSEEK_API_KEY}chat:options:model:${LLM_MODEL:deepseek-chat}max-tokens:500# 吐一个完整 JSON 对象要留足,小了会被腰斩temperature:0.2# 抽取任务要稳,降温让模型别自由发挥污染 JSON四、实测输出:口语再啰嗦,金额也算对了
以下是 2026-10-05 本机真实运行(DeepSeek,端口 8095,HTTP 200,UTF-8 解码)。先调主菜 /extract:
{"items":[{"productName":"机械键盘","quantity":2,"unitPrice":399,"subtotal":798},{"productName":"鼠标垫","quantity":1,"unitPrice":49,"subtotal":49}],"totalAmount":847}自己验算一遍:机械键盘 2×399=798,鼠标垫 1×49=49,总价 798+49=847。模型不光抽对了商品和数量,连金额都算对了。这个 JSON 就是 Order 对象被 Spring MVC 序列化后的样子——你 Controller 里 return 的是个 Java 对象,框架自动转 JSON 给你。
/extract-manual同一句输入跑出来和上面一模一样,证明 entity() 内部干的就是手写版那几步,只是自动了。
再扔一句口语化、含糊的,看鲁棒性:
请求 text=哎你好,给我整一台那个显示器,对了鼠标垫再捎三个呗 HTTP 200 {"items":[{"productName":"显示器","quantity":1,"unitPrice":1299,"subtotal":1299},{"productName":"鼠标垫","quantity":3,"unitPrice":49,"subtotal":147}],"totalAmount":1446}1 台显示器 1299 + 3 个鼠标垫 147 =1446。“整一台那个”"捎三个呗"这种语气词一点没干扰抽取——这正是 LLM 抽取比"正则写死"强的地方,正则遇到"整一台"直接傻眼。
排查提示:500 + JsonProcessingException,多半是模型输出没按 JSON 来(被 max-tokens 截断或温度太高自由发挥),调大 token、把温度降到 0.1 重试。
五、挑战题:改参数,看看会怎样
- ⭐加个字段:给 Order 加一个
private String customerNote;(备注),补上 getter/setter,重启再调/extract,text 传"帮我周六送到"——看模型会不会主动把这句填进新字段。答案就在 Order.java,加完字段你就懂"POJO 长什么样模型就输出成什么样"。 - ⭐⭐掐断 token:把 yml 的
max-tokens改成 50,再调/extract——观察是 500 还是输出半截 JSON。然后拿 try-catch 包一层返回友好错误。这题跑一遍你就明白为什么生产里 max-tokens 要按对象复杂度留足。 - ⭐⭐删掉价目表:把 system 里"已知单价:机械键盘=399、鼠标垫=49、显示器=1299"整行删掉,再调
/extract——看模型是怎么"编"价格的。看完你就知道踩坑节为什么说"别给模型编价格的空间"。
六、生产环境进阶:三个加分项
1. 抽取类任务降温。聊天用 0.7,抽数据用 0.1~0.3。温度一高模型就爱加"希望对您有帮助"这种尾巴,直接污染 JSON 解析炸掉。
2. max-tokens 按"对象大小"预算。500 token 对本课这个订单够用,字段一多(简历 20 个字段)就被腰斩成半截 JSON。生产按对象复杂度调大,别让框架在收尾处截断。
3. 错误容忍是必须的,不是可选的。模型的 JSON 永远不能 100% 干净:包代码块、多尾巴、漏逗号。一键版 entity() 内建剥代码块,外层仍要 try-catch 记日志降级;关键链路解析失败可以带格式要求再调一次。
七、面试回答模板
面试官:entity(Order.class) 这一步内部替你做了什么?
一句话:BeanOutputConverter 反射 POJO 生成 JSON Schema 拼进提示词,模型输出 JSON 后剥代码块再用 Jackson 解析成对象。展开说:六个步骤——new 转换器、反射字段、拼说明书、call 拿文本、剥代码块、JsonParser.fromJson。你只递了一个空模板,脏活全在框架里。(指向本课第一节)
追问:模型吐出来的 JSON 偶尔不合法,生产怎么兜底?
一句话:剥代码块 + try-catch 降级 + 降温 + 重试。展开说:模型可能把 JSON 包在三反引号代码块里、末尾多句客套话、max-tokens 截断;一键版框架内建剥代码块,外层仍要捕获 JsonProcessingException 记日志;抽取任务温度压到 0.1~0.3,关键链路失败带格式要求再调一次。(指向本课第六节)
追问:StructuredOutputConverter 和 BeanOutputConverter 什么关系?JsonParser 是注解吗?
一句话:前者是接口(getFormat 出说明书 + convert 转对象),BeanOutputConverter 是它的 POJO 版实现,entity() 用的就是它。JsonParser 是工具类(fromJson/toJson)不是注解,底层封装 Jackson,web starter 自带不用额外引 gson。(指向本课第一节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| POJO 没 getter/setter | Schema 缺字段、模型瞎填 | 补全 getter/setter(Jackson 反射靠它) |
| JSON 包在三反引号代码块里 | readValue 直接炸 | 手写版 stripCodeFence;entity() 内部已剥 |
| max-tokens 太小 | JSON 被腰斩成半截,500 | 按对象复杂度调大 |
| temperature 太高 | 模型加解释文字污染 JSON | 抽取类降到 0.1~0.3 |
| 中文输出乱码 | Invoke-RestMethod 出来是问号 | WebClient 指定 UTF8 解码 |
| 让模型自己猜价格 | 金额幻觉、算错钱 | 价目表写死在 system 里 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 3 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉Spring AI 实战精通营(10 课):https://gitee.com/j67mk2/spring-ai-journey
- 本文对应源码位置:
lesson-03/(内含OrderExtractController双接口对照——entity 一键版 + 手写解析版,配Order/OrderItem两个零注解 POJO)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI 工具调用:@Tool 让大模型自己查订单查库存》——本课模型会算钱了,但价目表是写死在 system 里的假数;下一课给模型装上"手",在 Java 方法上加
@Tool,它会自己决定什么时候调你的查订单、查库存方法,把真实数据库结果填进回答。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、结构化输出、entity
摘要建议(≤256 字):大模型默认输出自由文本,人看得懂程序没法算钱。本文用.entity(Order.class)一句话把"我要买2个机械键盘和1个鼠标垫"直接解析成 Order JavaBean,实测总价 847、口语句 1446 全算对;同时手写对照版展示它内部替你剥代码块、解析 JSON。逐行拆解 POJO 零注解玩法与错误容忍兜底,附 3 道挑战题与面试回答模板,源码在 gitee lesson-03 可 clone 直接跑。