在 Spring AI MCP 的默认工作流程中,大模型完成工具调用后,框架会将工具执行结果再次送回大模型做一次迭代总结。举例来说:用户问"TR001的审批状态",模型决策调用差旅查询工具 → 工具返回 出差申请单 TR001:已审批,差旅费 5000 元 → 模型再对这条结果做自然语言包装,输出"根据查询结果,出差申请单 TR001 目前状态为已审批,差旅费 5000 元"。
这套机制让最终回复更自然、更贴近对话体验——但它不是在所有场景下都合适。有时候,它不仅没有价值,反而会成为性能瓶颈和成本黑洞。
一、为什么要跳过模型总结?
在真实的业务系统中,你往往会遇到如下的场景:
场景一:多智能体协作(Multi-Agent)

多个 Agent 持续调用工具、互相传递结果,如果每一步工具执行完都要让模型再总结一遍:
• 延迟倍增 • token 成本增加 • 智能体的逻辑链路变长、变慢 • 最终用户等待时间过长
某些时候,Agent 根本不需要模型再去总结。例如:
• 一个 Agent 专门查询出差申请单状态,结果直接展示给用户 • 一个 Agent 专门计算差旅预算汇总,返回结构化数据给下游 • 调用工具的任务本身就是中间过程,无需总结
这些都是中间过程,只需要获取到结果就好。在最终汇总阶段,将各 Agent 的中间结果一起交给总结智能体做一次性的流式输出,用户体验会好很多。
场景二:工具本身输出就是最终答案
很多 MCP 工具的职责非常明确:提供业务结果,而不是给模型做二次润色的素材。以 TMC 差旅管理系统为例:
• 出差申请单查询工具("TR001的状态" → 返回单据信息即可,无需模型复述一遍) • 支付下单工具(返回支付结果/订单号,系统直接消费) • 审批操作工具(返回审批成功/失败,无需冗长解释) • 订单查询工具 • 文件上传工具 • 数据库查询工具
这些工具返回的通常是 结构化、可直接使用的业务数据(JSON、对象、列表等)。这类结果本来就是系统最终要用的内容。如果让模型再进一步总结,不但:
• 浪费时间和算力 • 增加 token 成本 • 延长响应延迟
更糟糕的是,模型的主观性还可能破坏结构化数据,例如:
• 字段名被改写 • 格式被重新组织 • 结构变成自然语言而无法被系统消费 • 甚至把本来简单的结果加工得变得又长又啰嗦
除此之外,还有一些 AI 工具本身已经经过模型推理,再次总结完全没有意义。例如:
• 基于 LightRAG 的知识图谱问答工具 • 本身就调用过大模型进行推理、推断、总结的高级 AI 能力组件 • 二次推理结果会让上下文变得更模糊,而不是更清晰
LightRAG 这类工具内部已经走过了「检索 → 推理 → 生成」全流程,返回的内容往往就是经过模型深度处理的最终结果。此时强行让 MCP Client 再回到大模型进行”总结”,不仅没有价值,还可能把它原本的严谨推理结果变得混乱。
核心结论:当工具已经返回了结构化、可直接消费的结果时,再让模型做迭代总结只会增加延迟、推高 token 成本,甚至可能因模型的主观改写破坏数据结构。在 Multi-Agent、高性能 API、自动化流程等场景中,链路里每一个额外的推理步骤都会拖慢整体速度。因此,当我们只需要模型负责”工具决策与参数生成”,而不需要对结果进行二次加工时,跳过模型总结是工程上最合理的选择。
二、Spring AI 的 returnDirect 参数
Spring AI 在 @Tool 注解中提供了一个 returnDirect 参数,用于控制工具调用后是否跳过模型的二次总结:
@Tool(description = "根据单号查询出差申请单信息", returnDirect = true)public String queryTravelRequest(String requestId) {if (requestId == null) {return"请提供出差申请单号"; }return switch (requestId) {case"TR001" -> "出差申请单 TR001:已审批,差旅费 5000 元(技术部)";case"TR002" -> "出差申请单 TR002:待审批,差旅费 3000 元(销售部)";case"TR003" -> "出差申请单 TR003:已驳回,预算不足(市场部)";default -> "未找到出差申请单 " + requestId; };}按文档理解,设置 returnDirect = true 后,工具返回的结果应该直接被透传,不再经过模型总结。理想效果是用户问"TR003的状态",直接得到:
出差申请单 TR003:已驳回,预算不足(市场部)但实际效果令人意外:MCP 工具上的 returnDirect = true 完全没有生效,模型依然对结果做了二次总结。预期输出应该是工具原始返回的 出差申请单 TR003:已驳回,预算不足(市场部),但实际上模型依然包装了一层自然语言。
问题出在哪?接下来从源码层面分析原因。
三、源码分析:为什么 MCP 的 returnDirect 不生效?
在之前的文章中已经详细分析过 MCP 的执行流程,这里直接定位到关键类:DefaultToolCallingManager 的 executeToolCall 方法。
核心逻辑是:从 ToolCallback.getToolMetadata() 中读取 returnDirect 字段。如果为 true,直接返回工具执行结果;为 false,则将结果再次送入模型做二次总结。
问题暴露了:我们在 @Tool 注解中设置的是 true,但这里读取到的是 false。继续追踪 ToolCallback 接口,它提供了一个 getToolMetadata() 默认方法:
这个默认实现直接返回 returnDirect = false。也就是说,MCP Server 端注解上设置的参数根本没有传递过来——SyncMcpToolCallback 没有重写这个方法,因此始终返回默认值 false。
ToolCallback 有三种主要实现类:SyncMcpToolCallback(MCP 方式)、FunctionToolCallback(传统 Function Call)、MethodToolCallback。而 SyncMcpToolCallback 完全继承了父类的默认实现,既没有重写 getToolMetadata(),也没有接收外部传入 returnDirect 的构造器。
结论很清楚:SyncMcpToolCallback 完全没有实现 getToolMetadata(),MCP 模式下 returnDirect 形同虚设。
那么 returnDirect 到底在什么场景下能生效?来看另一个实现类:FunctionToolCallback(传统 Function Call 模式)。
四、FunctionToolCallback 验证:returnDirect 能生效吗?
FunctionToolCallback 在构造函数中接收了 @Tool 注解的元数据信息,包括 returnDirect 属性值,并在 getToolMetadata() 中正确返回。
下面用 Function Call 方式验证——创建一个本地 TMC 差旅状态查询服务来替代 MCP 远程调用:
this.chatClient = ChatClient.builder(chatModel)// .defaultToolCallbacks(callbacks) // 注释掉 MCP 方式 .defaultTools(newTravelStatusService()) .build();创建 TravelStatusService,使用 @Tool 注解(注意这是本地 Function Call,不是 MCP 远程调用):
@Service@Slf4jpublic class TravelStatusService {@Tool(description = "根据出差申请单号查询审批状态", returnDirect = true)public String queryTravelStatus(String requestId) {if (requestId == null) {return "请提供出差申请单号"; }return switch (requestId) {case"TR001" -> "出差申请单 TR001:已审批,差旅费 5000 元(技术部)";case"TR002" -> "出差申请单 TR002:待审批,差旅费 3000 元(销售部)";case"TR003" -> "出差申请单 TR003:已驳回,预算不足(市场部)";default -> "未找到出差申请单 " + requestId; }; }}启动调试,returnDirect 已经正确读取为 true,验证结果符合预期:Function Call 模式下 returnDirect = true 确实跳过了模型总结,工具结果原样返回。
但这也说明了一个问题:returnDirect 这个参数功能在 Spring AI 中是存在的,只是 MCP 模式下没有被正确实现。MCP 作为通用工具协议的标准,必然需要解决这个问题。
五、MCP 场景下如何跳过模型总结?
核心问题在于如何接管 SyncMcpToolCallback 和 SyncMcpToolCallbackProvider 这两个类。
SyncMcpToolCallbackProvider 在注入工具时负责将 McpSyncClient 转换为 ToolCallback 列表,内部遍历 MCP 客户端、获取工具列表、为每个工具创建 new SyncMcpToolCallback(client, tool)。
思路很直接:通过继承来接管这两个类,重写获取元数据的方式,将 returnDirect 动态传入。
5.1 继承 SyncMcpToolCallback,重写 getToolMetadata()
package com.minsf.tmc.mcp.callback;import io.modelcontextprotocol.client.McpSyncClient;import io.modelcontextprotocol.spec.McpSchema;import org.springframework.ai.mcp.SyncMcpToolCallback;import org.springframework.ai.tool.metadata.ToolMetadata;public class ReturnDirectSyncMcpToolCallback extends SyncMcpToolCallback {private final boolean returnDirect;public ReturnDirectSyncMcpToolCallback(McpSyncClient client, McpSchema.Tool tool,boolean returnDirect) {super(client, tool);this.returnDirect = returnDirect; }@Overridepublic ToolMetadata getToolMetadata() {return ToolMetadata.builder() .returnDirect(returnDirect) .build(); }}5.2 继承 SyncMcpToolCallbackProvider,使用自定义 Callback
@Slf4jpublic class DirectReturnMcpToolCallbackProviderextends SyncMcpToolCallbackProvider {private final List<McpSyncClient> mcpClients;private final boolean returnDirect;public DirectReturnMcpToolCallbackProvider(List<McpSyncClient> mcpClients,boolean returnDirect) {super(mcpClients);this.mcpClients = mcpClients;this.returnDirect = returnDirect; }@Overridepublic ToolCallback[] getToolCallbacks() {var toolCallbacks=new ArrayList<ToolCallback>();for (McpSyncClient mcpClient : mcpClients) { List<McpSchema.Tool> toolList = Collections.emptyList();try { toolList = mcpClient.listTools().tools(); } catch (Exception e) {// 跳过该 MCP,继续处理其它的continue; }for (var tool : toolList) { toolCallbacks.add(new ReturnDirectSyncMcpToolCallback(mcpClient, tool, returnDirect)); } }vararray= toolCallbacks.toArray(newToolCallback[0]); validateToolCallbacks(array);return array; }private void validateToolCallbacks(ToolCallback[] toolCallbacks) { List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks); duplicateToolNames.forEach(s -> log.info("tool name found: {}", s));if (!duplicateToolNames.isEmpty()) {thrownewIllegalStateException("Multiple tools with the same name (%s)" .formatted(String.join(", ", duplicateToolNames))); } }}5.3 改造 ChatClient 注入方式
DirectReturnMcpToolCallbackProvidercallbackProvider=newDirectReturnMcpToolCallbackProvider(clients, true);this.chatClient = ChatClient.builder(chatModel) .defaultToolCallbacks(callbackProvider) .build();改造完成后启动测试,成功跳过了模型的迭代总结——用户问"TR001的状态",直接得到工具原始结果"出差申请单 TR001:已审批,差旅费 5000 元(技术部)"。
补充说明:跳过总结后返回中仍带有 text 字段,这是 MCP 协议层的行为——工具结果按 text/image/resource 组织,text 是承载方式,跳过总结仅指不再经过模型二次加工。
5.4 扩展:重写 call() 方法,定制 MCP 工具调用行为
继承 SyncMcpToolCallback 不仅能控制 returnDirect,还能通过重写 call() 方法在工具调用前后插入自定义逻辑。以 TMC 差旅场景为例,常见需求包括:
• 调用审计:记录每次 MCP 工具调用的工具名、参数、耗时、结果,方便排查问题 • 异常增强:MCP 协议层的技术错误转成 LLM 能理解的业务提示 • 结果裁剪:某些工具返回数据量过大,截断后再交给模型
下面是一个整合了日志审计和异常增强的完整示例:
@Slf4jpublic class AuditedMcpToolCallback extends SyncMcpToolCallback {private final boolean returnDirect;private final McpSchema.Tool tool;public AuditedMcpToolCallback(McpSyncClient client, McpSchema.Tool tool,boolean returnDirect) {super(client, tool);this.tool = tool;this.returnDirect = returnDirect; }@Overridepublic ToolMetadata getToolMetadata() {return ToolMetadata.builder() .returnDirect(returnDirect) .build(); }@Overridepublic ToolResponse call(ToolCall toolCall) {long start= System.currentTimeMillis();String toolName= tool.name();String arguments= toolCall.arguments(); log.info("[MCP审计] 调用工具: {}, 参数: {}", toolName, arguments);try {ToolResponse response=super.call(toolCall);long duration= System.currentTimeMillis() - start; log.info("[MCP审计] 调用完成: {}, 耗时: {}ms", toolName, duration);// MCP 协议层错误 → 转业务友好提示if (response.text() != null && response.text().contains("MCP error")) {return ToolResponse.builder() .text("差旅管理系统暂时不可用,请稍后重试。" + "如需紧急处理,请联系差旅管理员。") .build(); }return response; } catch (Exception e) {longduration= System.currentTimeMillis() - start; log.error("[MCP审计] 调用异常: {}, 耗时: {}ms", toolName, duration, e);// 技术异常 → LLM 可理解的提示return ToolResponse.builder() .text(String.format("调用 %s 时发生错误:%s。请告知用户稍后重试。", toolName, e.getMessage())) .build(); } }}在 DirectReturnMcpToolCallbackProvider.getToolCallbacks() 中,将 new ReturnDirectSyncMcpToolCallback(...) 替换为 new AuditedMcpToolCallback(...) 即可生效。
通过这种方式,继承 SyncMcpToolCallback 为 MCP 工具调用提供了通用扩展点——日志、审计、限流、熔断、结果转换等横切关注点都可以在回调层统一处理,无需侵入 MCP Server 的业务代码。
六、进阶:按工具粒度控制 returnDirect
上面的方案有一个局限:它是全局的——DirectReturnMcpToolCallbackProvider 构造函数中的 returnDirect 对所有工具一视同仁。但在实际项目中,通常只需要部分工具跳过总结(如数据查询类),而另一些工具仍然需要模型的自然语言包装(如复杂的分析结果)。
更精细的做法是从 MCP Server 端的工具元数据 中获取每个工具的 returnDirect 配置。MCP 协议支持在工具定义中携带 annotations 信息,可以用来传递这类元数据:
// MCP Server 端:在工具定义中标记 returnDirect@McpTool(name = "query_travel_status", description = "根据单号查询出差申请的审批状态", annotations = { @McpToolAnnotation(key = "returnDirect", value = "true") })public String queryTravelStatus(String requestId) { ... }@McpTool(name = "analyze_travel_budget", description = "分析部门差旅预算使用情况,给出建议")public String analyzeTravelBudget(String department) { ... }在 Client 端的 ReturnDirectSyncMcpToolCallback 中,可以从 McpSchema.Tool 对象中读取 annotations,实现按工具粒度的控制:
@Overridepublic ToolMetadata getToolMetadata() {// 从 MCP Tool 的 annotations 中读取每个工具的 returnDirect 配置booleanperToolReturnDirect= extractReturnDirectFromAnnotations(tool);return ToolMetadata.builder() .returnDirect(perToolReturnDirect) .build();}这样,queryTravelStatus 这类只需展示结果的工具可以跳过总结,而 analyzeTravelBudget 这种需要模型解读的工具仍然保持默认行为。
七、总结
本文从生产实践出发,分析了 Spring AI MCP 中 returnDirect 参数不生效的根因:
1. 根因定位: SyncMcpToolCallback未重写getToolMetadata(),默认始终返回returnDirect = false2. 解决方案:通过继承 SyncMcpToolCallback和SyncMcpToolCallbackProvider,将returnDirect参数透传进去3. 进阶方向:利用 MCP 协议的 tool annotations,从 Server 端按工具粒度传递 returnDirect配置,避免一刀切
此外,通过继承 SyncMcpToolCallback 的 call() 方法,还可以在 MCP 工具调用前后做更多定制化处理(如日志记录、参数校验、结果转换等),这套扩展模式适用于更广泛的 MCP 定制场景。
夜雨聆风