Spring AI企业级应用实战(9):结构化输出、JSON Schema、校验、自修复与版本兼容

文章摘要

前面的章节已经完成ChatClient统一调用层、流式输出、Chat Memory、Tool Calling、MCP、可观测性和安全治理。本篇继续解决企业AI应用的核心问题:如何把模型的自然语言输出转化为稳定、可校验、可版本化的Java对象。我们将使用Spring AI 2.0的ChatClient.entity()、Provider原生结构化输出和Schema校验能力,实现客户意图识别服务,并补齐业务校验、失败自修复、模型降级、Schema版本、审计、指标和测试。最终目标不是“让模型返回一段JSON”,而是建立可供下游业务安全消费的AI输出协议。

一、本篇要解决什么问题

前面的项目可以返回文本:

String answer = chatClient.prompt()
        .user(message)
        .call()
        .content();

但真实业务需要:

意图类型
置信度
是否需要人工
工单优先级
提取出的订单号
下一步动作

如果继续解析自然语言:

“我判断这可能是一个高优先级的退款请求……”

业务代码会变得非常脆弱。

我们希望得到:

{
  "intent": "REFUND_REQUEST",
  "confidence": 0.94,
  "priority": "HIGH",
  "requiresHumanReview": true,
  "entities": {
    "orderId": "A1007"
  }
}

然后直接转换为Java Record。

二、结构化输出的完整生产链路

用户输入
→ 输入安全检查
→ 加载Prompt与Schema版本
→ 选择支持目标Schema的模型
→ 调用Provider原生结构化输出
→ 本地JSON Schema校验
→ 必要时带错误自修复
→ 反序列化为Java对象
→ 业务规则校验
→ 审计与指标
→ 下游业务流程

需要特别区分三层校验:

JSON语法校验
Schema结构校验
业务规则校验

模型输出符合JSON,不代表业务上可以执行。

三、项目结构

spring-ai-enterprise
├── src/main/java/com/zyentor/ai
│   ├── config
│   │   └── StructuredAiConfig.java
│   ├── structured
│   │   ├── StructuredAiService.java
│   │   ├── StructuredAiServiceImpl.java
│   │   ├── SchemaCatalog.java
│   │   ├── SchemaDefinition.java
│   │   ├── ModelCapability.java
│   │   └── StructuredCallResult.java
│   ├── intent
│   │   ├── CustomerIntent.java
│   │   ├── CustomerIntentType.java
│   │   ├── Priority.java
│   │   ├── CustomerIntentValidator.java
│   │   └── CustomerIntentService.java
│   ├── audit
│   │   ├── StructuredCallAudit.java
│   │   └── StructuredCallAuditRepository.java
│   ├── metrics
│   │   └── StructuredOutputMetrics.java
│   └── web
│       └── CustomerIntentController.java
└── src/main/resources
    └── prompts
        └── customer-intent-v3.st

四、依赖配置

            org.springframework.ai
            spring-ai-bom
            2.0.0
            pom
            import






        org.springframework.ai
        spring-ai-starter-model-openai



        org.springframework.boot
        spring-boot-starter-web



        org.springframework.boot
        spring-boot-starter-validation



        org.springframework.boot
        spring-boot-starter-actuator



        io.micrometer
        micrometer-registry-prometheus

配置:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-5.6-luna
          temperature: 0

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus

结构化提取任务通常不需要高temperature。

五、设计输出类型

意图枚举:

package com.zyentor.ai.intent;

public enum CustomerIntentType {
    PRODUCT_INQUIRY,
    ORDER_QUERY,
    REFUND_REQUEST,
    TECHNICAL_SUPPORT,
    COMPLAINT,
    OTHER
}

优先级:

public enum Priority {
    LOW,
    MEDIUM,
    HIGH,
    URGENT
}

实体:

public record CustomerEntities(
        String orderId,
        String productName,
        String customerName
) {
}

最终对象:

public record CustomerIntent(
        CustomerIntentType intent,
        double confidence,
        Priority priority,
        boolean requiresHumanReview,
        String summary,
        CustomerEntities entities,
        List evidence
) {
}

六、为什么优先使用Record

Record具有:

  • 构造参数明确;
  • 不可变;
  • Jackson映射清晰;
  • Schema生成稳定;
  • 适合DTO;
  • 容易测试。

不要直接让模型输出JPA Entity:

@Entity
class CustomerTicket {
    ...
}

AI输出DTO和数据库实体必须分开。

七、控制Schema复杂度

虽然可以构造非常复杂的Java对象,但不建议:

  • 五层以上嵌套;
  • 循环引用;
  • 动态Map;
  • 多态继承;
  • 大量oneOf
  • 开放式Object字段;
  • 顶层数组。

跨Provider的稳定结构应保持:

顶层object
+简单字段
+有限枚举
+简单数组
+最多两三层嵌套

八、定义Schema版本

public record SchemaDefinition(
        String key,
        int version,
        Class type,
        boolean nativeOutput,
        boolean validateSchema,
        int maxRepairAttempts,
        String promptResource
) {
}

目录:

@Component
public class SchemaCatalog {

    private final Map> schemas =
            new ConcurrentHashMap();

    public SchemaCatalog() {
        register(new SchemaDefinition(
                "customer-intent",
                3,
                CustomerIntent.class,
                true,
                true,
                3,
                "classpath:/prompts/customer-intent-v3.st"
        ));
    }

    public  void register(SchemaDefinition definition) {
        String key = key(definition.key(), definition.version());

        if (schemas.putIfAbsent(key, definition) != null) {
            throw new IllegalStateException(
                    "重复Schema:" + key
            );
        }
    }

    public  SchemaDefinition get(
            String schemaKey,
            int version,
            Class type
    ) {
        SchemaDefinition raw = schemas.get(
                key(schemaKey, version)
        );

        if (raw == null) {
            throw new IllegalArgumentException(
                    "Schema不存在"
            );
        }

        if (!raw.type().equals(type)) {
            throw new IllegalArgumentException(
                    "Schema类型不匹配"
            );
        }

        @SuppressWarnings("unchecked")
        SchemaDefinition typed =
                (SchemaDefinition) raw;

        return typed;
    }

    private String key(String key, int version) {
        return key + ":v" + version;
    }
}

九、Prompt模板

customer-intent-v3.st

你是企业客户请求分类器。

请分析用户消息并输出结构化结果。

规则:
1. intent只能使用Schema定义的枚举值。
2. confidence范围为0到1。
3. 涉及退款、投诉、账号安全或信息不足时,requiresHumanReview应为true。
4. 不得创造用户未提供的订单号、姓名或产品名称。
5. evidence只能引用用户输入中实际出现的短语。
6. summary不超过80个中文字符。

用户消息:


已知上下文:

注意:

  • 不要求模型输出推理过程;
  • 不允许创造实体;
  • 明确枚举和数值边界;
  • 限制文本字段长度。

十、配置ChatClient

@Configuration
public class StructuredAiConfig {

    @Bean("structuredChatClient")
    ChatClient structuredChatClient(
            ChatClient.Builder builder
    ) {
        return builder
                .defaultSystem("""
                        你是企业AI结构化输出服务。
                        必须遵守输出Schema和业务约束。
                        不得在结构化结果之外添加解释。
                        """)
                .build();
    }
}

可以单独创建结构化输出ChatClient,避免普通聊天的Advisor、Memory和System Prompt干扰结构化任务。

十一、定义统一服务接口

public interface StructuredAiService {

     StructuredCallResult execute(
            String schemaKey,
            int schemaVersion,
            String prompt,
            Class outputType,
            AiRequestContext context
    );
}

结果:

public record StructuredCallResult(
        String requestId,
        String schemaKey,
        int schemaVersion,
        String model,
        int attempts,
        T data,
        List warnings
) {
}

十二、调用entity()

