news 2026/10/8 6:57:46

Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON

作者:鱼宵 | 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()用的就是它
JsonParserSpring 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 重试。

五、挑战题:改参数,看看会怎样

  1. ⭐加个字段:给 Order 加一个private String customerNote;(备注),补上 getter/setter,重启再调/extract,text 传"帮我周六送到"——看模型会不会主动把这句填进新字段。答案就在 Order.java,加完字段你就懂"POJO 长什么样模型就输出成什么样"。
  2. ⭐⭐掐断 token:把 yml 的max-tokens改成 50,再调/extract——观察是 500 还是输出半截 JSON。然后拿 try-catch 包一层返回友好错误。这题跑一遍你就明白为什么生产里 max-tokens 要按对象复杂度留足。
  3. ⭐⭐删掉价目表:把 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/setterSchema 缺字段、模型瞎填补全 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)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring 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 直接跑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 6:57:46

WorkBuddy 能生图、能生视频,只是很多人还没点开那个入口

朋友&#xff1a;"WorkBuddy 不就是另一个 DeepSeek 吗&#xff1f;写写文案、改改文档还行。"我&#xff1a;"你上次做电商详情页那八张图&#xff0c;是不是还在 Midjourney 出图、剪映剪视频、稿定做海报&#xff0c;三个工具来回切&#xff1f;"朋友&a…

作者头像 李华
网站建设 2026/10/8 6:57:22

阿里Java面试参考指南:程序员金九银十突击必备!

谈到Java面试&#xff0c;相信大家第一时间脑子里想到的词肯定是金三银四&#xff0c;金九银十。好像大家的潜意识里做Java开发的都得在这个时候才能出去面试&#xff0c;跳槽成功率才高&#xff01;但LZ不这么认为&#xff0c;LZ觉得我们做技术的一生中会遇到很多大大小小的面…

作者头像 李华
网站建设 2026/10/8 6:56:50

通信协议中的超帧:从SDH S1字节到LTE H-SFN的周期嵌套设计

做传输和无线接入的工程师&#xff0c;对 frame 这个词再熟悉不过。但第一次在 SDH 分析仪上看到 Hyperframe 这个字段时&#xff0c;不少人会愣一下&#xff1a;帧之上有多帧&#xff0c;多帧之上还有个超帧&#xff0c;这层层套娃到底图什么&#xff1f;后来在 LTE 的 eDRX 参…

作者头像 李华
网站建设 2026/10/8 6:56:38

ponytail技能框架实战:统一脚本、配置与排错经验

经常有朋友问我&#xff1a;插件装了一堆&#xff0c;工作流反而更乱了&#xff0c;怎么办&#xff1f;我最近一年都在用ponytail这个工具&#xff0c;它最初只是团队内部一个不起眼的命令行小插件&#xff0c;但磨合下来&#xff0c;居然把之前各自为政的脚本和配置收敛了一大…

作者头像 李华
网站建设 2026/10/8 6:56:25

OpenRig 多智能体编排实战:tmux 与 MCP 构建持久化协作系统

1. 从"一次性对话"到"常驻协作"&#xff1a;OpenRig 要解决的真实痛点如果你最近半年一直在折腾 AI Agent&#xff0c;大概率经历过这样一个阶段&#xff1a;一开始用单文件脚本调 API&#xff0c;感觉挺爽&#xff1b;接着开始加工具调用、加记忆、加多轮…

作者头像 李华