一、前两篇做了什么,这篇做什么
先说个背景,之前写过两篇相关文章。第一篇介绍了 AgentExecutor,让模型通过 ReAct 格式的纯文本输出来驱动工具调用——说白了,就是让模型输出类似 Action: xxx / Action Input: yyy 这样的文本,然后我们去解析它。这套方案的好处是不挑模型,什么模型都能用,但本质上还是在“读模型写的文章”来判断意图。

第二篇讲的是 MCP 的基础接入方式:用 McpManager 注册 HTTP 工具,手动调用 run() 执行,然后把结果拼回 Prompt 里。这个方式倒是能跑通,也能验证工具是否可用,但问题是——整个过程中,“调哪个工具”、“传什么参数”,模型根本就没有参与决策。
这一篇要解决的就是这个核心问题:让模型和 MCP 真正配合起来,实现一个完整的 Agent 功能。具体做法是把 MCP 的工具清单直接注册给支持 Function Calling 的模型。这样模型就不再输出文本格式的 Action,而是输出结构化的 ToolCall——包含函数名和 JSON 参数。开发者只需要负责执行这个 ToolCall,把结果写回 Prompt,然后让模型继续推理就行了。
这三种方式的核心区别,一句话说清楚:
| 方式 | 谁决定调哪个工具 | 工具参数格式 | 模型要求 |
|---|---|---|---|
| ReAct 文本驱动 | 模型(文本输出) | 字符串 / JSON 文本 | 任意 |
| Function Calling | 模型(结构化输出) | 标准 JSON Schema | 需支持 Function Calling |
二、场景:三步连贯推理,全程无人工介入
这一篇要做的场景挺有意思:自动检测当前环境下的公网 IP、定位城市、然后查询天气。整个过程需要用到三个工具,按顺序调用:
get_export_ip:获取本机公网出口 IPget_ip_location:根据 IP 查询城市、经纬度、ISP 信息get_weather_open_meteo:根据经纬度查询实时天气
这三个工具是串联的——上一步的返回值就是下一步的输入参数。用户只需要说一句话,Agent 就会自主完成全部推理和工具调用,最终给出一个包含“位置 + 天气”的总结。而且这三个工具都在 mcp.config.json 的 default 分组里声明好了,不需要改任何代码,框架就能自动加载。
三、核心代码逐步拆解
第一步:构建 Prompt 并注入工具清单
// Prompt 模板:系统指令明确调用顺序和输出格式
BaseRunnable prompt = ChatPromptTemplate.fromMessages(List.of(
BaseMessage.fromMessage(MessageType.SYSTEM.getCode(),
"""
你是一名能够调用 MCP HTTP 工具的智能体,需要按以下顺序完成任务:
1) 调用 get_export_ip 获取公网 IP;
2) 将该 IP 传给 get_ip_location,获取城市、经纬度以及网络信息;
3) 使用经纬度调用 get_weather_open_meteo,并设置 current_weather=true;
4) 总结位置与天气(只输出结论,不暴露工具名称)。
工具只在必要时调用,每个工具最多执行一次。
"""
),
BaseMessage.fromMessage(MessageType.HUMAN.getCode(), "用户问题:${input}")
));
// manifestForInput() 把 mcp.config.json 转成模型所需的 JSON Schema 格式
List tools = mcpManager.manifestForInput().get("default");
// 把工具清单注册给 LLM,模型推理时会自动决定何时调用哪个工具
ChatAliyun llm = ChatAliyun.builder()
.model("qwen3.6-plus")
.temperature(0f)
.tools(tools)
.build(); 注意这里的 System Prompt 写得比较细致——明确告诉模型要按照什么顺序调用工具。这样做有两个好处:一是能减少模型漏掉关键步骤的概率,二是调试起来更容易判断是哪个环节出了问题。
第二步:循环条件——有 ToolCall 就继续
int maxIterations = 5;
Function shouldContinue = round -> {
if (round >= maxIterations) {
return false; // 防止死循环
}
if (round == 0) {
return true; // 第一轮必须执行
}
// 检查上一轮 LLM 输出是否包含 ToolCall
AIMessage lastAi = ContextBus.get().getResult(llm.getNodeId());
return lastAi instanceof ToolMessage toolMessage &&
CollectionUtils.isNotEmpty(toolMessage.getToolCalls());
}; 退出条件其实很直观:模型如果不再输出 ToolCall,说明它已经拿到了足够的信息,准备给出最终回答了。
第三步:核心处理器——执行 ToolCall 并写回 Observation
这是整个链路最核心的一段代码:
TranslateHandler这里 appendToolMessage 的作用是把工具的返回结果以标准的 ToolMessage 格式写回 Prompt。这样模型在下一轮推理时就能看到完整的上下文——它知道自己调了什么工具、得到了什么结果。
第四步:组装完整链路
FlowInstance chain = chainActor.builder()
.next(prompt)
.loop(shouldContinue,
llm,
chainActor.builder()
.next(
Info.c(needsToolExecution, executeMcpTool),
Info.c(input -> ContextBus.get().getResult(llm.getNodeId()))
)
.build()
)
.next(new StrOutputParser())
.build();
ChatGeneration finalAnswer = chainActor.invoke(chain, Map.of(
"input", "不要询问额外信息,自动检测我的公网 IP,推断所在城市并告知当前天气后统一回复。"
));
System.out.println(finalAnswer.getText());整个链路的结构其实非常清晰:Prompt → 循环( LLM → [有ToolCall? 执行MCP : 透传] ) → 输出最终回答
四、完整推理过程
运行之后控制台会打印完整的推理轨迹:
> MCP Function-Calling ReAct 链开始执行...
[ToolCall] get_export_ip params -> {}
[Observation] 123.117.177.40
[ToolCall] get_ip_location params -> {"ip": "123.117.177.40"}
[Observation] {"country_name":"China","region_name":"Beijing Shi","city":"Dongcheng Qu", "latitude":39.9117,"longitude":116.4097,"org":"AS4134 Chinanet"}
[ToolCall] get_weather_open_meteo params -> {"latitude":39.9117,"longitude":116.4097,"current_weather":"true"}
[Observation] {"current_weather":{"temperature":18.3,"windspeed":6.1,"weathercode":1}}
> 链执行完成。
=== 最终回答 ===
检测到你的公网 IP 位于中国北京市东城区,当前温度约 18°C,天气晴朗,风速 6.1 km/h。可以看到,模型精确地执行了三次 ToolCall,没有遗漏任何一步,也没有多余的调用。每次 Observation 被写回 Prompt 后,模型在下一轮推理中就能自动提取所需字段——从 IP 到经纬度再到天气,整个过程不需要开发者做任何字段映射。
五、和 ReAct 文本驱动的本质区别
很多开发者会问:AgentExecutor 也能完成多步工具调用,这两种方式到底有什么实质区别?
这么说吧,ReAct 文本驱动(AgentExecutor)的方式下,模型输出的是纯文本,比如:
Action: get_ip_location
Action Input: {"ip": "123.117.177.40"}框架需要通过字符串解析来提取工具名和参数——本质上是“读模型写的文章”。
而 Function Calling(本篇采用的方式)下,模型输出的是结构化的 JSON:
{
"toolCalls": [
{
"function": {
"name": "get_ip_location",
"arguments": "{\"ip\":\"123.117.177.40\"}"
}
}
]
}框架直接解析 JSON,工具名和参数都是强类型字段,不存在格式歧义的问题。
这两种方式各有各的适用场景:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 模型不支持 Function Calling | ReAct 文本驱动 | 唯一可用选项 |
| 参数复杂(嵌套 JSON、数组) | Function Calling | 结构化输出更可靠 |
| 需要精确控制推理文本 | ReAct 文本驱动 | Thought 内容完全可读 |
| 对接标准 MCP 工具生态 | Function Calling | 工具 Schema 天然匹配 |
| 模型稳定性要求高 | Function Calling | 减少格式解析失败 |
六、工具调用失败怎么处理
真正上线之后,你就会发现生产环境里什么意外都可能发生:网络超时、参数错误……HTTP 工具调用失败是家常便饭。框架的处理方式是这样的:把错误信息同样以 ToolMessage 的形式写回 Prompt,让模型感知到“这次工具调用失败了”,然后由模型自己决定是重试、跳过还是向用户说明原因:
try {
Object result = mcpManager.runForInput("default", toolName, args);
String observation = result != null ? result.toString() : "工具无返回内容";
appendToolMessage(prompt, call, observation);
} catch (Exception e) {
log.error("调用 MCP 工具 {} 失败: {}", toolName, e.getMessage(), e);
// 把错误信息写回 Prompt,模型会在下一轮 Thought 中处理
appendToolMessage(prompt, call, "调用失败:" + e.getMessage());
}这种做法比直接抛出异常要友好得多——模型可以在最终回答中说“天气数据获取失败,已为您提供位置信息”,而不是让整个链路直接崩溃。
七、总结
这一篇展示的是一个完整的“MCP + Function Calling”多步推理链路。和之前手动调用的方式相比,这里的工具执行顺序和参数完全由模型决定;和 ReAct 文本驱动的方式相比,这里用的是模型原生的 ToolCall 输出,参数解析更可靠。
核心思路其实就一句话:把 MCP 工具清单交给模型,让模型决定调什么,开发者只负责执行和回写结果。
也许你觉得这段循环逻辑还是有点绕。没关系,下一篇文章会介绍 McpAgentExecutor——用一行代码搞掂整个流程。
