refactor(module11): restructure MCP client implementation and migrate to chapter 11
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
async def run_agent_with_streaming(app,query:str):
|
||||
"""
|
||||
通用流式运行器,负责将 LangGraph 的运行过程可视化输出到控制台
|
||||
|
||||
:param app: 编译好的 LangGraph 应用 (workflow.compile())
|
||||
:param query: 用户输入的问题
|
||||
"""
|
||||
print(f'\n用户:{query}\n')
|
||||
print("🤖 AI:",end="",flush=True)
|
||||
|
||||
# 构造输入消息
|
||||
inputs = {"messages":[HumanMessage(content=query)]}
|
||||
|
||||
# 核心:监听v2版本的事件流(相比v1更全面)
|
||||
async for event in app.astream_events(inputs,version="v2"):
|
||||
kind = event["event"]
|
||||
|
||||
# 1.监听LLM的流式吐字(嘴在动)
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = event["data"]["chunk"]
|
||||
# 过滤掉空的chunk(有时工具调用会产生空内容)
|
||||
if chunk.content:
|
||||
print(chunk.content,end="",flush=True)
|
||||
|
||||
# 2.监听工具开始调用(手在动)
|
||||
elif kind == "on_tool_start":
|
||||
tool_name = event["name"]
|
||||
# 不打印内部包装,只打印自定义的工具
|
||||
if not tool_name.startswith("_"):
|
||||
print(f"\n\n🔨 正在调用工具: {tool_name} ...")
|
||||
|
||||
# 3.监听工具调用结束(拿到结果)
|
||||
elif kind == "on_tool_end":
|
||||
tool_name = event["name"]
|
||||
if not tool_name.startswith("_"):
|
||||
print(f"✅ 调用完成,继续思考...\n")
|
||||
print("🤖 AI: ", end="", flush=True)
|
||||
print("\n\n😊 输出结束!")
|
||||
@@ -1,98 +0,0 @@
|
||||
from typing import Dict,Any,Type
|
||||
from langchain_core.tools import StructuredTool
|
||||
from m10_mcp_basics.mcp_client import MCPClient
|
||||
from pydantic import Field,create_model
|
||||
|
||||
class LangChainMCPAdapter:
|
||||
"""
|
||||
MCP适配器:将MCP客户端无缝转换为LangChain可用的工具集。
|
||||
实现了上下文管理器协议,
|
||||
"""
|
||||
def __init__(self,mcp_client:MCPClient):
|
||||
self.client = mcp_client
|
||||
|
||||
async def __aenter__(self):
|
||||
"""进入上下文,自动建立连接"""
|
||||
await self.client.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self,exc_type,exc_value,exc_tb):
|
||||
"""退出上下文,自动清理资源"""
|
||||
await self.client.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _schema_to_pydantic(name:str,schema:Dict[str,Any]):
|
||||
"""
|
||||
将MCP的JSON Schema动态转换为Pydantic模型
|
||||
这是让LLM理解参数要求的关键
|
||||
"""
|
||||
# print(f"🔧 调试: 工具 '{name}' 的 inputSchema = {schema}") # 查看 MCP 返回的原始 inputSchema
|
||||
|
||||
# 所有参数定义
|
||||
properties = schema.get("properties",{}) # 允许为空
|
||||
# 必需字段
|
||||
required = schema.get("required",[]) # 允许为空
|
||||
|
||||
# 初始空字典
|
||||
fields = {}
|
||||
|
||||
# 类型映射表:将JSON类型映射为Python类型
|
||||
type_map = {
|
||||
"string":str,
|
||||
"integer":int,
|
||||
"number":float,
|
||||
"boolean":bool,
|
||||
"array":list,
|
||||
"object":dict
|
||||
}
|
||||
|
||||
for field_name,field_info in properties.items():
|
||||
# 1.获取字段类型
|
||||
json_type = field_info.get("type","string")
|
||||
python_type = type_map.get(json_type,Any)
|
||||
|
||||
# 2.获取描述
|
||||
description = field_info.get("description","")
|
||||
|
||||
# 3.是否为必需项
|
||||
# 如果是必填,默认值为 ... (Ellipsis): 否则为None
|
||||
if field_name in required:
|
||||
default_value = ...
|
||||
else:
|
||||
default_value = None
|
||||
|
||||
# 4.构建Pydantic字段定义
|
||||
fields[field_name] = (python_type,Field(default=default_value,description=description))
|
||||
|
||||
# 动态创建一个Pydantic模型类
|
||||
return create_model(f"{name}Schema",**fields)
|
||||
|
||||
async def get_tools(self):
|
||||
"""
|
||||
核心方法:获取并转换工具
|
||||
返回的是标准的LangChain Tool列表,可以直接喂给bind_tools
|
||||
"""
|
||||
# 从MCP Server 获取原始工具列表
|
||||
mcp_tools = await self.client.list_tools()
|
||||
langchain_tools = []
|
||||
|
||||
|
||||
for tool_info in mcp_tools:
|
||||
# 1.动态生成参数模型 -- 要处理schema为空的情况
|
||||
# inputSchema一般会放好MCP各种工具/参数的介绍
|
||||
raw_schema = tool_info.get("input_schema",{})
|
||||
args_model = self._schema_to_pydantic(tool_info["name"],raw_schema)
|
||||
# 2.定义执行函数
|
||||
async def _dynamic_tool_func(tool_name=tool_info["name"],**kwargs):
|
||||
# ⚠️:必须绑定 tool_name 默认参数,否则循环会覆盖 tool_name
|
||||
return await self.client.call_tool(tool_name,kwargs)
|
||||
|
||||
# 3.包装成llm可调用的工具(注入args_schema)
|
||||
tool = StructuredTool.from_function(
|
||||
coroutine=_dynamic_tool_func,
|
||||
name=tool_info["name"],
|
||||
description=tool_info["description"],
|
||||
args_schema=args_model # 把说明书传给 LangChain
|
||||
)
|
||||
langchain_tools.append(tool)
|
||||
return langchain_tools
|
||||
@@ -1,73 +0,0 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional
|
||||
from mcp import ClientSession,StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self,command:str,args:list[str],env:dict=None):
|
||||
# MCP启动方式(npx/uvx/python -m xxx)
|
||||
self.params = StdioServerParameters(command=command,args=args,env=env)
|
||||
# 工程核心:资源栈
|
||||
self.exit_stack = AsyncExitStack()
|
||||
# 连接会话(长连接)
|
||||
self.session:Optional[ClientSession]=None
|
||||
|
||||
async def connect(self):
|
||||
"""建立MCP长连接(一次连接,多次调用)"""
|
||||
if self.session:
|
||||
return # 已连接无需重复
|
||||
|
||||
# 进入transport(读/写管道)
|
||||
transport = await self.exit_stack.enter_async_context(
|
||||
stdio_client(self.params)
|
||||
)
|
||||
# 创建JSON-RPC对话
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(transport[0],transport[1])
|
||||
)
|
||||
# 等待MCP服务器返回工具清单
|
||||
await self.session.initialize()
|
||||
|
||||
async def list_tools(self):
|
||||
"""查询工具列表,为LLM建立上下文用"""
|
||||
if not self.session:
|
||||
raise RuntimeError("未连接,请先 connect()")
|
||||
|
||||
result = await self.session.list_tools()
|
||||
|
||||
# 🔍 调试:打印工具的完整信息,确认工具是否被正确封装
|
||||
# if result.tools:
|
||||
# import json
|
||||
# # 使用 model_dump() (Pydantic v2) 或 dict() (v1) 查看原始数据
|
||||
# first_tool = result.tools[0]
|
||||
# print(f"\n🔍 [DEBUG] 原始工具数据: {first_tool}\n")
|
||||
|
||||
# 转为纯字典,LLM能读
|
||||
return[
|
||||
{
|
||||
"name":tool.name,
|
||||
"description":tool.description,
|
||||
"input_schema":tool.inputSchema
|
||||
}
|
||||
for tool in result.tools
|
||||
]
|
||||
|
||||
async def call_tool(self,name:str,args:dict):
|
||||
"""调用工具(工程化:加上防御性处理)"""
|
||||
if not self.session:
|
||||
raise RuntimeError("未连接,请先connect()")
|
||||
|
||||
result = await self.session.call_tool(name,args)
|
||||
|
||||
# 有些工具可能执行成功但无文本返回
|
||||
if hasattr(result,"content") and result.content:
|
||||
return result.content[0].text
|
||||
|
||||
return "工具执行成功,但无文本返回"
|
||||
|
||||
async def cleanup(self):
|
||||
"""关闭MCP服务、会话和transport"""
|
||||
if self.session:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
@@ -1,129 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
import asyncio
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langgraph.graph import StateGraph,MessagesState,START,END
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
from config import OPENAI_API_KEY,AMAP_MAPS_API_KEY
|
||||
from m10_mcp_basics.agent_stream import run_agent_with_streaming
|
||||
from m10_mcp_basics.mcp_client import MCPClient
|
||||
from m10_mcp_basics.mcp_bridge import LangChainMCPAdapter
|
||||
|
||||
|
||||
|
||||
# ===环境配置===
|
||||
# 环境兼容
|
||||
COMMAND = "npx.cmd" if sys.platform == "win32" else "npx"
|
||||
# 复制当前py进程的环境变量,并在复制的环境变量里新增一条,确保安全可控
|
||||
env_vars = os.environ.copy()
|
||||
env_vars["AMAP_MAPS_API_KEY"] = AMAP_MAPS_API_KEY
|
||||
|
||||
MCP_SERVER_CONFIGS = [
|
||||
{
|
||||
"name":"高德地图", # 打印使用了什么MCP,可移除
|
||||
"command":COMMAND,
|
||||
"args":["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env":env_vars
|
||||
}
|
||||
# {...} 之后MCP工具可随需求扩展增加
|
||||
]
|
||||
|
||||
# ===构建图逻辑===
|
||||
def build_graph(available_tools):
|
||||
"""
|
||||
这个函数只认tools列表,不关心tools的来源
|
||||
"""
|
||||
if not available_tools:
|
||||
print('⚠️ 当前没有注入任何工具,Agent将仅靠LLM回答。')
|
||||
llm = ChatOpenAI(
|
||||
model="deepseek-chat",
|
||||
api_key=OPENAI_API_KEY,
|
||||
base_url="https://api.deepseek.com",
|
||||
streaming=True
|
||||
)
|
||||
# 如果没工具,bind_tools 会被忽略或处理,LangGraph同样能正常跑纯对话
|
||||
llm_with_tools = llm.bind_tools(available_tools) if available_tools else llm
|
||||
|
||||
|
||||
sys_prompt = """
|
||||
你是一个专业的地理位置服务助手。
|
||||
1. 当用户查询模糊地点(如"西站")时,会优先使用相关工具获取具体经纬度或标准名称。
|
||||
2. 如果用户查询"附近"的店铺,请先确定中心点的坐标或具体位置,再进行搜索。
|
||||
3. 调用工具时,参数要尽可能精确。
|
||||
"""
|
||||
|
||||
async def agent_node(state:MessagesState):
|
||||
messages = [SystemMessage(content=sys_prompt)] + state["messages"]
|
||||
# ainvoke:异步调用版的invoke
|
||||
return {"messages":[await llm_with_tools.ainvoke(messages)]}
|
||||
|
||||
workflow = StateGraph(MessagesState)
|
||||
workflow.add_node("agent",agent_node)
|
||||
|
||||
# 动态逻辑:如果有工具才加工具节点,否则就是纯对话
|
||||
if available_tools:
|
||||
tool_node = ToolNode(available_tools)
|
||||
workflow.add_node("tools",tool_node)
|
||||
|
||||
def should_continue(state:MessagesState):
|
||||
last_msg = state["messages"][-1]
|
||||
if hasattr(last_msg,"tool_calls") and last_msg.tool_calls:
|
||||
return "tools"
|
||||
return END
|
||||
|
||||
workflow.add_edge(START,"agent")
|
||||
workflow.add_conditional_edges("agent",should_continue,{"tools":"tools",END:END})
|
||||
workflow.add_edge("tools","agent")
|
||||
else:
|
||||
workflow.add_edge(START,"agent")
|
||||
workflow.add_edge("agent",END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
# ===MCP工具批量初始化===
|
||||
async def load_mcp_tools(stack:AsyncExitStack,configs:list):
|
||||
"""
|
||||
负责遍历配置,批量建立连接,收集所有工具。
|
||||
使用stack将连接生命周期托管给上层
|
||||
"""
|
||||
all_tools = []
|
||||
for conf in configs:
|
||||
print(f'🔌 正在连接:{conf["name"]}...')
|
||||
# 初始化 Client
|
||||
client = MCPClient(
|
||||
command=conf["command"],
|
||||
args=conf["args"],
|
||||
env=conf.get("env") # 可选参数
|
||||
)
|
||||
# 🔥:enter_async_context 替代了async with 缩进
|
||||
# 这样无论有多少个MCP,代码层级都不会变深
|
||||
adapter = await stack.enter_async_context(LangChainMCPAdapter(client))
|
||||
# 批量获取一个MCP下的所有工具
|
||||
tools = await adapter.get_tools()
|
||||
print(f' ✅️ 获取工具{[t.name for t in tools]}')
|
||||
all_tools.extend(tools)
|
||||
|
||||
return all_tools
|
||||
|
||||
# ===主程序===
|
||||
async def main():
|
||||
# 使用ExitStack统一管理所有资源的关闭
|
||||
async with AsyncExitStack() as stack:
|
||||
# A.插件(MCP)注入阶段 -- 允许为空
|
||||
dynamic_tools = await load_mcp_tools(stack,MCP_SERVER_CONFIGS)
|
||||
|
||||
# B.图构建阶段
|
||||
app = build_graph(available_tools=dynamic_tools)
|
||||
|
||||
# C.运行阶段(流式)
|
||||
query = "帮我查一下杭州西湖附近的酒店"
|
||||
await run_agent_with_streaming(app,query)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -1,19 +0,0 @@
|
||||
from mcp import ClientSession,StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
class SimpleClient:
|
||||
def __init__(self,command:str,args:list[str],env:dict=None):
|
||||
# 指定要启动的工具和参数
|
||||
self.params = StdioServerParameters(command=command,args=args,env=env)
|
||||
|
||||
async def run_once(self,tool_name:str,tool_args:dict):
|
||||
# 语法糖: async with 自动帮我们 打开连接 -> 运行 -> 关闭连接
|
||||
async with stdio_client(self.params) as (read,write):
|
||||
# 建立父子进程管道(stdin/stdout)
|
||||
async with ClientSession(read,write) as session:
|
||||
# 用JSON-RPC与工具对话
|
||||
await session.initialize()
|
||||
|
||||
# 直接调用工具
|
||||
result = await session.call_tool(tool_name,tool_args)
|
||||
return result.content[0].text
|
||||
@@ -1,24 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from m10_mcp_basics.simple_client import SimpleClient
|
||||
from config import AMAP_MAPS_API_KEY
|
||||
|
||||
# 复制当前py进程的环境变量,并在复制的环境变量里新增一条,确保安全可控
|
||||
env_vars = os.environ.copy()
|
||||
env_vars["AMAP_MAPS_API_KEY"] = AMAP_MAPS_API_KEY
|
||||
|
||||
async def main():
|
||||
print('🔥 正在进行单次调用...')
|
||||
client = SimpleClient(
|
||||
command="npx",
|
||||
args=["-y","@amap/amap-maps-mcp-server",AMAP_MAPS_API_KEY],
|
||||
env=env_vars
|
||||
)
|
||||
|
||||
# 这一步会经历:启动进程 - 握手 - 调用 - 杀进程
|
||||
result = await client.run_once("maps_text_search", {"keywords": "北京大学"})
|
||||
print(f'✅️ 结果:{result[:300]}')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user