Spring AI 结构化输出:稳定生成可校验的业务 JSON

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 生成工单风险审核草稿"为例给出完整实现与边界。

一、先区分四种"正确"

模型返回的内容至少要通过四层门槛:

  1. 语法正确:是完整 JSON,没有代码围栏和尾随解释;
  2. 结构正确:必填字段、类型、枚举、数组和嵌套对象满足 JSON Schema;
  3. 业务正确:置信度范围、证据归属、金额上限、状态机等符合领域规则;
  4. 权限与事实正确:当前用户有权操作目标资源,引用证据真实存在且属于当前租户。

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) {
}

字段名表达稳定业务含义,枚举使用机器值而非可变中文展示文案,长度上限防止输出膨胀。tenantIdticketIdoperatorId 不由模型填写,它们来自请求上下文。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。

九、从"生成草稿"到"执行写入"的两阶段设计

模型输出即使完全合法,也不应直接更新工单状态。将生成与执行拆开:

  1. 生成 ReviewDecision 草稿;
  2. 经过 Schema、字段、业务、事实与权限校验;
  3. 保存草稿及模型、Prompt、Schema 版本;
  4. 前端展示结构化预览,由用户或规则引擎确认;
  5. 执行 API 根据当前资源状态再次授权与校验;
  6. 使用幂等键和乐观锁完成状态变更;
  7. 写审计与 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 文档整理。官方文档说明 entityresponseEntityuseProviderStructuredOutputvalidateSchemaStructuredOutputValidationAdvisor.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"进化为"任何错误都可检测、可阻断、可追踪"。

相关推荐
学习中.........1 小时前
并行 BPE 训练 与 手写 `Tokenizer`
人工智能·算法·机器学习·语言模型
lialaka1 小时前
「极客智库(The Architect‘s Nexus)」——全语音 3D AI 首席架构师与具身交互智能会话推演舱
人工智能·3d·交互
sevenll071 小时前
SqlKit - 覆盖 50+ 数据库的 AI 智能体 SQL 桌面客户端
数据库·人工智能·sql·智能体
狂师1 小时前
AI Agent评测体系怎么搭:90% 的团队都漏了这几环...
人工智能·agent·测试
宸津-代码粉碎机1 小时前
告别手动Jar部署!生产级无损热部署方案,彻底解决OOM与更新失效问题
java·大数据·开发语言·人工智能·python
独守一片天1 小时前
HarmonyOS 新生态 从原生应用到 AI Agent 的全场景智能底座
人工智能·安全·harmonyos
未知违规用户1 小时前
大模型项目: 学习FastAPI 服务器开发
服务器·人工智能·python·学习·fastapi
想会飞的蒲公英1 小时前
PyTorch reshape、view、transpose、广播到底怎么选?
人工智能·pytorch·python
DTAS尺寸公差分析软件1 小时前
国产自研-DTAS 3D公差分析软件-功能简介
人工智能·3d·尺寸公差分析·三维公差分析·公差计算软件·尺寸链分析软件