背景

在 Google ADK Java 版中,LlmAgent.outputKey(...) 是多 Agent 工作流里非常关键的状态传递机制。

例如一个 Draw.io 绘图工作流:

analystAgent -> drawAgent -> reviewerAgent

配置上通常会写成:

agents:
  - name: analystAgent
    output-key: analysis_result

  - name: drawAgent
    instruction: |
      输入需求来自:{analysis_result}
    output-key: draft_diagram

  - name: reviewerAgent
    instruction: |
      输入 XML 来自:{draft_diagram}
    output-key: review_result

非流式模式下,上游 Agent 输出会被写入 session.state,后续 Agent 通过 {analysis_result}{draft_diagram} 引用。

但切换到 Spring AI + ADK 流式输出后,可能出现:

analysis_result = "#"

或者:

draft_diagram = "<"

前端能正常看到 token 打字机效果,但 ADK 的 outputKey 只缓存了第一个 token 或不完整片段,导致后续 Agent 的 prompt 注入失败。

这个问题的本质不是 Spring AI 不能流式,也不是 ADK 的 Session 失效,而是 Spring AI 到 ADK 的流式适配层破坏了 ADK 的 Event.partial 语义。

一句话结论

outputKey 是否写入 session state 取决于 event.finalResponse()

event.finalResponse() 又取决于:

!event.partial().orElse(false)

所以流式模式下必须满足:

中间 token Event: partial = true
最终完整 Event: partial = false

如果中间 token 被标记成 partial=false,ADK 会把它误判为最终响应,并提前把残缺内容写入 outputKey

在这里插入图片描述

ADK 的 Event 机制

ADK 的执行结果不是直接返回字符串,而是一串 Event

一次普通 runner.runAsync(...) 可以简化成:

User Message
  -> Runner
  -> InvocationContext
  -> Agent.runAsync
  -> BaseLlmFlow
  -> BaseLlm.generateContent
  -> LlmResponse
  -> Event
  -> SessionService.appendEvent
  -> Session.state 更新

Event 是什么

Event 是 ADK 中一次执行过程的事件载体,包含:

  • id
  • invocationId
  • author
  • content
  • actions
  • partial
  • turnComplete
  • errorCode
  • usageMetadata

其中最关键的是:

@JsonProperty("partial")
public Optional<Boolean> partial() {
    return Optional.ofNullable(partial);
}

ADK 对 partial 的注释语义是:

partial is true for incomplete chunks from the LLM streaming response.
The last chunk's partial is False.

也就是说:

流式中间 chunk: partial=true
最后完整 chunk: partial=false

finalResponse 判断

ADK 的 Event.finalResponse() 逻辑如下:

@JsonIgnore
public final boolean finalResponse() {
    if (actions().skipSummarization().orElse(false)) {
        return true;
    }
    return functionCalls().isEmpty()
        && functionResponses().isEmpty()
        && !partial().orElse(false)
        && !hasTrailingCodeExecutionResult();
}

对于纯文本流式输出,核心条件就是:

!partial().orElse(false)

因此如果一个 token chunk 的 partial=false,ADK 就会认为它是最终回答。

outputKey 的写入机制

outputKey 的写入发生在 LlmAgent 内部。

核心逻辑如下:

private void maybeSaveOutputToState(Event event) {
    if (outputKey().isPresent() && event.finalResponse() && event.content().isPresent()) {
        Object output;
        String rawResult =
            event.content().flatMap(Content::parts).orElseGet(ImmutableList::of).stream()
                .filter(part -> !isThought(part))
                .map(part -> part.text().orElse(""))
                .collect(joining());

        output = rawResult;
        event.actions().stateDelta().put(outputKey().get(), output);
    }
}

注意两点:

  1. outputKey 不是在整个 Flowable<Event> complete 后统一写入。
  2. 它是在每个 Event 流经 LlmAgent 时,只要 event.finalResponse() 为 true 就写入。

这解释了为什么一个错误的中间 token Event 会污染 session state。

错误链路是:

Spring AI token chunk
  -> LlmResponse(partial=false)
  -> Event(partial=false)
  -> event.finalResponse() == true
  -> outputKey 写入第一个 token
  -> 后续 Agent 读取残缺 state

InvocationContext 的完整状态流转

InvocationContext 是 ADK 一次调用的运行态上下文。

它不是简单的 session 包装,而是包含完整调用期间需要共享的上下文:

private final BaseSessionService sessionService;
private final BaseArtifactService artifactService;
private final BaseMemoryService memoryService;
private final Plugin pluginManager;
private final Map<String, ActiveStreamingTool> activeStreamingTools;
private final String invocationId;
private final Session session;
private final Content userContent;
private final RunConfig runConfig;
private final Map<String, Object> callbackContextData;
private BaseAgent agent;

