ARTICLE · 1079052
[AI工程] Spring AI 第七篇:结构化输出与初代 Tools 实现
💡 上一篇我们把 ChatClient、Prompt 模板和 ChatMemory 串通了,模型能聊、能记住上下文,Demo 跑起来很有成就感。
但真正把代码接进业务,第一个撞上的墙是:模型回给你一段"人话"。
用户说"我要退票,订单号 XK3921",模型很贴心地回:“好的,已为您查询到订单 XK3921,请问确认退 9 月 20 日北京到上海的航班吗?”——看起来挺智能,但你的 Java 代码没法 if 这段话,也没拿到能直接喂给 refundService.cancel(orderNo) 的参数。
这一篇解决两件事:一是 Spring AI 2.0 的结构化输出怎么用、底层怎么转、2.0 到底加了哪两个关键开关;二是用结构化输出手搓一个"初代 Tools"——智能票务助手,让大模型第一次真正参与到企业业务流程里。
1. 为什么自然语言输出在 Java 里"不可用"
*先把问题定义清楚:不是模型不会回答,是它的回答没法被代码消费。*同一个需求,两种输出形态:
用户:"帮我退掉订单 XK3921"↓自由文本:"好的,已为您查询到订单 XK3921..." ❌ 代码无法判定下一步↓结构化输出:{"jobType":"CANCEL","keyInfos":[{"name":"张三","bookingCode":"XK3921"}]} ✅ → Job 对象 → switch(jobType)
简单理解成:结构化输出就是让模型按你给的"表单"填空,Java 端只管反序列化。
1.1 输出的三个层次
"好的,已为您..." | |||
.entity(Job.class) |
Spring AI 结构化输出干的事,本质是把"祈祷模型听话"换成"Schema 约束 + 校验兜底"。
1.2 一个容易忽略的限制:.entity() 只有 .call() 有
2.0 里 .entity() 仅支持 .call(),.stream() 路径上没有这个方法。原因不复杂:
.stream() --> 一段段文本分片 --> 拼不出完整 JSON --> 无法反序列化成 T.call() --> 一次拿到完整响应 --> 生成 / 校验 Schema --> T
类型化解析要求"完整响应"。理解了这一点,后面票务助手那段代码的写法就很自然了:先用 .call() 拿到结构化决策,再用 .stream() 吐对话内容——这不是随手拼的,而是被 API 约束逼出来的标准范式。
2. 2.0 结构化输出:四种典型用法
入口只有一个 ChatClient.prompt().user(...).call(),区别只在最后一步想要什么类型。
2.1 基础类型:一个 Boolean 就能做分支
别小看基础类型。在 Agent 里最常用的一类判断——这句话要不要转人工、是不是敏感内容、用户是否已授权——都可以直接落到 Boolean:
Boolean needHuman = chatClient.prompt().system("你是客服意图审核器。只输出 true 或 false,不要任何解释。").user("判断下面的用户输入是否需要人工客服介入:" + message).call().entity(Boolean.class);if (Boolean.TRUE.equals(needHuman)) {return transferToHuman(message);}
同理 Integer、String、Double 都能直接接。适合"一问一判"的轻量决策,不用为了一个 true/false 定义一个 record。
2.2 POJO / record:主力用法
这是实际项目里用得最多的形态。先定义目标类型(用 record 更贴合不可变语义):
public class AiJob {public enum JobType { CANCEL, QUERY, OTHER }public record Job(@JsonPropertyDescription("任务类型:CANCEL 退票、QUERY 查票、OTHER 其他闲聊")JobType jobType,@JsonPropertyDescription("从对话中提取的关键信息;用户没有提供则返回空数组")List<KeyInfo> keyInfos) {}public record KeyInfo(@JsonPropertyDescription("乘客姓名") String name,@JsonPropertyDescription("预定号 / 订单号") String bookingCode) {}}
字段上的描述不是注释,它会直接进入生成的 JSON Schema——Schema 质量决定模型填表质量,这一点后面原理部分会展开。
调用侧一行搞定:
AiJob.Job job = planningChatClient.prompt().user(message).call().entity(AiJob.Job.class);switch (job.jobType()) {case CANCEL -> refundService.cancel(job.keyInfos());case QUERY -> ticketService.query(job.keyInfos());case OTHER -> chatService.reply(message);}
模型实际被要求产出的东西,大致长这样(示意):
{"jobType": "CANCEL","keyInfos": [{ "name": "张三", "bookingCode": "XK3921" }]}
2.3 泛型类型:ParameterizedTypeReference
List<Job>、Map<String, Job> 这类带泛型的,Java 类型擦除后拿不到元素类型,要走 ParameterizedTypeReference:
List<AiJob.Job> jobs = chatClient.prompt().user("把下面这段话拆成多个独立任务:" + message).call().entity(new ParameterizedTypeReference<List<AiJob.Job>>() {});
2.4 还要保留原始响应:ResponseEntity 包装
.entity() 把解析后的对象给你,原始 ChatResponse(token 用量、模型元数据、rate limit 信息)就丢了。2.0 新增了这个包装形态:
ResponseEntity<ChatResponse, AiJob.Job> result = chatClient.prompt().user(message).call().responseEntity(AiJob.Job.class);AiJob.Job job = result.entity();long totalTokens = result.response().getMetadata().getUsage().getTotalTokens();
responseEntity() 的入参重载和 .entity() 基本一致,可以带 EntityParamSpec。计费、限流、链路追踪这类"横切需求"终于不用靠切面绕路了。
2.5 小结
判定/开关:基础类型 业务参数:POJO / record 批量结果: ParameterizedTypeReference需要 token 统计: responseEntity()
3. EntityParamSpec:2.0 真正的增强点
这两个开关回答的是同一个问题——模型给的东西不合 Schema 怎么办。
3.1 两个开关
AiJob.Job job = planningChatClient.prompt().user(message).call().entity(AiJob.Job.class, spec -> spec.useProviderStructuredOutput() // 走厂商原生 JSON 模式.validateSchema()); // 本地 Schema 校验 + 失败重试
validateSchema() | |||
useProviderStructuredOutput() | response_format)传给厂商,由模型解码阶段保证格式 | ||
3 次全部失败会抛异常。听起来是坏事,其实比"拿到一个字段为 null 的对象继续往下跑"好得多——失败要吵,不要静默。
3.2 原理
① 目标类型 Job.class↓ Jackson 注解 + 字段类型② 生成 JSON Schema↓ 两种下发方式③ 附加到请求:response_format 参数 | 拼进系统提示词↓④ 模型返回 JSON↓⑤ validateSchema? --否--> 直接反序列化(畸形就抛异常)↓ 是⑥ Schema 校验 --不通过--> 错误信息回填 prompt --> 重试(≤3 次)↓ 通过⑦ 反序列化为 AiJob.Job
比较关键的是第 ③ 步。Spring AI 在没有开启厂商原生模式时,走的是"提示词路线":把 Schema 和一段格式要求追加进 prompt,本质仍是说服模型。而 useProviderStructuredOutput() 是让厂商在解码阶段做约束,是强制。
Q1:不开 validateSchema(),模型乱回会怎样?
反序列化直接抛异常,没有任何补救机会。1.x 时代大家习惯在 prompt 里写"请严格返回 JSON,不要输出其他内容",然后把解析异常当偶发错误处理。2.0 把这件事变成了框架能力,validateSchema() 就是那个"自动纠错闭环"。
Q2:字段描述为什么要认真写?
Schema 里的 description 是模型唯一的"填表说明"。同样一个 keyInfos:
不写描述 --> 模型:不知道什么时候该返回空数组,于是开始编订单号写了描述 --> 模型:用户没给预定号就返回 [],代码才能判断"信息不全,要追问"
票务助手里那句"请输入姓名和订单号"能不能触发,取决于 keyInfos 的描述有没有明确写"缺失则返回空数组"。这是很多人调了半天不稳定的真正原因。
Q3:国产模型开了 JSON 模式是不是就够了?
不完全是。以千问(DashScope 兼容模式)为例,很多厂商提供的是 json_object 级别的弱约束:只保证是合法 JSON,不保证符合你的 Schema——字段名能给你编一个,枚举值能给你写个中文。所以我的做法是 useProviderStructuredOutput() 提升下限、validateSchema() 守住上限,两者叠加,并且别对"厂商一定支持"这件事乐观,先在小流量上验证。
Q4:3 次都失败,生产上怎么办?
不要让它冒到用户面前。典型处理是:捕获异常 → 落一条完整上下文(原始 prompt + Schema + 模型输出)→ 降级为人工或兜底文案 → 单独告警。这类失败往往说明 Schema 设计有问题或者模型能力边界到了,靠加重试解决不了。
3.3 什么时候值得开校验
validateSchema() | ||
validateSchema() | ||
.stream() | ||
4. 低级 API:把控制权拿回来
.entity() 是语法糖,糖后面的东西值得单独看一眼。
var converter = new BeanOutputConverter<>(AiJob.Job.class);String prompt = """你是票务任务路由器。%s用户输入:%s""".formatted(converter.getFormat(), message);ChatResponse response = chatModel.call(new Prompt(prompt));AiJob.Job job = converter.convert(response.getResult().getOutput().getText());
三件事分别对应了原理图里的三步:
converter.getJsonSchema() | ||
converter.getFormat() | ||
converter.convert(text) |
真实项目里我更倾向于在两个地方用低级 API:一是排查问题(怀疑 Schema 生成得不对时,先把它打印出来);二是需要非 JSON 格式(YAML、CSV,或自定义分隔),这时实现 StructuredOutputConverter<T> 自己接管解析。日常业务代码还是用 .entity(),别重复造轮子。
5. 初代 Tools 实现:智能票务助手
大模型如果无法和企业 API 互联,那将毫无意义。
用户说"我要退票",基础模型做不到——票务数据在我们系统里,必须调用我们自己的业务方法。那么在还没有正式 Tool Calling 的年代(或者说,在你想完全掌控调用链的时候),怎么让模型"调用"你的方法?
答案就是本篇标题里的组合拳:结构化输出 + 多模型协同。
5.1 思路:先问"该干什么",再决定"谁来干"
用户输入↓planningChatClient ──.entity(AiJob.Job)──> { jobType, keyInfos }↓ CANCEL ──> 信息齐全?──> 是 ──> 调 refundService(本地业务方法)↓ └── 否 ──> 追问"请输入姓名和订单号"↓ QUERY ──> 调 ticketService↓ OTHER ──> botChatClient ──.stream()──> 流式回复
模型在这里只承担一件事:把自然语言翻译成结构化决策。真正动手的还是 Java。这就是"初代 Tools"的全部秘密——工具调用这件事被拆成了"模型选工具 + 代码执行工具"两半,我们只是把两半都握在自己手里。
5.2 两个 ChatClient:一个冷静,一个热情
@Configurationpublic class AiConfig {@Beanpublic ChatClient planningChatClient(DashScopeChatModel chatModel,DashScopeChatProperties options,ChatMemory chatMemory) {DashScopeChatOptions dashScopeChatOptions = DashScopeChatOptions.fromOptions(options.getOptions());dashScopeChatOptions.setTemperature(0.7);return ChatClient.builder(chatModel).defaultSystem("""# 票务助手任务拆分规则## 1.要求### 1.1 根据用户内容识别任务## 2. 任务### 2.1 JobType:退票(CANCEL) 要求用户提供姓名和预定号,或者从对话中提取;### 2.2 JobType:查票(QUERY) 要求用户提供预定号,或者从对话中提取;### 2.3 JobType:其他(OTHER)""").defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).defaultOptions(dashScopeChatOptions).build();}@Beanpublic ChatClient botChatClient(DashScopeChatModel chatModel,DashScopeChatProperties options,ChatMemory chatMemory) {DashScopeChatOptions dashScopeChatOptions = DashScopeChatOptions.fromOptions(options.getOptions());dashScopeChatOptions.setTemperature(1.2);return ChatClient.builder(chatModel).defaultSystem("你是XS航空智能客服代理,请以友好的语气服务用户。").defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).defaultOptions(dashScopeChatOptions).build();}}
两个 temperature 不一样,这不是随手写的:
planningChatClient | 0.7 | JobType | |
botChatClient | 1.2 |
同一个模型、不同温度、扮演不同角色——这是多模型协同里成本最低的一种,不需要真的准备两个模型。
5.3 路由:由 AiJob 驱动
@RestControllerpublic class MultiModelsController {@AutowiredChatClient planningChatClient;@AutowiredChatClient botChatClient;@GetMapping(value = "/stream", produces = "text/stream;charset=UTF8")Flux<String> stream(@RequestParam String message) {Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();sink.tryEmitNext("正在计划任务...<br/>");new Thread(() -> {// 2.0:.entity() 只能挂在 .call() 上AiJob.Job job = planningChatClient.prompt().user(message).call().entity(AiJob.Job.class);switch (job.jobType()) {case CANCEL -> {if (job.keyInfos().isEmpty()) {sink.tryEmitNext("请输入姓名和订单号.");} else {// 真正的业务方法:票务数据在我们系统里sink.tryEmitNext(ticketService.cancel(job.keyInfos()));}sink.tryEmitComplete();}case QUERY -> {sink.tryEmitNext(ticketService.query(job.keyInfos()));sink.tryEmitComplete();}case OTHER -> {botChatClient.prompt().user(message).stream().content().doOnNext(sink::tryEmitNext).doOnComplete(sink::tryEmitComplete).doOnError(err -> sink.tryEmitError(err)).subscribe();}default -> {sink.tryEmitNext("解析失败");sink.tryEmitComplete();}}}).start();return sink.asFlux();}}
5.4 跑起来是什么效果
用户:你好,你们航班的餐食有素食吗?后端:planning → { jobType: "OTHER", keyInfos: [] }输出:正在计划任务... 您好!我们提供素食餐,需在起飞前 24 小时预订……(流式)用户:我要退票后端:planning → { jobType: "CANCEL", keyInfos: [] }输出:请输入姓名和订单号.用户:张三,XK3921后端:planning(带记忆)→ { jobType: "CANCEL", keyInfos: [{name:"张三",bookingCode:"XK3921"}] }输出:退票成功!
第三轮最值得注意:用户压根没提"退票"两个字,靠 MessageChatMemoryAdvisor 把上一轮上下文带进来,模型才补齐了 keyInfos。结构化输出 + 对话记忆,才能撑起多轮任务收集,缺一个都会退化成"每轮都要重新说一遍"。
5.5 四个实际会踩的坑
一是**CANCEL / QUERY 分支忘了 tryEmitComplete()**。原始草稿里这两个分支只 tryEmitNext 就返回了,SSE 连接不会关闭,浏览器一直挂着——这种问题在 Demo 里看不出来,一上网关就超时。
二是**new Thread(...) 不可控**。每个请求裸起线程,QPS 一高线程数就爆。生产写法:
Flux.defer(() -> {AiJob.Job job = planningChatClient.prompt().user(message).call().entity(AiJob.Job.class);return route(job);}).subscribeOn(Schedulers.boundedElastic());
阻塞的规划调用放到 boundedElastic,线程数由 Reactor 管,异常还能自然落到 sink.tryEmitError。
三是**planningChatClient 的异常没人接**。模型偶尔会给出 "退票" 而不是 CANCEL。加了 validateSchema() 后框架会自己重试;同时外层仍要 try/catch,兜底回一句"没能理解您的意思",别让 500 直接抛到前端。
四是**ChatMemory 的会话隔离**。MessageChatMemoryAdvisor 需要按用户传 conversationId,全部用默认值的话,A 用户的订单号会出现在 B 用户的上下文里——这是安全问题,不是 bug。
6. 这套"初代"方案和正式 Tool Calling 有什么区别
既然 2.0 已经有完整的工具调用能力,为什么还要理解"初代"写法?
初代 Tools :模型输出 Job --> Java switch --> Java 调方法 (路由在代码里)Tool Calling :模型输出 tool_calls --> 框架反射调用 @Tool 方法 (路由在模型手里)MCP :模型 + 外部进程提供的工具 --> 客户端动态发现
@Tool) | |||
|---|---|---|---|
| 你的 Java 代码 | |||
.entity() | arguments | ||
switch | @Tool 方法 | ||
| ≥2 次 | |||
| 好 | |||
Job | |||
Q:都 2026 年了还手写路由,是不是落后了?
恰恰相反,我认为这段代码是理解 Tool Calling 的最短路径。把 @Tool 抓包看一遍就会发现,框架做的事和上面这个 switch 一模一样:把方法签名转成 Schema 给模型 → 拿回结构化参数 → 反射执行 → 结果回填。所谓"自动",只是把你那段 switch 换成了框架的调度器。理解了初代实现,@Tool 出问题时你才知道该看 prompt、看 Schema、还是看返回 JSON。
而且初代方案有两个真实优势:延迟可控(一次 .call() 就决策完,不像 Tool Calling 可能来回多轮),以及不挑模型(换成小尺寸模型、或者厂商没实现工具协议时照样能跑,只要它还听话地产 JSON)。
笔者(后端 & 架构)的使用策略:
| 初代 Tools | ||
| 初代 Tools | ||
@Tool Tool Calling | switch | |
| MCP | ||
初代 Tools + validateSchema() | switch 里插权限校验和幂等,不接受模型自由发挥 |
一句话:模型只负责"点菜",Java 负责"上菜"。 无论哪一代 Tools,这个边界都不该变。
最后总结
如果只是想让代码能接住模型输出: .entity(Boolean.class)/.entity(Job.class)就够了,重点是把字段描述写清楚。如果输出会真正驱动业务动作: EntityParamSpec.validateSchema()是必选项,useProviderStructuredOutput()是加分项,别在没验证厂商支持前就依赖它。如果需要 token 统计或链路埋点:用 responseEntity(),别再写切面绕路。如果想搞懂 Tool Calling 到底是什么:先手搓一遍初代 Tools,结构化输出 → 路由 → 调业务方法,跑通了再看 @Tool,会有一种"原来如此"的通透感。对后端 / 架构开发者,我更关注的其实不是 API 本身,而是它带来的那条边界:决策交给模型,执行留在代码。可审计、可回滚、可单测的能力全在这里。
下一篇我们把这段 switch 换成 @Tool,看看同一个票务助手在"正式 Tools"形态下少写多少代码、又会多出哪些新的坑。
参考资料 & 致谢
[1] 结构化输出和初代Tools实现2.0 - 语雀
[2] Structured Output :: Spring AI Reference
[3] Spring AI 2.0.1 Available Now - Spring Blog
[4] Self-Correcting Structured Output in Spring AI 2.0 - Dan Vega
[5] Spring AI Recipe: Enabling Resilient Structured Output - Medium
[6] Releases · alibaba/spring-ai-alibaba - GitHub
[7] 格式化输出(Structured Output)- Spring AI Alibaba