Spring AI 1.x 系列【25】结构化输出案例演示

文章目录

  • [1. 前言](#1. 前言)
  • [2. 单个对象](#2. 单个对象)
    • [2.1 定义 Java 对象](#2.1 定义 Java 对象)
    • [2.2 开启原生结构化输出](#2.2 开启原生结构化输出)
      • [2.2.1 模型参数](#2.2.1 模型参数)
      • [2.2.2 ENABLE_NATIVE_STRUCTURED_OUTPUT 属性](#2.2.2 ENABLE_NATIVE_STRUCTURED_OUTPUT 属性)
    • [2.3 调用 entity 方法](#2.3 调用 entity 方法)
  • [3. 泛型集合类型](#3. 泛型集合类型)
    • [3.1 List](#3.1 List)
    • [3.2 Map](#3.2 Map)
  • [4. responseEntity()](#4. responseEntity())
  • [5. 自定义 StructuredOutputConverter](#5. 自定义 StructuredOutputConverter)

1. 前言

在之前我们提到过,想要实现结构化输出需要:

  1. 向提示词追加格式要求指令
  2. 将模型输出转换为结构化类型实例

分为两种模式:

  • 非原生模式 :追加格式指令文本、 Schema 要用户提示词中
  • 原生模式 :需要模型支持原生结构化输出,通过 ChatOptions 传递 Schema,更加可靠稳定

接下来,我们使用 Spring AI 提供的结构化输出功能,进行案例演示。

2. 单个对象

演示需求 :提取合同中的关键信息为 Java 对象。

2.1 定义 Java 对象

定义合同信息 record 类:

java 复制代码
/**
 * 合同信息实体类(适配 Spring AI 原生结构化输出)
 */
public record ContractInfo(
        /**
         * 合同编号(必填,格式:HT开头+8位数字)
         */
        @JsonProperty("contractNo") // 指定 JSON 字段名(确保和大模型输出映射)
        String contractNo,

        /**
         * 合同名称(必填)
         */
        @JsonProperty("contractName")
        String contractName,

        /**
         * 签订日期(必填,ISO-8601格式:yyyy-MM-dd)
         */
        @JsonProperty("signDate")
        String signDate,

        /**
         * 甲方名称(必填)
         */
        @JsonProperty("partyA")
        String partyA,

        /**
         * 乙方名称(必填)
         */
        @JsonProperty("partyB")
        String partyB,

        /**
         * 合同金额(必填,单位:元,保留2位小数)
         */
        @JsonProperty("amount")
        BigDecimal amount, // 用BigDecimal避免浮点精度问题,比Double更适合金额

        /**
         * 付款方式(可选,仅支持:一次性付清/分3期/分12期/月结)
         */
        @JsonProperty(value = "paymentMethod", required = false)
        String paymentMethod,

        /**
         * 合同备注(可选)
         */
        @JsonProperty(value = "remark", required = false)
        String remark,

        /**
         * 有效期至(可选,ISO-8601格式:yyyy-MM-dd)
         */
        @JsonProperty(value = "validUntil", required = false)
        String validUntil) {
}

支持通过 @JsonPropertyOrder 注解指定属性的精确顺序,例如:

java 复制代码
// 在 AI 模型输出内容中,contractName 始终在最前面
@JsonPropertyOrder({"contractName", "contractNo"})
record ActorsFilms(String actor, List<String> movies) {}

注意事项

  • 使用 BeanOutputConverter 时生效
  • 适用于 record 类和普通 Java 类。

2.2 开启原生结构化输出

2.2.1 模型参数

对于支持原生结构化输出的 AI 模型,需要配置输出格式化模式,相比基于提示词的方式更可靠。

注意 :智谱 AI 只支持配置 response-format,实际并非完整的原生结构化输出。

可以在 application.yml 中全局默认配置:

yml 复制代码
spring:
  application:
    name: ai-chat-demo
  ai:
    # 智谱 GLM 配置
    zhipuai:
      api-key: f9d9e7c26bd24406ad # 替换为你的智谱 API Key
      base-url: https://open.bigmodel.cn/api/paas # 可选,默认值可省略
      chat:
        options:
          model: glm-4.7 # 智谱模型名称(如 glm-4、glm-4-flash 等)
          response-format: json_object # text   

也可以在构建 ChatClient 时全局配置:

java 复制代码
        ZhiPuAiChatOptions chatOptions = ZhiPuAiChatOptions.builder()
                .responseFormat(ZhiPuAiApi.ChatCompletionRequest.ResponseFormat.jsonObject())
                .build();
        
        ChatClient client = ChatClient.builder(zhiPuAiChatModel)
                .defaultOptions(chatOptions)
                .build();

不过,一般全局默认应该是默认的 text 文件输出,在运行时配置 json 输出:

java 复制代码
// 动态设置 response-format
String result = client.prompt()
    .user("你的问题")
    .options(ZhiPuAiChatOptions.builder()
        .responseFormat(ZhiPuAiApi.ChatCompletionRequest.ResponseFormat.jsonObject())  // 或 ResponseFormat.text()
        .build())
    .call()
    .content();

2.2.2 ENABLE_NATIVE_STRUCTURED_OUTPUT 属性

需要设置 ENABLE_NATIVE_STRUCTURED_OUTPUT 属性,用于自动生成 output.schema

ChatClient 可配置以下属性:

java 复制代码
public enum ChatClientAttributes {

	//@formatter:off
	// 格式指令文本,追加到用户 prompt,告诉模型输出格式
	OUTPUT_FORMAT("spring.ai.chat.client.output.format"),
	// JSON Schema 字串,从 Java 类生成,传给模型原生 API
	STRUCTURED_OUTPUT_SCHEMA("spring.ai.chat.client.structured.output.schema"),
	// 原生模式开关,启用后 schema 直接传模型 API 而非注入 prompt
	STRUCTURED_OUTPUT_NATIVE("spring.ai.chat.client.structured.output.native");

	//@formatter:on

	private final String key;

	ChatClientAttributes(String key) {
		this.key = key;
	}

	public String getKey() {
		return this.key;
	}
}

配置如下:

java 复制代码
        ChatClient.CallResponseSpec callResponseSpec = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call();

2.3 调用 entity 方法

直接调用 entity 方法将输出转换为合同信息类:

java 复制代码
        String userInput = """
                2026年度办公设备采购合同
                合同编号:HT20260313
                甲方:北京科技有限公司
                乙方:上海办公设备销售有限公司
                甲乙双方经友好协商,就办公设备采购事宜达成如下协议:
                第一条 采购内容
                甲方向乙方采购办公设备一批,具体型号及数量详见附件清单。
                第二条 合同金额
                合同总金额为人民币贰万伍仟捌佰元整(¥25,800.00元)。
                第三条 付款方式
                合同签订后分3期支付:首付款30%于签约时支付,二期款40%于设备到货时支付,尾款30%于验收合格后支付。
                第四条 交货期限
                乙方须于2026年04月01日前完成全部设备交付及安装调试。
                第五条 质量保证
                乙方保证所供设备为全新正品,符合国家质量标准,提供一年免费保修服务。
                第六条 违约责任
                任何一方违约,应向守约方支付合同金额10%的违约金。
                第七条 本合同一式两份,双方各执一份,自签订之日起生效,有效期至2027年03月12日。
                甲方(盖章): 北京科技有限公司
                乙方(盖章): 上海办公设备销售有限公司
                签订日期: 2026年03月13日
                                """;
        ContractInfo contractInfo = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call()
                // 直接映射为ContractInfo实体(无需手动解析JSON)
                .entity(ContractInfo.class);

运行后,可以看到在模型请求中包含了格式要求指令 以及自动生成的输出 JSON Schema

打印信息中可以看到成功被转换为 Java 对象:

3. 泛型集合类型

3.1 List

对于泛型集合类型,需要使用 Spring Framework 提供的 ParameterizedTypeReference ,解决 Java 泛型类型擦除问题。

入口方法:

java 复制代码
		@Override
		@Nullable
		public <T> T entity(ParameterizedTypeReference<T> type) {
			Assert.notNull(type, "type cannot be null");
			return doSingleWithBeanOutputConverter(new BeanOutputConverter<>(type));
		}

BeanOutputConverter 会保留类型:

简单示例:

java 复制代码
        List<ContractInfo> contractInfoList = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call()
                .entity(new ParameterizedTypeReference<List<ContractInfo>>() {
                });

生成的 schema

java 复制代码
{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "array",
  "items" : {
    "type" : "object",
    "properties" : {
      "contractName" : {
        "type" : "string"
      },
      "contractNo" : {
        "type" : "string"
      },
      "signDate" : {
        "type" : "string"
      },
      "partyA" : {
        "type" : "string"
      },
      "partyB" : {
        "type" : "string"
      },
      "amount" : {
        "type" : "number"
      },
      "paymentMethod" : {
        "type" : "string"
      },
      "remark" : {
        "type" : "string"
      },
      "validUntil" : {
        "type" : "string"
      }
    },
    "required" : [ "contractName", "contractNo", "signDate", "partyA", "partyB", "amount", "paymentMethod", "remark", "validUntil" ],
    "additionalProperties" : false
  }
}

返回结果:

3.2 Map

Map 类型也是一样,简单示例:

java 复制代码
        Map<String, Object> objectMap = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call()
                .entity(new ParameterizedTypeReference<Map<String, Object>>() {
                });

生成的 schema

java 复制代码
{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "additionalProperties" : false
}

4. responseEntity()

responseEntity() 可以返回结构化的 ResponseEntity ,适用于需要同时访问结构化实体和响应元数据,双返回值:

  • response (R):原始 ChatResponse,包含所有元数据
  • entity (E):转换后的目标类型实体
java 复制代码
public record ResponseEntity<R, E>(@Nullable R response, @Nullable E entity) {

	@Nullable
	public R getResponse() {
		return this.response;
	}

	@Nullable
	public E getEntity() {
		return this.entity;
	}
}

三个重载方法:

java 复制代码
@Override
public <T> ResponseEntity<ChatResponse, T> responseEntity(Class<T> type) {
    Assert.notNull(type, "type cannot be null");
    return doResponseEntity(new BeanOutputConverter<>(type));
}

@Override
public <T> ResponseEntity<ChatResponse, T> responseEntity(ParameterizedTypeReference<T> type) {
    Assert.notNull(type, "type cannot be null");
    return doResponseEntity(new BeanOutputConverter<>(type));
}

@Override
public <T> ResponseEntity<ChatResponse, T> responseEntity(
        StructuredOutputConverter<T> structuredOutputConverter) {
    Assert.notNull(structuredOutputConverter, "structuredOutputConverter cannot be null");
    return doResponseEntity(structuredOutputConverter);
}

使用方式和执行逻辑和 entity 方法基本一致,只是返回值多了一个 ChatResponse

java 复制代码
/**
 * 执行结构化输出转换的核心方法,将 LLM 响应转换为目标类型。
 *
 * <p>处理流程:
 * <ol>
 *   <li>将格式指令存入 context,供后续 Advisor 使用</li>
 *   <li>如果是原生结构化输出模式,将 JSON Schema 存入 context</li>
 *   <li>调用模型获取响应</li>
 *   <li>使用转换器将响应文本转换为目标类型</li>
 * </ol>
 *
 * @param outputConverter 结构化输出转换器,包含格式指令和转换逻辑
 * @param <T> 目标类型
 * @return 包含 ChatResponse 和转换后实体的 ResponseEntity
 */
protected <T> ResponseEntity<ChatResponse, T> doResponseEntity(StructuredOutputConverter<T> outputConverter) {
    Assert.notNull(outputConverter, "structuredOutputConverter cannot be null");

    // 将格式指令存入 context,ChatModelCallAdvisor 会将其注入 prompt 或 ChatOptions
    this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat());

    // 原生结构化输出模式:将 JSON Schema 存入 context
    // ChatModelCallAdvisor 会将其设置到 StructuredOutputChatOptions 中
    if (Boolean.TRUE.equals(this.request.context().get(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()))
            && outputConverter instanceof BeanOutputConverter beanOutputConverter) {
        this.request.context()
            .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), beanOutputConverter.getJsonSchema());
    }

    // 执行请求并获取响应
    var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse();

    // 提取响应文本内容
    var responseContent = getContentFromChatResponse(chatResponse);
    if (responseContent == null) {
        return new ResponseEntity<>(chatResponse, null);
    }

    // 将响应文本转换为目标类型
    T entity = outputConverter.convert(responseContent);
    return new ResponseEntity<>(chatResponse, entity);
}

简单示例:

java 复制代码
        ResponseEntity<ChatResponse, ContractInfo> chatResponseContractInfoResponseEntity = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call()
                .responseEntity(ContractInfo.class);

返回结果:

5. 自定义 StructuredOutputConverter

我们可以自定义实现 StructuredOutputConverter ,以适配更多的场景,比如:

  • 实现中文的格式指令
  • 支持更多的 schema

简单示例:

java 复制代码
public class ChineseBeanOutputConverter<T> implements StructuredOutputConverter<T> {

    private final Type type;
    private final ObjectMapper objectMapper;
    private final String jsonSchema;
    private final ResponseTextCleaner textCleaner;

    public ChineseBeanOutputConverter(Class<T> clazz) {
        this(ParameterizedTypeReference.forType(clazz), new ObjectMapper(), null);
    }

    public ChineseBeanOutputConverter(Class<T> clazz, ObjectMapper objectMapper) {
        this(ParameterizedTypeReference.forType(clazz), objectMapper, null);
    }

    public ChineseBeanOutputConverter(Class<T> clazz, ObjectMapper objectMapper, ResponseTextCleaner textCleaner) {
        this(ParameterizedTypeReference.forType(clazz), objectMapper, textCleaner);
    }

    public ChineseBeanOutputConverter(ParameterizedTypeReference<T> typeRef) {
        this(typeRef, new ObjectMapper(), null);
    }

    public ChineseBeanOutputConverter(ParameterizedTypeReference<T> typeRef, ObjectMapper objectMapper, ResponseTextCleaner textCleaner) {
        this.type = typeRef.getType();
        this.objectMapper = objectMapper;
        this.textCleaner = textCleaner;
        this.jsonSchema = generateSchema();
    }

    /**
     * 生成 JSON Schema
     */
    private String generateSchema() {
        JacksonModule jacksonModule = new JacksonModule(
                JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
                JacksonOption.RESPECT_JSONPROPERTY_ORDER);

        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(
                com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12,
                com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)
                .with(jacksonModule)
                .with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);

        // 所有字段必填
        configBuilder.forFields().withRequiredCheck(f -> true);

        SchemaGeneratorConfig config = configBuilder.build();
        SchemaGenerator generator = new SchemaGenerator(config);
        JsonNode jsonNode = generator.generateSchema(this.type);

        return jsonNode.toPrettyString();
    }

    /**
     * 中文格式指令
     */
    @Override
    public String getFormat() {
        String template = """
                你的回答必须是 JSON 格式。
                不要包含任何解释说明,只提供符合 RFC8259 标准的 JSON 响应,严格按照以下格式输出,不得有任何偏差。
                不要在回答中包含 markdown 代码块标记。
                请移除输出中的 ```json markdown 标记。
                以下是你的输出必须遵循的 JSON Schema:
                ```%s```
                """;
        return String.format(template, this.jsonSchema);
    }

    @SuppressWarnings("unchecked")
    @Override
    public T convert(String text) {
        try {
            // 如果有文本清理器,先清理文本
            if (this.textCleaner != null) {
                text = this.textCleaner.clean(text);
            }
            return (T) this.objectMapper.readValue(text, this.objectMapper.constructType(this.type));
        } catch (JsonProcessingException e) {
            throw new RuntimeException("无法将文本转换为目标类型: " + this.type, e);
        }
    }

    public String getJsonSchema() {
        return this.jsonSchema;
    }
}

使用示例:

java 复制代码
        // 使用自定义转换器
        ChineseBeanOutputConverter<ContractInfo> converter = new ChineseBeanOutputConverter<>(ContractInfo.class);

        ContractInfo contractInfo = client
                .prompt()
                .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
                .user(userInput)
                .call()
                .entity(converter);

生成的 schema

相关推荐
视***间3 小时前
智赋机器人,算力启新程——视程空间以全栈算力方案,让智能机器人真正落地千行百业
人工智能·机器人·边缘计算·视程空间·高算力·2070tflops
鱼鳞_3 小时前
Java学习笔记_Day23(HashMap)
java·笔记·学习
hua_ban_yu3 小时前
新版本 idea 如何设置热部署
java·ide·intellij-idea
福客AI智能客服4 小时前
电商客服机器人:AI智能客服系统如何提升电商运营效率
人工智能·机器人
odng4 小时前
拉取最新代码报错修复说明
java
喵叔哟4 小时前
5.【.NET10 实战--孢子记账--产品智能化】--基础框架与微软官方包批量升级
人工智能·microsoft·.net
无籽西瓜a4 小时前
【西瓜带你学设计模式 | 第十四期 - 享元模式】享元模式 —— 内外状态分离与对象共享实现、优缺点与适用场景
java·设计模式·软件工程·享元模式
大黄说说4 小时前
Go语言并发编程:Goroutine与Channel构建的CSP模型
java·后端·spring