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

字段名表达稳定业务含义,枚举使用机器值而非可变中文展示文案,长度上限防止输出膨胀。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。

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

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

  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 文档整理。官方文档说明 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"进化为"任何错误都可检测、可阻断、可追踪"。

相关推荐
shaibdoio2 分钟前
意图识别实战:从规则匹配到传统机器学习,再到混合大模型方案
人工智能·机器学习
IT_陈寒3 分钟前
SpringBoot自动配置的坑我帮你踩过了
前端·人工智能·后端
198******126345 分钟前
2026企业AI办公工具选型全指南:自动资料整合与报告生成赛道全景
大数据·人工智能
vx_Biye_Design6 分钟前
springboot角色扮演服务平台65161-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·spring·课程设计
一木 之林6 分钟前
《OpenAI库基础学习总结:Client 初始化、流式输出 delta 拼接与多轮历史 messages 全流程拆解》
人工智能·学习·计算机视觉·stable diffusion·aigc
Ivanqhz9 分钟前
BURG(自底向上重写生成器)
服务器·数据库·人工智能·深度学习·算法
198******1263410 分钟前
Work Agent深度解读:AI长程任务执行的机制与落地形态
人工智能
m4Rk_11 分钟前
【论文阅读】Agent 记忆机制(90):HyperMem——用超图建模长期记忆中的高阶关联
论文阅读·人工智能·学习·开源·github
言乐616 分钟前
逻辑回归与利弊
人工智能·算法·机器学习·数据挖掘·逻辑回归
vx_Biye_Design17 分钟前
springboot咖啡厅顾客点单管理系统12080-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·课程设计·express