一次 runAsync 的高层状态流转如下:

session.state Sub Agent Root Agent InvocationContext SessionService ADK Runner Client/Controller session.state Sub Agent Root Agent InvocationContext SessionService ADK Runner Client/Controller runAsync(userId, sessionId, userMsg, runConfig) 获取 Session append 用户消息 Event 合并用户 stateDelta 创建 InvocationContext(invocationId, session, runConfig) rootAgent.runAsync(ctx) subAgent.runAsync(ctx) Event stream appendEvent(event) apply event.actions.stateDelta Event stream

状态写入的关键不是直接改 InvocationContext,而是:

event.actions.stateDelta
  -> SessionService.appendEvent
  -> session.state

也就是说,InvocationContext.session() 承载的是当前调用看到的会话对象,而状态真正落地发生在事件追加阶段。

Runner 如何串起来

Runner.runAsync(...) 做了几件关键事情:

  1. 根据 userId/sessionId 找到 Session。
  2. 把用户输入包装成一个用户 Event 追加到 Session。
  3. 创建本次调用的 InvocationContext
  4. 调用 root agent 的 runAsync(context)
  5. 对 Agent 产生的每个 Event 执行 sessionService.appendEvent(...)
  6. 把 Event 流返回给上层业务。

简化伪代码:

Flowable<Event> runAsync(...) {
    Session session = sessionService.getSession(...);
    InvocationContext context = newInvocationContextBuilder(session)
        .userContent(newMessage)
        .runConfig(runConfig)
        .build();

    appendNewMessageToSession(session, newMessage, context, stateDelta);

    return rootAgent.runAsync(context)
        .concatMap(event ->
            sessionService.appendEvent(session, event).toFlowable()
        );
}

这意味着每个 Agent Event 都有机会改变 Session。

SequentialAgent 为什么会把多个 Agent 输出都流出来

多 Agent 工作流中,SequentialAgent 的运行方式可以简化成:

protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
    return Flowable.fromIterable(subAgents())
        .concatMap(subAgent -> subAgent.runAsync(invocationContext));
}

它不会只返回最后一个 Agent 的输出,而是把每个子 Agent 的 Event 都向外流出。

所以一个工作流:

analystAgent -> drawAgent -> reviewerAgent

前端如果不做过滤,会看到:

analystAgent 的需求分析
drawAgent 的绘图输出
reviewerAgent 的审查结果

这不是重复 bug,而是 ADK Event 流的自然表现。

如果只想展示某个 Agent 的内容,需要按 event.author() 过滤:

private boolean shouldStreamEvent(ChatRequestDTO requestDTO, Event event) {
    if (!isDrawioAgent(requestDTO)) {
        return true;
    }
    return "drawAgent".equals(event.author());
}

出问题的流式链路

Spring AI 流式模型一般会按 token 返回多个 ChatResponse

ChatResponse("#")
ChatResponse(" 需求")
ChatResponse("分析")
...

Google ADK 的 Spring AI 适配器把它们转换成 ADK 的 LlmResponse,然后由 ADK 转成 Event

如果中间 token 被转换成:

LlmResponse(content="#", partial=false)

后续就会发生:

Event(content="#", partial=false)
  -> finalResponse() == true
  -> outputKey = "#"

错误时序如下:

