资讯动态

第8章:Spring AI 2.0结构化输出

发布时间:2026/10/13 15:49:13 来源:尧图企业网站定制
上章思考题答案先看服务器是否收到了取消。若聚合回调没执行或finishReason为空是订阅提前结束。若聚合全文比页面短一个字而订阅正常STOP再查页面是否把重叠 delta 拼重或拼漏。日志要同时打聚合长度和页面上报长度。流式超时应从请求开始算到流结束并按整段预算设置而不是沿用同步接口的 8 秒。第一块到达后把超时清零长回答仍会在后半段被掐断。1. 项目背景1.1 业务场景退款不能只停留在一段中文。工单系统要写入订单号、原因、金额、是否可退。邻里仓规定未发货可退金额先记 0是否可退为 true 或 false不能是「大概可以」。客服话术可以仍给人看但入库的必须是一个类型固定的对象。本章用BeanOutputConverter生成 JSON 说明并解析模型输出用StructuredOutputValidationAdvisor在字段缺失时把错误发回模型最多再试一次。给人看的句子可以仍是中文说明入库的必须是RefundTicket。订单号、原因、金额、是否可退四个字段缺一不可。金额未知写 0是否可退只接受 true 或 false。形状不对就不要建工单也不要用默认值把缺字段补成「不可退」或「0 元」。那会把解析失败伪装成业务结论。1.2 痛点让模型「用 JSON 回答」而不给模式它会在 JSON 前后加「好的如下」。解析器遇到说明文字就失败工单没建成用户却以为已经登记。有人用正则截取花括号遇到原因字段里本身有括号就截错。缺字段时若直接入库金额变成 null财务对账中断。没有转换器时的路径散文入库 → 人工抄单或脆弱正则 → 截错或缺字段仍当成功 → 脏数据。人工抄单在邻里仓的晚高峰会积压。正则遇到原因里的括号会把后半段截掉金额串到原因字段里财务对账对不上。缺字段若被收成 0 和 false报表上全是「不可退、金额 0」和「模型没填」无法区分。转换器和校验顾问把形状失败变成可见的拒绝工单表里只留下四格都齐的记录。业务真假仍由店规和测试样本约束不由 Schema 保证。是否退款问题BeanOutputConverter 提供格式DeepSeekJSON 文本Schema 通过?RefundTicketValidationAdvisor 再试一次2. 项目设计小胖说「这不就是点菜写成小票吗菜名、数量、能不能退四格填上。模型念完我再抄进系统。」小白问「它不填金额那一格呢抄的人用 0 填上和模型真的说了 0财务分得开吗重试的时候把错误告诉它它会不会改口把不可退改成可退来迎合你」小胖又说「那我就在提示词里写『必须是 JSON』以前也这么干。」小白追问「必须是 JSON 挡不住前后的客套话。你的解析器吃的是整段content()。客套话在第一个花括号外面。流式一块块来的时候你打算等拼完再验还是每一块都验」大师把对象定下来「定义 recordRefundTicket(String orderId, String reason, int amount, boolean refundable)。BeanOutputConverter根据这个类型生成格式说明和 JSON Schema。getFormat()放进系统提示词convert把模型文本转成对象。StructuredOutputValidationAdvisor用同一类型的 Schema 验输出。失败时把错误追加到对话里再调用模型次数由maxRepeatAttempts控制。本章设为 1也就是最多再试一次。这个顾问的adviseStream直接抛UnsupportedOperationException所以工单接口用同步call()不要用第 7 章的流。」技术映射小票四格是 record印刷在票面上的填写说明是getFormat()验票是StructuredOutputValidationAdvisor。小白又问格式说明的位置「getFormat()是一段英文说明要求只返回 JSON、不要 Markdown。把它放进用户消息还是放进系统消息用户原话里若再写一段自己的 JSON解析器会抓哪一段」小胖说「放用户消息后面模型离得近。」小白说「用户消息会被记进对话。格式说明不是用户的话。放错位置下一章的记忆会把这段英文也当成历史。而且用户若粘贴了一段不完整的 JSON整段输出里会有两段花括号。」大师指定位置「格式说明跟在店规后面放进这一次的系统消息。用户消息只留退款问题。convert吃的是模型整段输出所以系统消息里写明不要 Markdown、不要前后客套。用户粘贴的内容不要直接当模型输出去convert。转换器的输入是助手消息不是用户消息。助手消息前后若仍有说明文字先视为形状失败让顾问按上限再试一次而不是用正则从中间挖一段。」小白问重试的风险「错误信息里如果写着缺refundable模型会不会为了通过校验编一个 true未发货才可退。已发货的单被编成可退事故比解析失败更大。」小胖说「能进工单就行人工再改。」小白反对「人工改不回来已经通知用户的『可以退』。重试应该失败可见而不是悄悄改判定。」大师给约束「系统提示词写明判定规则未发货才可退金额未知时填 0不要发明优惠。校验顾问只负责形状不负责业务真假。重试一次后仍失败接口返回解析失败不入库。测试准备 5 条字段齐全的合法样本和 3 条故意缺字段的模型输出。缺字段样本不必都打到 DeepSeek可以把坏 JSON 直接交给convert和顾问能复用的校验路径。合法样本里的真实调用至少跑一条。」技术映射形状重试和业务判定是两件事。形状失败不入库。业务规则写在提示词和测试样本里。小胖问金额类型「用字符串存金额最省事什么都能接。」小白问「『大约 10 元』进了 int 字段会怎样是转换异常还是变成 00 和『不可退』在工单上意思不一样。原因字段里用户写了『不要退』和 refundable 为 false 又是不是一回事」小胖说「字符串全收下人工再看。」小白说「人工看不过来。类型选错脏数据会先入库再等人发现。」大师说「金额用int单位是分或元要在字段注释和提示词里写死本章示例用元的整数。转换失败视为形状失败不把异常吞成 0。refundable用 boolean不要用『是』『否』字符串。订单号必须等于请求里的订单号这是业务断言Schema 只能保证它是字符串。原因字段保留用户原话的摘要但不参与是否可退的判断。是否可退只看店规未发货为 true。原因里出现『不要退』时应走取消咨询而不是把 refundable 改成 false 来迎合这一句。」技术映射类型选择是工单契约。Schema 保证形状订单号一致性由测试断言。原因摘要不推翻店规。3. 项目实战3.1 环境准备沿用现有工程和ChatClient。不新增中间件。工单接口禁用流式。准备样本文件src/test/resources/refund-samples.json内含 5 条合法 JSON 和 3 条缺字段 JSON。合法样本的订单号使用LC202610060018到LC202610060022。样本和真实调用分开。八条样本只喂给convert或 Schema 校验不打到 DeepSeek因此没有密钥也必须能跑。真实调用只保留一条未发货咨询用环境变量开关包起来。maxRepeatAttempts在建造顾问时写成 1不要依赖默认值 3。默认 3 意味着形状一直不合格时最多再打三次模型费用和超时都会超出工单接口的预算。系统消息里同时放店规和getFormat()用户消息只放问题。校验顾问不要挂到第 7 章那个用于流式展示的客户端上adviseStream会抛UnsupportedOperationException。3.2 分步实现步骤 1定义 record 和转换器步骤目标本地就能把一条合法 JSON 变成对象不访问网络。packagecom.linlicang.aftersale;importorg.springframework.ai.converter.BeanOutputConverter;publicrecordRefundTicket(StringorderId,Stringreason,intamount,booleanrefundable){publicstaticBeanOutputConverterRefundTicketconverter(){returnnewBeanOutputConverter(RefundTicket.class);}}Stringraw {orderId:LC202610060018,reason:未发货,amount:0,refundable:true} ;RefundTicketticketRefundTicket.converter().convert(raw);运行结果文字描述ticket.orderId()为LC202610060018refundable()为 trueamount()为 0。getFormat()打印出来是一段要求按 Schema 输出的说明里面含有字段名。可能的坑模型输出包在 Markdown 代码块里。convert若拒绝这种包装要在提示词里写「不要使用 Markdown」。不要先用正则随便截取。另一坑是金额写成0.5int 转换失败。提示词写明整数元。这一步不访问网络。合法 JSON 能变成对象说明 record 的字段名和 JSON 键一致。getFormat()打印出来应包含四个字段名。测试把这段说明和店规拼在一起之前先单独看它避免店规里的中文把格式要求淹没。金额单位在提示词里写成整数元。用户说「大约十块」时模型应填 10 或在失败时被拒绝不要在 Java 里把转换异常收成 0。0 在工单上表示「金额未知」和「转换失败」必须分开。步骤 2缺字段样本必须失败步骤目标3 条坏样本不入库。Stringmissing {orderId:LC202610060018,reason:未发货,amount:0} ;org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,()-RefundTicket.converter().convert(missing));具体异常类型以BeanOutputConverter.convert的实现为准。断言使用assertThatThrownBy并检查消息里能看到失败而不是断言某一个内部类名永远不变。运行结果文字描述缺refundable的文本抛异常。测试名称写明样本编号5 条合法样本全部convert成功。可能的坑缺字段时 Jackson 把 boolean 收成 false。若你的转换器没有因为缺字段失败不能靠refundable()false判断「模型没填」。应看 Schema 校验是否报缺字段。这正是下一步顾问的职责。样本文件由测试部门维护五条合法、三条缺字段。合法样本的订单号从LC202610060018排到LC202610060022原因和金额各不相同避免五条其实是同一条复制。缺字段样本分别去掉refundable、amount、orderId。断言只要求转换失败不把某一个内部异常类名写死。类名随 Jackson 版本变化失败这件事不应该变。这八条在没有密钥时也要跑它们是工单契约的回归不是模型效果评估。步骤 3挂上校验顾问并真实调用一次步骤目标DeepSeek 返回的 JSON 能变成RefundTicket失败时最多再请求一次。packagecom.linlicang.aftersale;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.chat.client.advisor.StructuredOutputValidationAdvisor;publicclassRefundTicketService{privatefinalChatClientchatClient;publicRefundTicketService(ChatClient.Builderbuilder){varadvisorStructuredOutputValidationAdvisor.builder().outputType(RefundTicket.class).maxRepeatAttempts(1).build();this.chatClientbuilder.defaultAdvisors(advisor).build();}publicRefundTicketcreate(StringorderId,StringuserText){StringsystemAfterSalePrompts.renderSystem(orderId,App,简短,policy-2026.10)\nRefundTicket.converter().getFormat()\n未发货则 refundable 为 true金额未知填 0。不要 Markdown。;returnthis.chatClient.prompt().system(system).user(userText).call().entity(RefundTicket.class);}}运行结果文字描述问题「订单 LC202610060018 未发货能不能退」得到refundabletrueorderId与入参一致amount为 0。日志里若发生重试能看到顾问的校验失败信息然后是第二次调用。最终对象非空。可能的坑outputType和entity的类型不一致顾问按 A 验、解析按 B 解析。两边都用RefundTicket.class。另一个坑是maxRepeatAttempts设得很大模型反复编造费用上涨。本章固定为 1。真实调用只抽一条未发货咨询避免样本文件里的五条都打到 DeepSeek。成功时订单号必须等于入参refundable为 true金额为 0。若模型把订单号改成别的号码Schema 仍然可以通过因为订单号只是字符串。业务断言要写在测试里不能交给 Schema。重试日志若出现说明第一次的形状不合格。最终对象非空即可不必断言一定发生了重试。没有重试的成功也是成功。发生重试时把顾问打出的校验路径记下来例如缺了哪一个字段不要把模型第一次的原文写进普通日志。原文可能含有用户留下的手机号。字段路径足够让开发判断是提示词没约束住还是模型在 JSON 前后加了客套话。步骤 4重试仍失败则不入库步骤目标形状失败不会变成工单。在服务里捕获转换或校验最终失败返回空的Optional并计数rejected。测试用一个假的ChatModel第一次和第二次都返回缺字段 JSON断言Optional为空且call次数不超过 2初次加一次重试。运行结果文字描述假模型被调用 2 次工单列表仍为空。没有 DeepSeek 费用。可能的坑顾问重试发生在内部业务又自己重试一轮总次数变成 4。业务层不要再包一层无条件重试。假模型两次都返回缺字段 JSON 时call不超过 2工单列表仍为空。这个用例不需要密钥用来卡住「失败也入库」。生产上最终失败应返回「没登记成功」让用户再描述一次而不是展示一个字段为空的退款单。空对象和失败不是一回事。接口用Optional空表示失败调用方不得把空解成金额 0、不可退。3.3 完整代码清单RefundTicket、RefundTicketService、refund-samples.json、测试。源码对照spring-ai-model/.../converter/BeanOutputConverter.javaspring-ai-client-chat/.../advisor/StructuredOutputValidationAdvisor.java3.4 测试验证无密钥测试读 8 条样本5 条convert成功3 条失败。有密钥测试只跑一条真实未发货咨询断言订单号一致、refundable为 true、金额为 0。TestEnabledIfEnvironmentVariable(namedDEEPSEEK_API_KEY,matches.)voidliveTicketMatchesOrder(){RefundTicketticketservice.create(LC202610060018,这单未发货能不能退);assertThat(ticket.orderId()).isEqualTo(LC202610060018);assertThat(ticket.refundable()).isTrue();assertThat(ticket.amount()).isZero();}测试部门维护样本文件。开发不把样本写死在 Java 字面量里避免和测试各改各的。4. 项目总结4.1 优点与缺点对比项散文加正则BeanOutputConverter 加校验顾问字段缺失可能变成默认值校验失败前后客套话正则易错格式说明要求纯 JSON重试各写各的顾问最多再试一次流式看起来还能截顾问不支持流业务真假都不保证仍不保证只保证形状优点工单字段有 Java 类型不是一串散文。合法与非法样本可以不花钱就回归。形状错误会再给模型一次机会而不是立刻失败。重试次数有上限。订单号一致性可以单独断言。缺点模型仍可能编造refundabletrue。不支持流式。重试意味着最坏两次调用的费用。Markdown 包裹会让解析失败。4.2 适用场景适用退款、补发、地址修改这类要入库的工单。测试能提供固定 JSON 样本。失败时可以告诉用户「没登记成功」而不是展示半个对象。字段集合稳定。需要把形状错误和业务规则分开。不适用只给人看的闲聊。不要为了一句问候付出 Schema 成本。必须边生成边展示 JSON。顾问不支持流强行使用会抛异常。4.3 注意事项maxRepeatAttempts保持 1除非你算过费用。提示词声明金额单位和「不要 Markdown」。订单号以请求参数为准不信任模型回写的另一个号码。不要在校验失败时用 0 和 false 填默认值后入库。4.4 常见踩坑工单金额全是 0但用户说的是 20 元。根因是转换失败被吞掉代码写了 0。处理转换失败不入库。已发货仍显示可退。根因是重试提示只说「补全字段」模型用 true 填空。处理提示词写明判定规则并用已发货样本做回归。流式接口突然 500。根因是把校验顾问挂到了流式客户端。处理工单走同步客户端流式客户端不要挂这个顾问。4.5 思考题模型返回的orderId与请求参数不一致但 Schema 通过了。你入库用哪一个号码另一个号码应记在哪里供审计重试时把校验错误追加进用户消息错误文本里若包含内部字段名会不会把内部命名暴露给后续日志日志应该记录 Schema 路径还是记录模型原文答案在第 9 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