Spring AI 1.x 系列【26】结构化输出执行流程

文章目录

  • [1. 创建 BeanOutputConverter](#1. 创建 BeanOutputConverter)
    • [1.1 构造方法](#1.1 构造方法)
    • [1.2 创建 Cleaner](#1.2 创建 Cleaner)
    • [1.3 自动生成 JSON Schema](#1.3 自动生成 JSON Schema)
  • [2. 执行请求](#2. 执行请求)
    • [2.1 请求上下文](#2.1 请求上下文)
    • [2.2 原生模式](#2.2 原生模式)
    • [2.3 非原生模式](#2.3 非原生模式)
  • [3. 输出转换](#3. 输出转换)
    • [3.1 提取文本内容](#3.1 提取文本内容)
    • [3.2 清洗](#3.2 清洗)
    • [3.3 转换](#3.3 转换)

1. 创建 BeanOutputConverter

1.1 构造方法

entity 方法会自动创建 BeanOutputConverter

java 复制代码
@Override
@Nullable
public <T> T entity(Class<T> type) {
    Assert.notNull(type, "type cannot be null");
    // 1. 根据传入的 Class 自动创建 BeanOutputConverter
    var outputConverter = new BeanOutputConverter<>(type);
    // 2. 进入核心执行方法
    return doSingleWithBeanOutputConverter(outputConverter);
}

BeanOutputConverter 构造方法中会初始化各种属性:

java 复制代码
    private BeanOutputConverter(Type type, ObjectMapper objectMapper, ResponseTextCleaner textCleaner) {
        this.logger = LoggerFactory.getLogger(BeanOutputConverter.class);
        Objects.requireNonNull(type, "Type cannot be null;");
        this.type = type;
        this.objectMapper = objectMapper != null ? objectMapper : this.getObjectMapper();
        this.textCleaner = textCleaner != null ? textCleaner : createDefaultTextCleaner();
        this.generateSchema();
    }

对象信息如下:

1.2 创建 Cleaner

构造方法中会初始化多个清理器,用于在解析 LLM 响应文本之前,对文本进行清理预处理,处理不同 AI 模型的各种响应格式和模式。

  • WhitespaceCleaner:去除文本首尾空白字符
  • ThinkingTagCleaner:移除 Markdown 代码块格式
  • MarkdownCodeBlockCleaner:移除 AI 模型的"思考标签"(reasoning tokens
java 复制代码
	private static ResponseTextCleaner createDefaultTextCleaner() {
		return CompositeResponseTextCleaner.builder()
			.addCleaner(new WhitespaceCleaner())
			.addCleaner(new ThinkingTagCleaner())
			.addCleaner(new MarkdownCodeBlockCleaner())
			.addCleaner(new WhitespaceCleaner()) // Final trim after all cleanups
			.build();
	}

1.3 自动生成 JSON Schema

还会自动生成 JSON Schema

java 复制代码
/**
 * Generates the JSON schema for the target type.
 * 为目标 Java 类型生成 JSON Schema。
 */
private void generateSchema() {
    // 创建 Jackson 模块,配置:
    // - RESPECT_JSONPROPERTY_REQUIRED: 尊重 @JsonProperty(required=true) 注解
    // - RESPECT_JSONPROPERTY_ORDER: 尊重 @JsonPropertyOrder 注解的字段顺序
    JacksonModule jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
            JacksonOption.RESPECT_JSONPROPERTY_ORDER);

    // 构建 Schema 生成器配置:
    // - DRAFT_2020_12: 使用 JSON Schema 2020-12 版本
    // - PLAIN_JSON: 生成纯 JSON 格式(不含 Java 类型信息)
    // - FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT: 默认禁止额外属性(additionalProperties: false)
    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);

    // 将所有字段标记为 required(必填)
    configBuilder.forFields().withRequiredCheck(f -> true);

    // 如果 Kotlin 反射存在,添加 Kotlin 模块支持(处理 Kotlin data class 等)
    if (KotlinDetector.isKotlinReflectPresent()) {
        configBuilder.with(new KotlinModule());
    }

    // 构建 Schema 生成器并生成 JSON Schema
    SchemaGeneratorConfig config = configBuilder.build();
    SchemaGenerator generator = new SchemaGenerator(config);
    JsonNode jsonNode = generator.generateSchema(this.type);

    // 后置处理:允许子类自定义 Schema
    postProcessSchema(jsonNode);

    // 格式化输出:使用美化打印,确保可读性
    ObjectWriter objectWriter = this.objectMapper.writer(new DefaultPrettyPrinter()
        .withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator())));

    // 将 JsonNode 转换为 JSON 字符串
    try {
        this.jsonSchema = objectWriter.writeValueAsString(jsonNode);
    }
    catch (JsonProcessingException e) {
        logger.error("Could not pretty print json schema for jsonNode: {}", jsonNode);
        throw new RuntimeException("Could not pretty print json schema for " + this.type, e);
    }
}

生成关键配置说明:

配置项 作用
RESPECT_JSONPROPERTY_REQUIRED 读取 @JsonProperty(required=true) 注解
RESPECT_JSONPROPERTY_ORDER 读取 @JsonPropertyOrder 注解控制字段顺序
DRAFT_2020_12 使用最新 JSON Schema 规范版本
PLAIN_JSON 生成纯 JSON,不含 Java 类型元数据
FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT 设置 additionalProperties: false
withRequiredCheck(f -> true) 所有字段默认必填

2. 执行请求

2.1 请求上下文

doSingleWithBeanOutputConverter 方法中,会先判断当前转换器中是否包含 Format 格式化指令,有则添加到上下文中:

java 复制代码
    // ====================== 步骤1:设置格式提示(兜底方案:所有模型都能用)======================
    if (StringUtils.hasText(outputConverter.getFormat())) {
        // 把转换器生成的格式指令(如 JSON、Schema)放入请求上下文
        // 作用:框架会自动将 {format} 拼接到 Prompt 末尾
        this.request.context()
            .put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), 
                  outputConverter.getFormat());
    }

BeanOutputConverter 默认的指令如下:

java 复制代码
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.

getFormat() 方法中,默认也会将 JsonSchema 也拼接到一起:

java 复制代码
	@Override
	public String getFormat() {
		String template = """
				Your response should be in JSON format.
				Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
				Do not include markdown code blocks in your response.
				Remove the ```json markdown from the output.
				Here is the JSON Schema instance your output must adhere to:
				```%s```
				""";
		return String.format(template, this.jsonSchema);
	}

最终 spring.ai.chat.client.output.format 属性其实包含了格式指令、 输出 JsonSchema

json 复制代码
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "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
}```

接着判断 STRUCTURED_OUTPUT_NATIVE 属性,有则添加 spring.ai.chat.client.structured.output.schema

java 复制代码
    // ====================== 步骤2:如果开启原生结构化输出 → 注入 JSON Schema ====================
    // 判断条件:
    // 1. 上下文开启了 NATIVE 模式(ENABLE_NATIVE_STRUCTURED_OUTPUT)
    // 2. 转换器必须是 BeanOutputConverter(只有它能生成 JSON Schema)
    if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey())
        && outputConverter instanceof BeanOutputConverter beanOutputConverter) {

        // 将自动生成的 JSON Schema 放入上下文
        // 作用:传递给模型客户端(如 OpenAI),调用模型原生 Structured Output API
        this.request.context()
            .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), 
                  beanOutputConverter.getJsonSchema());
    }

BeanOutputConverter 默认生成的 Schme 如下:

json 复制代码
{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "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
}

最终请求上下文对象对下:

2.2 原生模式

设置好请求上下文后,进入到调用 AI 模型阶段:

java 复制代码
    // ====================== 步骤3:调用 AI 模型 ====================
    // 执行真正的模型调用
    var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse();

ChatModelCallAdvisor 中,如果同时满足以下三个条件,会将 OutputSchema 配置到 ChatOptions 中:

  1. context 中设置了 STRUCTURED_OUTPUT_NATIVE 标志(用户通过 advisor 启用)
  2. outputSchema 不为空(由 BeanOutputConverter 从目标 Java 类生成)
  3. ChatOptions 实现了 StructuredOutputChatOptions 接口(模型支持原生结构化输出)

因为我们使用的是 ZhiPuAiChatOptions 并没有实现 StructuredOutputChatOptions 接口:

java 复制代码
public class ZhiPuAiChatOptions implements ToolCallingChatOptions {

所以,这里不会在 ChatOptions 中配置 JSON Schema

java 复制代码
		if (chatClientRequest.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey())
				&& StringUtils.hasText(outputSchema) && chatClientRequest.prompt()
					.getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions) {

			// 将 JSON Schema 设置到模型的原生结构化输出配置中。
			// 模型 API 将使用此 schema 来保证输出格式符合要求。
			structuredOutputChatOptions.setOutputSchema(outputSchema);

			// 返回原始请求,不修改 prompt。
			// prompt 保持干净------不会追加任何格式指令文本。
			return chatClientRequest;
		}
		// .............

2.3 非原生模式

继续执行,通过 prompt 文本指令引导模型输出指定格式,而非使用模型的原生 API

java 复制代码
Prompt augmentedPrompt = chatClientRequest.prompt()
	.augmentUserMessage(userMessage -> userMessage.mutate()
		.text(userMessage.getText() + System.lineSeparator() + outputFormat)
		.build());

return ChatClientRequest.builder()
	.prompt(augmentedPrompt)
	.context(Map.copyOf(chatClientRequest.context()))
	.build();

此时,提示词内容如下:

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

Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "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
}```
', metadata={messageType=USER}, messageType=USER}], modelOptions=ZhiPuAiChatOptions: {"response_format":{"type":"json_object"}}}, context={spring.ai.chat.client.structured.output.native=true, spring.ai.chat.client.structured.output.schema={
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "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
}, spring.ai.chat.client.output.format=Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "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. 输出转换

3.1 提取文本内容

在模型回复后,返回模型响应对象 ChatClientResponse

java 复制代码
    var stringResponse = getContentFromChatResponse(chatResponse);
    if (stringResponse == null) {
        return null;
    }

然后从 ChatResponse 中提取文本内容:

java 复制代码
/**
 * 从 ChatResponse 中提取文本内容。
 *
 * <p>提取路径:ChatResponse → Generation → AssistantMessage → text
 *
 * <pre>
 * ChatResponse
 *   └─ results: List&lt;Generation&gt;
 *        └─ Generation
 *             └─ output: AssistantMessage
 *                  └─ text: "响应文本内容"
 * </pre>
 *
 * @param chatResponse 聊天响应对象,可为 null
 * @return 响应文本内容,如果响应为空或没有内容则返回 null
 */
@Nullable
private static String getContentFromChatResponse(@Nullable ChatResponse chatResponse) {
    return Optional.ofNullable(chatResponse)
        .map(ChatResponse::getResult)
        .map(Generation::getOutput)
        .map(AbstractMessage::getText)
        .orElse(null);
}

提取到的文本内容去下

java 复制代码
{"contractName":"2026年度办公设备采购合同","contractNo":"HT20260313","signDate":"2026年03月13日","partyA":"北京科技有限公司","partyB":"上海办公设备销售有限公司","amount":25800,"paymentMethod":"合同签订后分3期支付:首付款30%于签约时支付,二期款40%于设备到货时支付,尾款30%于验收合格后支付。","remark":"乙方须于2026年04月01日前完成全部设备交付及安装调试。乙方保证所供设备为全新正品,符合国家质量标准,提供一年免费保修服务。任何一方违约,应向守约方支付合同金额10%的违约金。","validUntil":"2027年03月12日"}

3.2 清洗

接着调用输出转换器并返回:

java 复制代码
			return outputConverter.convert(stringResponse);

第一步会先进行清洗:

java 复制代码
	@Override
	public T convert(@NonNull String text) {
		try {
			// Clean the text using the configured text cleaner
			text = this.textCleaner.clean(text);

			return (T) this.objectMapper.readValue(text, this.objectMapper.constructType(this.type));
		}
		catch (JsonProcessingException e) {
			logger.error(SENSITIVE_DATA_MARKER,
					"Could not parse the given text to the desired target type: \"{}\" into {}", text, this.type);
			throw new RuntimeException(e);
		}
	}

默认的清理器有:

它们都实现了 ResponseTextCleaner 接口:

java 复制代码
@FunctionalInterface
public interface ResponseTextCleaner {

	/**
	 * Clean the given text by removing unwanted patterns, tags, or formatting.
	 * @param text the raw text from LLM response
	 * @return the cleaned text ready for parsing
	 */
	String clean(String text);

}

3.3 转换

最后调用 ObjectMapper 将文本转换为 Java 对象:

java 复制代码
return (T) this.objectMapper.readValue(text, this.objectMapper.constructType(this.type));
相关推荐
阿维的博客日记2 小时前
锁消除和锁粗化
java·逃逸分析
清空mega2 小时前
动手学深度学习——物体检测
人工智能
蓝色的杯子2 小时前
OpenClaw一文详细了解-手搓OpenClaw-4 Tool Runtime
人工智能·python
波动几何2 小时前
你好,我是 Adaptive Skill Stack
人工智能
皮皮学姐分享-ppx2 小时前
1447上市公司数字化转型速度的计算(2000-2022年)
大数据·人工智能
Slow菜鸟2 小时前
Spring Cloud 教程(四) | OpenFeign 的作用
后端·spring·spring cloud
张二娃同学2 小时前
Claude Code 使用教程:下载安装、CC Switch 配置、MiniMax API 获取与启动实操
人工智能·windows·深度学习·github·claude code
yitian_hm2 小时前
RAG实战:从原理到代码,构建企业级知识库问答系统
人工智能
AI品信智慧数智人2 小时前
文旅景区小程序集成数字人智能语音交互系统,山东品信解锁AI伴游新玩法✨
人工智能·小程序