夜雨聆风学习资料网

ARTICLE · 1079052

[AI工程] Spring AI 第七篇:结构化输出与初代 Tools 实现

[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 输出的三个层次

层次
模型输出长什么样
Java 侧怎么处理
能进业务流程吗
自由文本
"好的,已为您..."
只能展示给人看
❌
半结构化
Markdown 代码块里塞一段 JSON
正则剥壳 + 手工解析,模型多回一句"希望对你有帮助"就崩
⚠️
结构化输出
严格符合 JSON Schema 的 JSON
.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()
反序列化前用 JSON Schema 校验响应;不通过则把错误信息拼回 prompt 让模型重答,默认最多 3 次
可能多 1~3 次模型往返,延迟翻倍
输出会真正驱动业务动作(退票、下单、写库)
useProviderStructuredOutput()
把 Schema 作为 API 级参数(如 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()
批量结构化(RAG 重排)
落库
开校验,且把重试次数考虑进超时

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()
看框架到底生成了什么 Schema
第 ② 步
converter.getFormat()
拿到要拼进 prompt 的格式说明
第 ③ 步
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 {    @Bean    public 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();    }    @Bean    public 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 不一样,这不是随手写的:

ChatClient
temperature
职责
为什么这么设
planningChatClient0.7
任务分类、参数提取
要稳,同一个输入最好永远给出同一个 JobType
botChatClient1.2
面向用户聊天
要活,回复不能像机器人

同一个模型、不同温度、扮演不同角色——这是多模型协同里成本最低的一种,不需要真的准备两个模型。

5.3 路由:由 AiJob 驱动

@RestControllerpublic class MultiModelsController {    @Autowired    ChatClient planningChatClient;    @Autowired    ChatClient 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           :模型 + 外部进程提供的工具 --> 客户端动态发现
维度
初代 Tools(本篇)
Tool Calling(@Tool)
MCP
谁决定调哪个方法
你的 Java 代码
模型
模型
参数来源
.entity()
 返回的 POJO
模型生成的 arguments
模型生成
新增一个能力
改系统提示词 + 改 switch
加一个 @Tool 方法
部署一个 Server
往返次数
通常 1 次
≥2 次
(含工具结果回填)
≥2 次
流式友好度
好(只有规划是阻塞的)
一般,工具轮次结束才能稳定输出
一般
弱模型兼容性
好
,不依赖厂商工具协议
差,模型不支持就废了
差
可观测 / 可测试
单测直接断言 Job
需要 mock 工具执行
需要外部依赖
适用阶段
学习原理、流程确定的场景
生产项目、工具数量多
跨系统 / 生态复用

Q:都 2026 年了还手写路由,是不是落后了?

恰恰相反,我认为这段代码是理解 Tool Calling 的最短路径。把 @Tool 抓包看一遍就会发现,框架做的事和上面这个 switch 一模一样:把方法签名转成 Schema 给模型 → 拿回结构化参数 → 反射执行 → 结果回填。所谓"自动",只是把你那段 switch 换成了框架的调度器。理解了初代实现,@Tool 出问题时你才知道该看 prompt、看 Schema、还是看返回 JSON。

而且初代方案有两个真实优势:延迟可控(一次 .call() 就决策完,不像 Tool Calling 可能来回多轮),以及不挑模型(换成小尺寸模型、或者厂商没实现工具协议时照样能跑,只要它还听话地产 JSON)。

笔者(后端 & 架构)的使用策略:

场景
我的选择
理由
学习/面试,想讲清 Tool 原理
初代 Tools
调用链全在自己手里,能打印每一步
工具 ≤ 5 个、流程固定的客服/工单
初代 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

相关学习资料