MCP

如何跳过 MCP 的模型总结

在 ✅Spring AI MCP 调用实现原理这一章节中,我们已经深入了解了 MCP 的内部工作流程,在大模型决策选择工具,框架调用工具结束后,会将工具执行结果再次丢给大模型做迭代总结。这个机制的好处是:模型不仅能决定调用哪个工具、…

TL;DR

在 ✅Spring AI MCP 调用实现原理这一章节中,我们已经深入了解了 MCP 的内部工作流程,在大模型决策选择工具,框架调用工具结束后,会将工具执行结果再次丢给大模型做迭代总结。这个机制的好处是:模型不仅能决定调用哪个工具、…

在 ✅Spring AI MCP 调用实现原理这一章节中,我们已经深入了解了 MCP 的内部工作流程,在大模型决策选择工具,框架调用工具结束后,会将工具执行结果再次丢给大模型做迭代总结。这个机制的好处是:模型不仅能决定调用哪个工具、如何调用工具,还能对工具结果进行自然语言的总结,使最终回答更自然、更贴近用户需求。 但这套机制不是在所有场景下都合适,有时候甚至会成为性能瓶颈。

为什么要跳过模型总结?

在真实的业务系统中,你往往会遇到如下的场景:

多智能体协作(Multi-Agent)

多个 Agent 持续调用工具、互相传递结果,如果每一步工具执行完都要让模型再总结一遍: - 延迟倍增 - token 成本增加 - 智能体的逻辑链路变长、变慢 - 最终用户等待时间过长 某些时候,Agent 根本不需要模型再去总结。例如: - 一个 Agent 专门用于执行计算任务 - 一个 Agent 专门用于进行数据清洗 - 调用工具的任务就是中间过程,无需总结 这些都是中间过程,只需要获取到结果就好,在最终总结的时候,将这些中间结果一起抛给最后一个总结智能体中,让总结智能体流式输出,这样体验就会好很多。

工具本身输出就是最终答案

很多 MCP 工具的职责非常明确:提供业务结果,而不是给模型做二次润色的素材。例如: - 天气查询工具 - 支付下单工具 - 订单查询工具 - 文件上传工具 - 流式数据读取工具 - 数据库查询工具 这些工具返回的通常是 结构化、可直接使用的业务数据(JSON、对象、列表等)。这类结果本来就是系统最终要用的内容。如果让模型再进一步总结,不但: - 浪费时间和算力 - 增加 token 成本 - 延长响应延迟 更糟糕的是,模型的主观性还可能破坏结构化数据,例如: - 字段名被改写 - 格式被重新组织 - 结构变成自然语言而无法被系统消费 - 甚至把本来简单的结果加工得变得又长又啰嗦 除此之外,还有一些 AI 工具本身已经经过模型推理,再次总结完全没有意义。例如: - 基于 LightRAG 的知识图谱问答工具 - 本身就调用过大模型进行推理、推断、总结的高级 AI 能力组件 - 二次推理结果会让上下文变得更模糊,而不是更清晰 LightRAG 这类工具内部已经走过了「检索 → 推理 → 生成」全流程,返回的内容往往就是经过模型深度处理的最终结果。此时强行让 MCP Client 再回到大模型进行“总结”,不仅没有价值,还可能把它原本的严谨推理结果变得混乱。 综上所述,跳过模型总结的核心原因在于:在某些实际业务场景中,模型的迭代推理不仅没有必要,反而会带来明显的性能开销与工程风险。工具已经提供了结构化、可直接消费的结果时,再让模型进行总结只会增加延迟、提高 token 成本,并可能因模型的主观性导致数据被重新组织甚至失真。此外,在高性能 API、自动化流程、多智能体协作等场景中,链路中每一个额外的推理步骤都会显著影响整体速度与稳定性。因此,当我们只需要模型负责工具决策与参数生成,而不需要它对结果进行改写时,跳过模型总结不仅是优化,更是工程上最合理的选择。

returnDirect

Spring AI 提供了一个参数,使我们可以方便的进行选择是否跳过模型迭代总结,这个参数就是returnDirect。

@Tool(description = "根据城市名称查询天气信息",returnDirect = true)
public String getWeather(String city) {
    if (city == null) {
        return "请提供城市名称";
    }
    return switch (city) {
        case "北京" -> "北京: 晴, 25°C";
        case "上海" -> "上海: 多云, 22°C";
        case "深圳" -> "深圳: 小雨, 28°C";
        default -> city + ": 下雪, -20°C";
    };
}

我们只需要在 @Tool 注解中增加这个参数即可实现跳过。我们看下他的效果如何。 是不是有点意外,完全没作用啊,这不还是二次总结了,我们的期望是直接获取到我们的工具返回结果才对:

南京:下雪,-20°C

为啥会这样呢,我们深入到源码中来分析下这个原因。

源码分析

