乐于分享
好东西不私藏

Spring AI Alibaba 多智能体传参踩坑:outputKey 被 TodoListInterceptor 带偏了

Spring AI Alibaba 多智能体传参踩坑:outputKey 被 TodoListInterceptor 带偏了
我最近在用 Spring AI Alibaba 的 Agent Framework 开发智能体时,遇到一个很“玄学”的问题。
流程并不复杂:两个`ReactAgent`顺序执行,第一个 Agent 负责产出结果,第二个 Agent 通过框架提供的`outputKey`功能读取前一个 Agent 的输出。理论上,这应该是一条很稳定的链路。
但实际运行时,第二个 Agent 只能偶尔拿到正确数据。有时拿到的是预期结果,有时拿到的却像是一段执行总结,内容大概是在回顾任务清单完成情况。
一开始我怀疑过提示词、模型稳定性、并发状态、上下文覆盖,最后才发现,真正的问题出在一个看起来很无辜的组件上:`TodoListInterceptor`

先说结论

这不是简单的“模型偶发不听话”,而是一次输出契约被隐式改变的问题。
在我的场景里,第一个`ReactAgent`使用了`TodoListInterceptor`。它会给模型注入待办清单管理能力,引导智能体在复杂任务中创建、更新、完成 todo。这样做本身没问题,官方源码里也能看到它会向模型请求追加系统提示,并提供`write_todos`工具。
问题在于:当 Agent 作为子图节点参与顺序编排时,Spring AI Alibaba 会把子 Agent 的最终响应写回父图 state。如果配置了`outputKey`,写回的 key 就是这个`outputKey`;而写回值来自子图流的最后一次响应处理。
源码里还有一个关键处理:最后一次响应如果包含`messages`,框架会过滤父图已有消息,并在默认不返回 reasoning 内容时,只保留过滤后消息列表里的最后一条消息。
于是,一个微妙的问题出现了:如果`TodoListInterceptor`引导 Agent 在任务完成后追加了一段“清单执行情况总结”,那这段总结就可能成为最后一条`AssistantMessage`。此时`outputKey`里保存的就不再是业务结果,而是 todo 总结。下游 Agent 当然就会“偶尔读错”。

为什么这个问题容易被误判

它容易被误判,是因为`outputKey`这个名字会让人自然理解成“保存我想要的业务输出”。
但在多 Agent 编排里,`outputKey`更准确地说,是“把这个 Agent 最终写回父图的输出放到哪个 state key 里”。如果 Agent 的最终输出本身不是一个稳定的业务结果,而是由对话消息流决定,那么`outputKey`只是换了存放位置,并没有改变“取哪条消息作为结果”的事实。
这也是我排查时踩进去的坑:我以为后一个 Agent 没有正确读取`outputKey`,实际更接近于,前一个 Agent 写进`outputKey`的内容已经不是我期望的那份结果。
单独调用`ReactAgent.call()`时,源码显示它在设置了`outputKey`后,会从 Agent state 里取对应 key;没有`outputKey`时,才会从`messages`中取最后一个`AssistantMessage`。但当 Agent 被作为子图节点放进父流程,关键逻辑发生在子图输出回填父图 state 的阶段:框架把最后处理后的图响应放进`outputKey`
这两个层次要分清,否则很容易把问题定位错。

TodoListInterceptor 做了什么

从官方源码看,`TodoListInterceptor`是一个`ModelInterceptor`。它的职责不是改写业务结果,而是增强模型请求:如果原请求没有 system message,就创建一个带 todo 使用说明的 system message;如果已有 system message,就在原 system message 后追加 todo 指令。
它还通过`getTools()`暴露`write_todos`工具。`WriteTodosTool`会把 todo 列表写入工具上下文中的额外 state,并返回一个成功消息,提示模型继续使用 todo 跟踪进度。
这套设计适合复杂任务:它让 Agent 更像一个会规划、会更新进度的执行者。但它也会改变消息轨迹。模型在完成业务任务后,可能还会补一段“我已经完成了哪些任务”的总结。
如果你的下游依赖“最后一条 assistant message 就是业务结果”,这就变成了不稳定因素。

我最后的处理:去掉 TodoListInterceptor

定位到这里之后,我最后的处理方式很直接:去掉第一个`ReactAgent`上的`TodoListInterceptor`
原因也很简单。这个 Agent 在链路里的核心职责不是“向用户展示执行过程”,而是“给下一个 Agent 产出稳定入参”。既然 todo 清单总结会进入消息轨迹,并且有机会成为最后一条消息,那它对这个 Agent 来说就不是必要增强,而是额外干扰。
去掉之后,前一个 Agent 的输出重新回到比较单纯的业务结果,下游通过`outputKey`获取数据也就稳定了。
这个处理并不意味着`TodoListInterceptor`不能用。它适合那些需要让用户看到执行进度、需要长任务拆解、或者需要在交互界面展示 todo 状态的 Agent。但如果某个 Agent 位于自动化编排链路中,它的输出要继续传给后续节点,那么我会更谨慎地使用这类会改变消息轨迹的 interceptor。
如果不想去掉它,另一个方向是放弃`outputKey`,改成自己显式传递消息。
简单一点的做法,是使用 hook。在前一个 Agent 执行完成后,从 state 或消息列表里取出真正的业务结果,自己组装成下一个 Agent 的输入。这样链路会更直白:哪些内容给人看,哪些内容给下游 Agent 用,都由代码决定,而不是交给“最后一条消息”来碰运气。
复杂一点的做法,是引入额外的记忆。对于大型任务来说,这几乎是绕不开的。大模型上下文窗口再大也是有限的,任务过程中的中间结论、约束、用户偏好、已完成步骤、未完成问题,不应该全部塞进下一轮 prompt 里。更稳妥的方式,是把这些信息沉淀到外部 memory,再让后续 Agent 按需读取。
这样做的好处是,`TodoListInterceptor`可以继续负责过程管理,memory 负责长期上下文,hook 负责 Agent 之间的短程消息传递。三者各司其职,业务结果就不会再和 todo 总结、执行日志、自然语言收尾混在一起。
但对我这次的场景来说,最小、最稳、也最符合职责边界的做法,就是移除`TodoListInterceptor`

这次问题给我的提醒

Agent 框架里很多能力都是“增强上下文”的:todo、memory、hook、interceptor、消息压缩、人工确认,看起来都只是辅助能力。但只要你的系统把“对话消息”当成“业务协议”,这些辅助能力就可能改变协议本身。
所以,多 Agent 编排里要尽量把两件事分开:
一件是给人看的执行过程。
另一件是给机器读的业务结果。
前者可以自然语言,可以总结,可以有进度感;后者应该稳定、结构化、可校验。`outputKey`可以做轻量 state 传递,但它不是天然的结构化结果保证。任务一旦变大,就不要只依赖`outputKey`和上下文窗口硬扛,显式消息传递和额外记忆会更可靠。
这次排查的价值也在这里:问题表面上是“后一个 Agent 偶尔拿不到参数”,本质上是“上一个 Agent 的最终消息不再等价于业务输出”。
知道这一层之后,再看`TodoListInterceptor`就不会觉得它莫名其妙了。它只是认真地帮 Agent 管任务;只是我们不该把任务复盘当成下游入参。