SmartBizTrip 纵向流程图:主助手核心方法与清晰分支 中心纵向主流程,分支在左右泳道内处理后汇入。每个核心方法旁配有数据或示例,包含追问、规划、城市服务、A2A票务、MCP和SSE。 SmartBizTrip:主助手 ChatService 怎么处理一次请求 沿中间主线往下看;分支只在左右泳道展开,再汇入共同步骤。示例:查 2026-05-01 北京到成都的二等座高铁票。 先认角色:职责身份和通信身份是两回事 ChatService|主助手 / 总协调员 接住问题、识别意图、选择处理路径 调用子助手时,它扮演 A2A 客户端 生活例子:差旅服务台总台 TicketAssistant|子助手 / 票务专员 接收 A2A 任务,负责票务问题 在 A2A 调用中,它是服务端 生活例子:接到工单的票务窗口 query_train|工具 / 查票功能 执行具体车次查询,不负责理解全局需求 生活例子:票务专员使用的查询系统 通过 MCP 暴露给票务助手 网页 / FastAPI|接待入口 接收问题、把回复传回网页 不负责判断该找哪个子助手 生活例子:前台接待员 先看两个协作概念:它们连接的对象不同 A2A|Agent 与 Agent 之间交接任务 解决:主助手怎样把一个任务交给子助手,并收回任务结果 本项目:ChatService(A2A 客户端) → Task → TicketServer(服务端) Task 携带任务;status / artifacts 表示处理状态和结果 生活例子:总协调员派工单给票务专员,之后收回执 MCP|Agent 与工具 / 数据能力之间的连接方式 解决:助手怎样发现并调用具体工具,而不是再找一个助手 本项目:TicketAssistant → query_train → TicketService / MySQL @ticket_mcp.tool 注册工具;工具输入参数并返回查询结果 生活例子:票务专员使用订票系统窗口查车次 记忆:A2A 是“助手交办给助手”;MCP 是“助手调用工具”。后续流程图中会看到这两段实际调用。 系统启动(先准备好服务,用户发消息时才开始下面的请求流程) API 服务创建主助手 api_server.py → ChatService.__init__() 准备 LLM、memory、AgentNetwork / A2AClient 子助手服务独立启动 TicketServer.run_server(..., 5006) 等待 A2A 任务;并非由 ChatService 启动 MCP 工具服务注册查票工具 mcp_ticket_server.py → @ticket_mcp.tool query_train 可供票务助手按需调用 用户问题从这里进入;下面的 SSE 分段只发生在回复方向 用户提出需求 “查 2026 年 5 月 1 日北京到成都的二等座高铁票。” 网页固定把完整问题 POST 到聊天接口 static/index.html → fetch('/api/chat/stream') 请求示例:{"message":"查 2026-05-01 北京到成都的二等座高铁票"} FastAPI 路由接收请求 @app.post('/api/chat/stream') → StreamingResponse(sse_generator(...)) 它把 request.message 交给生成器;此时还没有调用子助手 SSE 生成器调用业务方法 async for chunk in chat_service.chat_stream(message) 输入仍是完整用户问题;生成器负责稍后转发回复片段 进入主助手:ChatService.chat_stream(user_input) 总协调员开始处理本轮用户需求 一条用户需求 → 一次主流程调度 1|先保存用户消息 self.memory.add_message("user", user_input) 只是记住本轮内容,还没有查车票 本步记录 “查 2026-05-01 北京到成都二等座高铁票” 后续识别可结合近期对话、偏好和任务上下文 生活例子:总台先把客人的原话记进服务记录本 2|intent_agent(user_input):理解需求并分类 intent_prompt() | self.llm → chain.invoke(...) 模型读取:本轮问题、短期对话、日期、用户偏好、任务上下文 JSON 经清理 / 解析后交回 chat_stream 生活例子:把“我想查票”整理成给票务专员的工单 本步返回(示意) intents = ['train'] user_queries['train'] = '查 2026-05-01 北京到成都二等座' follow_up_message = '' 输出的是“要做什么 / 怎么问专员 / 是否要追问” 实际字段内容由模型按提示词生成 follow_up_message 是否非空? 或 intents 是否包含 out_of_scope 是:追问 / 范围外 否:信息够了 本轮先回复用户,然后结束本轮处理 yield follow_up_message 缺日期示例:“请问您计划哪天出发?” 跳过规划和子助手;用户补充后会开始新一轮请求 本例 follow_up_message = '',继续往下 信息已包括日期、出发地、目的地和席别 所以下一步才判断需不需要规划 左右两条线各走各的,不在此处交叉回流 _should_skip_planning(intents) 规则判断:要不要调用规划模型? 是:跳过规划 否:调用规划模型 简单路径|直接执行意图 单一意图通常跳过;有 complex 通常进入规划 本例 intents=['train'] → 直接查票,不额外规划 多个独立查询也可能跳过,按项目规则判断 下一步:交给 _call_agent_intent_stream() 规划路径|planning_agent(intents, user_queries) 只有规则没有跳过时,才请求 LLM 评估计划 返回 need_plan 和 steps 例如多个有关联的差旅事项可能需要排先后 调用规划模型不代表一定执行计划;继续看 need_plan need_plan? 规划模型给出的结果 否:直接执行 是:执行计划 need_plan=False 像简单路径一样直接分派 不经过 ReAct 计划执行 该分支在汇合线处进入下步 _react_loop_stream() → _execute_plan() 检查依赖,按顺序执行 execute_step() 示例:先确认行程时间,再安排接送 多个步骤结果收集后统一汇总 汇合:简单路径 / 计划步骤都进入对应业务处理 6|_call_agent_intent_stream(intent, query_str) 按意图进入具体业务;复杂计划由 execute_step 分步触发 intent='train'; query_str=整理后的查票问题 总台确认“这是查票需求”,然后决定联系票务专员 本例传入值 intent = 'train' query_str = '查 2026-05-01 北京到成都二等座' 城市建议另走 city_service 特殊路径 intent == city_service? 城市建议特例,还是普通业务意图? 是:城市服务 否:普通意图 城市服务分支|不进入票务 A2A / MCP _fetch_weather_for_city_service(query_str) 1. 先向天气子助手拿天气文本 weather_info 2. 主模型 chain.astream({query, weather_info}) 例:“成都出差要注意什么?”→ 查天气 → 生成建议 这条分支产出的数据 weather_info = “天气子助手返回的天气描述” advice_chunks = 主模型逐段生成的建议 建议 chunk 直接进入主助手的回复收集 生活例子:总台问天气窗口,再自己整理出差提醒 本分支的示例回复 “成都当天的天气是……建议携带雨具……” 示例文本用于说明结构;实际内容来自天气结果和模型 此分支完成后直接接到下方共同的回复处理 普通业务分支|A2A 子助手 → MCP 工具 conf.intent.get('train') → TicketAssistant 组装 Message / Task,发送 A2A 请求 await agent.send_task_async(task) 主助手是 A2A 客户端;子助手是服务端 TicketServer.handle_task(task) 收任务文本 → AgentExecutor.invoke() 票务专员打开工单,判断下一步做什么 AgentExecutor 选择工具或追问 to_langchain_tool('http://127.0.0.1:8001') 查票信息齐全 → 选择 query_train 参数示例:2026-05-01 / 北京 / 成都 / 二等座 MCP 工具执行查询 @ticket_mcp.tool('query_train') → TicketService _execute_query() → train_tickets (MySQL) 示意结果:匹配车次记录;实际看数据库内容 子助手返回 A2A Task completed + artifacts → parts[0].text 否则 → status.message(追问 / 错误等) 示例:车次文本在 artifacts;缺信息提示在 status 随后由主助手总结成面向用户的回复 7|整理用户可读答案 summarize_ticket_prompt() | self.llm → chain.astream(...) 票务原始结果先整体返回,再由主模型流式总结 计划分支先汇总各步骤;城市建议在自身分支生成 给用户的回复 chunk 示例 chunk 1: “查到了,” chunk 2: “北京到成都有两趟……” chunk 的边界由模型生成过程决定 不是数据库记录一行一行直接推给网页 8|chat_stream 收集并保存完整回复 parts.append(chunk) → yield chunk → ''.join(parts) memory.add_message('assistant', response) 记忆保存完整回答,不把每个 chunk 当独立消息 这一阶段的数据变化 多段 chunk → 一条完整 assistant response 追问分支最终也会把回复交给网页并留存 用户补充信息后,再开始新一轮 chat_stream 9|SSE 生成器包装每段回复 data: {"chunk":"..."},结束时 data: [DONE] SSE 只负责把 chunk 传到浏览器 请求上行是完整问题;回复下行才是多个 chunk 实际事件示例 data: {"chunk":"查到了,"} data: {"chunk":"北京到成都有两趟"} data: [DONE] 是结束信号,不是正文 网页逐段接收 SSE,把 chunk 追加到同一个聊天气泡 最终显示:“查到了,北京到成都有两趟符合条件的列车。” 这就是一次问题从网页进入、由主助手调度、再返回网页的完整闭环 看分支时记住这三个判断 ① 追问分支:信息不足 → 先问用户,本轮结束;信息够 → 继续。 ② 规划分支:简单意图直接执行;需要规划时再看 need_plan,计划执行后与直接路径汇合。 ③ 业务分支:city_service 取天气并生成建议;普通意图找子助手,票务助手再按需调用 MCP。 连代码时追踪数据:message → user_input → intents/user_queries → Task → artifacts/status.message → chunk → SSE。