上一篇已经验证了异步工具调用、ToolNode 和 tools_condition。这些组件组合起来,就能形成一条完整的工具调用循环:模型判断是否需要工具,工具节点执行请求,工具结果返回模型,模型再决定继续调用还是结束。
本文把两个公网 MCP Server 接入同一张 LangGraph:
- Bing MCP 负责搜索网页和抓取搜索结果。
- 12306 MCP 负责日期、车站编码、余票、中转和经停站查询。
模型继续使用本地 Qwen3。公网服务负责提供实时数据,本地模型负责选择工具和整理回答。
1. 智能小秘书的完整结构

一次请求可能经过下面的路径:
用户问题
-> 本地 Qwen3
-> tools_condition
-> ToolNode
-> MCP Tool
-> ToolMessage
-> 本地 Qwen3
-> 最终回答
如果问题不需要外部数据,tools_condition 会直接结束流程。如果模型生成 Tool Call,流程进入 ToolNode;工具结果回来后再回到 Qwen3。
2. 启动本地 Qwen3
本文使用现有 .venv_tool_server:
cd source/_posts/llm_learning
source .venv_tool_server/bin/activate
"$VIRTUAL_ENV/bin/python" -m mlx_lm server \
--model model \
--host 127.0.0.1 \
--port 18080 \
--prompt-cache-size 0 \
--chat-template-args '{"enable_thinking": false}'
–prompt-cache-size 0 是本次实测后的必要补充。开启缓存连续测试多个不同问题时,最终回答曾混入上一个车站查询的内容。关闭 Prompt Cache 后,四组独立问题没有再发生串入。
这条命令适用于本文锁定的 mlx-lm==0.31.1。不要直接复制到更早的 MLX-LM 版本,那些版本可能不支持该参数。
3. 配置两个 MCP Server
客户端环境继续使用 .venv_mcp:
source .venv_mcp/bin/activate
python -m pip install -r langgraph/p13_mcp_secretary_workflow/requirements.txt
两个服务放在同一个 MultiServerMCPClient 配置中:
MCP_SERVERS = {
"bing_cn": {
"transport": "http",
"url": "https://mcp.api-inference.modelscope.net/f07308ccca444b/mcp",
},
"railway_12306": {
"transport": "http",
"url": "https://mcp.api-inference.modelscope.net/5699af7157784b/mcp",
},
}
client = MultiServerMCPClient(MCP_SERVERS)
tools = await client.get_tools()
配置名称 bing_cn 和 railway_12306 用于区分连接。真正发送给模型的是 MCP Server 返回的 Tool 名称。
4. 查看 MCP 提供的工具
运行:
python langgraph/p13_mcp_secretary_workflow/01_list_mcp_tools.py
本次连接共返回十个工具:
| MCP Server | 工具 | 作用 |
|---|---|---|
| Bing | bing_search | 根据关键词搜索网页 |
| Bing | crawl_webpage | 根据搜索结果抓取网页正文 |
| 12306 | get-current-date | 获取上海时区日期,解析相对日期 |
| 12306 | get-stations-code-in-city | 查询城市中的全部车站 |
| 12306 | get-station-code-of-citys | 查询城市代表编码 |
| 12306 | get-station-code-by-names | 根据具体车站名查询编码 |
| 12306 | get-station-by-telecode | 根据 Telecode 查询车站详情 |
| 12306 | get-tickets | 查询直达余票 |
| 12306 | get-interline-tickets | 查询中转余票 |
| 12306 | get-train-route-stations | 查询列车经停站 |
外部 MCP 的工具可能调整,因此代码不把“十个”作为永久断言,只检查本案例真正依赖的工具:
REQUIRED_TOOLS = {
"bing_search",
"get-current-date",
"get-station-code-by-names",
"get-tickets",
}
missing_names = REQUIRED_TOOLS - {tool.name for tool in tools}
if missing_names:
raise RuntimeError(f"MCP Server 缺少必需工具:{sorted(missing_names)}")
本次实测中,MCP Adapter 返回的 args_schema 是普通 dict。应直接序列化:
print(json.dumps(current_tool.args_schema, ensure_ascii=False, indent=2))
不能假设它一定是 Pydantic 类并调用 .model_json_schema(),否则会产生 AttributeError。
5. 先直接测试 MCP Tool
在加入模型和 Workflow 前,先直接调用工具。这样能把“外部服务失败”和“模型没有选择工具”分开排查。
MCP_TIMEOUT_SECONDS = 45
tools = {current_tool.name: current_tool for current_tool in await client.get_tools()}
station_result = await asyncio.wait_for(
tools["get-station-code-by-names"].ainvoke(
{"stationNames": "杭州东|上海虹桥"}
),
timeout=MCP_TIMEOUT_SECONDS,
)
station_codes = json.loads(extract_text(station_result))
assert station_codes["杭州东"]["station_code"] == "HGH"
assert station_codes["上海虹桥"]["station_code"] == "AOH"
余票具有时效性,脚本使用运行后三天的日期,只检查接口是否正常返回,不把票价和余票数量写成长期固定结论:
travel_date = (date.today() + timedelta(days=3)).isoformat()
ticket_result = await asyncio.wait_for(
tools["get-tickets"].ainvoke(
{
"date": travel_date,
"fromStation": "HGH",
"toStation": "AOH",
"trainFilterFlags": "G",
"sortFlag": "startTime",
"limitedNum": 2,
"format": "text",
}
),
timeout=MCP_TIMEOUT_SECONDS,
)
如果公网 MCP 在 45 秒内没有返回,asyncio.wait_for() 会结束等待,并由脚本输出清晰错误。这样不会把外部服务偶发延迟误判成程序死循环。
真实稳定输出:
Bing 搜索返回非空: True
杭州东车站编码: HGH
上海虹桥车站编码: AOH
余票查询返回非空: True
6. 创建智能小秘书 Workflow
03_secretary_workflow.py 的核心代码如下:
async def create_secretary_graph():
"""异步加载 MCP Tools,再编译模型与工具循环。"""
mcp_client = MultiServerMCPClient(MCP_SERVERS)
tools = await mcp_client.get_tools()
tool_names = {current_tool.name for current_tool in tools}
missing_names = REQUIRED_TOOLS - tool_names
if missing_names:
raise RuntimeError(f"MCP Server 缺少必需工具:{sorted(missing_names)}")
tools_by_name = {current_tool.name: current_tool for current_tool in tools}
http_client = httpx.Client(trust_env=False)
model = ChatOpenAI(
model="default_model",
base_url="http://127.0.0.1:18080/v1",
api_key="not-needed",
temperature=0,
max_tokens=512,
http_client=http_client,
)
model_with_tools = model.bind_tools(tools)
model_with_bing_search = model.bind_tools(
[tools_by_name["bing_search"]],
tool_choice="required",
)
def chatbot(state: MessagesState) -> dict:
system_message = SystemMessage(
content=(
"你是一个中文智能小秘书。需要互联网资料时使用 Bing 工具;"
"需要火车站或余票信息时使用 12306 工具。"
"相对日期必须先调用 get-current-date。"
"不要编造工具没有返回的信息。用户明确要求不调用工具时直接回答。"
)
)
last_message = state["messages"][-1]
if isinstance(last_message, HumanMessage) and "搜索" in last_message.content:
response = model_with_bing_search.invoke(
[system_message, *state["messages"]]
)
else:
response = model_with_tools.invoke([system_message, *state["messages"]])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "chatbot")
builder.add_conditional_edges(
"chatbot",
tools_condition,
{"tools": "tools", "__end__": END},
)
builder.add_edge("tools", "chatbot")
return builder.compile()
MCP Tools 需要异步加载,因此图工厂也是 async def。ToolNode 可以执行这些异步 MCP Tools。
7. 为什么搜索分支只绑定一个工具
第一次实测时,Qwen3 收到了全部十个工具。用户明确要求“搜索 LangGraph ToolNode”,模型却没有调用 bing_search,而是直接生成了一段不可靠的回答。
仅设置:
tool_choice="bing_search"
在本文锁定的 MLX-LM 环境中也没有稳定生效。最终使用的方式是:
model.bind_tools(
[tools_by_name["bing_search"]],
tool_choice="required",
)
当用户明确写出“搜索”时,只向模型暴露 bing_search,并要求必须调用。其他问题仍使用完整工具集合。
这不是替模型执行工具,而是应用层根据明确用户意图缩小可选范围。执行仍由 ToolNode 和 MCP Server 完成。
8. 连续三次调用 12306 工具
“三天后杭州东到上海虹桥最早的两趟高铁余票”不是一个 Tool Call 就能完成。

