google-adk-spring-ai 1.2.0 映射机制与 ADK + Spring AI 对接分析

google-adk-spring-ai 1.2.0 映射机制与 ADK + Spring AI 对接分析

背景

起因是PPT生成Agent需要结构化输出来渲染PPT,开发时让AI分析需求,发现目前adk+springAI又又不支持。故借机学习一下google-adk-spring-ai 1.2.0 的映射机制。

Google ADK Java 版本身有一套模型抽象:

text 复制代码
LlmAgent
  -> BaseLlm
  -> LlmRequest
  -> LlmResponse
  -> Event
  -> Session State

Spring AI 也有一套模型抽象:

text 复制代码
ChatModel
  -> Prompt
  -> ChatOptions
  -> ChatResponse

google-adk-spring-ai:1.2.0 的作用,就是把 ADK 的 BaseLlm 接口适配到 Spring AI 的 ChatModel / StreamingChatModel

也就是说,它不是一个新的模型实现,而是一层适配器:

text 复制代码
ADK LlmRequest
  -> Spring AI Prompt
  -> Spring AI ChatModel.call(...)
  -> Spring AI ChatResponse
  -> ADK LlmResponse

这篇文章基于本地依赖:

text 复制代码
com.google.adk:google-adk:1.2.0
com.google.adk:google-adk-spring-ai:1.2.0

结合反编译结果和项目实践,分析官方适配器的映射机制、能力边界,以及在 ADK + Spring AI 下如何补齐结构化输出。

一句话结论

google-adk-spring-ai:1.2.0 官方适配器主要支持:

text 复制代码
ADK Content      -> Spring AI Message
ADK Part         -> Spring AI text/media/tool call
ADK tools        -> Spring AI ToolCallback
ADK generation config 部分字段 -> Spring AI ChatOptions
Spring AI ChatResponse -> ADK LlmResponse

但它不支持把:

text 复制代码
GenerateContentConfig.responseSchema
GenerateContentConfig.responseMimeType

自动映射成 Spring AI OpenAI 的:

text 复制代码
OpenAiChatOptions.responseFormat

因此,如果你在 ADK agent 里配置了 outputSchema,使用官方 new SpringAI(chatModel) 时,schema 不会自动落到 OpenAI-compatible HTTP 请求体的 response_format

Maven 依赖与包结构

依赖:

xml 复制代码
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk-spring-ai</artifactId>
  <version>1.2.0</version>
</dependency>

jar 中核心类:

text 复制代码
com.google.adk.models.springai.SpringAI
com.google.adk.models.springai.MessageConverter
com.google.adk.models.springai.ConfigMapper
com.google.adk.models.springai.ToolConverter
com.google.adk.models.springai.StreamingResponseAggregator
com.google.adk.models.springai.SpringAIEmbedding
com.google.adk.models.springai.observability.SpringAIObservabilityHandler
com.google.adk.models.springai.error.SpringAIErrorMapper

几个类的职责可以概括为:

职责
SpringAI ADK BaseLlm 实现,持有 Spring AI ChatModel / StreamingChatModel
MessageConverter ADK LlmRequest 和 Spring AI Prompt 互转;Spring AI ChatResponse 转 ADK LlmResponse
ConfigMapper ADK GenerateContentConfig 部分字段转 Spring AI ChatOptions
ToolConverter ADK BaseTool / FunctionDeclaration 转 Spring AI ToolCallback
StreamingResponseAggregator 聚合 Spring AI 流式响应为 ADK 最终响应
SpringAIObservabilityHandler 请求日志、指标、观测能力
SpringAIErrorMapper Spring AI 异常归一化

SpringAI 适配器的核心机制

构造方式

官方适配器提供多种构造方式:

java 复制代码
new SpringAI(ChatModel chatModel);
new SpringAI(ChatModel chatModel, String modelName);
new SpringAI(StreamingChatModel streamingChatModel);
new SpringAI(StreamingChatModel streamingChatModel, String modelName);
new SpringAI(ChatModel chatModel, StreamingChatModel streamingChatModel, String modelName);

构造时会做几件事:

text 复制代码
保存 ChatModel
如果 chatModel 同时实现 StreamingChatModel,则保存 streamingChatModel
创建 ObjectMapper
创建 MessageConverter
创建 SpringAIObservabilityHandler

反编译后能看到类似逻辑:

java 复制代码
this.chatModel = Objects.requireNonNull(chatModel, "chatModel cannot be null");
this.streamingChatModel =
    chatModel instanceof StreamingChatModel ? chatModel : null;
this.objectMapper = new ObjectMapper();
this.messageConverter = new MessageConverter(this.objectMapper);
this.observabilityHandler =
    new SpringAIObservabilityHandler(createDefaultObservabilityConfig());

因此在 ADK agent 中可以这样使用:

java 复制代码
LlmAgent agent = LlmAgent.builder()
    .name("assistant")
    .model(new SpringAI(chatModel))
    .instruction("你是一个助手")
    .build();

这会让 ADK 把一次 agent 调用交给 Spring AI 的 ChatModel 执行。

非流式调用链路

SpringAI.generateContent(LlmRequest request, boolean stream) 根据 stream 参数选择调用路径。

非流式时,调用链路是:

text 复制代码
SpringAI.generateContent(llmRequest, false)
  -> generateContent(llmRequest)
  -> MessageConverter.toLlmPrompt(llmRequest)
  -> chatModel.call(prompt)
  -> MessageConverter.toLlmResponse(chatResponse)
  -> Flowable.just(llmResponse)

反编译后关键逻辑可以概括为:

java 复制代码
Prompt prompt = messageConverter.toLlmPrompt(llmRequest);
observabilityHandler.logRequest(prompt.toString(), model());

ChatResponse chatResponse = chatModel.call(prompt);

LlmResponse llmResponse = messageConverter.toLlmResponse(chatResponse);
observabilityHandler.logResponse(extractTextFromResponse(llmResponse), model());

return Flowable.just(llmResponse);

这里的关键点是:

text 复制代码
ADK 的 LlmRequest 并不会直接交给 Spring AI。
必须先由 MessageConverter 转成 Spring AI Prompt。

所以所有能力是否生效,取决于 MessageConverter / ConfigMapper 是否把对应字段映射过去。

流式调用链路

流式时会要求存在 StreamingChatModel

java 复制代码
if (stream && streamingChatModel == null) {
    return Flowable.error(new IllegalStateException("StreamingChatModel is not configured"));
}

流式链路可以概括为:

text 复制代码
SpringAI.generateContent(llmRequest, true)
  -> generateStreamingContent(llmRequest)
  -> MessageConverter.toLlmPrompt(llmRequest)
  -> streamingChatModel.stream(prompt)
  -> MessageConverter.toLlmResponse(chatResponse, true)
  -> StreamingResponseAggregator
  -> ADK streaming LlmResponse

流式集成要特别关注 ADK 的 partial / turnComplete 语义。中间 token 应该是:

text 复制代码
partial = true
turnComplete = false

最终完整响应应该是:

text 复制代码
partial = false
turnComplete = true

否则 ADK 的 outputKey 可能会过早把残缺 token 写入 session state。

MessageConverter:消息映射机制

MessageConverter 是整个适配层最关键的类。

它负责:

text 复制代码
ADK LlmRequest -> Spring AI Prompt
Spring AI ChatResponse -> ADK LlmResponse
ADK tools -> Spring AI tool callbacks

LlmRequest 转 Prompt

核心方法:

java 复制代码
public Prompt toLlmPrompt(LlmRequest request)

反编译后的流程可以整理为:

text 复制代码
1. 收集 request.getSystemInstructions()
2. 遍历 request.contents()
3. role=system 的内容拼接进 system instructions
4. role=user/model/assistant 的内容转换为 Spring AI Message
5. 用 ConfigMapper 把 request.config 转成 ChatOptions
6. 如果 request.tools 不为空,用 ToolConverter 转成 Spring AI ToolCallback
7. 返回 new Prompt(messages, options)

伪代码:

java 复制代码
List<Message> messages = new ArrayList<>();
List<String> systemInstructions = new ArrayList<>();

systemInstructions.addAll(request.getSystemInstructions());

