这几个月我一直在折腾基于 Spring AI 的文本抽取类服务,从最开始只敢让模型“输出完事”,到后来把每一类返回结果都锁进 Java 强类型,中间踩的坑足够写满一屏。如果你也遇到同一个问题——模型理解得挺好,但一让它返回 JSON,就会给你夹带一堆 Markdown 代码块、繁体字段名、多余注释,甚至嵌套数组直接少个括号——那这篇实战笔记你应该用得上。
这次要聊的核心路线有三条:引导解码(Prompt 配合 BeanOutputConverter 生成 JSON Schema)、自研工具(用 @Tool 注解的函数调用强制让模型把结构化参数交给你)、原生调用(走模型侧自带的 JSON Mode / Structured Output 能力)。这三条路不是互相替代,而是分别对应不同场景下的“兜底、强约束、最优解”。我会用同一个业务需求——从聊天文本中抽取订单信息——把三条路线完整跑一遍,代码、配置、踩坑、取舍都会给到。
1. 场景与选型:先把“结构化输出”的边界画清楚
1.1 业务需求:从自然语言里稳定抽订单
我手头的项目是某客服系统里的工单转订单模块。客服人员每天都在对话框里接收大量订单说明,比如“用户张三下午三点半下单,要两箱牛奶和三包咖啡豆,总共168.5元,送到朝阳区某街道”,又或者邮件里来一句“请尽快补发一箱A4纸,单价45元,地址是某某仓储中心”。
这些文本本身没有统一格式,但下游订单表是强结构化的:订单号、客户姓名、商品明细列表、总金额、收货地址、期望送达日期。我需要把模型输出的自由文本变成能直接写库的 Java Record,再交给订单服务做后续处理。如果模型返回的 JSON 偶尔多一个字段、少一个逗号、或者金额带着单位字符串 “168.5元”,Jackson 解析那一步就会直接炸掉。
所以说到底,所谓“结构化输出”不是让模型输出好看一点,而是让模型的返回结果在语法层面和业务层面都满足下游系统契约。这个契约一旦打破,轻则一次解析失败,重则数据静默写错,后面核账的时候才发现问题。
1.2 纯 Prompt 约束为什么不够
很多初学 Spring AI 的人第一反应是:在提示词里写一句“请输出JSON格式”不就行了?实测下来,这个思路在演示环境经常能用,但生产环境会把你坑得很惨。
原因在于,大模型本质上是“按概率生成Token”的,JSON 语法只是它训练数据里的一种文本风格,不是它内部的强制约束。它可能在输出 JSON 内容之前加一句“好的,以下是结果:”,也可能在结尾补一个句号,更可能在嵌套对象内部少写一个右括号。这些问题在单次测试时不一定出现,但一旦跑上成百上千条真实数据,失败率就会被放大到不可接受。更麻烦的是,你很难通过调 Prompt 找到稳定复现问题的模式,很多失败样本是不可解释的。
纯 Prompt 约束还有一个隐性成本:每个业务类型都要在提示词里手写一遍 JSON 示例,维护起来非常痛苦。字段一多,提示词就变得又长又难读,模型还容易在长文本里“忘记”你的格式要求。所以我更愿意把格式约束从“人肉维护”变成“程序自动生成”,这也是引导解码这条路线吃香的原因。
1.3 三条路线到底差在哪
在展开实战之前,先把三条路线画出一条清晰的分界线:
- 引导解码:把 Java 类型自动转换成 JSON Schema,塞进 Prompt,让模型“照着格式答题”。约束是软的,胜在实现简单、不依赖模型额外能力。
- 自研工具:给模型一个 @Tool 方法,模型必须按方法签名生成参数 JSON 来调用工具。利用的是函数调用机制对参数结构的强约束,比 Prompt 更硬。
- 原生调用:模型服务商在解码阶段就锁定 JSON 语法,比如 response_format 参数。这是模型侧的原生能力,稳定性最高,但对供应商有依赖。
这三个方案不是非此即彼的关系。我现在的选择习惯是:原生优先、工具兜底、引导解码保底。后面每一章我都会给出完整代码,你对照自己的场景挑着用。
2. 第一重:引导解码,让模型照着 Schema 答题
2.1 原理:BeanOutputConverter 帮你生成格式契约
引导解码的核心思路很简单:你定义一个 Java Record,Spring AI 用 BeanOutputConverter 把它转换成 JSON Schema,再把这段 Schema 拼进提示词,模型看到这段“标准答案模板”之后会倾向于按这个结构输出。
看一个最直接的用法。先定义订单对象:
public record OrderInfo( String orderId, String customerName, List<OrderItem> items, BigDecimal totalAmount, String address, LocalDate deliveryDate ) { public record OrderItem(String productName, Integer quantity, BigDecimal unitPrice) {} }这个 Record 包含了下一阶段要落库的全部字段。Spring AI 会把字段名、类型、嵌套关系全部解析成 JSON Schema。如果你想知道它到底生成了什么,可以手动调一下:
BeanOutputConverter<OrderInfo> converter = new BeanOutputConverter<>(OrderInfo.class); String jsonSchema = converter.getFormat();当你看到这段 Schema 时,基本就明白引导解码的原理了。它相当于在提示词里放了一份“答题卡”,并强调“你的响应必须是符合以下 JSON Schema 的 JSON 对象”。模型如果配合,输出的字段名和类型就自然和 Java 类型对齐了。
2.2 最小实现:ChatClient.entity() 一行搞定
在 Spring AI 1.0 的 ChatClient 里,引导解码已经被封装成了一个很顺手的 API。你不用手动创建 BeanOutputConverter,直接调用.entity(Class)就行:
@Service public class OrderExtractionService { private final ChatClient chatClient; public OrderExtractionService(ChatClient chatClient) { this.chatClient = chatClient; } public OrderInfo extractOrder(String rawText) { return chatClient.prompt() .system("你是订单抽取助手。只输出JSON对象,不要输出Markdown代码块,不要添加任何解释文字。") .user("请从下面的文本中抽取订单信息:\n" + rawText) .call() .entity(OrderInfo.class); } }这段代码的核心逻辑就一行:.entity(OrderInfo.class)。它背后做了三件事:用 BeanOutputConverter 生成 Schema、把 Schema 注入到用户消息里、调用模型获取结果并把文本反序列化成 OrderInfo。
这里有个容易被忽略的点:system 提示词里那句“只输出JSON对象,不要输出Markdown代码块”依然值得写。虽然引导解码已经在 Prompt 里放了 Schema,但模型偶尔还是会在输出里加上```json 这样的围栏标记。这句话能把这个概率再压低一截。我实测从 5% 左右的失败率降到了 1% 以下,成本几乎为零。
2.3 进阶:列表返回和复杂嵌套
有时候你要抽取的不止一条记录,可能是从一段长文本里找出所有订单。Spring AI 的.entity()支持 ParameterizedTypeReference,可以直接转 List:
List<OrderInfo> orders = chatClient.prompt() .system("从文本中抽取所有订单,按出现顺序输出JSON数组。") .user(rawText) .call() .entity(new ParameterizedTypeReference<List<OrderInfo>>() {});嵌套对象在 Record 里天然支持,比如 OrderInfo 里的 List 会被自动展开成嵌套 JSON 数组。如果你在真实项目里碰到字段名不规范的情况,比如数据库字段是下划线风格,可以在 Record 上用 Jackson 注解映射:
public record OrderInfo( @JsonProperty("order_id") String orderId, @JsonProperty("customer_name") String customerName ) {}这么做的好处是让 Spring AI 生成的 Schema 直接就对应你下游需要的字段名,模型输出的 JSON 也会带上下划线字段,反序列化时再映射回 Java 字段,两层都顺了。
2.4 引导解码的坑与心得
第一,别让“魔改Prompt”破坏了 Schema 注入。我见过有人为了追求更好的抽取效果,在 user 消息里手动写了一份 JSON 示例,结果和 Spring AI 自动生成的 Schema 冲突,导致模型输出混乱。建议让 Spring AI 的 Schema 作为唯一格式权威,自有示例最多放在 system 里当补充说明。
第二,Record 的字段建议设置默认值。模型识别不到某个字段时,可能直接不输出该字段。如果业务允许“未知”或“空值”,可以在 Record 构造函数里给默认值,别把非空校验卡在反序列化这一步。
第三,引导解码是三条路线里“最软”的一种。它依赖模型对 JSON Schema 的理解能力,如果模型本身不擅长 JSON 生成,或者上下文太长导致指令被稀释,失败率会明显上升。所以在生产环境里,我通常只把它作为兜底或快速验证方案。
3. 第二重:自研工具,用函数调用把结构焊死
3.1 思路转变:不是读结果,而是捕获参数
引导解码是“让模型把答案写在答题卡上”,自研工具则是“让模型把答案填进调用参数里”。区别在于,函数调用机制天生要求模型生成合法的 JSON 参数,否则调用根本没法发起。换句话说,模型在生成调用参数的时候,得先过一遍模型服务商自己的参数解析,这比单纯“根据文本写 JSON”要严格得多。
思路转变很关键:你不要再去发明一个“工具”让模型执行什么业务逻辑,而是把这个工具当成一个“结构化数据采集器”。工具方法本身不做复杂计算,它负责把模型给出的强类型参数接住,然后落库或者转发。模型以为自己是在“完成任务”,实际上是在替你完成数据抽取和结构化确认。
3.2 实现:定义一个订单采集工具
我用 @Tool 定义一个采集器,参数就是我要的订单字段:
@Component public class OrderSinkTool { private final OrderRepository orderRepository; public OrderSinkTool(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Tool(name = "save_extracted_order", description = "当从文本中识别到订单信息时,调用该工具保存订单") public String saveExtractedOrder( @ToolParam(description = "订单号,未识别到则填 unknown") String orderId, @ToolParam(description = "客户姓名") String customerName, @ToolParam(description = "商品明细列表,每项包含商品名、数量、单价") List<OrderItemParam> items, @ToolParam(description = "订单总金额,数字类型") BigDecimal totalAmount, @ToolParam(description = "收货地址,未识别到则填空字符串") String address ) { OrderInfo order = new OrderInfo(orderId, customerName, items, totalAmount, address, null); orderRepository.save(order); return "订单已保存"; } public record OrderItemParam(String productName, Integer quantity, BigDecimal unitPrice) {} }这个方法的目的不是真的“执行什么业务”,而是通过工具参数的 JSON Schema 约束模型,让它必须给出符合 Java 类型的参数。比如 totalAmount 是 BigDecimal,模型就不能填 “168.5元”,必须给数值;items 是 List,模型就不能只在描述里写“两箱牛奶”,必须结构化成一个数组。
3.3 调用:把工具注册进 ChatClient
Spring AI 1.x 的 ChatClient 支持直接传入带 @Tool 方法的对象:
String reply = chatClient.prompt() .system("你是订单抽取助手。只要在文本中识别到订单信息,就必须调用 save_extracted_order 工具保存,不要解释过程。") .tools(new OrderSinkTool()) .user(rawText) .call() .content();如果你的应用里大部分请求都需要带上这个工具,可以在 ChatClient 构建阶段直接设成默认工具:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(new OrderSinkTool()) .build();这样每次调用都带上工具定义,模型看到“只要有订单就必须调工具”的指令后,会自动把抽取结果作为参数传过来。工具方法的返回值会作为这次工具调用的结果返回给模型,再由模型生成最终文本回复。所以如果你不关心回复内容,只要确定工具被调用了,那么核心结果其实已经落在 OrderRepository 里了。
3.4 自研工具的坑与心得
这个方案最大的坑是:模型不一定每次都会主动调用工具。如果文本里确实没有订单信息,模型可能选择不调用,直接说“我没找到订单”。这其实是合理的,但你需要自己在调用逻辑里判断是否发生了工具调用,不能假设每次都有落库结果。我通常会在工具方法内部返回一个可识别的字符串,同时在调用方检查最终的响应,如果发现没走工具,就做一次重试或进入人工处理队列。
第二个坑是参数描述的准确性。@ToolParam 的 description 不只是给人看的,它是给模型看的字段语义提示。描述写得越具体,模型越不容易填错。比如 quantity 如果只写“数量”,模型可能填成字符串;写成“商品数量,整数”,模型就会尽量给数字。这一点和引导解码里的 Schema 注释是同一个道理。
第三个坑是部分模型服务商对 Function Calling 的支持参差不齐。有的本地模型不支持工具调用,有的支持但生成的参数 JSON 不稳定。遇到这种情况,自研工具方案就不如原生调用可靠,甚至不如引导解码兼容广泛。
4. 第三重:原生调用,让模型服务商在解码阶段帮你兜底
4.1 JSON Mode / Structured Output 到底强在哪
前面两条路线本质上都是在“哄”模型输出合法 JSON,要么靠提示词,要么靠工具参数机制。但真正从解码层面约束 JSON 语法的是模型服务商自己提供的原生能力,也就是常说的 JSON Mode 或者 Structured Output。
这类能力的大致原理是:在采样生成阶段,模型服务商在解码器层面增加一个过滤器,保证输出的 Token 序列始终符合 JSON 语法。模型可以自由决定 JSON 里的内容,但“它必须是一段合法 JSON”这件事在生成阶段就被兜住了。这比任何 Prompt 技巧都硬。
不过得说清楚,原生调用不负责“字段是否完整、类型是否符合业务含义”。它保证的是“这是一段合法 JSON”,至于该填 CustomerName 的字段填成了 null,它管不了。所以原生调用通常要和前端的 Java 类型解析、后端的参数校验组合使用。
4.2 在 Spring AI 里打开原生 JSON Mode
如果你用的模型服务商兼容 OpenAI 协议,可以在 OpenAiChatOptions 里设置 response_format。最简单的 JSON 对象模式是这样:
OpenAiChatOptions options = OpenAiChatOptions.builder() .model("my-chat-model") .responseFormat("json_object") .temperature(0.2) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultOptions(options) .build(); OrderInfo order = chatClient.prompt() .user("请从下面的文本中提取订单信息,直接输出JSON对象:\n" + rawText) .call() .entity(OrderInfo.class);注意 json_object 模式下,大部分服务商要求提示词里必须出现“JSON”这个关键词,否则会直接报错。我在上面的 user 消息里写了“直接输出JSON对象”,就是为了满足这个硬性条件。
如果你的服务商支持 strict schema 模式,还可以直接把 Java 类型背后的 JSON Schema 提交给模型服务商,让它在解码时不仅保证合法 JSON,还保证符合这个 Schema:
OpenAiChatOptions options = OpenAiChatOptions.builder() .model("my-chat-model") .responseFormat("json_schema") .jsonSchemaName("order_info") .jsonSchemaStrict(true) .jsonSchemaDescription("从文本中抽取的订单信息") .build();不同版本的 Spring AI 对 jsonSchema 系列属性的命名可能略有差异,建议你以引入版本的 OpenAiChatOptions 源码注释为准。思路是一样的:把格式约束下推到模型服务商侧。
4.3 原生模式与 Java 类型绑定的组合拳
原生调用通常要配合引导解码的“.entity()”一起用,或者说它就是引导解码在原生模式下的升级版。你定义了 OrderInfo,Spring AI 自动生成 Schema,你把这个 Schema 交给模型服务商,同时启用了原生解码约束,最后把返回的文本再反序列化成 OrderInfo。
这种组合的好处是:把“格式合法性”和“类型适配”两件事分开。格式合法性由模型服务商在解码时保证,类型适配由 Spring AI 和 Jackson 在本地方完成。每一层只做一件事,问题出在哪一步,排查范围就缩小到哪一步。
另一个比较实用的组合是:原生调用 + 校验。响应反序列化成功不代表数据质量好,我一般会在落库之前再加一层业务校验,比如 totalAmount 必须大于 0、deliveryDate 不能晚于某个上限。原生约束解决的是“能不能解析”,业务校验解决的是“该不该入库”。
4.4 兼容性说明:别把原生调用想得太美
原生调用看着最稳,但兼容性限制也最明显。部分模型服务商只支持 json_object,不支持 json_schema 和 strict 模式;还有的本地或私有化模型服务商虽然兼容 OpenAI 协议,但对 response_format 参数直接忽略,你设了它也不执行。
我踩过的最典型的一次坑是:换了一个本地部署的模型,代码里设置了 json_object,结果模型照常返回带 Markdown 标记的文本,甚至返回了两段 JSON 拼在一起。后来排查才发现,该模型的网关层把不支持的 response_format 字段静默丢弃了。所以在切换模型或供应商时,一定要先做一个“空跑验证”,用一段最小文本确认原生约束真的生效,再放心把流量切过去。
5. 三套方案怎么选:对比、组合与生产兜底策略
5.1 三条路线横向对比
| 对比项 | 引导解码 | 自研工具 | 原生调用 |
|---|---|---|---|
| 约束强度 | 软约束,模型可能不遵守 | 中等偏硬,依赖函数调用机制 | 硬约束,解码阶段保证语法 |
| 依赖模型能力 | 只要求模型能处理 JSON 指令 | 要求支持 Function Calling | 要求支持 JSON Mode / Structured Output |
| 依赖供应商程度 | 低 | 中 | 高 |
| 实现成本 | 低 | 高 | 低 |
| 字段完整性保障 | 弱 | 中 | 中 |
| 典型失败场景 | 输出夹带 Markdown、字段名漂移 | 工具不触发、参数描述不准 | 供应商静默忽略 response_format |
| 适合场景 | 快速原型、兜底、模型验证 | 需要把结果直接落库或联动业务逻辑 | 生产环境、模型供应商能力稳定 |
从表格可以看出,每条路线的优势场景差异很大。我的经验是:引导解码适合当“电梯测试”工具,快速验证一个模型能不能胜任抽取任务;自研工具适合在抽取结果同时要触发业务动作的场景;原生调用适合大批量、高频率的稳定生产链路。
5.2 生产环境里的组合降级策略
既然单一路线都有短处,我现在的做法是把这三条路线做成一条“降级链”。主链路优先走原生调用,一旦发现当前模型服务商不支持响应格式参数,或者连续出现解析失败,就降级到引导解码;引导解码如果也失败,再尝试自研工具路线。降级不是简单地在 catch 里换一个方法,而是要在调用前探测能力,在调用中收集失败信号,在调用后校验结果。
实际代码上可以做成一个简单的路由:
try { return extractWithNativeMode(rawText); } catch (UnsupportedResponseFormatException e) { return extractWithGuideDecoding(rawText); } catch (JsonParseException e) { return extractWithToolCalling(rawText); }这只是一种示意,真正的生产实现还要考虑超时、重试、失败计数和告警。但中心思想是明确的:没有哪一条路线是银弹,组合起来的整体稳定性才能满足生产要求。
5.3 我生产环境的默认配置清单
下面这份配置是我在项目里沉淀下来的基线,你可以按实际情况调整:
- 模型温度默认设 0.2 左右,结构化抽取任务不需要创造性,温度太高容易让格式漂移。
- system 提示词里固定写一句“只输出 JSON,不要其他内容”,不管选哪条路线都加上。
- 用 Record 而不是 Class 定义结构化返回类型,代码更简洁,Spring AI 对 Record 的 Schema 生成支持也最好。
- 开启模型原始响应日志,至少保留最近 100 条的原始文本,方便出问题时离线复现。
- 在落库前做业务校验,而不是只依赖 Jackson 反序列化,因为“合法 JSON”不等于“业务正确”。
- 每次升级 Spring AI 版本后,跑一遍三条路线的冒烟用例,重点验证 OpenAiChatOptions 的 builder 方法有没有变化。
6. 常见问题与排查技巧实录
6.1 JSON 解析失败:从报错反推根因
| 现象 | 根因 | 解决办法 |
|---|---|---|
| Jackson 报 Unexpected character 或 Unrecognized token | 模型输出被 Markdown 围栏包裹 | 在 system 中强调不要输出代码块;解析前用正则剥离 ```json 标记 |
| 嵌套对象字段缺失,反序列化为 null | 模型没识别到信息或上下文覆盖不足 | 给字段提供默认值;在 schema 描述里加入“如果未识别则填空值”说明 |
| 金额字段报 NumberFormatException | 模型返回了带单位字符串,如“168.5元” | 把字段类型说明写清楚;在工具参数描述里强调“纯数字,不包含单位” |
| 数组长度不稳定,有时多一项有时少一项 | 模型对明细的切分方式不一致 | 提高温度到 0.2 以下;在提示词里给出一个明细切分的示例 |
排查这类问题有个通用套路:先把模型原始响应打印出来看,不要急着改代码。很多时候问题在响应文本层面就能一锤定音,比如看到开头有“好的”,结尾有句号,那就知道是文本污染,加提示词就能解决。
6.2 字段名映射:中文接口名与 Java 字段
业务系统有时候需要输出中文字段名,比如“客户姓名”而不是 customerName。模型对中文字段名的跟随性其实很好,但 Java Record 字段用中文会非常别扭。我建议的做法是:Java 字段保持英文,用 @JsonProperty 给模型侧生成一个中文字段名,再反序列化映射回来:
public record OrderInfo( @JsonProperty("客户姓名") String customerName, @JsonProperty("商品明细") List<OrderItem> items ) {}这样 Schema 里会出现中文字段名,模型更容易按业务语境生成,反序列化时 Jackson 又能正确映射到 Java 字段。如果你用下划线风格,同理用这个注解映射。
6.3 工具不触发:从三个角度排查
如果自研工具这条路的命中率很低,先别急着怀疑模型能力,按顺序排查三件事:
第一,工具描述是否足够强。@Tool 里的 description 会原样进入模型的工具选择逻辑,描述里写清楚“什么时候必须调用”比“这个工具是干嘛的”更重要。比如“只要文本里出现订单号或商品数量就调用”就比“订单工具”有效得多。
第二,系统提示词有没有给模型明确的调用指令。模型通常会参考 system 里的行为约束,如果你只给工具不给指令,它很可能选择不调用。我习惯在 system 里写“识别到订单信息后必须调用工具保存,不要直接输出结果”。
第三,模型服务商是否真的支持函数调用。部分接入网关会在中间层把 tools 参数过滤掉,表现形式就是模型完全无视工具。可以先禁用原生调用,单独用一段带明确工具的 Prompt 做冒烟测试,确认模型响应里是否包含 tool_calls。
6.4 版本迭代:Spring AI 升级踩坑记录
Spring AI 的 API 变动节奏比较快,我在升级过程中踩过最明显的坑就是 OpenAiChatOptions 的 builder 方法。旧版本里有一套 withXxx 风格的链式方法,新版本精简成了 xxx 风格,直接编译失败算好的,最怕的是编译通过但参数没真正传进去。
所以我现在的做法是:不盲目跟最新快照版,尽量停留在稳定版本号上;升级后第一件事就是跑一次“结构化输出冒烟用例”,覆盖三条路线。依赖上留意 spring-ai-bom 的版本统一,避免多个模块用了不一致的 Spring AI 版本导致运行时行为诡异。
最后再分享一个小技巧:不管用哪条路线,我都把“模型原始响应”和“最终落库结构”一起记录到日志里。出问题的时候,回放这几个字段就能快速定位是模型的问题、Schema 的问题、还是枚举值对不上。这套方法帮我省掉了大量联调时间。结构化输出看着只是个格式问题,真正做好了,它就是整个AI应用生产化的地基。