Session State LlmAgent ADK BaseLlmFlow SpringAI Adapter Spring AI Stream Session State LlmAgent ADK BaseLlmFlow SpringAI Adapter Spring AI Stream state 已经被残缺 token 污染 token " LlmResponse(" Event(" event.finalResponse() == true outputKey = " token " 需求" LlmResponse(" 需求", partial=true/false)

修复方案

正确修复思路是自定义 Spring AI 到 ADK 的模型适配器,确保:

中间 token:
  partial=true
  turnComplete=false

最终聚合响应:
  partial=false
  turnComplete=true

本项目新增了:

cheeseai-drawio-domain/src/main/java/org/cheese/domain/agent/service/armory/matter/model/OutputKeySafeSpringAI.java

核心代码:

private Flowable<LlmResponse> generateStreamingContent(LlmRequest llmRequest) {
    return Flowable.create(emitter -> {
        Prompt prompt = messageConverter.toLlmPrompt(llmRequest);
        StreamingResponseAggregator aggregator = new StreamingResponseAggregator();

        Disposable subscription = streamingChatModel.stream(prompt).subscribe(
            response -> {
                LlmResponse chunk = messageConverter.toLlmResponse(response, true);
                aggregator.processStreamingResponse(chunk);

                if (hasText(chunk) && !hasFunctionCall(chunk)) {
                    emitter.onNext(chunk.toBuilder()
                        .partial(Boolean.TRUE)
                        .turnComplete(Boolean.FALSE)
                        .build());
                }
            },
            emitter::onError,
            () -> {
                if (!aggregator.isEmpty()) {
                    emitter.onNext(aggregator.getFinalResponse());
                }
                emitter.onComplete();
            }
        );
    }, BackpressureStrategy.BUFFER);
}

修复后的时序:

Frontend Session State LlmAgent ADK BaseLlmFlow OutputKeySafeSpringAI OpenAI/SpringAI Stream Frontend Session State LlmAgent ADK BaseLlmFlow OutputKeySafeSpringAI OpenAI/SpringAI Stream token "A" aggregator.append("A") LlmResponse("A", partial=true) Event("A", partial=true) finalResponse=false token "B" aggregator.append("B") LlmResponse("B", partial=true) Event("B", partial=true) finalResponse=false stream complete LlmResponse("AB", partial=false) outputKey = "AB" 可选择过滤最终聚合事件,避免重复打印

为什么前端还要过滤最终聚合事件

修复后,ADK 会收到:

token A
token B
完整 AB

其中最后的完整 AB 是为了让 ADK 正确写 outputKey

如果它也直接发给前端,前端会出现:

A
B
AB

也就是重复打印最终全文。

因此 Controller 层需要过滤最终聚合 Event:

if (event.partial().orElse(false)) {
    sawPartialResponse.set(true);
} else if (sawPartialResponse.get() && event.finalResponse()) {
    return;
}

含义:

partial=true 的 token Event:发给前端
partial=false 的最终聚合 Event:只给 ADK 写 state,不发给前端

Draw.io 场景下的展示过滤

绘图工作流里还需要解决另一个问题:多 Agent 的所有 Event 都会流出来。

例如:

analystAgent -> drawAgent -> reviewerAgent

如果前端只想实时渲染 Draw.io XML,就应只展示 drawAgent 的事件:

private boolean shouldStreamEvent(ChatRequestDTO requestDTO, Event event) {
    if (!isDrawioAgent(requestDTO)) {
        return true;
    }
    return "drawAgent".equals(event.author());
}

同时,模型有时会在 XML 前输出需求分析文本。对于 Draw.io 渲染,可以做 XML-only 适配:直到看到 <mxfile 才开始向前端发送。

关键逻辑:

int mxfileStart = current.indexOf("<mxfile");

if (mxfileStart >= 0) {
    String xmlChunk = current.substring(mxfileStart);
    xmlMode = true;
    return drawioXmlStreamAssembler.accept(xmlChunk);
}

if (xmlOnly) {
    return List.of();
}

最终架构图

filtered

Frontend chat_stream

AgentServiceController

ChatService.handleMessageStream

ADK Runner.runAsync

InvocationContext

SequentialAgent

analystAgent

drawAgent

reviewerAgent

OutputKeySafeSpringAI

Spring AI OpenAiChatModel.stream

Token ChatResponse

LlmResponse partial=true

ADK Event partial=true

Frontend token

Stream complete

Aggregated LlmResponse partial=false

ADK Event finalResponse=true

event.actions.stateDelta

SessionService.appendEvent

session.state outputKey

排查 checklist

遇到 Spring AI + ADK 流式 outputKey 异常时,可以按下面顺序排查:

  1. 打印每个 EventauthorpartialfinalResponse()stringifyContent()
  2. 确认中间 token 是否被标记为 partial=true
  3. 确认最终完整响应是否存在 partial=false
  4. 确认 LlmAgent.outputKey 是否只在最终完整 Event 上写入。
  5. 确认 SessionService.appendEvent 是否应用了 event.actions.stateDelta
  6. 多 Agent 场景下,确认前端是否需要按 event.author() 过滤。
  7. 如果使用 Draw.io/XML 渲染,确认是否应该丢弃 <mxfile 之前的解释文本。
  8. 不要把 streamUsage=true 当成 Event partial 语义修复。

结论

ADK 的状态缓存机制高度依赖 Event 语义。

可以把 ADK 的流式执行理解为:

Event 是执行轨迹
Event.actions.stateDelta 是状态变更
SessionService.appendEvent 是状态落地
Event.partial 是流式片段和最终响应的分界线
outputKey 依赖 finalResponse()
finalResponse 依赖 partial=false

因此,解决 Spring AI + ADK 流式 outputKey 缓存失败的关键,不是打开 streamUsage,而是保证:

所有中间 token Event.partial=true
最终聚合 Event.partial=false
最终聚合 Event 用于状态缓存
前端过滤最终聚合 Event,避免重复打印

这套方案可以同时满足:

  • 前端实时 SSE token 输出
  • 多 Agent outputKey 正确注入
  • session.state 稳定流转
  • 后续 Agent instruction 正确引用上游结果
  • Draw.io/XML 类场景避免前缀文本污染前端渲染
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