for (Content content : request.contents()) {
    String role = content.role().orElse("user").toLowerCase();
    if ("system".equals(role)) {
        systemInstructions.add(extractText(content));
    } else {
        messages.addAll(toSpringAiMessages(content));
    }
}

if (!systemInstructions.isEmpty()) {
    messages.add(new SystemMessage(String.join("\n\n", systemInstructions)));
}

ChatOptions options =
    configMapper.toSpringAiChatOptions(request.config());

if (!request.tools().isEmpty()) {
    List<ToolCallback> tools =
        toolConverter.convertToSpringAiTools(request.tools());
    options = ToolCallingChatOptions.builder()
        .toolCallbacks(tools)
        .copySomeOptionsFrom(options)
        .build();
}

return new Prompt(messages, options);

ADK role 到 Spring AI Message

MessageConverter.toSpringAiMessages(...) 根据 ADK Content.role 分流:

ADK role Spring AI Message
user UserMessage
model AssistantMessage
assistant AssistantMessage
system SystemMessage

未知 role 会抛出异常。

User content 映射

ADK Content.parts 中的不同 part 会被映射为:

ADK Part Spring AI 表达
text UserMessage.text
inlineData Media,使用 ByteArrayResource
fileData Media,使用 URI
functionResponse 当前 user content 处理里基本跳过

简化逻辑:

java 复制代码
for (Part part : content.parts()) {
    if (part.text().isPresent()) {
        text.append(part.text().get());
    } else if (part.inlineData().isPresent()) {
        media.add(new Media(mimeType, byteArrayResource));
    } else if (part.fileData().isPresent()) {
        media.add(new Media(mimeType, uri));
    }
}

UserMessage.builder()
    .text(text.toString())
    .media(media)
    .build();

这意味着官方适配器可以承载文本和多模态媒体输入,但具体模型是否支持媒体,取决于底层 Spring AI ChatModel 和模型供应商。

Assistant content 映射

ADK model / assistant 内容会转成 Spring AI AssistantMessage

其中:

ADK Part Spring AI 表达
text AssistantMessage.content
functionCall AssistantMessage.ToolCall

简化逻辑:

java 复制代码
if (part.text().isPresent()) {
    text.append(part.text().get());
}

if (part.functionCall().isPresent()) {
    FunctionCall call = part.functionCall().get();
    toolCalls.add(new AssistantMessage.ToolCall(
        call.id().orElseThrow(),
        "function",
        call.name().orElseThrow(),
        toJson(call.args().orElse(Map.of()))
    ));
}

这让 ADK 的函数调用历史可以被转换成 Spring AI 可理解的 assistant tool call 历史。

ChatResponse 转 LlmResponse

MessageConverter.toLlmResponse(...) 会把 Spring AI 的 ChatResponse 转回 ADK 的 LlmResponse

关键流程:

text 复制代码
1. 取 ChatResponse.getResult()
2. 取 Generation.getOutput(),即 AssistantMessage
3. AssistantMessage 转 ADK Content
4. 如果是流式,计算 partial / turnComplete
5. 构造 LlmResponse

伪代码:

java 复制代码
Generation generation = chatResponse.getResult();
AssistantMessage output = generation.getOutput();
Content content = convertAssistantMessageToContent(output);

boolean partial =
    stream && isPartialResponse(output);

boolean turnComplete =
    !stream || isTurnCompleteResponse(chatResponse);

return LlmResponse.builder()
    .content(content)
    .partial(partial)
    .turnComplete(turnComplete)
    .build();

这也是为什么 Spring AI 流式输出适配时,partial 语义非常重要。

ConfigMapper:生成参数映射机制

ConfigMapper 负责把 ADK 的:

java 复制代码
Optional<GenerateContentConfig>

映射成 Spring AI 的:

java 复制代码
ChatOptions

支持映射的字段

toSpringAiChatOptions(...) 支持的字段包括:

ADK GenerateContentConfig Spring AI ChatOptions
temperature temperature
maxOutputTokens maxTokens
topP topP
topK topK
stopSequences stopSequences
presencePenalty 目前 lambda 空实现,实际没有设置
frequencyPenalty 目前 lambda 空实现,实际没有设置