@Service
public class StructuredAiServiceImpl
        implements StructuredAiService {

    private final ChatClient chatClient;
    private final SchemaCatalog schemaCatalog;
    private final StructuredOutputMetrics metrics;
    private final StructuredCallAuditRepository auditRepository;

    public StructuredAiServiceImpl(
            @Qualifier("structuredChatClient")
            ChatClient chatClient,
            SchemaCatalog schemaCatalog,
            StructuredOutputMetrics metrics,
            StructuredCallAuditRepository auditRepository
    ) {
        this.chatClient = chatClient;
        this.schemaCatalog = schemaCatalog;
        this.metrics = metrics;
        this.auditRepository = auditRepository;
    }

    @Override
    public  StructuredCallResult execute(
            String schemaKey,
            int schemaVersion,
            String prompt,
            Class outputType,
            AiRequestContext context
    ) {
        SchemaDefinition schema =
                schemaCatalog.get(
                        schemaKey,
                        schemaVersion,
                        outputType
                );

        long start = System.nanoTime();

        try {
            T data = chatClient.prompt()
                    .advisors(spec -> spec
                            .param("requestId", context.requestId())
                            .param("tenantId", context.tenantId())
                            .param("schemaKey", schemaKey)
                            .param("schemaVersion", schemaVersion)
                    )
                    .user(prompt)
                    .call()
                    .entity(
                            outputType,
                            entitySpec -> {
                                if (schema.nativeOutput()) {
                                    entitySpec.useProviderStructuredOutput();
                                }

                                if (schema.validateSchema()) {
                                    entitySpec.validateSchema();
                                }
                            }
                    );

            metrics.recordSuccess(
                    schemaKey,
                    schemaVersion,
                    elapsed(start)
            );

            auditRepository.save(
                    StructuredCallAudit.success(
                            context,
                            schemaKey,
                            schemaVersion,
                            "configured-model"
                    )
            );

            return new StructuredCallResult(
                    context.requestId(),
                    schemaKey,
                    schemaVersion,
                    "configured-model",
                    1,
                    data,
                    List.of()
            );
        }
        catch (RuntimeException exception) {
            metrics.recordFailure(
                    schemaKey,
                    exception.getClass().getSimpleName(),
                    elapsed(start)
            );

            auditRepository.save(
                    StructuredCallAudit.failed(
                            context,
                            schemaKey,
                            schemaVersion,
                            exception
                    )
            );

            throw mapException(exception);
        }
    }

    private long elapsed(long start) {
        return (System.nanoTime() - start) / 1_000_000;
    }
}

十三、useProviderStructuredOutput()的作用

默认结构化输出可能采用:

Schema格式指令加入Prompt
→ 模型输出文本
→ 客户端转换

启用Provider原生结构化输出后:

JSON Schema作为API级约束
→ Provider控制输出结构

优势:

  • 格式更稳定;
  • 减少额外解释;
  • Prompt更清晰;
  • 更适合机器消费。

但必须实测具体Provider和模型版本。

十四、validateSchema()的作用

它提供响应侧保护:

模型返回JSON
→ 按目标Schema验证
→ 不符合时把错误加入Prompt
→ 重新调用

例如首次输出:

{
  "intent": "refund",
  "confidence": "high"
}

校验错误:

intent不属于枚举
confidence应为number

第二次模型会收到具体错误,而不是盲目重新生成。

十五、为什么还需要业务校验

Schema通过的结果:

{
  "intent": "REFUND_REQUEST",
  "confidence": 0.97,
  "requiresHumanReview": false
}

结构正确,但业务规则可能要求:

所有退款请求必须人工复核

定义业务校验器:

@Component
public class CustomerIntentValidator {

