背景
在 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 中一次执行过程的事件载体,包含:
idinvocationIdauthorcontentactionspartialturnCompleteerrorCodeusageMetadata
其中最关键的是:
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);
}
}
注意两点:
outputKey不是在整个Flowable<Event>complete 后统一写入。- 它是在每个 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(...) 做了几件关键事情:
- 根据
userId/sessionId找到 Session。 - 把用户输入包装成一个用户 Event 追加到 Session。
- 创建本次调用的
InvocationContext。 - 调用 root agent 的
runAsync(context)。 - 对 Agent 产生的每个 Event 执行
sessionService.appendEvent(...)。 - 把 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 异常时,可以按下面顺序排查:
- 打印每个
Event的author、partial、finalResponse()、stringifyContent()。 - 确认中间 token 是否被标记为
partial=true。 - 确认最终完整响应是否存在
partial=false。 - 确认
LlmAgent.outputKey是否只在最终完整 Event 上写入。 - 确认
SessionService.appendEvent是否应用了event.actions.stateDelta。 - 多 Agent 场景下,确认前端是否需要按
event.author()过滤。 - 如果使用 Draw.io/XML 渲染,确认是否应该丢弃
<mxfile之前的解释文本。 - 不要把
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 类场景避免前缀文本污染前端渲染