反编译后能看到:

java 复制代码
config.temperature().ifPresent(value -> builder.temperature(value.doubleValue()));
config.maxOutputTokens().ifPresent(builder::maxTokens);
config.topP().ifPresent(value -> builder.topP(value.doubleValue()));
config.topK().ifPresent(value -> builder.topK(value.intValue()));
config.stopSequences()
    .filter(list -> !list.isEmpty())
    .ifPresent(builder::stopSequences);

presencePenaltyfrequencyPenalty 对应的 lambda 是空方法:

java 复制代码
private static void lambda$toSpringAiChatOptions$5(Float value) {
    return;
}

private static void lambda$toSpringAiChatOptions$4(Float value) {
    return;
}

所以在 google-adk-spring-ai:1.2.0 中,不能认为所有 ADK generation config 都完整映射到了 Spring AI。

createDefaultChatOptions

ConfigMapper.createDefaultChatOptions() 的默认值是:

java 复制代码
ChatOptions.builder()
    .temperature(0.7)
    .maxTokens(1000)
    .build();

但在 MessageConverter.toLlmPrompt(...) 的主流程中,是否使用默认 options 要看调用路径。通常 request 有 config 时使用映射后的 options;没有 config 时可能返回 null options。

isConfigurationValid 的关键信号

ConfigMapper.isConfigurationValid(...) 非常关键。

反编译后可见,它会明确把以下配置判为不支持:

java 复制代码
if (config.responseSchema().isPresent()) {
    return false;
}

if (config.responseMimeType().isPresent()) {
    return false;
}

也就是说,官方 Spring AI 适配器在 1.2.0 版本里不是忘记映射 responseSchema,而是它的配置校验层也把 responseSchema / responseMimeType 归类为不支持。

这对结构化输出的影响很直接:

text 复制代码
ADK LlmAgent.outputSchema(...)
  -> LlmRequest.config.responseSchema 存在
  -> ConfigMapper 不映射 responseSchema
  -> Spring AI Prompt.options 没有 responseFormat
  -> OpenAI HTTP body 没有 response_format

ToolConverter:工具映射机制

ToolConverter 负责把 ADK tools 转成 Spring AI tools。

ADK tool 的核心信息来自:

java 复制代码
BaseTool.declaration()

也就是 FunctionDeclaration

工具注册表

createToolRegistry(...) 会遍历 ADK tools:

java 复制代码
for (BaseTool tool : tools.values()) {
    if (tool.declaration().isPresent()) {
        FunctionDeclaration declaration = tool.declaration().get();
        registry.put(tool.name(), new ToolMetadata(
            tool.name(),
            tool.description(),
            declaration
        ));
    }
}

Schema 转 Spring AI 工具入参 schema

convertSchemaToSpringAi(Schema) 会处理:

ADK Schema Spring AI schema map
type type
description description
properties properties
required required

类型会转换为 JSON Schema 常见小写值:

ADK Type JSON Schema type
STRING string
NUMBER number
INTEGER integer
BOOLEAN boolean
ARRAY array
OBJECT object

简化逻辑:

java 复制代码
Map<String, Object> result = new HashMap<>();

schema.type().ifPresent(type ->
    result.put("type", convertTypeToString(type)));

schema.description().ifPresent(description ->
    result.put("description", description));

schema.properties().ifPresent(properties ->
    result.put("properties", convertedProperties));

schema.required().ifPresent(required ->
    result.put("required", required));

ADK tool 转 Spring AI ToolCallback

convertToSpringAiTools(...) 会把 ADK BaseTool 转成 Spring AI FunctionToolCallback

java 复制代码
FunctionToolCallback.builder(tool.name(), function)
    .description(tool.description())
    .inputType(Map.class)
    .inputSchema(schemaJson)
    .build();

这里有一个非常重要的区别:

text 复制代码
ToolConverter 会映射工具入参 schema。
ConfigMapper 不会映射模型输出 responseSchema。

所以不能因为 tool schema 能生效,就推断 outputSchema 也能生效。两条链路完全不同。