模型依次需要:
- 调用 get-current-date,把“三天后”转换成日期。
- 调用 get-station-code-by-names,得到 HGH 和 AOH。
- 调用 get-tickets,带上日期、车站编码、车型和排序条件。
每次 Tool Call 执行后,ToolMessage 都回到 chatbot。模型读取已有结果,再生成下一个 Tool Call。
图中的三个工具不是彼此直接调用。每一步都完整经过一次“Qwen3 生成 Tool Call → ToolNode 执行 MCP Tool → ToolMessage 返回 Qwen3”的循环,后一个工具的参数来自模型对前面结果的读取和整理。
9. 运行完整 Workflow
运行:
python langgraph/p13_mcp_secretary_workflow/04_run_secretary_workflow.py
运行脚本会断言每组问题必须出现预期工具,防止“模型直接编造答案”被误写成成功结果。
一次实测的稳定字段与模型回答如下:
用户问题:请用一句话说明智能小秘书能做什么,不要调用工具。
最终回答:智能小秘书可以协助您查询火车票余票、搜索互联网信息、获取车站代码、查询列车路线等。
用户问题:请查询杭州东和上海虹桥的车站编码。
调用工具: get-station-code-by-names
工具参数: {'stationNames': '杭州东|上海虹桥'}
工具结果已返回: True
最终回答:杭州东的车站编码是 **HGH**,上海虹桥的车站编码是 **AOH**。
用户问题:请搜索 LangGraph ToolNode 的作用,并用一句中文总结。
调用工具: bing_search
工具参数: {'query': 'LangGraph ToolNode 的作用', 'count': 5}
工具结果已返回: True
最终回答:LangGraph 的 ToolNode 用于在构建有状态、多步骤的 LLM 应用时,提供一个低层级的 Agent 编排框架,帮助开发者管理复杂的 AI 工作流。
用户问题:请查询三天后杭州东到上海虹桥最早的两趟高铁余票。
调用工具: get-current-date
调用工具: get-station-code-by-names
调用工具: get-tickets
最终回答非空: True
最后一组不展示实时日期、余票和票价,只保留调用顺序。模型回答存在非确定性,工具名称、参数结构和调用链才是本案例需要稳定验证的部分。
10. 常见问题
10.1 MCP Server 无法连接
公网服务可能超时、下线或调整 Schema。先运行 01_list_mcp_tools.py 和 02_test_mcp_tools.py,不要一开始就在完整 Workflow 中排查。
如果底层返回 HTTPStatusError: 410 Gone,表示 ModelScope 上对应的临时部署已被移除,不是本地 Qwen3、LangGraph 或 VPN 导致的错误。获取新的 MCP 地址后,替换三个脚本顶部 MCP_SERVERS 中的 url,再从直接调用开始验证。
MultiServerMCPClient 会并发初始化多个连接,因此底层错误可能包在 ExceptionGroup 中。本文脚本会递归展开子异常,直接显示真正的 HTTP 状态码,而不是只输出 unhandled errors in a TaskGroup。
10.2 args_schema 没有 model_json_schema
本次实测返回普通字典。使用 json.dumps(tool.args_schema),不要假设所有 Tool Schema 都是 Pydantic 类。
10.3 模型没有调用 Bing
OpenAI 兼容接口不保证每个 tool_choice 形式都被推理服务完整支持。本文对明确搜索请求只绑定 bing_search 并使用 required。
10.4 多个问题的内容互相串入
本文锁定的 MLX-LM 在 Prompt Cache 开启时曾出现上一题内容混入。启动服务时加入 –prompt-cache-size 0 后重新验证。
10.5 本机请求被 VPN 转发
本地 Qwen3 使用 httpx.Client(trust_env=False),确保 127.0.0.1 不经过系统代理。公网 MCP 仍正常使用网络连接。
11. 总结
| 知识点 | 作用 | 本文验证结果 |
|---|---|---|
| MultiServerMCPClient | 同时管理多个 MCP Server | 成功连接 Bing 与 12306 |
| MCP Tool Schema | 告诉模型工具参数结构 | 本次实测返回普通 dict |
| bind_tools() | 把 MCP Tools 交给 Qwen3 | 本地模型收到十个工具 |
| ToolNode | 执行 MCP Tool并生成 ToolMessage | 搜索、编码、余票均执行成功 |
| tools_condition | 在工具循环和结束之间路由 | 无 Tool Call 时直接结束 |
| 必需工具检查 | 防止外部服务变更后静默失败 | 缺少工具时抛出明确错误 |
| 搜索工具约束 | 防止模型跳过搜索直接编造 | bing_search 实际被调用 |
| 多步工具循环 | 用已有工具结果准备下一次调用 | 日期、车站、余票连续完成 |
| Prompt Cache | 影响连续独立请求的上下文 | 关闭后未再出现内容串入 |
| 实时数据 | 输出会随运行时间变化 | 只保留稳定调用链和字段 |
智能小秘书的核心不是“绑定很多工具”,而是让模型、工具节点、外部 MCP 和状态路由形成可验证的循环。下一篇会在工具真正产生副作用前暂停流程,加入第一种人工审批方式。