【Spring AI + Google ADK 】流式输出时 outputKey 状态缓存失败的问题

背景

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

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

text 复制代码
analystAgent -> drawAgent -> reviewerAgent

配置上通常会写成:

yaml 复制代码
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 流式输出后,可能出现:

text 复制代码
analysis_result = "#"

或者:

text 复制代码
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() 又取决于:

java 复制代码
!event.partial().orElse(false)

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

text 复制代码
中间 token Event: partial = true
最终完整 Event: partial = false

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

ADK 的 Event 机制

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

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

text 复制代码
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

其中最关键的是:

java 复制代码
@JsonProperty("partial")
public Optional<Boolean> partial() {
    return Optional.ofNullable(partial);
}

ADK 对 partial 的注释语义是:

text 复制代码
partial is true for incomplete chunks from the LLM streaming response.
The last chunk's partial is False.

也就是说:

text 复制代码
流式中间 chunk: partial=true
最后完整 chunk: partial=false

finalResponse 判断

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

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

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

java 复制代码
!partial().orElse(false)

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

outputKey 的写入机制

outputKey 的写入发生在 LlmAgent 内部。

核心逻辑如下:

java 复制代码
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。

错误链路是:

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

InvocationContext 的完整状态流转

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

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

java 复制代码
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 #mermaid-svg-4iRFHbq9fh5g7IRf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4iRFHbq9fh5g7IRf .error-icon{fill:#552222;}#mermaid-svg-4iRFHbq9fh5g7IRf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4iRFHbq9fh5g7IRf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4iRFHbq9fh5g7IRf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4iRFHbq9fh5g7IRf .marker.cross{stroke:#333333;}#mermaid-svg-4iRFHbq9fh5g7IRf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4iRFHbq9fh5g7IRf p{margin:0;}#mermaid-svg-4iRFHbq9fh5g7IRf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4iRFHbq9fh5g7IRf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4iRFHbq9fh5g7IRf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-4iRFHbq9fh5g7IRf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-4iRFHbq9fh5g7IRf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-4iRFHbq9fh5g7IRf .sequenceNumber{fill:white;}#mermaid-svg-4iRFHbq9fh5g7IRf #sequencenumber{fill:#333;}#mermaid-svg-4iRFHbq9fh5g7IRf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-4iRFHbq9fh5g7IRf .messageText{fill:#333;stroke:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4iRFHbq9fh5g7IRf .labelText,#mermaid-svg-4iRFHbq9fh5g7IRf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .loopText,#mermaid-svg-4iRFHbq9fh5g7IRf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4iRFHbq9fh5g7IRf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-4iRFHbq9fh5g7IRf .noteText,#mermaid-svg-4iRFHbq9fh5g7IRf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-4iRFHbq9fh5g7IRf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4iRFHbq9fh5g7IRf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4iRFHbq9fh5g7IRf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4iRFHbq9fh5g7IRf .actorPopupMenu{position:absolute;}#mermaid-svg-4iRFHbq9fh5g7IRf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-4iRFHbq9fh5g7IRf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4iRFHbq9fh5g7IRf .actor-man circle,#mermaid-svg-4iRFHbq9fh5g7IRf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-4iRFHbq9fh5g7IRf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} runAsync(userId, sessionId, userMsg, runConfig)获取 Sessionappend 用户消息 Event合并用户 stateDelta创建 InvocationContext(invocationId, session, runConfig)rootAgent.runAsync(ctx)subAgent.runAsync(ctx)Event streamappendEvent(event)apply event.actions.stateDeltaEvent stream

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

text 复制代码
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 流返回给上层业务。

简化伪代码:

java 复制代码
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 的运行方式可以简化成:

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

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

所以一个工作流:

text 复制代码
analystAgent -> drawAgent -> reviewerAgent

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

text 复制代码
analystAgent 的需求分析
drawAgent 的绘图输出
reviewerAgent 的审查结果

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

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

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

出问题的流式链路

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

text 复制代码
ChatResponse("#")
ChatResponse(" 需求")
ChatResponse("分析")
...

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

如果中间 token 被转换成:

text 复制代码
LlmResponse(content="#", partial=false)

后续就会发生:

text 复制代码
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 #mermaid-svg-gM8K8w6FKRNSKGFO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gM8K8w6FKRNSKGFO .error-icon{fill:#552222;}#mermaid-svg-gM8K8w6FKRNSKGFO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gM8K8w6FKRNSKGFO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gM8K8w6FKRNSKGFO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gM8K8w6FKRNSKGFO .marker.cross{stroke:#333333;}#mermaid-svg-gM8K8w6FKRNSKGFO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gM8K8w6FKRNSKGFO p{margin:0;}#mermaid-svg-gM8K8w6FKRNSKGFO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gM8K8w6FKRNSKGFO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gM8K8w6FKRNSKGFO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-gM8K8w6FKRNSKGFO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-gM8K8w6FKRNSKGFO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-gM8K8w6FKRNSKGFO .sequenceNumber{fill:white;}#mermaid-svg-gM8K8w6FKRNSKGFO #sequencenumber{fill:#333;}#mermaid-svg-gM8K8w6FKRNSKGFO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-gM8K8w6FKRNSKGFO .messageText{fill:#333;stroke:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gM8K8w6FKRNSKGFO .labelText,#mermaid-svg-gM8K8w6FKRNSKGFO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .loopText,#mermaid-svg-gM8K8w6FKRNSKGFO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gM8K8w6FKRNSKGFO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-gM8K8w6FKRNSKGFO .noteText,#mermaid-svg-gM8K8w6FKRNSKGFO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-gM8K8w6FKRNSKGFO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gM8K8w6FKRNSKGFO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gM8K8w6FKRNSKGFO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gM8K8w6FKRNSKGFO .actorPopupMenu{position:absolute;}#mermaid-svg-gM8K8w6FKRNSKGFO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-gM8K8w6FKRNSKGFO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gM8K8w6FKRNSKGFO .actor-man circle,#mermaid-svg-gM8K8w6FKRNSKGFO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-gM8K8w6FKRNSKGFO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} state 已经被残缺 token 污染 token "LlmResponse("Event("event.finalResponse() == trueoutputKey = "token " 需求"LlmResponse(" 需求", partial=true/false)

修复方案

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

text 复制代码
中间 token:
  partial=true
  turnComplete=false

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

本项目新增了:

text 复制代码
cheeseai-drawio-domain/src/main/java/org/cheese/domain/agent/service/armory/matter/model/OutputKeySafeSpringAI.java

核心代码:

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 #mermaid-svg-TcwcbnYurKXEFB4e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TcwcbnYurKXEFB4e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TcwcbnYurKXEFB4e .error-icon{fill:#552222;}#mermaid-svg-TcwcbnYurKXEFB4e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TcwcbnYurKXEFB4e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TcwcbnYurKXEFB4e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TcwcbnYurKXEFB4e .marker.cross{stroke:#333333;}#mermaid-svg-TcwcbnYurKXEFB4e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TcwcbnYurKXEFB4e p{margin:0;}#mermaid-svg-TcwcbnYurKXEFB4e .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-TcwcbnYurKXEFB4e text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-TcwcbnYurKXEFB4e .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-TcwcbnYurKXEFB4e .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-TcwcbnYurKXEFB4e .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-TcwcbnYurKXEFB4e .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-TcwcbnYurKXEFB4e #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-TcwcbnYurKXEFB4e .sequenceNumber{fill:white;}#mermaid-svg-TcwcbnYurKXEFB4e #sequencenumber{fill:#333;}#mermaid-svg-TcwcbnYurKXEFB4e #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-TcwcbnYurKXEFB4e .messageText{fill:#333;stroke:none;}#mermaid-svg-TcwcbnYurKXEFB4e .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-TcwcbnYurKXEFB4e .labelText,#mermaid-svg-TcwcbnYurKXEFB4e .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-TcwcbnYurKXEFB4e .loopText,#mermaid-svg-TcwcbnYurKXEFB4e .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-TcwcbnYurKXEFB4e .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-TcwcbnYurKXEFB4e .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-TcwcbnYurKXEFB4e .noteText,#mermaid-svg-TcwcbnYurKXEFB4e .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-TcwcbnYurKXEFB4e .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-TcwcbnYurKXEFB4e .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-TcwcbnYurKXEFB4e .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-TcwcbnYurKXEFB4e .actorPopupMenu{position:absolute;}#mermaid-svg-TcwcbnYurKXEFB4e .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-TcwcbnYurKXEFB4e .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-TcwcbnYurKXEFB4e .actor-man circle,#mermaid-svg-TcwcbnYurKXEFB4e line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-TcwcbnYurKXEFB4e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} token "A"aggregator.append("A")LlmResponse("A", partial=true)Event("A", partial=true)finalResponse=falsetoken "B"aggregator.append("B")LlmResponse("B", partial=true)Event("B", partial=true)finalResponse=falsestream completeLlmResponse("AB", partial=false)outputKey = "AB"可选择过滤最终聚合事件,避免重复打印

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

修复后,ADK 会收到:

text 复制代码
token A
token B
完整 AB

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

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

text 复制代码
A
B
AB

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

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

java 复制代码
if (event.partial().orElse(false)) {
    sawPartialResponse.set(true);
} else if (sawPartialResponse.get() && event.finalResponse()) {
    return;
}

含义:

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

Draw.io 场景下的展示过滤

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

例如:

text 复制代码
analystAgent -> drawAgent -> reviewerAgent

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

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

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

关键逻辑:

java 复制代码
int mxfileStart = current.indexOf("<mxfile");

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

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

最终架构图

