ticket_server.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. # 【启动 S3】本文件是独立的 A2A 子助手进程:监听 5006,调用 MCP 工具服务 8001。
  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:5006),通过 A2A(Agent2Agent)协议与主助手通信。
  11. 与 weather_server 一样,本模块使用参数化工具调用:
  12. - 模型选择工具并提取参数,MCP 服务内部负责 SQL 查询
  13. - ticket_server 使用 LangChain Agent + MCP Tools 模式(工具调用模式)
  14. LLM 直接决定调用哪个 MCP 工具(高铁票查询/航班查询/会展活动查询/预定),
  15. 然后由 LangChain 的 AgentExecutor 自动完成工具调用和结果处理
  16. 工作流程:
  17. 1. 主助手通过 A2A 协议向本服务器发送任务(Task)
  18. 2. 本服务器收到任务后,提取用户的自然语言查询
  19. 3. 使用 LangChain Agent(基于工具调用的 Agent)处理查询:
  20. a. LLM 分析用户输入,决定调用哪个 MCP 工具
  21. b. LangChain 自动调用 MCP Server 的工具(端口 8001)
  22. c. 工具返回结果后,LLM 将结果格式化为友好的中文回复
  23. 4. 将结果返回给主助手
  24. 涉及的关键技术:
  25. - LangChain Agent: 一种让 LLM 自主选择和使用工具的框架
  26. - Tool Calling Agent: 一种 Agent 类型,LLM 以结构化格式调用工具
  27. - MCP Tools: MCP Server 提供的工具,本系统中包括高铁票查询、航班查询等
  28. - AgentExecutor: LangChain 的执行器,负责运行 Agent 循环(思考→调用工具→处理结果→继续)
  29. MCP Server(端口 8001)提供的工具包括:
  30. - 高铁票查询工具
  31. - 航班查询工具
  32. - 会展门票查询工具
  33. - 高铁票预定工具
  34. - 航班预定工具
  35. - 会展门票预定工具
  36. """
  37. # 单次票务任务示例:主助手发送“北京到上海高铁”
  38. # → handle_task → to_langchain_tool → StructuredTool → AgentExecutor.invoke
  39. # → 模型选择 query_train 及城市/日期/座席参数 → MCP :8001 → MySQL
  40. # → 工具返回 JSON → 模型整理文字 → task.artifacts → 主助手。
  41. # 查询和预订工具按需要选择,不会把注册的六个工具依次全部执行。
  42. # ==================== 导入依赖 ====================
  43. import asyncio # 异步IO库
  44. # from mcp import ClientSession # MCP 客户端会话
  45. # from mcp.client.streamable_http import streamablehttp_client # MCP HTTP 流式客户端
  46. from python_a2a import MCPClient, MCPTools
  47. from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
  48. from python_a2a import to_langchain_tool
  49. # A2AServer: A2A 服务器基类
  50. # run_server: 启动服务器的函数
  51. # AgentCard: 代理卡片
  52. # AgentSkill: 代理技能
  53. # TaskStatus: 任务状态对象
  54. # TaskState: 任务状态枚举(COMPLETED / FAILED / INPUT_REQUIRED)
  55. from langchain_openai import ChatOpenAI # LangChain 的大模型接口
  56. from langchain_core.tools import StructuredTool
  57. from langchain_core.prompts import ChatPromptTemplate # 提示模板
  58. # from langchain_mcp_adapters.tools import load_mcp_tools # 从 MCP 会话加载工具
  59. from langchain.agents import create_tool_calling_agent, AgentExecutor
  60. # create_tool_calling_agent: 创建基于工具调用的 Agent
  61. # AgentExecutor: Agent 执行器,负责运行 Agent 循环
  62. from datetime import datetime # 时间处理
  63. import pytz # 时区库
  64. from config import Config # 项目配置
  65. from create_logger import logger # 日志模块
  66. # 1. 初始化大模型
  67. conf = Config()
  68. # 初始化LLM
  69. # 【启动 S3.1】创建模型客户端;模型推理在后面的 invoke/ainvoke 才发生。
  70. llm = ChatOpenAI(
  71. model=conf.model_name,
  72. base_url=conf.base_url,
  73. api_key=conf.api_key,
  74. temperature=conf.temperature
  75. )
  76. # 2. 定义AgentCard,6个skill
  77. # ==================== Agent Card(代理卡片) ====================
  78. # 描述票务代理的能力、技能等信息
  79. # 卡片用于说明和展示能力;当前主助手通过 Config.intent 的映射选择代理。
  80. # TODO 这里的6个skill作用:是给主路由在选择子Agent的时候作为参考使用的,和MCP层的工具没有对应的关系。可以将6个skill合成2个,
  81. # 1. 查询票务(高铁票、航班、会展活动) 2. 预定票(高铁票、航班、会展活动)
  82. # 【启动 S3.2】定义对外说明。skills 是能力介绍,实际可执行工具来自 MCP。
  83. # 主助手当前按 Config.intent 路由;卡片声明的能力也不等于此方法已实现逐片输出。
  84. # 卡片字段说明:name 是代理标识;url 是访问地址;description/skills 向调用方介绍能力。
  85. # 这里描述技能不会自动生成对应 Python 函数,实际工具仍由 MCP 服务注册和提供。
  86. # 主助手 Config.intent 中的名称、AgentNetwork 的注册名称和这里的 name 要相互对应。
  87. agent_card = AgentCard(
  88. name="TicketAssistant", # 代理名称
  89. description="基于 LangChain 提供票务查询和预订服务的统一助手", # 代理描述
  90. url="http://localhost:5006", # 代理的访问地址
  91. version="2.0.0", # 版本号
  92. capabilities={"streaming": True, "memory": True}, # 支持的能力
  93. skills=[
  94. # 技能1:高铁票查询
  95. AgentSkill(
  96. name="query train tickets",
  97. description="查询高铁票,支持指定出发城市、到达城市、日期和座位类型",
  98. examples=["高铁票 北京 上海 2025-07-31", "北京到广州的高铁 明天 二等座"]
  99. ),
  100. # 技能2:航班查询
  101. AgentSkill(
  102. name="query flight tickets",
  103. description="查询航班,支持指定出发城市、到达城市、日期和舱位类型",
  104. examples=["航班 北京 上海 2025-07-31", "北京到深圳的航班 后天 经济舱"]
  105. ),
  106. # 技能3:会展门票查询
  107. AgentSkill(
  108. name="query event tickets",
  109. description="查询会展门票,支持指定城市、活动名称、日期和票档类型",
  110. examples=["会展活动 成都企业数字化转型峰会 2026-05-01", "上海大型展会参展门票 2026-06-10"]
  111. ),
  112. # 技能4:高铁票预定
  113. AgentSkill(
  114. name="order train tickets",
  115. description="根据车次、座位类型和数量预定高铁票",
  116. examples=["预定G1次列车 2025-11-15 北京到上海 二等座 1张"]
  117. ),
  118. # 技能5:航班预定
  119. AgentSkill(
  120. name="order flight tickets",
  121. description="根据航班号、舱位类型和数量预定航班",
  122. examples=["预定MU5101 2025-12-11 上海到北京 公务舱 2张"]
  123. ),
  124. # 技能6:会展门票预定
  125. AgentSkill(
  126. name="order event tickets",
  127. description="根据活动名称、场地、日期、票档和数量预定会展门票",
  128. examples=["预定成都企业数字化转型峰会 2026-05-01 成都 VIP商务席 2张"]
  129. )
  130. ]
  131. )
  132. MCP_URL = 'http://127.0.0.1:8001'
  133. # 3. 定义类,继承A2AServer
  134. class TicketServer(A2AServer):
  135. # 4. 实现初始化
  136. def __init__(self):
  137. # 【启动 S3.3】将卡片交给 A2AServer;父类提供通信框架,本类负责实现 handle_task。
  138. # 输入:模块加载时创建的 agent_card。super() 将当前实例交给父类初始化。
  139. # 本类继承了 A2A 通信框架,只覆盖业务回调 handle_task;此时还没有用户查询需要执行。
  140. super().__init__(agent_card=agent_card)
  141. # 5. 实现handle_task
  142. # 接收用户的请求,使用langchain-agent自动去调用对应的工具集
  143. def handle_task(self, task):
  144. # 5.1 从task里面获取message
  145. # 【主流程 7.1】A2A 框架收到任务后调用此方法;先从 task.message 取输入。
  146. # 输入 task 来自主助手的 A2A 请求;它包含任务编号、message,处理后还会带上 status/artifacts。
  147. # 本方法不是被 ChatService 直接当本地 Python 方法调用,而是由此进程的 A2A 框架接收请求后触发。
  148. # 输出仍是 task 对象,只是在原对象上填写了结果状态和产物。
  149. # (task.message or {}) 在消息为空时退回空字典,再用 get 避免直接按键访问报 KeyError。
  150. # 与天气、差旅助手一致,取出 content 中的文本交给模型。
  151. content = (task.message or {}).get("content", {})
  152. query = content.get("text", "") if isinstance(content, dict) else ""
  153. # 5.2 获取MCP Server所有的工具集(tools)
  154. # 【主流程 7.2】先请求 MCP 工具定义,取得工具名、说明、参数结构和调用包装器。
  155. # 加载定义并不等于已查票;只有执行器调用选中的工具时才执行查询。
  156. tools = to_langchain_tool(MCP_URL) ## 向票务工具服务获取可用工具,把它们包装成LangChain执行器可以使用的工具对象
  157. # python-a2a 0.5.4 返回单参数 Tool;保留 MCP JSON Schema,以支持查票的多个命名参数。
  158. # 【主流程 7.3】保留多个具名参数的结构,例如 departure_city、arrival_city、date。
  159. # func 仍是原 MCP 调用函数,StructuredTool 负责让参数结构与执行器匹配。
  160. tools = [StructuredTool(
  161. # 工具对象的五个字段各有用途:name 用于匹配模型选中的工具,description 帮助模型理解用途;
  162. # func 保存真正发起 MCP 调用的函数;args_schema 规定参数名/类型;metadata 保留原工具信息。
  163. # 列表里的每一项转换成独立 StructuredTool,模型随后可在这些工具中选择。
  164. name=tool.name,
  165. description=tool.description,
  166. func=tool.func,
  167. args_schema=tool.metadata['parameters'],
  168. metadata=tool.metadata,
  169. ) for tool in tools]
  170. # 5.3 构建提示词模板(prompt)
  171. # 提示模板分三部分:system 给规则和当前日期,human 放本次输入,placeholder 放执行过程消息。
  172. # {input}/{current_date} 由 invoke/ainvoke 的参数提供;{agent_scratchpad} 由 AgentExecutor 随工具调用更新。
  173. # 占位符不是立刻执行的代码,只是在实际请求模型前被替换成消息内容。
  174. prompt = ChatPromptTemplate.from_messages([
  175. ("system",
  176. "你是一个票务预定助手,能够调用工具来完成高铁票、航班或会展票的预定。你需要仔细分析工具需要的参数,然后从用户提供的信息中提取信息。如果用户提供的信息不足以提取到调用工具所有必要参数,则向用户追问,以获取该信息。不能自己编撰参数。预订工具仅用于模拟演示,回复必须明确说明模拟、未实际下单,不得声称已出票或生成保单。当前日期{current_date}"),
  177. ("human", "{input}"),
  178. ("placeholder", "{agent_scratchpad}"),
  179. ])
  180. # 5.4 构建Agent(tools + prompt + llm)
  181. # 【主流程 7.4】组装模型、工具和提示词;agent_scratchpad 保存工具调用/返回消息。
  182. # Agent 决定下一步,AgentExecutor 驱动循环并实际调用工具。
  183. agent = create_tool_calling_agent(llm, tools, prompt) ## agent 组织提示词和模型调用,决定下一步动作
  184. # agent 负责根据输入和已有工具结果产生下一步动作;executor 负责运行这个过程。
  185. # 若模型请求工具,executor 按工具名找到对象并调用 func,再把返回值交回模型;直到得到最终回答。
  186. # verbose=True 打印执行过程,便于观察调用轨迹,不代表业务结果会自动保存到记忆或数据库。
  187. agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True) ## agent_executor负责跑执行过程,把模型选择的工具真正调用起来,再把结果交给模型
  188. # 5.5 执行请求Agent的invoke 这里,真正开始处理请求
  189. try:
  190. # 【主流程 7.5 → 8】从这里开始运行工具调用循环;必要时可调用多次工具。
  191. result = agent_executor.invoke({'input': query,
  192. 'current_date':datetime.strftime(datetime.now(), '%Y-%m-%d')
  193. })
  194. # invoke 返回字典,output 键里才是 Agent 最终回复文本;它不是原始 SQL 行列表。
  195. # 若缺少信息,模型可能直接追问并结束,不会保证每次任务都调用一个工具。
  196. output = result['output']
  197. # 5.6 根据返回结果封装Task.Status和artifacts
  198. # ① 缺少参数
  199. # 【主流程 9.1】当前用回复中的关键词区分追问,否则标为 COMPLETED。
  200. # 这是文字判断,不是业务成功校验;预订工具返回的仍然是模拟结果。
  201. # 这个 if 检查的是文本中是否出现“缺少”两个字,不能识别所有可能的追问表达。
  202. # 命中就把追问放进 status.message;未命中才进入 else 填写正常产物。
  203. if "缺少" in output:
  204. task.status = TaskStatus(
  205. state=TaskState.INPUT_REQUIRED,
  206. message={"role": "agent", "content": {"text": output}}
  207. )
  208. else:
  209. # ② 运行成功
  210. task.status = TaskStatus(
  211. state=TaskState.COMPLETED,
  212. message={"role": "agent", "content": {"text": "运行成功"}}
  213. )
  214. # 产物结构是列表→parts 列表→文本项;主助手从 artifacts[0]["parts"][0]["text"] 读取 output。
  215. # status 中的“运行成功”只说明此回调走完,真正给用户的文本在 artifacts。
  216. task.artifacts = [{"parts": [{"type": "text", "text": output}]}]
  217. # 【主流程 9.2】把同一个 Task 连同状态或结果交回 A2A 框架,再由框架发送给主助手。
  218. return task
  219. # 这个 try 从执行器 invoke 才开始;前面的工具加载和 Agent 构造异常不在此 try 的捕获范围。
  220. # 被捕获的执行异常写入 FAILED 状态,让主助手能读取错误文本。
  221. except Exception as e:
  222. # ③ 运行失败
  223. task.status = TaskStatus(TaskState.FAILED,
  224. message={"role": "agent", "content": {"text": f"agent执行失败:{e}"}}
  225. )
  226. return task
  227. if __name__ == "__main__":
  228. # 创建票务查询服务器实例
  229. ticket_server = TicketServer()
  230. # 打印服务器信息,方便确认启动状态
  231. print("\n=== 服务器信息 ===")
  232. print(f"名称: {ticket_server.agent_card.name}")
  233. print(f"描述: {ticket_server.agent_card.description}")
  234. print("\n技能:")
  235. # 启动时只打印卡片中声明的技能,帮助人工核对名称和配置;不会依次调用这些技能。
  236. for skill in ticket_server.agent_card.skills:
  237. print(f"- {skill.name}: {skill.description}")
  238. # 启动 A2A 服务器,监听 5006 端口
  239. # 主助手会通过 http://localhost:5006 连接本服务器
  240. # 【启动 S3.4】开始监听 A2A 请求;后续 handle_task 按请求触发,不按函数定义顺序执行。
  241. run_server(ticket_server, host="127.0.0.1", port=5006)