    public ValidationResult validate(
            CustomerIntent result
    ) {
        List errors = new ArrayList();
        List warnings = new ArrayList();

        if (
            result.confidence()  1
        ) {
            errors.add("confidence超出0到1范围");
        }

        if (
            result.intent()
                == CustomerIntentType.REFUND_REQUEST
            && !result.requiresHumanReview()
        ) {
            errors.add("退款请求必须进入人工复核");
        }

        if (result.confidence()  result =
                structuredAiService.execute(
                        "customer-intent",
                        3,
                        prompt,
                        CustomerIntent.class,
                        context
                );

        ValidationResult validation =
                validator.validate(result.data());

        if (!validation.valid()) {
            throw new BusinessValidationException(
                    validation.errors()
            );
        }

        return result.data();
    }
}

十七、不要让模型直接决定业务动作

错误链路:

模型输出approved=true
→ 系统自动退款

正确链路:

模型输出意图与证据
→ 业务代码读取订单
→ 权限和金额校验
→ 审批规则
→ 幂等执行

结构化输出是决策输入,不是最终授权。

十八、模型能力表

不同模型对JSON Schema支持不同。

public record ModelCapability(
        String model,
        boolean nativeStructuredOutput,
        boolean topLevelArray,
        Set supportedKeywords,
        int maxSchemaBytes
) {
}

路由:

public String route(
        SchemaDefinition schema,
        List candidates
) {
    return candidates.stream()
            .filter(ModelCapability::nativeStructuredOutput)
            .filter(capability ->
                    capability.maxSchemaBytes()
                            >= estimateSchemaSize(schema)
            )
            .findFirst()
            .orElseThrow(
                    () -> new NoCompatibleModelException(
                            schema.key()
                    )
            )
            .model();
}

十九、降级策略

推荐三级:

一级

原生结构化输出
+Schema校验

二级

Prompt结构化输出
+Schema校验

三级

返回稳定错误
+转人工

不要在关键业务中降级为“随便返回文本并继续执行”。

二十、错误分类

public enum StructuredOutputErrorCode {
    EMPTY_RESPONSE,
    INVALID_JSON,
    SCHEMA_MISMATCH,
    BUSINESS_RULE_VIOLATION,
    PROVIDER_NOT_SUPPORTED,
    RETRY_EXHAUSTED,
    MODEL_TIMEOUT,
    RATE_LIMITED
}

异常:

public class StructuredOutputException
        extends RuntimeException {

    private final StructuredOutputErrorCode code;
    private final boolean retryable;

    public StructuredOutputException(
            StructuredOutputErrorCode code,
            String message,
            boolean retryable,
            Throwable cause
    ) {
        super(message, cause);
        this.code = code;
        this.retryable = retryable;
    }
}

二十一、Schema升级兼容性

新增可选字段

通常兼容旧消费者。

新增必填字段

破坏性变化,应发布新版本。

修改枚举

新增枚举值可能让旧消费者反序列化失败。

修改类型

confidence: string
→ number

必须升级版本。

删除字段

先标记Deprecated,再经过迁移期删除。

建议保存Schema快照并自动Diff。

二十二、BeanOutputConverter升级注意事项

Spring AI 2.0中,Schema生成逻辑进一步与工具调用保持一致。

升级前后检查:

required字段变化
@JsonProperty行为
Kotlin可选属性
日期format
自定义Schema扩展点

测试不是只看对象能否返回,还要比较生成的JSON Schema。

二十三、审计对象

public record StructuredCallAudit(
        String requestId,
        String tenantId,
        String schemaKey,
        int schemaVersion,
        String model,
        String status,
        String errorCode,
        String inputHash,
        String outputHash,
        Instant createdAt
) {
}

不建议默认保存完整用户输入和完整模型输出。

对于需要审计的高风险场景,可以:

  • 单独加密;
  • 严格访问控制;
  • 设置保留期;
  • 记录访问日志。

二十四、指标

@Component
public class StructuredOutputMetrics {

    private final MeterRegistry registry;

