LangGraph 系列 13:使用 Bing 与 12306 MCP 构建智能小秘书 Workflow


上一篇已经验证了异步工具调用、ToolNode 和 tools_condition。这些组件组合起来,就能形成一条完整的工具调用循环:模型判断是否需要工具,工具节点执行请求,工具结果返回模型,模型再决定继续调用还是结束。

本文把两个公网 MCP Server 接入同一张 LangGraph:

  • Bing MCP 负责搜索网页和抓取搜索结果。
  • 12306 MCP 负责日期、车站编码、余票、中转和经停站查询。

模型继续使用本地 Qwen3。公网服务负责提供实时数据,本地模型负责选择工具和整理回答。

1. 智能小秘书的完整结构

双 MCP 智能小秘书 Workflow

一次请求可能经过下面的路径:

用户问题
-> 本地 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 就能完成。

余票问题中的连续工具调用

模型依次需要:

  1. 调用 get-current-date,把“三天后”转换成日期。
  2. 调用 get-station-code-by-names,得到 HGH 和 AOH。
  3. 调用 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 和状态路由形成可验证的循环。下一篇会在工具真正产生副作用前暂停流程,加入第一种人工审批方式。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录