ADK + Spring AI 的完整运行机制

一个典型调用链:

text 复制代码
用户消息
  -> Runner
  -> InvocationContext
  -> LlmAgent.runAsync
  -> BaseLlmFlow
  -> LlmRequest
  -> SpringAI.generateContent
  -> MessageConverter.toLlmPrompt
  -> ChatModel.call
  -> ChatResponse
  -> MessageConverter.toLlmResponse
  -> Event
  -> SessionService.appendEvent
  -> outputKey 写入 state

在这条链路中,google-adk-spring-ai 只负责中间这段:

text 复制代码
LlmRequest <-> Prompt
LlmResponse <-> ChatResponse
Tools <-> ToolCallback

而不负责:

text 复制代码
把所有 ADK GenerateContentConfig 字段完整映射到每一种 Spring AI provider 的专有 options

这是一个合理的设计取舍。Spring AI 是统一抽象,但不同模型 provider 的结构化输出参数并不统一:

Provider 结构化输出方式
OpenAI response_format
Gemini response schema / MIME type
Anthropic tool use / JSON discipline
Ollama format / provider-specific options

因此 google-adk-spring-ai 的通用 ChatOptions 映射层无法天然覆盖所有 provider-specific structured output。

结构化输出为什么不会自动生效

ADK outputSchema 的核心链路

在 ADK 核心里:

java 复制代码
LlmAgent.builder()
    .outputSchema(schema)
    .build();

最终会让 LlmRequest 带上:

text 复制代码
GenerateContentConfig.responseSchema = schema
GenerateContentConfig.responseMimeType = application/json

这说明 ADK 核心是支持结构化输出的。

Spring AI OpenAI 的结构化输出链路

Spring AI OpenAI 要让模型按 JSON Schema 输出,需要设置:

java 复制代码
OpenAiChatOptions.builder()
    .responseFormat(ResponseFormat.builder()
        .type(ResponseFormat.Type.JSON_SCHEMA)
        .jsonSchema(...)
        .build())
    .build();

最终 HTTP body 里应出现:

json 复制代码
{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "result",
      "strict": true,
      "schema": {}
    }
  }
}

官方适配器缺少的桥

官方 google-adk-spring-ai:1.2.0 的主流程是:

text 复制代码
LlmRequest.config
  -> ConfigMapper.toSpringAiChatOptions
  -> ChatOptions

ChatOptions 是 Spring AI 通用接口,没有 OpenAI 专用的 responseFormat 字段。

官方适配器也没有做:

text 复制代码
GenerateContentConfig.responseSchema
  -> OpenAiChatOptions.responseFormat

因此结构化输出链路断在这里:

text 复制代码
ADK outputSchema 存在
Spring AI Prompt.options 不包含 responseFormat
OpenAI-compatible request body 不包含 response_format

示例一:官方适配器普通问答

普通问答可以直接使用官方适配器:

java 复制代码
ChatModel chatModel = openAiChatModel();

LlmAgent agent = LlmAgent.builder()
    .name("chatAgent")
    .model(new SpringAI(chatModel))
    .instruction("你是一个中文助手。")
    .build();

请求流转:

text 复制代码
ADK user Content
  -> MessageConverter
  -> Spring AI UserMessage
  -> ChatModel.call
  -> ChatResponse
  -> ADK LlmResponse

这类场景没有特殊 provider options,官方适配器足够。

示例二:官方适配器工具调用

如果 ADK agent 有 tools:

java 复制代码
LlmAgent agent = LlmAgent.builder()
    .name("toolAgent")
    .model(new SpringAI(chatModel))
    .instruction("你可以调用工具完成任务。")
    .tools(getWeatherTool)
    .build();

适配器会做:

text 复制代码
ADK BaseTool
  -> FunctionDeclaration
  -> ToolConverter.convertToSpringAiTools
  -> Spring AI FunctionToolCallback
  -> ToolCallingChatOptions

工具参数 schema 会通过 inputSchema(...) 写入 Spring AI tool callback。

这说明 google-adk-spring-ai 对工具映射是有实际支持的,尤其是 function declaration 的参数 schema。

示例三:官方适配器 outputSchema 不会自动落地

