trip_server.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. # 【启动 S3】本文件是独立的 A2A 子助手进程:监听 5007,调用 MCP 工具服务 8003。
  2. # 启动时先执行模块顶层配置、模型对象和代理卡片定义,再进入 __main__ 创建服务器。
  3. # 类/函数定义本身不会执行查询;run_server 开始监听后,有 Task 到达才进入 handle_task。
  4. # 主流程 7:接收任务 → 加载工具 → 模型选择工具和参数 → 执行器调用工具(主流程 8)。
  5. # 主流程 9:工具结果交回模型生成回复 → 封装 Task 状态及 artifacts → 返回主助手。
  6. """
  7. 需求:实现基于A2A的差旅管家服务器,处理用户的商务用车、会务套餐、保险查询及预订请求
  8. 架构说明:
  9. 本服务器是 SmartBizTrip 系统中的又一个子代理(Sub-Agent),负责处理所有差旅相关任务。
  10. 它运行在独立的进程中(localhost:5007),通过 A2A(Agent2Agent)协议与主助手通信。
  11. 与 ticket_server.py 和 weather_server.py 保持一致的架构模式:
  12. - 使用 LangChain Agent + MCP Tools 模式
  13. - LLM 从自然语言中提取参数(城市、日期、类型等)
  14. - 调用 MCP Server 的参数化工具(端口 8003)
  15. - MCP Server 返回结果后,LLM 将结果格式化为友好的中文回复
  16. 工作流程:
  17. 1. 主助手通过 A2A 协议向本服务器发送任务(Task)
  18. 2. 本服务器收到任务后,提取用户的自然语言查询
  19. 3. 使用 LangChain Agent 处理查询:
  20. a. LLM 分析用户输入,决定调用哪个 MCP 工具
  21. b. LangChain 自动调用 MCP Server 的工具(端口 8003)
  22. c. 工具返回结果后,LLM 将结果格式化为友好的中文回复
  23. 4. 将结果返回给主助手
  24. MCP Server(端口 8003)提供的工具包括:
  25. - 商务用车查询工具、会务套餐查询工具、保险查询工具
  26. - 商务用车预订工具、会务套餐预订工具、保险购买工具
  27. 架构图(一次请求的调用关系):
  28. 主助手 → [A2A :5007] TripServer.handle_task
  29. ↓
  30. query_trip → 加载 MCP 工具定义
  31. ↓
  32. AgentExecutor → 模型选择工具和参数
  33. ↓
  34. [MCP :8003] 执行所选工具
  35. ├─ 用车/保险查询 → MySQL
  36. ├─ 会务套餐查询 → Embedding + Milvus
  37. └─ 预订工具 → 模拟结果
  38. ↓
  39. 工具结果 → 模型整理 → Task → 主助手
  40. 请求/响应流向:
  41. 用户 → 主助手 → [A2A] → TripServer.handle_task()
  42. ↓
  43. query_trip() → LangChain Agent (LLM)
  44. ↓
  45. MCP Tools → MCP Server :8003
  46. ↓
  47. MCP Server 返回工具结果
  48. ↓
  49. LLM 格式化为中文回复
  50. ↓
  51. handle_task() 设置 TaskStatus
  52. ↓
  53. 主助手 → 用户
  54. """
  55. # ==================== 导入依赖 ====================
  56. import asyncio # 异步 IO 库
  57. # create_tool_calling_agent: 创建基于工具调用的 Agent
  58. # AgentExecutor: Agent 执行器,负责运行 Agent 循环
  59. from datetime import datetime # 时间处理
  60. import pytz # 时区库
  61. from langchain.agents import AgentExecutor, create_tool_calling_agent
  62. from langchain_core.prompts import ChatPromptTemplate # 提示模板
  63. from langchain_core.tools import StructuredTool # 【新改动1】支持多个具名参数的工具
  64. from langchain_mcp_adapters.tools import load_mcp_tools # 从 MCP 会话加载工具
  65. # A2AServer: A2A 服务器基类
  66. # run_server: 启动服务器的函数
  67. # AgentCard: 代理卡片
  68. # AgentSkill: 代理技能
  69. # TaskStatus: 任务状态对象
  70. # TaskState: 任务状态枚举(COMPLETED / FAILED / INPUT_REQUIRED)
  71. from langchain_openai import ChatOpenAI # LangChain 的大模型接口
  72. from python_a2a import (
  73. A2AServer,
  74. AgentCard,
  75. AgentSkill,
  76. MCPClient,
  77. TaskState,
  78. TaskStatus,
  79. run_server,
  80. to_langchain_tool,
  81. )
  82. from config import Config # 项目配置
  83. from create_logger import logger # 日志模块
  84. conf = Config() # 全局配置实例
  85. # ==================== 初始化大模型 ====================
  86. # 创建一个 LLM 实例,供 Agent 使用
  87. # 【启动 S3.1】创建模型客户端;模型推理在后面的 invoke/ainvoke 才发生。
  88. llm = ChatOpenAI(
  89. model=conf.model_name,
  90. base_url=conf.base_url,
  91. api_key=conf.api_key,
  92. temperature=conf.temperature,
  93. )
  94. # ==================== MCP 客户端配置 ====================
  95. # MCP Server 运行在 8003 端口,提供差旅相关的工具(查询 + 预订)
  96. MCP_URL = "http://127.0.0.1:8003"
  97. # ==================== 差旅查询函数 ====================
  98. async def query_trip(conversation: str) -> dict:
  99. """
  100. 通过 LangChain Agent + MCP Tools 执行差旅查询或预订
  101. 与 ticket_server.py 的 handle_task 一样使用工具调用模式:
  102. 1. 连接 MCP Server 加载所有可用的工具
  103. 2. 创建一个 LangChain Agent,让它自主选择调用哪个工具
  104. 3. AgentExecutor 负责执行 Agent 循环
  105. 参数:
  106. conversation (str): 用户的查询内容,例如:
  107. "我想租一辆车,北京,明天"
  108. "有没有成都客户拜访的会务套餐"
  109. "买一份差旅保险"
  110. "帮我预订北京政企拜访3日套餐"
  111. 返回值:
  112. dict: 查询结果,格式为:
  113. - {"status": "success", "message": "格式化后的查询结果"}
  114. - {"status": "error", "message": "错误信息"}
  115. """
  116. # 调用者:本文件的 handle_task。conversation 是整个任务文本,可能包含历史和当前需求。
  117. # 输出:字典 {"status": "success"/"error", "message": "回复或错误文本"},由 handle_task 再转成 A2A 状态。
  118. # 这里的 success 表示执行器返回了结果,不等于工具一定查到了数据。
  119. try:
  120. # 【主流程 7.2】连接 MCP 读取工具定义;此处尚未执行具体业务查询。
  121. tools = to_langchain_tool(MCP_URL)
  122. # 【新改动1】保留 MCP 参数定义,将单参数 Tool 转为 StructuredTool,
  123. # 使取车城市、还车城市、日期等参数能按名称传给原工具函数。
  124. # 【主流程 7.3】把工具参数结构交给 StructuredTool,保留原 MCP 调用函数。
  125. # 模型输出参数名称和值,执行器按照这个结构传参。
  126. tools = [
  127. StructuredTool(
  128. # 工具对象的五个字段各有用途:name 用于匹配模型选中的工具,description 帮助模型理解用途;
  129. # func 保存真正发起 MCP 调用的函数;args_schema 规定参数名/类型;metadata 保留原工具信息。
  130. # 列表里的每一项转换成独立 StructuredTool,模型随后可在这些工具中选择。
  131. name=tool.name,
  132. description=tool.description,
  133. func=tool.func,
  134. args_schema=tool.metadata["parameters"],
  135. metadata=tool.metadata,
  136. )
  137. for tool in tools
  138. ]
  139. # 定义 Agent 的系统 Prompt
  140. # 提示模板分三部分:system 给规则和当前日期,human 放本次输入,placeholder 放执行过程消息。
  141. # {input}/{current_date} 由 invoke/ainvoke 的参数提供;{agent_scratchpad} 由 AgentExecutor 随工具调用更新。
  142. # 占位符不是立刻执行的代码,只是在实际请求模型前被替换成消息内容。
  143. prompt = ChatPromptTemplate.from_messages(
  144. [
  145. (
  146. "system",
  147. """你是一个差旅管家助手,能够调用工具来完成商务用车查询/预定、会务套餐查询/预订、保险查询/购买。
  148. 你需要仔细分析用户的问题,从问题中提取工具需要的参数,然后调用对应的工具。
  149. 如果用户提供的信息不足以提取到调用工具所有必要参数,则向用户追问,以获取该信息。不能自己编撰参数。预订工具仅用于模拟演示,回复必须明确说明模拟、未实际下单,不得声称已出票或生成保单。
  150. 查询到结果后,请用清晰的中文格式化输出,格式如下:
  151. - 商务用车:取车城市 到 还车城市 日期: 车型XX,公司XX,价格XX元/天,余量XX
  152. - 会务套餐:城市 套餐名称: 天数XX天,价格XX元,余位XX/总数XX,亮点XX,出发日期列表
  153. - 保险:保险类型: 名称XX,价格XX元/份,保障范围XX,保险公司XX
  154. 预定成功后请直接告知用户预定结果。
  155. 当前日期是{current_date}。""",
  156. ),
  157. ("human", "{input}"), # 用户输入会替换到这里
  158. ("placeholder", "{agent_scratchpad}"), # Agent 思考过程的占位符
  159. ]
  160. )
  161. # 创建基于工具调用的 Agent
  162. # 【主流程 7.4】组装 Agent,随后创建执行器;此时尚未调用模型处理本次请求。
  163. # agent_scratchpad 承载本轮工具调用与工具返回消息,不是跨轮用户记忆。
  164. agent = create_tool_calling_agent(llm, tools, prompt)
  165. # 创建 Agent 执行器
  166. # agent 负责根据输入和已有工具结果产生下一步动作;executor 负责运行这个过程。
  167. # 若模型请求工具,executor 按工具名找到对象并调用 func,再把返回值交回模型;直到得到最终回答。
  168. # verbose=True 打印执行过程,便于观察调用轨迹,不代表业务结果会自动保存到记忆或数据库。
  169. agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
  170. # 获取当前日期,注入到 Prompt 中
  171. current_date = datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d")
  172. # 执行 Agent
  173. # 【主流程 7.5 → 8】执行器开始循环:问模型 → 执行所选工具 → 将结果交回模型。
  174. # await 等待本次循环的最终结果;此处返回一个完整回复,不直接向主助手推送每个片段。
  175. response = await agent_executor.ainvoke(
  176. {"input": conversation, "current_date": current_date}
  177. )
  178. # ainvoke 已经完成整轮工具循环,response["output"] 是模型整理后的文字。
  179. # 将它包装成普通字典返回给 handle_task,后者再决定 COMPLETED、INPUT_REQUIRED 或 FAILED。
  180. return {"status": "success", "message": response["output"]}
  181. except ExceptionGroup as eg:
  182. # MCP 工具内部使用 anyio.create_task_group(),失败时抛出 ExceptionGroup
  183. first_exc = eg.exceptions[0] if eg.exceptions else eg
  184. logger.error(f"差旅 MCP 查询出错:{first_exc}")
  185. return {"status": "error", "message": f"差旅 MCP 查询出错:{first_exc}"}
  186. except BaseException as e:
  187. # 兜底捕获(包括 ExceptionGroup 等 BaseException 子类)
  188. logger.error(f"差旅 MCP 查询出错:{str(e)}")
  189. return {"status": "error", "message": f"差旅 MCP 查询出错:{str(e)}"}
  190. # ==================== Agent Card(代理卡片) ====================
  191. # 描述差旅管家代理的能力、技能等信息
  192. # 【启动 S3.2】定义对外说明。skills 是能力介绍,实际可执行工具来自 MCP。
  193. # 主助手当前按 Config.intent 路由;卡片声明的能力也不等于此方法已实现逐片输出。
  194. # 卡片字段说明:name 是代理标识;url 是访问地址;description/skills 向调用方介绍能力。
  195. # 这里描述技能不会自动生成对应 Python 函数,实际工具仍由 MCP 服务注册和提供。
  196. # 主助手 Config.intent 中的名称、AgentNetwork 的注册名称和这里的 name 要相互对应。
  197. agent_card = AgentCard(
  198. name="TripAssistant", # 代理名称
  199. description="基于 LangChain 提供差旅管家服务的统一助手,支持商务用车、会务套餐、保险的查询与预订", # 代理描述
  200. url="http://localhost:5007", # 代理的访问地址
  201. version="1.0.0", # 版本号
  202. capabilities={"streaming": True, "memory": True}, # 支持的能力
  203. skills=[
  204. # 技能1:商务用车查询
  205. AgentSkill(
  206. name="query car rental",
  207. description="查询商务用车信息,支持指定取车城市、还车城市、日期和车型",
  208. examples=[
  209. "商务用车 成都 成都 2026-05-01",
  210. "成都明天安排一辆商务SUV接送客户",
  211. ],
  212. ),
  213. # 技能2:会务套餐查询(语义搜索)
  214. AgentSkill(
  215. name="query meeting package",
  216. description="通过语义搜索查询会务套餐信息,支持自然语言描述需求(如成都办数字化转型峰会,需要接送和酒店)和可选的城市过滤",
  217. examples=[
  218. "成都办数字化转型峰会,需要接送和酒店",
  219. "适合销售团队的短途会务套餐",
  220. "客户接待晚宴方案",
  221. "北京政企拜访会务套餐",
  222. ],
  223. ),
  224. # 技能3:保险查询
  225. AgentSkill(
  226. name="query insurance",
  227. description="查询差旅保险产品,支持指定保险类型和日期",
  228. examples=["差旅保险 2025-08-01", "买一份综合型差旅保险"],
  229. ),
  230. # 技能4:商务用车预订
  231. AgentSkill(
  232. name="order car rental",
  233. description="根据日期、车型和数量预订商务用车服务",
  234. examples=["预订商务用车 2025-08-01 SUV 1辆"],
  235. ),
  236. # 技能5:会务套餐预订
  237. AgentSkill(
  238. name="order meeting package",
  239. description="根据日期、套餐名称和人数预订会务套餐",
  240. examples=["预订成都客户拜访2日套餐 2025-08-01 2人"],
  241. ),
  242. # 技能6:保险购买
  243. AgentSkill(
  244. name="order insurance",
  245. description="根据保险类型、日期和份数购买差旅保险",
  246. examples=["购买综合型保险 2025-08-01 1份"],
  247. ),
  248. ],
  249. )
  250. # ==================== 差旅查询服务器类 ====================
  251. class TripServer(A2AServer):
  252. """
  253. 差旅管家 A2A 服务器
  254. 这个类继承自 A2AServer,负责处理所有差旅相关的任务。
  255. 与票务子助手一样使用工具调用;本类的具体流程如下:
  256. 1. 接收来自主助手的任务(Task)
  257. 2. 从任务中提取用户的查询内容
  258. 3. 调用 query_trip 函数执行查询
  259. 4. 将查询结果设置到任务状态中,返回给主助手
  260. """
  261. def __init__(self):
  262. """
  263. 初始化差旅查询服务器
  264. """
  265. # 【启动 S3.3】将卡片交给 A2AServer;父类提供通信框架,本类负责实现 handle_task。
  266. # 输入:模块加载时创建的 agent_card。super() 将当前实例交给父类初始化。
  267. # 本类继承了 A2A 通信框架,只覆盖业务回调 handle_task;此时还没有用户查询需要执行。
  268. super().__init__(agent_card=agent_card) # 调用父类初始化,注册 Agent Card
  269. def handle_task(self, task):
  270. """
  271. 处理来自 A2A 客户端的任务 —— 本服务器的核心方法
  272. 完整流程:
  273. 1. 提取输入:从任务消息中获取用户的查询内容
  274. 2. 调用 MCP 查询:通过 query_trip 函数执行查询
  275. 3. 根据查询结果设置任务状态:
  276. - 成功:COMPLETED 状态,结果放在 task.artifacts
  277. - 需要输入:INPUT_REQUIRED 状态,追问信息放在 task.status.message
  278. - 失败:FAILED 状态,错误信息放在 task.status.message
  279. 参数:
  280. task: A2A 任务对象
  281. 返回值:
  282. task: 处理后的任务对象(已设置状态和结果)
  283. """
  284. # ========== 步骤1:提取输入 ==========
  285. # 【主流程 7.1】A2A 框架收到任务后调用此方法;先从 task.message 取输入。
  286. # 输入 task 来自主助手的 A2A 请求;它包含任务编号、message,处理后还会带上 status/artifacts。
  287. # 本方法不是被 ChatService 直接当本地 Python 方法调用,而是由此进程的 A2A 框架接收请求后触发。
  288. # 输出仍是 task 对象,只是在原对象上填写了结果状态和产物。
  289. # 先取 message 内的 content;下一行确认它是字典,再取 text。
  290. # 消息为空、content 类型不对或没有 text 时,本实现使用空字符串交给后续处理。
  291. content = (task.message or {}).get("content", {})
  292. conversation = content.get("text", "") if isinstance(content, dict) else ""
  293. logger.info(f"用户查询: {conversation}")
  294. try:
  295. # ========== 步骤2:调用 MCP 查询 ==========
  296. # 同步 handle_task 用 asyncio.run 驱动异步查询,等查询结束后才设置任务状态。
  297. trip_result = asyncio.run(query_trip(conversation))
  298. logger.info(f"MCP 查询返回: {trip_result}")
  299. # ========== 步骤3:根据结果设置任务状态 ==========
  300. # 这里按查询函数返回的 status 分支;先确认执行器是否正常返回,再从 message 检查是否为追问。
  301. if trip_result.get("status") == "success":
  302. result_text = trip_result.get("message", "")
  303. # 检查是否是追问消息
  304. # 【主流程 9.1】用回复关键词判断是否需要补充信息;否则把文本放入 artifacts。
  305. # COMPLETED 表示处理结束,仍需结合结果文本判断有无数据或是否仅为模拟。
  306. # 任意一个关键词出现就视为需要补信息;只是当前示例的字符串规则,不是模型返回的专门状态字段。
  307. if "请提供" in result_text or "请确认" in result_text:
  308. task.status = TaskStatus(
  309. state=TaskState.INPUT_REQUIRED,
  310. message={"role": "agent", "content": {"text": result_text}},
  311. )
  312. else:
  313. # 成功文本放到 artifacts 的第一个 part;主助手按同一层级提取文字。
  314. # 其他状态的解释文本放在 status.message,因此调用方需要同时处理这两种返回位置。
  315. task.artifacts = [
  316. {"parts": [{"type": "text", "text": result_text}]}
  317. ]
  318. task.status = TaskStatus(state=TaskState.COMPLETED)
  319. # 查询函数已捕获到错误时走这个分支;未知 status 走后面的 else,同样标为 FAILED。
  320. elif trip_result.get("status") == "error":
  321. task.status = TaskStatus(
  322. state=TaskState.FAILED,
  323. message={
  324. "role": "agent",
  325. "content": {
  326. "text": trip_result.get("message", "查询失败,请重试。")
  327. },
  328. },
  329. )
  330. else:
  331. task.status = TaskStatus(
  332. state=TaskState.FAILED,
  333. message={
  334. "role": "agent",
  335. "content": {"text": "查询失败,请重试或提供更多细节。"},
  336. },
  337. )
  338. # 【主流程 9.2】返回带有状态和结果的 Task,A2A 框架再把它传回主助手。
  339. return task
  340. except Exception as e:
  341. logger.error(f"查询失败: {str(e)}")
  342. task.status = TaskStatus(
  343. state=TaskState.FAILED,
  344. message={
  345. "role": "agent",
  346. "content": {"text": f"查询失败: {str(e)} 请重试或提供更多细节。"},
  347. },
  348. )
  349. return task
  350. # ==================== 主函数:启动服务器 ====================
  351. if __name__ == "__main__":
  352. # 创建差旅查询服务器实例
  353. trip_server = TripServer()
  354. # 打印服务器信息
  355. print("\n=== 服务器信息 ===")
  356. print(f"名称: {trip_server.agent_card.name}")
  357. print(f"描述: {trip_server.agent_card.description}")
  358. print("\n技能:")
  359. # 启动时只打印卡片中声明的技能,帮助人工核对名称和配置;不会依次调用这些技能。
  360. for skill in trip_server.agent_card.skills:
  361. print(f"- {skill.name}: {skill.description}")
  362. # 启动 A2A 服务器,监听 5007 端口
  363. # 【启动 S3.4】开始监听 A2A 请求;后续 handle_task 按请求触发,不按函数定义顺序执行。
  364. run_server(trip_server, host="127.0.0.1", port=5007)