如果你做过几个真实的Spring AI项目大概率会遇到同一个尴尬场景模型回答写得很漂亮但你要把回答里的字段存进数据库、推进审批流、做统计却发现只能靠正则去抠。我最初做客服工单自动分类时就被“返回1002还是1002.0”这种问题折磨过后来把整套方案切换成Spring AI 1.x的结构化输出API问题才真正收敛。这篇文章不是API文档翻译我会从底层原理讲到完整调用代码再梳理401、上下文长度超限、实体绑定失败这些高频坑最后聊文档解析和Agent编排的进阶用法。适合已经跑通过基础对话接口、想让模型输出真正可用于业务的Java开发者。1. 为什么项目越做越离不开结构化输出1.1 被自由文本统治的日子正则解析的噩梦最早接大模型做客服工单分类时我的做法特别简单粗暴把工单内容丢给模型让它返回一段话比如“该工单属于账号问题建议转给账号支持组优先级高”。听起来没问题但接下来的活全是坑。工单号要提取、客户等级要提取、问题类别要提取、处理优先级要提取于是我在代码里写了十几个正则表达式在纯文本里找。一开始还勉强能用后来发现模型只要换个说法就漏。金额一会儿是“998.00”一会儿是“998元”客户等级一会儿是“S/A/B”一会儿是“高级客户”更离谱的是模型偶尔还会多解释一句“以下是分析结果”把正则的锚点全部打乱。这种代码维护一段时间后我唯一的感受就是想删掉重来。因为每次改prompt都可能引发连锁变化你在正则里修的每一个边界模型换一次措辞就失效了。打个比方大模型像一个表达能力极强但纪律性很差的实习生。你让他“把这个表填一下”他给你写了一篇带排比句的作文。信息都在句子也确实通顺但你的业务系统不是用来读作文的它只认字段和值。1.2 “请返回JSON”这条路为什么走不通很多人第一反应是给prompt加一句“请以JSON格式返回”我试过有效但不可靠。最典型的问题有四类模型偶尔把JSON包在Markdown代码块里直接JSON.parse会报错输出里夹杂解释性文字比如“好的以下是抽取结果{...}”字段名不稳定这次是partyA下次是party_a再下次直接是中文“甲方”还有同样的prompt换一个模型版本就彻底变样。这些问题不是模型“笨”而是自由文本生成的目标函数里根本没有“严格JSON”这条硬约束。你在prompt里写的“请返回JSON”只是软性建议模型在概率分布里采样时总会有一小部分概率跑偏。生产环境里哪怕只有1%的跑偏下游接口就可能报错、数据就会脏排查起来比写代码还痛苦。1.3 结构化输出API到底做了什么Spring AI 1.x的结构化输出API核心思路是把“软约束”变成“软约束硬解析”。第一步是生成约束。它把你定义的Java类型转换成一个JSON Schema描述拼进提示词里告诉模型“你只能按这个结构输出不要输出任何多余的说明”。第二步是严格解析。拿到模型回复后跳过Markdown包裹、跳过额外文字直接用Jackson把内容绑定回Java Bean或者Map。这套逻辑在Spring AI里被抽象成了StructuredOutputConverterT接口以及一系列实现类。更进一步的封装是ChatClient的entity()方法它把整个流程折叠成一行调用这也是绝大多数项目里最常用的写法。需要提醒的是别把它当神器模型仍然有小概率输出坏JSON但比例会从“经常发生”降到“偶尔发生”而且一旦出错异常信息里能直接暴露原始输出调试起来轻松很多。2. 三种常用转换器以及各自的适用边界2.1 BeanOutputConverter直接绑定Java Bean的首选如果返回结果在代码层面能定义成一个固定的类或record第一个要用的就是BeanOutputConverter。它的构造方式有两种直接传Class或者传TypeReference。直接传Class的写法最简单BeanOutputConverterContractInfo converter new BeanOutputConverter(ContractInfo.class);但项目里遇到泛型的情况很常见比如你要绑定的是ListContractInfo。这时候Class对象拿不到真实的泛型参数必须用TypeReferenceBeanOutputConverterListContractInfo converter new BeanOutputConverter(new TypeReferenceListContractInfo() {});为什么我强调这一点因为在Java里泛型会被擦除List.class只代表“这是一个List”至于List里面装的是ContractInfo还是别的什么Class对象完全不关心。而BeanOutputConverter需要根据完整类型生成JSON Schema如果看不到元素类型它就没法告诉模型“数组里的每个对象应该长什么样”绑定自然失败。它的用法也很直白。getFormat()返回一段提示词模板你把它拼到用户消息后面convert(String)拿到模型回复后转成目标对象。最底层的手写调用长这样String responseText chatModel.call( new Prompt(userText \n converter.getFormat()) ).getResult().getOutput().getText(); ContractInfo info converter.convert(responseText);这样写的好处是中间每一步都可观测适合想看清楚模型到底输出了什么的人。日常开发其实用不上这么原始但理解了这层逻辑后面排查问题会顺手很多。2.2 MapOutputConverter字段不固定时的兜底方案有些场景你根本不知道模型会抽出哪些字段。比如我调试阶段经常做的事情是把一份完全没见过的文档丢给模型让它“把里面出现的公司名、人名、时间、金额能抽多少抽多少”。这种时候不可能预先定义好Bean用MapOutputConverter最合适。MapOutputConverter converter new MapOutputConverter(); String format converter.getFormat(); MapString, Object result converter.convert(responseText);MapOutputConverter返回的是MapString, Object好处是结构灵活模型输出的任何字段都能接住坏处是没有类型安全你拿到一个Object后得自己判断它是String、Integer还是List。我一般把它当“探索性工具”用先看看模型面对某类文本时到底会输出什么结构确认字段稳定之后再定义对应的record切回BeanOutputConverter。2.3 StringOutputConverter和手动转换轻量场景的选择StringOutputConverter适用场景比较窄你只想要一段干净的文本比如摘要、翻译、润色结果。它同样会往提示词里加“不要输出解释文字”这样的约束然后直接把模型的输出作为String返回。如果你的需求只是“把这段文字变成一段通顺的总结”用它就够了不需要定义任何结构。手动转换则是我个人很推荐的一种习惯。所谓手动转换就是不依赖entity()的自动编排自己在代码里组装“清理模型输出Jackson反序列化”这两个步骤。原因很简单可以在中间加日志把模型原始输出和清理后的JSON都记录下来。一旦线上出了解析问题日志能直接告诉你模型到底返回了什么鬼东西而不是看到一个笼统的解析异常。这个习惯帮我在无数个排错现场快速定位问题比翻模型调用日志高效得多。2.4 选型对比表转换器返回类型适用场景注意点BeanOutputConverterT固定Bean/record业务字段明确、下游强类型消费泛型必须用TypeReferenceMapOutputConverterMapString,Object字段不固定、快速探查模型输出拿到Object后需自行判断类型StringOutputConverterString只要干净文本不需要字段校验无法防结构变化手动ObjectMapper自定义需要中间日志、要自己控流程必须自己处理脏JSON和杂音就我的项目经验来说只要业务上能定义出DTO优先选BeanOutputConverter或者ChatClient.entity()。Map和String适合做前期探索和轻量场景长期跑生产不建议。3. Converter背后的原理约束、解析与entity()封装3.1 getFormat()到底给模型看了什么很多人在代码里用了getFormat()但没真正看过它返回的内容。我调试的时候打印过里面是一段英文指令加上一串JSON Schema。核心意思大概是你的回复必须是JSON格式不要输出额外解释不要用Markdown代码块包裹JSON结构必须匹配以下Schema。模型看到这串内容后会对输出格式形成非常强的上下文约束。这和你随手写的“请返回JSON”差别很大。随手写的“请返回JSON”模型理解的是“我要JSON”但JSON长什么样、字段叫什么、类型是什么全靠模型自由发挥而getFormat()给的是精确到每个字段类型和结构的Schema模型没有发挥空间。这个过程表面上是调一个方法实际上模型端已经拿到了一份“填表说明”。这也是为什么用结构化输出API之后脏数据比例能明显下降。因为约束被显式编码成了模型必须遵循的格式规范。3.2 ChatClient的entity()是如何替你干活的很多人第一次接结构化输出就是用这行代码ContractInfo info chatClient.prompt() .user(请从合同中抽取关键信息) .call() .entity(ContractInfo.class);这行代码看着简单但背后替你完成了三件事先根据ContractInfo.class构造一个BeanOutputConverter然后把converter.getFormat()生成的约束拼进用户消息最后拿到客服端的原始文本后调用convert()转成ContractInfo。换句话说它和我上一节写的手动代码在逻辑上完全等价只是把细节藏了起来。如果你想处理泛型对应的写法是这样的ListContractInfo list chatClient.prompt() .user(请从多份合同中抽取关键信息) .call() .entity(new ParameterizedTypeReferenceListContractInfo() {});这里用的是ParameterizedTypeReference而不是普通Class。原因和BeanOutputConverter一样泛型擦除导致Class无法携带元素类型信息entity()无法生成完整的JSON Schema所以你必须用ParameterizedTypeReference把类型信息传进去。3.3 泛型、JSON Schema与类型安全的关系深入一点看整个结构化输出的链路是由类型驱动来工作的你定义的Java类型决定JSON SchemaJSON Schema决定模型输出的JSON结构模型输出的JSON再被Jackson反序列化回Java类型。所以类型的完整性直接决定整条链路是否成立。这就是为什么泛型不能用Class、为什么record要用JsonProperty显式声明别名、为什么字段类型要用BigDecimal而不是Double来处理金额。每一个细节最终都会反映到JSON Schema上。如果类型定义得随意模型端拿到的Schema就混乱绑定自然不稳定。把这层关系理清楚之后你大概率会养成一个好习惯先认真设计DTO再写业务逻辑。4. 一份可以直接抄的“招标文件关键信息抽取”代码4.1 需求拆解页码、章节、段落都要结构化我先说一个真实场景。客户发来一份招标文件PDF希望能自动提取出合同编号、甲方、乙方、合同金额、签订日期并且把每个关键条款都对应到原文的页码、章节和段落。这个需求听起来不复杂但直接塞给模型就出问题招标文件很长整本喂进去容易顶爆上下文抽出来的条款如果不知道它在原文哪里下游做审批、归档、审计都没法定位。我的方案是先用文档解析把PDF按页切成片段再对每一页做结构化抽取把页码信息直接写进DTO里。这样最终产出的每一条结构化数据都自带页码和章节信息正好对应上“结构化文本包含页码、章节、段落”的需求。4.2 DTO与完整调用代码先定义DTO我用的是record加上JsonPropertypublic record ContractInfo( JsonProperty(contract_no) String contractNo, JsonProperty(party_a) String partyA, JsonProperty(party_b) String partyB, JsonProperty(amount) BigDecimal amount, JsonProperty(sign_date) String signDate, JsonProperty(clauses) ListClause clauses) { } public record Clause( JsonProperty(page_number) int pageNumber, JsonProperty(chapter) String chapter, JsonProperty(content) String content) { }这里用JsonProperty不是为了好看而是要让Jackson的字段名和模型输出的JSON字段名保持一致。如果你不给注解Java字段用的是驼峰风格JSON Schema里也默认生成驼峰但模型训练数据里下划线风格字段出现频率也很高它就容易自由发挥。显式声明别名之后模型端拿到的Schema会明确要求使用contract_no这种命名跑偏概率低很多。接下来是两套调用代码。第一套是底层手动写法适合想看到完整过程的人BeanOutputConverterContractInfo converter new BeanOutputConverter(new TypeReferenceContractInfo() {}); String promptTemplate 请从下面的招标/合同文本中抽取关键信息。 文本内容 %s %s ; String prompt promptTemplate.formatted(pageText, converter.getFormat()); String content chatModel.call(new Prompt(prompt)) .getResult() .getOutput() .getText(); ContractInfo info converter.convert(content);第二套是日常推荐的ChatClient写法ContractInfo info chatClient.prompt() .user(u - u.text(请从下面的招标/合同文本中抽取关键信息\n\n{contractText}) .param(contractText, pageText)) .call() .entity(ContractInfo.class);两套写法最终效果一样。区别在于第一套可以在content拿到手之后加日志、做清理、甚至打点监控适合你在排查线上问题时用第二套代码量更少适合稳定运行后保持整洁。4.3 工具函数与Agent编排中的结构化产物结构化输出不只用于最终答案更可以用于Agent的工具返回值。我做过一个售后工单路由Agent它需要根据工单内容判断应该转给哪个部门、优先级多高、置信度多少。这个判断结果如果让模型用自然语言返回我后面还得再从文本里解析一次等于绕回了正则噩梦。正确做法是把这个结果定义成结构化的工具返回值public record RouteResult(String department, String priority, double confidence) { } Component public class RoutingTools { Tool(description 根据工单内容判断需要转交的部门) public RouteResult route(Ticket ticket) { // 实际业务逻辑分类、打分、计算置信度 return new RouteResult(账号支持, P1, 0.97); } }然后让Agent在决策时调用这个工具String answer chatClient.prompt() .user(请分析这个售后工单并决定下一步动作) .tools(new RoutingTools()) .call() .content();这样一来工具的入参和出参都是BeanAgent的推理发生在结构化数据之上。下游系统可以直接消费RouteResult完全不需要再从Agent的最终文本里提取信息。这个设计让整个链路的分工清晰了很多模型负责理解和决策工具负责输出可靠的结构化数据。5. 踩坑实录401、上下文长度超限与绑定失败5.1 401 unauthorized的完整排查链路先看一个常见的报错unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****。这行报错说明API key不正确而不是网络不通、模型不存在之类的问题。我见过太多次因为key配置导致401的案例总结出下面这套排查顺序。第一步检查key有没有复制全字符串首尾是否带着空格或换行。很多人从.env文件或者聊天工具里复制key一不留神就带上了隐形字符请求发出去API服务端校验失败。用IDE打开字符串变量看或者直接打印[ apiKey ]确认首尾。第二步检查环境变量是否互相覆盖。最常见的场景是代码里配置了OPENAI_API_KEY但部署机器的环境变量里同时有别的key变量Spring配置的key优先级被覆盖成了另一个值。排查方法是把配置来源捋清楚看当前生效的到底是哪个变量。第三步检查base-url是否正确。如果你配置的endpoint和key不是同一个服务的服务端拿你的key去对应的平台校验自然也是401。Spring AI里用spring.ai.openai.base-url这类配置指定endpoint务必和控制台里提供的一致。第四步检查key的权限范围。有些key在子账号下创建只被授权访问部分模型。你代码里切到一个没有权限的模型也会报401。最后用curl做一个最小化验证把框架因素全部排除掉curl -i https://your-model-endpoint/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer ${YOUR_KEY} \ -d {model: your-model, messages: [{role: user, content: hello}]}如果curl通过、Spring代码还是401问题基本出在配置装载或请求头构造上重点看框架日志里实际发出的Authorization请求头是否和你预期一致。5.2 1M上下文是怎么被撑爆的以及怎么拆另一个高频报错是this models maximum context length is 1048576 tokens。这个数字看起来很大但中文文本换算成token并不少一份几十页的招标文件原文很容易占到几十万token。如果你再叠加system prompt、JSON Schema、历史消息很快就顶到上限。遇到这种情况我的第一反应不是去减小输入而是重新审视“我到底有没有必要把全文一次性交给模型”。最佳解法是拆分先用文档解析把长文档按页或按段落切开分别做结构化抽取最后再聚合。如果你用的模型上下文确实很大但任务本身不需要全文那就必须切。切的时候注意保持每个片段的信息完整性保证关键字段不会被切断。比如合同金额可能在第5页出现但合同编号在第3页这时候对每一页单独做结构化抽取能抽到多少字段抽多少抽不到的字段该为空就为空最后做合并时按业务优先级兜底。另外一定要设置输出token上限避免模型“自由发挥”生成超长内容。别以为输入不超就没事输出一样会占用上下文额度。5.3 泛型丢失、record反序列化失败和JSON杂音绑定阶段的坑我总结下来有三个最典型。第一个是泛型丢失。前面反复强调过绑定ListContractInfo时如果用Class而不是ParameterizedTypeReferenceJackson和转换器都拿不到真正的元素类型运行时就会抛异常。这个问题在小规模demo里可能遇不到一旦数据量上来、模型真的输出一个数组必现。第二个是record反序列化失败。Jackson绑定record需要构造函数参数名。Spring Boot的Maven插件默认会编译期加上-parameters参数所以Spring Boot项目里基本不会遇到这个问题但如果你是在非Spring Boot的裸Java工程里用Spring AI就要注意给编译器加参数否则record字段要么反序列化失败要么全部变成null。排查这种问题时先看一眼编译出的类里有没有保留参数名比改一堆代码高效得多。第三个是JSON杂音。模型偶尔会把结果包在Markdown代码块里或者在前缀加一句解释。虽然getFormat()里已经明确要求“不要附加任何内容”但模型还是会犯错。我在生产环境里会加一个小工具方法在交给converter之前先清理private String trimJsonFence(String raw) { return raw.replaceAll(^\\s*(json)?\\s*, ) .replaceAll(\\s*\\s*$, ) .trim(); }如果你用的是比较新的Spring AI 1.x小版本还可以用StrictJsonMessageConverter这类消息转换器对模型输出做更严格的要求。但最稳妥的方案始终是先清理再解析最后用异常日志兜底。5.4 用“回放测试”把脏输出拦截在回归里做结构化输出越久我越觉得“回放测试”是性价比最高的护栏。思路很简单把模型的历史输出保存成固定的JSON文件不管它是正常结果还是脏数据都放在测试资源目录里。测试用例里直接用converter.convert()去解析这些样本断言字段是否齐全、类型是否正确、JSON杂音是否被清理干净。Test void should_parse_contract_fixture() { String raw TestResource.read(fixtures/contract_raw.json); ContractInfo info converter.convert(trimJsonFence(raw)); assertEquals(HT-2024-001, info.contractNo()); assertEquals(2, info.clauses().size()); assertEquals(5, info.clauses().get(0).pageNumber()); }这么做的好处是每次调整提示词、升级模型版本、改动DTO时都能在CI里立刻看到哪些历史输出结构被破坏。我踩过太多次“今天修好了明天又炸”的坑原因就是没人守着历史样本。结构化输出的稳定性不是靠一时运气而是靠一组固定的回放样本长期守出来的。6. 进阶文档解析、Agent编排与跨框架迁移6.1 文档解析怎么输出带页码、章节的结构化文本上一章提到的招标文件抽取前置依赖就是文档解析。Spring AI里有一组DocumentReader按粒度不同分为按页、按段落、按表格等。我用得最多的是PagePdfDocumentReader它会把PDF按页切分成Document列表每一页的页码会放在metadata里PagePdfDocumentReader reader new PagePdfDocumentReader( new FileSystemResource(/tmp/tender.pdf), PagePdfDocumentReader.builder() .pageTopMargin(20) .pageBottomMargin(20) .build() ); ListDocument pages reader.get(); for (Document page : pages) { int pageNumber (int) page.getMetadata().get(page_number); // 把page.getText()交给结构化输出方案抽取结果里带上pageNumber }如果你需要更细的章节维度可以换ParagraphPdfDocumentReader它会按段落切分metadata里同样能拿到页码信息如果文档里有大量表格TablePdfDocumentReader更适合。这里有个接线板级的重要提醒上述解析器只对文本型PDF有效。如果PDF是扫描件必须先做OCR否则解析出来的文本是空的或者乱码。判断方法很简单用PDF阅读器直接选中一段文字能选中就说明是文本型选不中就乖乖加OCR前置。文档解析和结构化输出组合起来才能算真正把“产出带页码、章节、段落的结构化文本”这个需求落地。否则模型能力再强输入都是乱糟糟的一坨输出也不会好到哪去。6.2 Agent编排工具返回结构化结果模型负责决策再展开一下Agent编排。现在很多项目开始做多工具Agent流程是模型读任务、决定调哪个工具、拿到工具结果、再决定下一步动作。这里最容易犯的错误是让工具返回自然语言描述模型再把描述转述一遍最后你从转述的文本里解析关键信息。一圈下来结构化信息被稀释得不成样子。用Bean做工具返回值模型拿到的就是结构清晰的字段。它不需要再“解释”工具结果而是基于字段做判断效率和质量都高很多。我在上一章的售后工单路由例子里RouteResult里的confidence字段直接参与模型下一步决策置信度高模型直接确认转交置信度低模型会追问更多信息。如果没有结构化字段这种精细化的控制根本无从谈起。如果你用了Spring AI的ToolCallingManager或者更高层的Agent API思路是一样的尽量让每个工具的入参和出参都是Bean不要用String硬扛。这样Agent的可编排性、可测试性、可观测性都会好很多。6.3 Dify工作流转成Spring AI Java代码时结构化配置怎么迁最近不少人在找“Dify工作流转成Spring AI Java代码”的脚手架我也处理过几次这类迁移。Dify的可视化工作流里LLM节点和结构化输出节点本质上是“一段Prompt加一份JSON Schema”。迁移到Spring AI时思路并不复杂LLM节点对应ChatClient调用结构化输出节点对应BeanOutputConverter或entity()。最容易踩坑的地方是字段映射。Dify里你定义的结构化字段如果叫contract_no到了Java DTO里就不能只写contractNo而不加映射。因为LLM节点里的Schema已经在引导模型输出下划线风格Jackson绑定时如果字段名对不上结果就是整段JSON解析成功但Bean里全是null。解决办法是用JsonProperty把字段名对齐Dify Schema里叫什么Java端就映射到什么。如果工作流里还有知识库检索节点迁移时对应Spring AI的DocumentReader或者VectorStore检索把检索结果作为上下文拼进结构化抽取的提示词里。这个节点迁移的核心是保住“文档经过检索后再交给结构化输出”的顺序顺序变了效果会差很多。6.4 Spring AI Alibaba与百炼Qwen模型的配合最后聊一下阿里生态。spring-ai-alibaba把DashScope封装成了标准的ChatModel实现所以BeanOutputConverter、ChatClient.entity()这一整套结构化输出API在百炼Qwen模型上是可以直接沿用的。配置的时候注意endpoint和model名称要和DashScope控制台保持一致模型版本号不同对JSON格式的服从度也会略有差异。这里有一个小建议不管接的是Qwen还是其他模型DTO里的字段别名都用JsonProperty固定下来。因为不同模型训练数据对字段命名风格的偏好不一样有的倾向驼峰有的倾向下划线。显式固定别名后模型端看到的Schema里字段名是唯一的天然减少风格漂移。哪怕你下一个项目准备上“Spring AI 2.0连接百炼qwen3.7”这类新组合1.x阶段积累的DTO设计、converter使用习惯、文档解析选型经验基本都能平移过去。把结构化输出的底层逻辑吃透换模型换版本都只是换配置的事。我自己的体会是结构化输出API把“让模型写作文”改成了“让模型填表格”项目的可维护性高了一大截。如果你也正被自由文本折磨下次接到模型返回值先别急着写正则问自己一句这个结果能不能定义成一个record能不能交给BeanOutputConverter至于模型偶尔还是输出奇怪东西也别马上堆提示词先把converter的报错和原始输出打出来看多半是字段类型或JSON杂音的问题。祝你的Spring AI项目少踩坑。