#mermaid-svg-bviDzgKPIfapJDBL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bviDzgKPIfapJDBL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bviDzgKPIfapJDBL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bviDzgKPIfapJDBL .error-icon{fill:#552222;}#mermaid-svg-bviDzgKPIfapJDBL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bviDzgKPIfapJDBL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bviDzgKPIfapJDBL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bviDzgKPIfapJDBL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bviDzgKPIfapJDBL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bviDzgKPIfapJDBL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bviDzgKPIfapJDBL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bviDzgKPIfapJDBL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bviDzgKPIfapJDBL .marker.cross{stroke:#333333;}#mermaid-svg-bviDzgKPIfapJDBL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bviDzgKPIfapJDBL p{margin:0;}#mermaid-svg-bviDzgKPIfapJDBL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bviDzgKPIfapJDBL .cluster-label text{fill:#333;}#mermaid-svg-bviDzgKPIfapJDBL .cluster-label span{color:#333;}#mermaid-svg-bviDzgKPIfapJDBL .cluster-label span p{background-color:transparent;}#mermaid-svg-bviDzgKPIfapJDBL .label text,#mermaid-svg-bviDzgKPIfapJDBL span{fill:#333;color:#333;}#mermaid-svg-bviDzgKPIfapJDBL .node rect,#mermaid-svg-bviDzgKPIfapJDBL .node circle,#mermaid-svg-bviDzgKPIfapJDBL .node ellipse,#mermaid-svg-bviDzgKPIfapJDBL .node polygon,#mermaid-svg-bviDzgKPIfapJDBL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bviDzgKPIfapJDBL .rough-node .label text,#mermaid-svg-bviDzgKPIfapJDBL .node .label text,#mermaid-svg-bviDzgKPIfapJDBL .image-shape .label,#mermaid-svg-bviDzgKPIfapJDBL .icon-shape .label{text-anchor:middle;}#mermaid-svg-bviDzgKPIfapJDBL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bviDzgKPIfapJDBL .rough-node .label,#mermaid-svg-bviDzgKPIfapJDBL .node .label,#mermaid-svg-bviDzgKPIfapJDBL .image-shape .label,#mermaid-svg-bviDzgKPIfapJDBL .icon-shape .label{text-align:center;}#mermaid-svg-bviDzgKPIfapJDBL .node.clickable{cursor:pointer;}#mermaid-svg-bviDzgKPIfapJDBL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bviDzgKPIfapJDBL .arrowheadPath{fill:#333333;}#mermaid-svg-bviDzgKPIfapJDBL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bviDzgKPIfapJDBL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bviDzgKPIfapJDBL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bviDzgKPIfapJDBL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bviDzgKPIfapJDBL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bviDzgKPIfapJDBL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bviDzgKPIfapJDBL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bviDzgKPIfapJDBL .cluster text{fill:#333;}#mermaid-svg-bviDzgKPIfapJDBL .cluster span{color:#333;}#mermaid-svg-bviDzgKPIfapJDBL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bviDzgKPIfapJDBL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bviDzgKPIfapJDBL rect.text{fill:none;stroke-width:0;}#mermaid-svg-bviDzgKPIfapJDBL .icon-shape,#mermaid-svg-bviDzgKPIfapJDBL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bviDzgKPIfapJDBL .icon-shape p,#mermaid-svg-bviDzgKPIfapJDBL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bviDzgKPIfapJDBL .icon-shape .label rect,#mermaid-svg-bviDzgKPIfapJDBL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bviDzgKPIfapJDBL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bviDzgKPIfapJDBL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bviDzgKPIfapJDBL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 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 的流式执行理解为:

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

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

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

这套方案可以同时满足:

  • 前端实时 SSE token 输出
  • 多 Agent outputKey 正确注入
  • session.state 稳定流转
  • 后续 Agent instruction 正确引用上游结果
  • Draw.io/XML 类场景避免前缀文本污染前端渲染
相关推荐
wuhanzhanhui1 小时前
智能工厂升级新风口,2026武汉智能工业自动化及机器人展览会引领未来
人工智能
swordbob1 小时前
缓存延迟双删的两种策略
java·缓存
云烟成雨TD1 小时前
Agent Scope Java 2.x 系列【4】模型层
java·人工智能·agent
dozenyaoyida1 小时前
AI与大模型新闻日报 | 2026-06-12
人工智能·ai·大模型·新闻
Blb1236541 小时前
技术解析-固体绝缘材料表面电阻率测试
人工智能·功能测试·制造·材料工程
云淡风轻~窗明几净1 小时前
角谷猜想的任意算法测试
数据结构·人工智能·算法
SaaS_Product1 小时前
同步盘操作教程:如何自动同步文件
人工智能·云计算·saas·onedrive
Z-D-K1 小时前
考验AI的“自我和意识“-AI对《红楼梦》后40回的改写(21)
人工智能·ai·aigc·交互·agi
CIO_Alliance1 小时前
API激增时代,如何用iPaaS实现API全生命周期治理
人工智能·ai·ipaas·系统集成·企业ai化转型