用户一句话进来,主控用 Prompt 做意图识别和查询改写,再按意图把请求分发天气 / 票务查询 / 订票等A2A Agent。查询类 Agent 做 NL2SQL,通过内网 MCP 查本地 MySQL;天气数据由定时任务从和风同步进库,实现一次写入多次查询,避免每次外网调用的成本和延迟。订票会先 A2A 查余票,再调订票 MCP。整体是主控编排、领域 Agent 推理、MCP 统一数据访问。技术栈是 LangChain + Qwen、python-a2a、FastMCP、MySQL。
SmartVoyage 是一个旅游多智能体项目。用户做攻略时要同时关注天气、交通、票务、景点等信息,比较分散,所以我们做了一站式自然语言入口:用户一句话提问,系统自动识别意图并完成查询或订票。
整体链路是:用户从前端或命令行进入后,主控用 Prompt 加大模型做意图识别和查询改写,结合对话历史输出意图、改写后的问题和必要时的追问。意图支持天气、机票、火车、演唱会、订票、景点等,也可以多意图一起处理。然后按意图分发:天气、查票、订票分别打到对应的 A2A Agent;景点推荐目前由主控侧大模型直接生成。Agent 通过 Task 通信,查票和天气结果回来后主控还会再做一次结果润色,再返回给用户。
以天气为例讲核心实现。天气 Agent 收到改写后的问题后做 NL2SQL:把完整表结构写进 Prompt,生成可执行的 SELECT;信息不全就追问。生成 SQL 后,Agent 作为 MCP 客户端调用天气 MCP,把 SQL 当工具参数传入;MCP 连本地 MySQL 执行查询并按约定返回状态和数据。之所以不让 Agent 每次直接调和风,一是要把数据访问做成独立可复用的 MCP 服务,和推理解耦;二是第三方按次计费、走外网,贵且慢。我们用定时任务按官方 API 拉取天气、解析入库,查询时走内网 MCP 查本地库,实现一次写入、多次查询。实时性靠调度频率平衡,频率过高会压库。
票务查询和天气同构,也是 Agent 做 NL2SQL,再经票务 MCP 查本地票表。订票更体现 Agent 协作:订票 Agent 先通过 A2A 调查票 Agent 确认余票,有票再调订票 MCP 完成下单。技术栈主要是 LangChain 加通义、python-a2a、FastMCP 和 MySQL。这套「主控编排、领域 Agent 推理、MCP 统一数据访问、本地库承载高频查询」的模式,也可以迁移到征信等场景,换的是协议和合规细节,主链路一致。
流程总览:
python
用户输入
→ ① 意图识别 + 查询改写
→ ② 按意图路由到 A2A Agent
→ ③ Agent:NL2SQL
→ ④ Agent:调用 MCP(传 SQL)
→ ⑤ MCP:查 MySQL 返回 JSON
→ ⑥ 主控:结果润色输出
旁路:定时任务同步和风数据 → MySQL
1. 用户入口 ➕️ 2. 主控: 意图识别 + 查询改写
2. 主控:意图识别 + 查询改写 总述:
主控不是 Router 组件,而是 Prompt + LLM(intent_prompt → intent_agent)。
输入:用户问题 + 最近几轮对话历史 + 当前日期。
输出严格 JSON,三类信息:
① intents:如 weather、train、flight、concert、order、attraction,支持多意图组合
② user_queries:按意图改写后的明确问题(把「明天」补成日期、把缺省城市从上下文补全)
③ follow_up_message:意图不清时追问;超范围时直接回复引导语
分支处理:
① out_of_scope → 直接用 follow_up 回复
② 有追问 → 先问清再往下走
③ 有效意图 → 进入分发
口述要点:主控只负责「看懂要干什么、把问题说清楚」,不负责查库;查库在子 Agent + MCP。
用户一句话进来,系统启动时会:①创建 LLM 实例;②初始化AgentNetwork:(需要智能选型时)多个Agent可注册进AgentNetwork:通过network.add(name, url)将AgentServer登记进AgentNetwork。意图识别两种方案:Router或提示词。方式一:当使用AIAgentRouter时:Network再按url地址发现各端的AgentCard/Skill交给AIAgentRouter,Router基于用户查询,对照network中各Agent的Card/Skill能力描述使用LLM做意图匹配,返回目标agent名称及置信度confidence;方式二:提示词方案;意图识别我们没用单独的Router组件,而是Prompt + LLM:在提示词里写清有哪些意图、如何改写、如何输出JSON,再把用户问题和历史喂给模型(这里的「历史」是主控自己维护的一串对话文本,不是数据库里的日志),由模型返回意图列表intents:
(意图列表:"intents": "weather", "train"是由LLM 吐出的 JSON 解析后得到的,例如:)
bash
{
"intents": ["weather", "train"],
"user_queries": {
"weather": "明天上海的天气",
"train": "明天上海到北京的高铁"
},
"follow_up_message": ""
}
主控再根据这个JSON去调对应的A2A Agent:主控拿到列表后,用普通 if/elif 做映射,再agent.send_task_async(task)远程发Task:
bash
for intent in intents:
if intent == "weather":
agent_name = "WeatherQueryAssistant"
elif intent in ["flight", "train", "concert"]:
agent_name = "TicketQueryAssistant"
elif intent == "order":
agent_name = "TicketOrderAssistant"
...
elif agent_name:
query_str = user_queries.get(intent, {})
agent = agent_network.get_agent(agent_name)
...
task = Task(...)
raw_response = asyncio.run(agent.send_task_async(task))
JSON 里的: intent=weather、主控映射到WeatherQueryAssistant 5005; intent=train/flight/concert、主控映射到TicketQueryAssistant 5006; intent=order、主控映射到TicketOrderAssistant 5007;同时用 user_queriesintent 当该 Agent 的输入(改写后的问题),不是原始整句一股脑丢过去。
即主控不是 Router 组件,而是 Prompt + LLM:用一段写好的提示词模板,把用户问题交给大模型,让模型自己判断意图并输出结构化结果------不是框架里一个叫 Router 的现成组件在做路由;
拿到名字后再通过get_agent(name).send_task_async(task) 取Client发Task;
意图识别只产出意图列表intents、改写后的问题user_queries、追问话术或超范围时的直接回复(即可选的追问/兜底回复)follow_up_message(只有第三项为空且意图有效时,才进入分发即按intents映射并调用对应Agent或景点LLM;主控用 for intent in intents 查表映射到agent_name即Agent名字,再raw_response=asyncio.run(network.get_agent(agent_name).send_task_async(task))通过AgentNetwork 把 Task 发到对应端口的 A2A 服务去执行。
(本质是按这个 URL 远程调用已在跑的服务。启动顺序一般是:先起 MCP (8001--8003)→ 再起三个 Agent (5005--5007)→ 最后起 main.py / app.py ;只跑主控、不跑 Agent,分发时会连不上。
agent来源 :a2a_server/下 weather_server.py/ticket_server.py/order_server.py ---WeatherQueryServer(A2AServer)--- run_server(..., port=5005),每个服务里有 AgentCard / AgentSkill,并实现 handle_task(NL2SQL、调 MCP 等);
MCP来源:mcp_server/下 mcp_weather_server.py/mcp_ticket_server.py/mcp_order_server.py --- create_weather_mcp_server();)
方式二:
Prompt = 写给大模型的说明书(规则、意图列表、输出格式)
LLM = 真正做判断的模型(如 qwen-plus)
Prompt + LLM = 把说明书和用户问题拼在一起,让模型「读完后按格式回答」--- 本项目里用来做意图识别。
main.py:chain = SmartVoyagePrompts.intent_prompt() | llm
这是 LangChain 的写法:① intent_prompt():一段带占位符的模板(规则 + {query} / {conversation_history} / {current_date});② | llm:模板填好后交给大模型;③ chain.invoke({...}):得到模型返回的 JSON 字符串( Prompt要求尽量输出为JSON),再 json.loads 解析出 intents、user_queries;分类逻辑写在 Prompt 里,由 LLM 执行,而不是 if/else 或独立 Router 模块。
Q:为什么要进行查询改写?
改写发生在:意图识别 Prompt(结合 conversation_history);
结果放在:user_queries;
谁用:分发时 query_str = user_queries.get(intent),再放进 Task 发给对应 Agent;
主控改写,不是替用户回答,Prompt 也写了:不要答题、不改原意,只补上下文让问题更明确;
用户说话依赖上下文、经常省略;Agent 办事需要明确的城市、日期、出发到达等。查询改写 = 在分发前把省略补全,让每个 Agent 拿到「可执行」的问题 。
Q:为什么要拼进去再解析回来?
因为大模型接口本质是 chat completion:in text → out text即LLM 只吃文本、也只吐文本,主控后面的代码却要拿结构化字段(intents列表、user_queries字典)去做 if intent == "weather" 这种分支。中间必须有一次「约定格式的文本 ↔ 程序对象」转换。 Prompt是给模型看的说明书,模型只认文本 → 所以要把问题「拼」进 Prompt 送进去;模型只能回文字,我们约定它回 JSON 这种文字;模型按约定吐 JSON 文本字符串 {"intents": ["weather"], "user_queries": {"weather": "明天上海天气"}, "follow_up_message": ""},不能对字符串写for intent in intents;后面代码需要按字段路由(即要拿 结构化字段(intents 列表、user_queries 字典)去做 if intent=="weather" 这种分支) → 所以要把模型吐出的JSON字符串使用 json.loads 转成字典(如下),这时才能当程序数据用,再「解析」成 intents、user_queries,
python
intent_output = json.loads(intent_response)
# 变成真正的 dict:
# {
# "intents": ["weather"],
# "user_queries": {"weather": "明天上海天气"},
# "follow_up_message": ""
# }
intents = intent_output.get("intents", []) # → ["weather"]
user_queries = intent_output.get("user_queries", {}) # → {"weather": "明天上海天气"}
主控才能按意图分发(即主控读字典里的 intents,决定调哪个 Agent);如果中间不做解析,主控拿着一串字没法可靠地 if intent == "weather"。所以「拼进去再解析回来」不是浪费步骤,而是 文本接口 → 程序可用数据 的必要转换。
Q:Router和提示词这两种意图识别方案的区别:

3. 按意图分发
先调 intent_agent(prompt),得到 intents、user_queries、follow_up_message:
若超范围或有追问 → 直接返回,不进循环
否则进入 else,执行 for intent in intents: ------ 按意图分发就在这里 :对每个 intent 循环处理(如下) :循环内再做:映射 Agent 名 → get_agent → send_task_async → 可选 summarize → 收集到 responses,最后 "\n\n".join(responses) 合并返回。一句话:分发发生在意图识别之后、调用各 A2A Agent 之前 ;

对 Agent 类意图:取出该意图改写后的 query_str,拼一点历史,封装成 A2A 的 Message + Task,send_task_async 发给对应 Agent,拿回 artifacts 或失败信息。
天气 / 票务查询回来后,主控还会再过一层 总结 Prompt(summarize_weather_prompt / summarize_ticket_prompt),把结构化结果润色成自然语言;订票结果一般直接展示。多意图时把多段回复用空行拼在一起返回用户;



