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`。