我们在 ✅Spring AI MCP 调用实现原理已经详细的对MCP的执行流程进行了分析,这边其他流程不再赘述,直接定位到重点的类,就是:DefaultToolCallingManager 的 executeToolCall 方法。 这边是它的一些代码片段,可以看到是会从 toolcallback 的 toolmetadata 中获取这个 returnDirect的,那为啥我设置的是true,获取的是false呢?我们接着往下看。 我们进入到 ToolCallback 这个类,看下他的元数据是怎么获取的。他提供了一个默认方法,默认方法的返回其实就是false,那就是说我们在MCP Server中设置的参数没传过来,没有生效。 我们还可以看到 ToolCallback 有几种默认实现类,我们现在用的是MCP方式 SyncMcpToolCallback。 发现这个类完全没有对这个元数据进行实现,天塌了,这不就是不支持嘛。是不是很好奇这个 returnDirect 到底啥情况下才能生效呢? 我们看下其他的实现类,比如:FunctionToolCallback,也就是传统的Function-Call。

FunctionCall 能否生效?

他这边是有元数据的相关操作的,那我们就试试看,使用functioncall的方式能不能实现跳过的效果。 修改一下chatclient的注入工具方式,改为tools:

        this.chatClient = ChatClient.builder(chatModel)
//                .defaultToolCallbacks(callbacks)
                .defaultTools(new WeatherService())
                .build();

自己在同项目,再重新创建一个WeatherService,注意不是远程调用,function call是本地调用。

@Service
@Slf4j
public class WeatherService {

    @Tool(description = "根据城市名称查询天气信息",returnDirect = true)
    public String getWeather(String city) {
        if (city == null) {
            return "请提供城市名称";
        }
        return switch (city) {
            case "北京" -> "北京: 晴, 25°C";
            case "上海" -> "上海: 多云, 22°C";
            case "深圳" -> "深圳: 小雨, 28°C";
            default -> city + ": 下雪, -20°C";
        };
    }
}

到这里就ok了,我们再次请求访问看下效果。看到我们断点处的returnDirect已经是读取到true了。 是不是就跟我们的预期是一样的。说明这个参数功能对于MCP而言就是Spring AI没有帮我们实现啊。那MCP现在已经作为了通用工具的标准,他肯定也是需要解决这个问题的,那应该怎么做呢?其实也是有办法的。

MCP 如何跳过?

核心问题就在于如何接管 SyncMcpToolCallback,而这个类实际上是我们在注入工具的时候设置的: 也就是这个 SyncMcpToolCallbackProvider,在他的内部会去初始化 SyncMcpToolCallback 了解其原理后,我们如何来接管这两个类?是不是就可以自然而然的想到我们的继承,我只要继承这两个类,修改其获取元数据的方式,是不是就可以将 returnDirect 动态传入进去了。 那我们首先开发一个 ReturnDirectSyncMcpToolCallback 继承 SyncMcpToolCallback,只需要重写getToolMetadata即可。

package cn.hollis.llm.HelloLlm.springai.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;
    }

    @Override
    public ToolMetadata getToolMetadata() {
        return ToolMetadata.builder()
                .returnDirect(returnDirect)
                .build();
    }
}

接着创建一个 DirectReturnMcpToolCallbackProvider 用于接收 McpSyncClient 并转换成 toolcallback

@Slf4j
public class DirectReturnMcpToolCallbackProvider extends SyncMcpToolCallbackProvider {

    private final List<McpSyncClient> mcpClients;

    private boolean returnDirect;

    public DirectReturnMcpToolCallbackProvider(List<McpSyncClient> mcpClients, boolean returnDirect) {
        super(mcpClients);
        this.mcpClients = mcpClients;
        this.returnDirect = returnDirect;
    }

    @Override
    public ToolCallback[] getToolCallbacks() {
        var toolCallbacks = new ArrayList<>();

        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 CustomSyncMcpToolCallback(mcpClient, tool, returnDirect));
            }
        }
        var array = toolCallbacks.toArray(new ToolCallback[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()) {
            throw new IllegalStateException(
                    "Multiple tools with the same name (%s)".formatted(String.join(", ", duplicateToolNames)));
        }
    }
}

最后我们再改造一下注入 chatclient 的方式,将 returnDirect 设置为true透传进去即可。

DirectReturnMcpToolCallbackProvider callbackProvider = new DirectReturnMcpToolCallbackProvider(clients,true);

this.chatClient = ChatClient.builder(chatModel)
       .defaultToolCallbacks(callbackProvider)
       .build();

改造到这就ok了,接下来我们尝试调用一下: 到这里我们可以看到成功跳过了模型的迭代总结,至于为什么这边会有text,这个其实就需要去看SyncMcpToolCallback 的 call 方法的返回值: 通过继承的方式,我们重写了provider 和 toolcallback 类,成功实现了跳过MCP模型总结,甚至通过这种方式,我们还可以重写 SyncMcpToolCallback的 call 方法,在调用MCP工具时做一些定制化的处理,这个大家可以自己尝试去做一下。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。