【Spring AI + Google ADK 】流式输出时 outputKey 状态缓存失败的问题
背景
在 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 中一次执行过程的事件载体,包含:
idinvocationIdauthorcontentactionspartialturnCompleteerrorCodeusageMetadata
其中最关键的是:
@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);
}
}
注意两点:
outputKey不是在整个Flowable<Event>complete 后统一写入。- 它是在每个 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 的高层状态流转如下:
状态写入的关键不是直接改 InvocationContext,而是:
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 流返回给上层业务。
简化伪代码:
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 = "#"
错误时序如下:
修复方案
正确修复思路是自定义 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);
}
修复后的时序:
为什么前端还要过滤最终聚合事件
修复后,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();
}
最终架构图
排查 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 的流式执行理解为:
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 类场景避免前缀文本污染前端渲染
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)