Spring AI 结构化输出:稳定生成可校验的业务 JSON
摘要:结构化输出是"可验证",不是"永不出错"
在 AI 原型中,开发者常写一句"请严格返回 JSON",然后从 Markdown 代码块中截取花括号。样本少时看似可用,进入生产后就会出现缺字段、枚举拼错、数字变字符串、额外解释、半截 JSON 和业务值越界。更危险的是,解析成功的对象可能被直接写库或触发工具,让概率性输出变成确定性副作用。
Spring AI 的 ChatClient 提供 call().entity(...) 将模型结果映射为 Java 类型;需要同时获得原始 ChatResponse 与实体时,可使用 responseEntity(...)。当前 API 还支持 validateSchema() 做响应 Schema 校验与反馈重试,并可在模型支持时启用 provider-native structured output。但不同模型与供应商的原生能力差异明显,默认并非所有模型通用。
生产方案应把可靠性拆成多层:模型尽量按 Schema 生成,框架负责解析和结构校验,Bean Validation 约束字段,业务代码检查跨字段规则,最终副作用经过确认、幂等与事务。本文以"AI 生成工单风险审核草稿"为例给出完整实现与边界。
一、先区分四种"正确"
模型返回的内容至少要通过四层门槛:
- 语法正确:是完整 JSON,没有代码围栏和尾随解释;
- 结构正确:必填字段、类型、枚举、数组和嵌套对象满足 JSON Schema;
- 业务正确:置信度范围、证据归属、金额上限、状态机等符合领域规则;
- 权限与事实正确:当前用户有权操作目标资源,引用证据真实存在且属于当前租户。
Schema 最多直接覆盖前两层的一部分。confidence=0.8 在结构上是数字,但若业务要求 0 到 1 才合法,仍需范围约束;evidenceIds=["E-1"] 符合数组 Schema,但 E-1 可能来自其他租户;decision=APPROVE 是合法枚举,却不能替代人工审批。
因此"解析成 DTO"只能表示数据进入了确定性代码,不能表示模型判断可信。把这些层次分开,才能为不同失败设计不同处理:格式错误可有限修复,业务冲突需要拒绝或重新生成,权限失败必须直接阻断,不能让模型通过重试猜出别人的资源。
二、为什么不要直接复用数据库实体
结构化输出类型应是专用 DTO 或 record,不是 JPA Entity。数据库实体往往包含 id、tenantId、创建人、审核状态、乐观锁版本和内部备注。若将其直接暴露给模型,模型可能填写本应由服务端生成的字段,反序列化还可能触发不必要的多态类型或默认值。
一个明确的输出契约如下:
java
public enum ReviewDecisionType {
APPROVE_DRAFT,
REJECT_DRAFT,
NEED_HUMAN_REVIEW
}
public record ReviewDecision(
@NotNull ReviewDecisionType decision,
@NotBlank @Size(max = 500) String reason,
@NotNull @Size(max = 5)
List<@Pattern(regexp = "E-[0-9]{1,12}") String> evidenceIds,
@NotNull @DecimalMin("0.0") @DecimalMax("1.0")
BigDecimal confidence,
@Size(max = 3) List<@Size(max = 100) String> warnings) {
}
字段名表达稳定业务含义,枚举使用机器值而非可变中文展示文案,长度上限防止输出膨胀。tenantId、ticketId 和 operatorId 不由模型填写,它们来自请求上下文。DTO 映射到持久化命令时由服务端补齐这些可信字段。
Optional、null 与缺失值要提前约定。对模型契约,必填字段优先使用非 Optional 类型并在 Schema 标记 required;真正可选字段可以为空列表而不是 null,减少调用方分支。改变字段必填性属于契约变更,需要版本化和回归测试。
三、ChatClient 的 entity 基本用法
当前 Spring AI ChatClient 可以直接返回类型对象:
java
@Service
public class TicketReviewGenerator {
private final ChatClient chatClient;
public TicketReviewGenerator(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
你负责生成风险审核草稿。
只能根据提供的证据判断,不得编造证据编号。
""")
.build();
}
public ReviewDecision generate(String sanitizedTicketText) {
return chatClient.prompt()
.user(user -> user
.text("""
根据以下工单内容生成审核草稿。
工单内容:
<ticket>{ticket}</ticket>
""")
.param("ticket", sanitizedTicketText))
.call()
.entity(ReviewDecision.class);
}
}
entity 会根据目标类型生成 Schema,引导模型输出,并将响应转换成对象。默认路径通常把 Schema 作为文本指令加入上下文,然后解析模型返回。这比手工截取 JSON 稳定,但仍是 best effort:模型可能没有遵守,转换也可能失败。调用方必须捕获独立的结构化输出异常,不能把所有异常都包装成"模型不可用"。
对于泛型集合使用 ParameterizedTypeReference。不过顶层数组在某些 provider-native structured output 中存在限制,生产契约更推荐包装对象,例如 ReviewDecisionList(List<ReviewDecision> items),也便于以后增加 version、warnings 和 pagination。
四、responseEntity 同时保留实体与模型元数据
若要记录 Token、模型元数据或生成信息,使用 Spring AI 的 responseEntity(...),它与 Spring MVC 的单泛型 ResponseEntity 不是同一个类型:
java
org.springframework.ai.chat.client.ResponseEntity<
ChatResponse, ReviewDecision> result = chatClient.prompt()
.user("根据已提供的工单上下文生成审核草稿")
.call()
.responseEntity(ReviewDecision.class);
ReviewDecision decision = result.entity();
ChatResponse response = result.response();
var usage = response.getMetadata().getUsage();
将 usage、模型、Prompt 版本和输出契约版本记录到观测系统,能分析结构失败是否与模型切换或输入长度有关。不要把完整原始响应默认写普通日志;它可能包含工单隐私和被模型复述的敏感内容。可保存脱敏摘要、hash 和受限样本引用。
responseEntity 很适合检查重试成本,但不能跨版本假设 usage 已自动累计。稳定版 Spring AI 2.0.0 的 StructuredOutputValidationAdvisor 在重试循环结束后直接返回最后一轮 ChatClientResponse,其中的 usage 不代表前面失败轮次之和;若只读取最终响应,会低估成本。后续 2.0.1-SNAPSHOT 引入了递归 Advisor 的 usage 累计能力,但项目在实际升级到包含该实现的稳定版本前,应通过每轮模型 observation、供应商账单或自定义 Advisor 单独累计,并用集成测试核对。
五、默认提示 Schema 与供应商原生约束
entity 默认将 Schema 作为提示信息交给模型。它的优点是跨模型通用,缺点是模型仍可能不遵守。对于支持 StructuredOutputChatOptions 的 ChatModel,可以请求 provider-native structured output:
java
ReviewDecision decision = chatClient.prompt()
.user("生成工单风险审核草稿")
.call()
.entity(ReviewDecision.class, spec -> spec
.useProviderStructuredOutput());
原生模式把 JSON Schema 作为供应商 API 级约束发送,而不是仅作为普通文本。它通常能提高格式稳定性,但不是 Spring AI 对所有模型的统一保证。当前官方文档明确说明:原生能力不默认启用,模型和供应商支持差异明显;底层 ChatModel 不支持 StructuredOutputChatOptions 时,该选项不会产生预期原生约束。不同供应商还可能限制顶层数组、Schema 关键字或推理模式。
因此启用前要建立 capability matrix,记录 provider、model、版本、是否支持原生 Schema、已知限制和回退路径。模型升级后运行真实集成测试,不要只看接口调用未报错就认为约束生效。即使原生输出稳定,业务校验和权限校验仍不能删除。
六、validateSchema 与反馈重试
当前 ChatClient 的 EntityParamSpec 可启用 validateSchema():
java
ReviewDecision decision = chatClient.prompt()
.user("生成工单风险审核草稿")
.call()
.entity(ReviewDecision.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());
它会根据实体 Schema 校验完整 JSON 响应。验证失败时,把错误反馈追加到用户提示并重新调用模型,最多重复 maxRepeatAttempts 次;当前默认重复次数为 3。按当前 Builder 语义,0 表示不重试、模型只调用一次,因此默认最坏情况可能包含首次调用加三次修复调用。Schema 验证需要完整响应,启用时不支持 streaming。
若要自定义重复次数,可以构建专用 StructuredOutputValidationAdvisor:
java
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputType(ReviewDecision.class)
.maxRepeatAttempts(1)
.build();
ChatClient reviewClient = ChatClient.builder(chatModel)
.defaultAdvisors(validationAdvisor)
.build();
一个 Advisor 绑定具体 outputType 时,适合专用 ChatClient,不要让所有完全不同的响应都经过 ReviewDecision Schema。重复次数越大,成功机会可能提高,但延迟和 Token 成本也同步增加。在线链路通常限制为少量修复;持续失败进入明确错误或人工队列,不能无限"让模型再试一次"。
七、Schema 验证不等于 Bean Validation
JSON Schema 由 Java 类型生成,是否完整表达 Jakarta Validation 的所有注解取决于版本与生成器能力。即使 validateSchema() 通过,应用仍应显式运行 Bean Validation:
java
@Component
public class ReviewDecisionValidator {
private final Validator validator;
private final EvidenceRepository evidenceRepository;
public ReviewDecisionValidator(
Validator validator,
EvidenceRepository evidenceRepository) {
this.validator = validator;
this.evidenceRepository = evidenceRepository;
}
public void validate(
ReviewContext context,
ReviewDecision decision) {
Set<ConstraintViolation<ReviewDecision>> violations =
validator.validate(decision);
if (!violations.isEmpty()) {
throw new GeneratedOutputConstraintException(
safeConstraintCodes(violations));
}
if (decision.decision() == ReviewDecisionType.APPROVE_DRAFT
&& decision.evidenceIds().isEmpty()) {
throw new GeneratedOutputBusinessException(
"APPROVE_REQUIRES_EVIDENCE");
}
boolean allOwned = evidenceRepository.allBelongToTicket(
context.tenantId(),
context.ticketId(),
decision.evidenceIds());
if (!allOwned) {
throw new GeneratedOutputBusinessException(
"EVIDENCE_SCOPE_INVALID");
}
}
}
第一段校验字段约束,第二段校验跨字段规则,第三段查询事实与权限。仓储方法同时带 tenantId 和 ticketId,避免先查出其他租户证据再判断。错误使用稳定 code,不把数据库细节或其他证据 ID 返回模型。
八、格式修复与业务修复应分开
格式或 Schema 错误可以把精简错误反馈给模型,例如"confidence 必须是数字"。业务错误不一定适合自动修复。例如模型引用了无权限证据,若把"E-100 不属于当前工单"反馈回去,攻击者可能据此枚举数据;这类错误应直接拒绝。
可以建立修复决策表:
| 失败类型 | 是否自动修复 | 原因 |
|---|---|---|
| JSON 截断、字段类型错误 | 有限次数 | 通常是生成格式问题 |
| 缺少必填字段 | 有限次数 | 可给最小 Schema 提示 |
| 枚举值非法 | 有限次数 | 返回允许值即可 |
| 业务跨字段冲突 | 视场景一次 | 需要避免反复成本 |
| 资源无权、租户不匹配 | 否 | 防止枚举与越权 |
| 内容安全违规 | 否 | 进入安全处理 |
| 供应商超时或 429 | 由调用韧性策略决定 | 与结构修复不同 |
不要把原始异常栈和完整 Schema 每轮都重复塞回 Prompt,既泄露内部实现又增加 Token。内置 Advisor 会处理结构验证反馈;自定义业务修复时只使用经过白名单的错误码和短说明,并设置总 deadline。
九、从"生成草稿"到"执行写入"的两阶段设计
模型输出即使完全合法,也不应直接更新工单状态。将生成与执行拆开:
- 生成 ReviewDecision 草稿;
- 经过 Schema、字段、业务、事实与权限校验;
- 保存草稿及模型、Prompt、Schema 版本;
- 前端展示结构化预览,由用户或规则引擎确认;
- 执行 API 根据当前资源状态再次授权与校验;
- 使用幂等键和乐观锁完成状态变更;
- 写审计与 Outbox,异步发送通知。
草稿保存时用独立表,不把它混入正式审核记录。确认请求引用 draftId 和 expectedVersion,不能把前端回传的整份模型 JSON 当成可信数据。执行时重新读取草稿,确认未过期、属于当前用户和租户,资源状态未变化。
sql
update ticket
set review_status = :targetStatus,
version = version + 1,
reviewed_by = :operatorId
where tenant_id = :tenantId
and id = :ticketId
and version = :expectedVersion
and review_status = 'PENDING';
影响行数为 0 表示状态冲突或资源变化,需要重新审核,不能强行覆盖。幂等键防止用户双击或网络重试执行两次。结构化输出解决"数据形状",两阶段执行解决"副作用边界"。
十、Schema 版本演进与兼容
DTO 变化要像 API 契约一样管理。新增可选字段通常向后兼容;新增必填字段、重命名枚举、改变数值单位会破坏旧 Prompt、旧草稿和回放数据。建议保存 outputSchemaVersion,Prompt 版本明确绑定目标 DTO 与 Schema hash。
枚举演进尤其容易失败。删除旧枚举前,先让读取端兼容新旧值,迁移存量草稿,再修改生成契约。不要让模型输出展示文案作为枚举,国际化或运营改词会破坏解析。金额使用 BigDecimal 并明确货币和单位,时间使用带时区语义的格式,不能依赖模型猜"今天"的时区。
回放历史请求时使用当时 Schema 与 Prompt,不要拿当前 DTO 强行解析旧响应。若需要统一分析,先通过显式迁移器转换。契约发布与模型灰度一起记录,才能判断失败率变化来自 Prompt、模型还是 Schema。
十一、ObjectMapper 与反序列化安全
模型输出属于不可信输入。ObjectMapper 不应启用可由 JSON 指定任意 Java 类型的危险默认多态反序列化,也不要将类名暴露为字段让模型选择实现。使用封闭 DTO、明确枚举和允许字段,限制嵌套深度、字符串长度、数组大小和总响应字节。
额外字段采用拒绝还是忽略,需要业务选择。高风险写入契约倾向拒绝未知字段,尽早发现模型漂移;兼容型只读场景可忽略,但记录 unknown field 指标。无论哪种策略,都不能把未知 Map 原样传给数据库或工具。
模型返回 Markdown 围栏时,低层 converter 可以做有限规范化,但不要用贪婪正则从任意文本里找第一个 { 和最后一个 }。字符串内花括号、多个对象和恶意前后缀都会误解析。优先使用框架 converter、完整 JSON parser 和明确错误。
十二、流式输出的边界
用户希望看到流式文本,与业务需要完整可校验对象存在天然冲突。当前 validateSchema() 激活时不支持 streaming,因为验证必须拿到完整响应。不要一边把字段流式展示给用户,一边在末尾才发现整个对象无效却已经触发 UI 或副作用。
可选方案有三种:低风险预览流式显示"生成中"的纯文本,完成后再解析正式对象;服务端聚合完整流,验证通过后一次返回;或者把长任务设计成异步作业,前端轮询状态。涉及工具和数据库操作时,始终以最终验证对象为准。
手工聚合流还要设置最大响应大小、总超时和取消处理。客户端断开后应取消上游生成,避免继续消耗 Token;但供应商是否真正取消计费要按 API 验证。
十三、异常协议与可观测性
对外错误可以分为:
AI_OUTPUT_INVALID:达到结构修复上限;AI_OUTPUT_BUSINESS_INVALID:业务规则不满足;AI_PROVIDER_UNAVAILABLE:模型超时、限流或故障;AI_REVIEW_REQUIRES_HUMAN:策略要求人工处理;AI_CONTRACT_VERSION_UNSUPPORTED:契约版本不兼容。
响应不返回原始模型输出和堆栈,只返回 requestId 与安全提示。内部记录 outputContract、schemaHash、promptVersion、provider、model、nativeMode、validationAttempts、首次解析结果、最终状态、输入输出 Token 与耗时。
指标至少包括首次结构通过率、Schema 重试分布、最终结构失败率、Bean Validation 失败率、业务规则失败率、未知枚举、人工确认率和执行冲突率。将"最终成功率"单独看会掩盖大量昂贵重试。高基数 ticketId 不进入指标标签,详细样本放受控数据集。
十四、测试策略
单元测试覆盖 DTO 约束、跨字段规则、证据归属和映射;使用固定模型桩依次返回合法 JSON、代码围栏、缺字段、错误枚举、数字字符串、超长数组、额外字段、半截 JSON 和业务越界。验证每类失败是否修复、拒绝或转人工,并确认重复次数不超过预算。
集成测试必须针对实际 provider 与 model,因为 provider-native 支持不是所有模型通用。测试普通 entity、native mode、native 加 validateSchema、顶层对象、包装列表、长中文、Unicode、低温度和模型升级。验证 streaming 与 validateSchema 的不兼容被代码层阻止。
端到端测试从生成草稿到用户确认、数据库 CAS、Outbox 和重复确认,证明无效对象永不落正式表、跨租户证据被拒绝、网络重试不会重复执行。性能报告记录模型、Prompt、Schema 大小、修复轮次、Token 和 P95/P99,不编造"结构化输出 100% 成功"。
十五、性能与成本取舍
Schema 越复杂,提示 Token 越多,模型遵守难度也可能上升。不要创建包含数十层嵌套的万能 DTO;按业务命令拆分小契约。few-shot 示例能提高稳定性,但也增加输入成本,先用评测证明必要性。
原生结构化输出可能减少格式错误,却受模型支持限制;validateSchema() 能自纠错,却可能额外调用多轮。可以按风险分级:低风险草稿允许一次普通 entity;核心流程使用 native 加验证;大量离线任务可把失败样本进入重处理队列。每个层级都设置 Token 和总耗时预算。
缓存结构化结果时,Key 包含输入 hash、Prompt、模型、Schema 与业务数据版本。含用户数据的缓存按租户隔离并加密。模型生成结果不是永久事实,源数据变化后应失效。
十六、常见误区
第一个误区是"写了返回 JSON 就稳定"。提示只是软约束。第二个误区是"entity 成功就可以写库"。还缺 Bean Validation、业务、事实、权限和状态校验。第三个误区是认为 provider-native 在全部模型上自动生效;它依赖底层模型能力且默认不通用。
第四个误区是无限修复。每次修复都增加延迟与 Token,maxRepeatAttempts 必须受总预算限制。第五个误区是把数据库 Entity 直接作为输出类型。第六个误区是用正则截 JSON。第七个误区是忽略 streaming 与完整 Schema 验证的冲突。
第八个误区是修复时把内部堆栈和敏感事实发回模型。第九个误区是只统计最终解析率,不看首次成功和重试成本。第十个误区是顶层 List 到处通用;某些原生接口存在限制,包装对象更易演进。
十七、延伸
如果被问"Spring AI 如何拿结构化对象",可以说明 ChatClient.prompt().call().entity(Type.class);需要模型元数据时用 responseEntity。默认是基于 Schema 的提示与转换,模型支持时可用 useProviderStructuredOutput(),但不是所有 provider/model 都支持。
如果问"validateSchema 做什么",回答它校验完整 JSON 响应,失败后把验证错误反馈给模型并按 maxRepeatAttempts 有限重复,默认重复次数为 3;不支持流式验证。随后强调 Schema 不能替代 Bean Validation 和业务校验。
进一步问"如何避免模型结果直接产生副作用",可以回答专用 DTO、四层校验、草稿与执行两阶段、用户确认、幂等键、乐观锁和 Outbox。能解释结构正确与业务正确的区别,比只会调用 entity 更重要。
十八、官方能力核对与版本说明
本文主体 API 语义按 Spring AI 2.0.0 的 ChatClient 与 Structured Output 文档整理。官方文档说明 entity、responseEntity、useProviderStructuredOutput、validateSchema 与 StructuredOutputValidationAdvisor.maxRepeatAttempts;provider-native 支持、usage 是否跨递归调用累计以及其他细节需要按具体 Spring AI、provider 和 model 版本核对。
项目升级依赖时,应以编译期版本的 Reference 与 Javadoc 为准,并运行能力探测。尤其要关注 EntityParamSpec 方法名、Advisor 顺序、默认重复次数、Token 累计方式和 provider 已知限制,不能把某个 SNAPSHOT 行为无条件套到旧版本。
十九、总结
Spring AI 结构化输出把"模型文本"转换成"可进入 Java 校验链的对象"。entity 适合直接取得 DTO,responseEntity 同时保留 ChatResponse;模型支持时可启用 provider-native 约束,validateSchema 与有限重复提高格式稳定性。但这些能力都不等于业务结果可信。
稳定生产链路应使用专用契约,依次通过 JSON Schema、Bean Validation、跨字段规则、事实与权限校验,再以草稿、确认、幂等和事务执行副作用。把首次通过率、重试成本、业务失败和契约版本持续记录下来,系统才能从"偶尔能解析 JSON"进化为"任何错误都可检测、可阻断、可追踪"。