假设写:

java 复制代码
Schema outputSchema = Schema.builder()
    .type("OBJECT")
    .properties(Map.of(
        "capital",
        Schema.builder()
            .type("STRING")
            .description("首都")
            .build()))
    .required("capital")
    .build();

LlmAgent agent = LlmAgent.builder()
    .name("capitalAgent")
    .model(new SpringAI(chatModel))
    .instruction("给定国家,只返回 JSON。")
    .outputSchema(outputSchema)
    .outputKey("found_capital")
    .build();

ADK 内部请求会有:

text 复制代码
LlmRequest.config.responseSchema present
LlmRequest.config.responseMimeType = application/json

但官方 Spring AI 适配器生成的 Prompt.options 不会包含:

text 复制代码
OpenAiChatOptions.responseFormat

因此 OpenAI 兼容请求体不会自动出现:

json 复制代码
{
  "response_format": {
    "type": "json_schema"
  }
}

最终模型仍然可能返回:

text 复制代码
中国的首都是北京。

或者返回字段不完整的 JSON。

项目中的补齐方案

在项目中,可以不用官方 SpringAI,而是自定义一个 BaseLlm 包装 Spring AI ChatModel

核心思想:

text 复制代码
先复用官方 MessageConverter 把 ADK LlmRequest 转成 Prompt
再把 ADK responseSchema 映射成 OpenAiChatOptions.responseFormat
最后调用 chatModel.call(patchedPrompt)

示例:

java 复制代码
public class OutputKeySafeSpringAI extends BaseLlm {

    private final ChatModel chatModel;
    private final MessageConverter messageConverter;
    private final ResponseSchemaMapper responseSchemaMapper;

    public OutputKeySafeSpringAI(ChatModel chatModel) {
        super(extractModelName(chatModel));
        this.chatModel = chatModel;
        this.messageConverter = new MessageConverter(new ObjectMapper());
        this.responseSchemaMapper = new ResponseSchemaMapper();
    }

    private Flowable<LlmResponse> generateBlockingContent(LlmRequest llmRequest) {
        return Flowable.fromCallable(() -> {
            Prompt prompt = messageConverter.toLlmPrompt(llmRequest);
            Prompt patchedPrompt = responseSchemaMapper.apply(prompt, llmRequest);
            ChatResponse response = chatModel.call(patchedPrompt);
            return messageConverter.toLlmResponse(response);
        });
    }
}

关键就在:

java 复制代码
Prompt patchedPrompt = responseSchemaMapper.apply(prompt, llmRequest);

ResponseSchemaMapper

ResponseSchemaMapper 读取 ADK 的 responseSchema

java 复制代码
Schema responseSchema = llmRequest.config()
    .flatMap(GenerateContentConfig::responseSchema)
    .orElse(null);

如果不存在 schema,原样返回 prompt:

java 复制代码
if (responseSchema == null) {
    return prompt;
}

如果存在 schema,则构造 Spring AI OpenAI 的 ResponseFormat

java 复制代码
ResponseFormat responseFormat = ResponseFormat.builder()
    .type(ResponseFormat.Type.JSON_SCHEMA)
    .jsonSchema(ResponseFormat.JsonSchema.builder()
        .name("ppt_project")
        .schema(toSchemaMap(responseSchema))
        .strict(Boolean.TRUE)
        .build())
    .build();

再合并到 OpenAiChatOptions

java 复制代码
OpenAiChatOptions patchedOptions =
    mergeOptions(prompt.getOptions(), llmRequest.config().orElse(null), responseFormat);

return new Prompt(prompt.getInstructions(), patchedOptions);

最终链路变成:

text 复制代码
ADK LlmRequest.config.responseSchema
  -> ResponseSchemaMapper
  -> OpenAiChatOptions.responseFormat
  -> Spring AI OpenAiChatModel.createRequest
  -> ChatCompletionRequest.response_format
  -> HTTP body

Agent 装配示例

项目中可以在 agent 装配阶段根据配置生成 ADK schema:

java 复制代码
LlmAgent.Builder builder = LlmAgent.builder()
    .name(agentConfig.getName())
    .description(agentConfig.getDescription())
    .model(new OutputKeySafeSpringAI(chatModel))
    .instruction(agentConfig.getInstruction())
    .outputKey(agentConfig.getOutputKey());

resolveOutputSchema(agentConfig).ifPresent(builder::outputSchema);

schema 可以由 DTO 生成:

java 复制代码
static Optional<Schema> resolveOutputSchema(Agent agentConfig) {
    if (!Boolean.TRUE.equals(agentConfig.getStructuredOutput())) {
        return Optional.empty();
    }

    Class<?> schemaClass = Class.forName(agentConfig.getOutputSchemaClass());
    return Optional.of(FunctionCallingUtils.buildSchemaFromType(schemaClass));
}

对应 YAML:

yaml 复制代码
agents:
  - name: pptGenerator
    description: 生成前端可直接渲染的 PPT 项目 JSON
    instruction: |
      你是一个 PPT 设计专家。
      只输出完整 PptProjectDTO JSON。
    outputKey: ppt_project
    structuredOutput: true
    outputSchemaClass: org.cheese.api.dto.PptProjectDTO

注意,这里的 structuredOutput / outputSchemaClass 是项目自己的配置约定,不是 google-adk-spring-ai 自动识别的 YAML 能力。

如何判断结构化输出是否真的生效

要分三层看。

第一层:ADK request 是否有 responseSchema

在 ADK plugin 里打印:

java 复制代码
boolean present = request.config()
    .flatMap(GenerateContentConfig::responseSchema)
    .isPresent();

期望日志:

text 复制代码
Response Schema: present

如果这里是 absent,说明 agent 装配时没有设置 outputSchema

第二层:Spring AI Prompt options 是否有 responseFormat

如果使用官方 SpringAI,这里通常没有。

如果使用自定义 ResponseSchemaMapper,应该能从 Prompt.getOptions() 看到:

text 复制代码
OpenAiChatOptions.responseFormat.type = JSON_SCHEMA

第三层:HTTP body 是否有 response_format

最终要看请求体:

json 复制代码
{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "ppt_project",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {}
      }
    }
  }
}

判断表:

现象 结论
ADK responseSchema absent agent 没有设置 outputSchema
ADK responseSchema present,但 HTTP 无 response_format 缺少 ADK -> Spring AI provider options 桥接
HTTP 有 response_format,但输出仍不符合 模型或 OpenAI-compatible 网关不支持,或 schema 太复杂
输出符合 JSON Schema,但业务不可渲染 需要业务 verifier / repairer

官方适配器的能力边界

google-adk-spring-ai:1.2.0 适合:

text 复制代码
普通文本对话
系统指令 + 用户消息转换
多模态 media 的基础映射
Spring AI ToolCallback 工具调用
基础 generation config 映射
Spring AI ChatResponse 转 ADK LlmResponse

不适合直接期待:

text 复制代码
ADK outputSchema 自动变成 OpenAI response_format
所有 GenerateContentConfig 字段完整映射
所有 provider-specific options 自动支持
Live connection 支持
复杂结构化输出自动修复

其中 live connection 在 SpringAI.connect(...) 里直接抛出:

java 复制代码
throw new UnsupportedOperationException(
    "Live connection is not supported for Spring AI models.");

为什么官方没有直接映射 responseSchema

从架构上看,这是因为 google-adk-spring-ai 面向的是 Spring AI 的通用模型抽象,而结构化输出是 provider-specific 能力。

ADK 的结构化输出字段:

text 复制代码
GenerateContentConfig.responseSchema
GenerateContentConfig.responseMimeType

更接近 Gemini / Google GenAI 的配置模型。

Spring AI OpenAI 的结构化输出字段:

text 复制代码
OpenAiChatOptions.responseFormat
ResponseFormat.Type.JSON_SCHEMA

是 OpenAI provider 专有 options。

如果官方适配器在通用 ConfigMapper 里直接依赖 OpenAiChatOptions,就会让 google-adk-spring-ai 从通用适配器变成 OpenAI 专用适配器。这可能是官方当前没有做这层映射的主要原因。

因此业务项目中更合理的方式是:

text 复制代码
保留官方 MessageConverter 的通用消息转换能力
在项目自己的模型包装层里按 provider 补充专有 options

推荐实现模式

模式一:普通 agent 使用官方 SpringAI

适合不需要结构化输出的 agent:

java 复制代码
.model(new SpringAI(chatModel))

例如:

text 复制代码
普通问答
总结
分类
不严格依赖 JSON 结构的回复

模式二:结构化输出 agent 使用自定义 BaseLlm

适合需要 JSON Schema 的 agent:

java 复制代码
.model(new OutputKeySafeSpringAI(chatModel))

例如:

text 复制代码
PPT 项目 JSON
Draw.io 结构化中间结果
表单抽取
工作流状态对象
前端可直接渲染的数据结构

自定义 wrapper 可以复用官方组件:

text 复制代码
MessageConverter
StreamingResponseAggregator
SpringAIErrorMapper

但要自己补:

text 复制代码
ResponseSchemaMapper
provider-specific options merge
HTTP request logging
schema verifier / repairer

模式三:按 provider 做 mapper 策略

如果系统未来支持多个 Spring AI provider,可以把 mapper 做成策略:

java 复制代码
interface ResponseSchemaBridge {
    boolean supports(ChatModel chatModel);
    Prompt apply(Prompt prompt, LlmRequest request);
}

OpenAI 兼容模型:

text 复制代码
GenerateContentConfig.responseSchema
  -> OpenAiChatOptions.responseFormat(JSON_SCHEMA)

Gemini Spring AI 模型:

text 复制代码
GenerateContentConfig.responseSchema
  -> Gemini provider-specific options

不支持结构化输出的模型:

text 复制代码
降级为 prompt JSON instruction
后端 verifier + retry repair

排障清单

1. 确认使用的是官方 SpringAI 还是自定义 wrapper

如果代码里是:

java 复制代码
.model(new SpringAI(chatModel))

outputSchema 不会自动映射到 OpenAI response_format

如果代码里是:

java 复制代码
.model(new OutputKeySafeSpringAI(chatModel))

则看自定义 wrapper 是否调用了:

java 复制代码
responseSchemaMapper.apply(prompt, llmRequest)

2. 确认 ADK request 有 schema

日志应有:

text 复制代码
Response Schema: present

3. 确认 HTTP 请求体有 response_format

开启请求日志后查:

text 复制代码
"response_format"
"type":"json_schema"
"strict":true

4. 确认模型或网关支持 JSON Schema

OpenAI-compatible 接口不代表完整支持 OpenAI structured outputs。

如果请求体已经有 response_format,但模型仍然返回残缺 JSON,需要检查:

text 复制代码
当前模型是否支持 json_schema
网关是否透传 response_format
schema 是否过大或包含不支持字段
max_tokens 是否太小导致截断

5. 保留后端 repairer

即使结构化输出生效,也要保留后端修复:

text 复制代码
固定字段归一化
空 slides 修复
空 elements 修复
transform 越界修复
业务字段默认值补齐

因为 JSON Schema 只能保证结构,不保证 PPT 业务质量。

总结

google-adk-spring-ai:1.2.0 是一个通用适配器,它把 ADK 的模型请求转换为 Spring AI 的模型请求。

它的核心映射包括:

text 复制代码
LlmRequest -> Prompt
Content -> Message
Part -> text/media/tool call
GenerateContentConfig 部分字段 -> ChatOptions
BaseTool -> ToolCallback
ChatResponse -> LlmResponse

但它没有把 ADK 的结构化输出:

text 复制代码
responseSchema / responseMimeType

映射成 Spring AI OpenAI 的:

text 复制代码
responseFormat / response_format

因此 ADK + Spring AI 要真正支持结构化输出,推荐使用项目自定义 wrapper:

text 复制代码
官方 MessageConverter
  + 自定义 ResponseSchemaMapper
  + provider-specific OpenAiChatOptions.responseFormat
  + HTTP 日志验证
  + 后端 verifier / repairer

这样既能复用官方适配器的消息、工具、响应转换能力,又能补齐 OpenAI-compatible 模型的结构化输出能力。