    public void recordSuccess(
            String schemaKey,
            int version,
            long durationMs
    ) {
        registry.counter(
                "ai.structured.calls",
                "schema", schemaKey,
                "version", String.valueOf(version),
                "status", "success"
        ).increment();

        registry.timer(
                "ai.structured.duration",
                "schema", schemaKey
        ).record(
                Duration.ofMillis(durationMs)
        );
    }
}

建议指标:

first_attempt_success_rate
repair_attempt_count
repair_success_rate
schema_failure_rate
business_validation_failure_rate
provider_fallback_count
cost_per_valid_object
p95_latency

二十五、为什么“有效对象成本”更重要

模型A:

单次成本低
结构成功率80%

模型B:

单次成本高20%
结构成功率99%

如果模型A需要大量重试和人工处理,最终总成本可能更高。

应该计算:

总模型费用
+重试费用
+失败处理费用
÷ 有效对象数量

二十六、Controller

@RestController
@RequestMapping("/api/customer-intent")
public class CustomerIntentController {

    private final CustomerIntentService service;

    @PostMapping
    public CustomerIntentResponse classify(
            Authentication authentication,
            @Valid @RequestBody CustomerIntentRequest request
    ) {
        UserContext user = currentUser(authentication);

        AiRequestContext context =
                new AiRequestContext(
                        UUID.randomUUID().toString(),
                        user.tenantId(),
                        user.userId(),
                        "CUSTOMER_INTENT"
                );

        CustomerIntent intent = service.classify(
                request.message(),
                request.context(),
                context
        );

        return new CustomerIntentResponse(
                context.requestId(),
                intent
        );
    }
}

不要从请求体直接读取并信任tenantId。

二十七、测试结构化输出转换

class CustomerIntentSchemaTest {

    private final BeanOutputConverter converter =
            new BeanOutputConverter(CustomerIntent.class);

    @Test
    void shouldConvertValidJson() {
        String json = """
                {
                  "intent": "REFUND_REQUEST",
                  "confidence": 0.95,
                  "priority": "HIGH",
                  "requiresHumanReview": true,
                  "summary": "客户申请退款",
                  "entities": {
                    "orderId": "A1007",
                    "productName": null,
                    "customerName": null
                  },
                  "evidence": ["申请退款", "订单A1007"]
                }
                """;

        CustomerIntent result = converter.convert(json);

        assertThat(result.intent())
                .isEqualTo(
                        CustomerIntentType.REFUND_REQUEST
                );
    }
}

二十八、必须测试的失败场景

□ 返回Markdown代码块
□ 缺少必填字段
□ confidence返回字符串
□ 枚举返回中文
□ 顶层数组
□ 多余字段
□ 输出为空
□ Provider不支持原生Schema
□ 自修复次数耗尽
□ 业务规则失败
□ Prompt Injection要求忽略Schema
□ 模型切换后Schema行为变化

二十九、灰度发布

新Schema或新模型:

离线测试集
→ 影子流量
→ 1%
→ 10%
→ 50%
→ 100%

观察:

  • 首次成功率;
  • 修复次数;
  • 延迟;
  • Token;
  • 业务校验失败;
  • 人工退回;
  • 旧版兼容。

三十、完整调用链

HTTP请求
→ 身份与租户校验
→ 输入安全检查
→ CustomerIntentService
→ 加载Prompt v3
→ 加载Schema v3
→ 模型能力路由
→ Provider原生结构化输出
→ Schema校验和自修复
→ CustomerIntent对象
→ 业务规则校验
→ 审计与Metrics
→ 返回稳定API对象

总结

生产级结构化输出不是一句:

请返回JSON

而是一套完整协议治理能力:

简单稳定的AI DTO
+Schema版本
+Provider原生约束
+本地Schema校验
+有限自修复
+业务规则校验
+模型能力路由
+指标与审计

完成这一层后,模型输出才能真正安全地进入工作流、数据库、审批和工具执行。

下一篇将继续实现:

Spring AI企业级应用实战(10):自动化评测、黄金测试集、Prompt回归与发布门禁。

延伸阅读

如果你正在关注企业级AI应用、Spring AI、RAG、Agent与MCP工程化落地,欢迎访问 智元界

https://www.zyentor.com/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。