chat_service.py 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275
  1. # 本文件接续 api_server.py 顶部的主流程 3~11;网页默认入口为 ChatService.chat_stream。
  2. # 阅读时先找 chat_stream,再顺着其调用进入 intent_agent、规划方法和代理调用方法。
  3. # chat 是非流式入口,两者互为替代;它们不是先后调用关系。
  4. # 主助手按计划分派任务;子助手中的 AgentExecutor 才执行“模型选工具 → 工具返回 → 再问模型”的循环。
  5. """
  6. 需求:SmartBizTrip核心对话服务,供CLI和API共同调用
  7. 架构说明:
  8. 本模块实现了企业差旅智能体的核心对话流程,采用优化的 Planning + ReAct 架构:
  9. 1. 意图识别(intent_agent):分析用户输入,识别出用户想做什么(查天气/查航班/查接待资源等)
  10. 2. 启发式路由(_should_skip_planning):快速判断任务复杂度
  11. - 单意图或独立多意图:跳过规划,直接执行(省去 1 次 LLM 调用)
  12. - 含依赖关系的复杂任务:调用 planning_agent 生成执行计划
  13. 3. ReAct循环(react_loop):针对复杂任务,逐步执行每个步骤
  14. - Action(行动):直接调用对应的 agent 执行查询(跳过 Thought 推理,省去每步 1 次 LLM 调用)
  15. - Observation(观察):记录执行结果,供后续步骤参考
  16. 4. 结果汇总:所有步骤完成后,生成一条连贯的最终回复
  17. 整体流程:
  18. 用户输入 → 意图识别 → [跳过规划 / 调用planning_agent] → [直接执行 / ReAct循环] → 返回回复
  19. A2A架构说明:
  20. Agent2Agent (A2A) 是一种代理间通信协议。本系统通过 AgentNetwork 管理多个子代理:
  21. - WeatherQueryAssistant(天气查询代理):运行在 localhost:5005
  22. - TicketAssistant(票务代理):运行在 localhost:5006
  23. - TripAssistant(差旅管家代理):运行在 localhost:5007
  24. 主助手通过 A2A 协议向子代理发送任务请求,获取查询结果后整合回复用户。
  25. 架构图:
  26. +─────────────────────────────────────────────────────────────────────────────+
  27. │ ChatService 类 │
  28. │ │
  29. │ ┌────────────────── 成员变量 ──────────────────┐ │
  30. │ │ agent_urls{} ── 子代理地址映射 │ │
  31. │ │ agent_network ── AgentNetwork 实例 │ │
  32. │ │ llm ── ChatOpenAI (LLM) │ │
  33. │ │ memory ── ConversationMemory │ │
  34. │ │ messages[] ── 当前对话消息列表 │ │
  35. │ │ conversation_history ── 完整对话字符串 │ │
  36. │ │ _available_tools_text ── 工具列表缓存 │ │
  37. │ └───────────────────────────────────────────────┘ │
  38. │ │
  39. │ ┌────────────────── 方法分层 ──────────────────┐ │
  40. │ │ │ │
  41. │ │ [入口层] │ │
  42. │ │ chat() ── 标准对话入口 │ │
  43. │ │ chat_stream() ── 流式对话入口 │ │
  44. │ │ │ │
  45. │ │ [智能决策层] │ │
  46. │ │ intent_agent() ── 意图识别 (LLM) │ │
  47. │ │ _should_skip_planning() ── 启发式路由规则 │ │
  48. │ │ planning_agent() ── 任务规划 (LLM) │ │
  49. │ │ │ │
  50. │ │ [执行层] │ │
  51. │ │ react_loop() ── ReAct 循环 (标准) │ │
  52. │ │ _react_loop_stream() ── ReAct 循环 (流式) │ │
  53. │ │ execute_step() ── 执行单个计划步骤 │ │
  54. │ │ │ │
  55. │ │ [代理调用层] │ │
  56. │ │ _call_agent_intent() ── 统一调用 (标准) │ │
  57. │ │ _call_agent_intent_stream() ── 统一调用 (流式)│ │
  58. │ │ │ │
  59. │ │ [辅助层] │ │
  60. │ │ get_available_tools() ── 动态工具列表 │ │
  61. │ │ get_agent_cards() ── 获取代理卡片 │ │
  62. │ │ _init_db_and_load_memory() ── DB初始化+加载 │ │
  63. │ │ update_user_profile() ── 更新用户偏好 │ │
  64. │ │ get_memory_state() ── 记忆状态摘要 │ │
  65. │ │ clear_memory() ── 清空所有记忆 │ │
  66. │ └───────────────────────────────────────────────┘ │
  67. └─────────────────────────────────────────────────────────────────────────────+
  68. 请求处理流程 (chat / chat_stream,图中 1~6 为函数内部阶段,跨文件编号见 #【主流程】):
  69. 用户输入
  70. │
  71. ▼
  72. ┌─────────────────┐
  73. │ 1. 写入记忆 │ memory.add_message("user", input)
  74. └────────┬────────┘
  75. ▼
  76. ┌─────────────────┐
  77. │ 2. 意图识别 │ intent_agent() → LLM → (intents, queries, follow_up)
  78. └────────┬────────┘
  79. ▼
  80. ┌──────────────────────────────────────────────┐
  81. │ 3. 特殊情况判断 │
  82. │ out_of_scope? → 返回追问 │
  83. │ follow_up? → 返回追问 │
  84. └────────────────────┬─────────────────────────┘
  85. ▼
  86. ┌──────────────────────────────────────────────┐
  87. │ 4. 任务规划 │
  88. │ _should_skip_planning()? │
  89. │ ├─ YES → plan = {need_plan: false} │
  90. │ └─ NO → planning_agent() → LLM 生成计划 │
  91. └────────────────────┬─────────────────────────┘
  92. ▼
  93. ┌──────────────────────────────────────────────┐
  94. │ 5. 执行 │
  95. │ need_plan? │
  96. │ ├─ YES → react_loop() │
  97. │ │ ├─ 按 depends_on 分组 │
  98. │ │ ├─ 组内并行 execute_step() │
  99. │ │ │ → _call_agent_intent() │
  100. │ │ └─ 汇总 → LLM 生成连贯回复 │
  101. │ │ │
  102. │ └─ NO → 遍历 intents │
  103. │ → _call_agent_intent() 每个意图 │
  104. │ → 拼接所有回复 │
  105. └────────────────────┬─────────────────────────┘
  106. ▼
  107. ┌─────────────────┐
  108. │ 6. 写入记忆 │ memory.add_message("assistant", response)
  109. └────────┬────────┘
  110. ▼
  111. 返回回复
  112. _call_agent_intent() 内部路由:
  113. intent
  114. │
  115. ├─ city_service → 尝试查询天气 → city_service_prompt() + LLM → 生成建议
  116. │
  117. ├─ flight/train/event/car_rental/...
  118. │ │
  119. │ ├─ 提取实体 → memory.extract_entities()
  120. │ ├─ 更新上下文 → memory.update_task_context()
  121. │ ├─ A2A → agent.send_task_async(task)
  122. │ │ ├─ WeatherQueryAssistant → summarize_weather_prompt() + LLM
  123. │ │ ├─ TicketQueryAssistant/TripAssistant → 总结;TicketAssistant → 原文
  124. │ │ │ (流式版本把 TicketAssistant 也纳入总结)
  125. │ │ └─ 其他 → 直接返回原始结果
  126. │ └─ 返回总结结果
  127. │
  128. └─ 未识别 → "暂不支持此意图"
  129. 子代理调用关系:
  130. ChatService (由 API :8080 调用)
  131. │
  132. ├── [A2A :5005] → WeatherQueryAssistant (天气查询)
  133. ├── [A2A :5006] → TicketAssistant (票务预定)
  134. ├── [A2A :5007] → TripAssistant (差旅管家: 商务用车/会务套餐/保险)
  135. │
  136. └── [直连 LLM] → city_service (先尝试查询天气,再生成城市接待建议)
  137. 优化策略总结:
  138. 1. 启发式路由 → 简单任务跳过 planning_agent,省 1 次 LLM 调用
  139. 2. 省略 Thought → ReAct 循环中跳过 Thought 推理,每步省 1 次 LLM 调用
  140. 3. 并行执行 → 无依赖步骤通过 asyncio.gather() 并发执行
  141. 4. 工具说明 → get_available_tools 可整理代理卡片;当前对话主线未调用
  142. """
  143. # ==================== 导入依赖 ====================
  144. import asyncio # 异步IO库,用于并发调用多个 agent(异步网络请求)
  145. import json # JSON处理,用于解析大模型的JSON输出和序列化数据
  146. import re # 正则表达式,用于清理大模型返回的JSON(去掉代码块标记)
  147. import uuid # 唯一标识符生成,用于给每个任务分配唯一ID
  148. from datetime import datetime # 时间处理,用于获取当前日期和时间戳
  149. import mysql.connector # MySQL 数据库驱动,用于记忆持久化
  150. import pytz # 时区库,用于将时间转换到 Asia/Shanghai 时区
  151. from langchain_core.prompts import ChatPromptTemplate # LangChain 的提示模板类
  152. # AgentNetwork: 代理网络管理器,负责注册和获取子代理
  153. # A2AClient: A2A 客户端,支持自定义超时
  154. # TextContent: 消息文本内容包装
  155. # Message: A2A 消息对象,包含角色和内容
  156. # MessageRole: 消息角色枚举(USER/ASSISTANT)
  157. # Task: A2A 任务对象,用于向子代理发送请求
  158. from langchain_openai import ChatOpenAI # LangChain 的大模型接口,兼容 OpenAI API 格式
  159. # python_a2a 是 A2A 协议的 Python 客户端库
  160. from python_a2a import AgentNetwork, Message, MessageRole, Task, TextContent
  161. from python_a2a.client import A2AClient
  162. # 导入项目内部模块
  163. from config import Config # 配置模块(模型地址、API Key、意图映射等)
  164. from create_logger import logger # 日志模块,用于记录运行日志
  165. from main_prompts import SmartBizTripPrompts # Prompt 模板管理
  166. from memory import ConversationMemory # 记忆管理(短期记忆、用户偏好、任务上下文)
  167. conf = Config() # 全局配置实例,包含模型参数、意图映射等
  168. class ChatService:
  169. """
  170. SmartBizTrip 对话服务类
  171. 这是整个系统的核心类,负责处理用户的所有输入并生成回复。
  172. 内部流程为:意图识别 → 任务规划 → 执行(直接/ReAct循环)→ 返回回复
  173. 使用示例:
  174. service = ChatService() # 创建服务实例
  175. response = await service.chat("北京明天天气怎么样?") # 发送用户输入
  176. print(response) # 打印助手回复
  177. """
  178. def __init__(self):
  179. """
  180. 初始化 ChatService 实例(启动 S4.2)
  181. 完成以下初始化工作:
  182. 1. 配置子代理网络(注册天气、票务和差旅代理的客户端)
  183. 2. 初始化大模型连接
  184. 3. 初始化记忆管理器
  185. 4. 初始化对话历史和工具列表缓存
  186. """
  187. # ========== 1. 配置 A2A 代理网络 ==========
  188. # 记录每个子代理的访问地址
  189. # 调用者:api_server.py 创建全局 ChatService() 时自动进入。
  190. # 输入:没有业务问题参数,使用模块级 conf 配置。输出:初始化好的 self,不返回聊天文本。
  191. # self.xxx 是服务实例上保留的状态,后面的每次请求都能读取;方法里的普通局部变量只属于本次调用。
  192. self.agent_urls = {
  193. "WeatherQueryAssistant": "http://localhost:5005", # 天气查询代理的地址
  194. "TicketAssistant": "http://localhost:5006", # 票务代理的地址
  195. "TripAssistant": "http://localhost:5007" # 差旅管家代理的地址
  196. }
  197. # 创建代理网络实例,给它起个名字
  198. self.agent_network = AgentNetwork(name="企业差旅助手网络")
  199. # 向网络中注册子代理,使用自定义超时(120秒),避免子代理 LLM+MCP 调用链超时
  200. # 这里只登记通信客户端;不会在当前进程创建或启动远端子助手。
  201. self.agent_network.add("WeatherQueryAssistant", A2AClient("http://localhost:5005", timeout=120))
  202. self.agent_network.add("TicketAssistant", A2AClient("http://localhost:5006", timeout=120))
  203. self.agent_network.add("TripAssistant", A2AClient("http://localhost:5007", timeout=120))
  204. # ========== 2. 初始化大模型连接 ==========
  205. # 使用 LangChain 的 ChatOpenAI 接口连接大模型
  206. # 实际模型与地址来自 Config;创建对象尚未发送模型推理请求。
  207. self.llm = ChatOpenAI(
  208. model=conf.model_name, # 配置指定的模型名称
  209. api_key=conf.api_key, # API 密钥
  210. base_url=conf.base_url, # API 基础地址
  211. temperature=0.1 # 温度参数:越小输出越确定、越稳定(0.1表示比较严谨)
  212. )
  213. # ========== 3. 初始化记忆和对话状态 ==========
  214. # self.memory 负责结构化记忆与持久化;不是大模型的内部记忆。
  215. # short_term_limit=10 限制消息条数,通常一轮用户+助手就占两条。
  216. self.memory = ConversationMemory(short_term_limit=10) # 短期记忆最多保留10条对话
  217. self.messages = [] # 对话消息列表,格式:[{"role": "user/assistant", "content": "..."}]
  218. self.conversation_history = "" # 完整对话历史字符串,用于调试和展示
  219. self._available_tools_text = "" # 保存整理后的工具说明,当前对话主线未读取它
  220. # 城市提取 prompt,用于从城市接待建议查询中提取目的地城市
  221. # 把城市提取规则保存成模板,后续传入 conversation_history/query 后才能得到本轮模型输入。
  222. # 创建模板是本地对象操作;实际推理发生在 _fetch_weather_for_city_service 的 chain.invoke。
  223. self._city_extract_prompt = ChatPromptTemplate.from_template(
  224. """从以下用户查询和对话历史中提取目的地城市(用于天气查询)。规则:
  225. - 只输出城市名称,不要输出其他内容。
  226. - 如果查询中没有明确城市,尝试从对话历史中推断。
  227. - 如果无法确定城市,输出"未知"。
  228. 对话历史:{conversation_history}
  229. 用户查询:{query}
  230. """)
  231. # ========== 4. 初始化数据库连接,加载持久化记忆 ==========
  232. self._init_db_and_load_memory()
  233. def get_available_tools(self) -> str:
  234. """
  235. 从 A2A 代理网络中动态获取可用工具列表,格式化为文本
  236. 这是 ReAct 架构的关键:告诉大模型当前有哪些工具/agent 可以调用,
  237. 让大模型在推理时从这些工具中选择合适的。
  238. 本方法从已注册的代理读取卡片;新增代理仍需注册和配置路由。
  239. 当前 chat/chat_stream 没有调用本方法,也没有把其结果注入主流程提示词。
  240. 返回值:
  241. str: 格式化的工具列表文本,例如:
  242. - Agent: WeatherQueryAssistant, 查询天气信息
  243. Skill: 天气查询: 根据城市查询天气
  244. - Agent: TicketQueryAssistant, 查询票务信息
  245. Skill: 票务查询: 查询航班、高铁票等
  246. """
  247. # 【辅助】按需读取代理卡片并整理说明;当前 chat/chat_stream 主线没有调用此方法。
  248. # 此方法赋值 _available_tools_text,但本方法自身没有“缓存非空就直接返回”的判断。
  249. # 输入:self.agent_network 中已经注册的代理。输出:多行字符串,描述代理名、技能和说明。
  250. # 函数名中的 tools 指整理出的能力说明文本,与 A2A 子助手加载到的 StructuredTool 对象不同。
  251. try:
  252. # 调用 get_agent_cards() 获取所有在线 agent 的卡片信息
  253. cards = self.get_agent_cards()
  254. lines = []
  255. for card in cards:
  256. # 每个 agent 一行,包含名称和描述
  257. lines.append(f"- Agent: {card['name']}, {card['description']}")
  258. # 每个 agent 的技能作为子项列出
  259. for skill in card['skills']:
  260. lines.append(f" Skill: {skill}")
  261. self._available_tools_text = "\n".join(lines)
  262. except Exception as e:
  263. # 如果 agent server 没有启动或网络不通,使用默认的工具列表作为降级方案
  264. logger.warning(f"获取可用工具列表失败: {e}")
  265. self._available_tools_text = (
  266. "- WeatherQueryAssistant: 查询天气信息\n"
  267. "- TicketQueryAssistant: 查询票务信息(航班/高铁票/会展门票)\n"
  268. "- TicketAssistant: 预定票务\n"
  269. "- TripAssistant: 差旅管家(商务用车/会务套餐/保险的查询与预订)"
  270. )
  271. return self._available_tools_text
  272. def _init_db_and_load_memory(self):
  273. """
  274. 初始化数据库连接,并从数据库加载持久化记忆
  275. 启动流程:
  276. 1. 创建 MySQL 连接,注入到 memory 实例
  277. 2. 从数据库加载用户偏好、查询历史、短期对话
  278. """
  279. # 【启动 S4.2.1】尝试连接数据库,依次恢复偏好、查询历史、短期消息。
  280. # 连接失败会保留内存模式;这不表示依赖数据库的业务查询也能运行。
  281. # 调用者:__init__。输入:conf 中的数据库参数;输出:通过修改 self.memory/self.messages 恢复状态。
  282. # 三种 load 方法各自读取对应表;此方法不创建表,不把 SQL 初始化文件自动执行一遍。
  283. try:
  284. db_conn = mysql.connector.connect(
  285. host=conf.host,
  286. user=conf.user,
  287. password=conf.password,
  288. database=conf.database
  289. )
  290. self.memory.set_db_connection(db_conn)
  291. logger.info("数据库连接初始化成功")
  292. # 从数据库恢复记忆数据
  293. self.memory.load_profile_from_db()
  294. self.memory.load_entities_from_db()
  295. self.memory.load_messages_from_db()
  296. # 将加载的短期消息同步到 messages 列表
  297. for msg in self.memory.short_term_messages:
  298. self.messages.append({"role": msg["role"], "content": msg["content"]})
  299. logger.info(f"记忆加载完成: 偏好={len(self.memory.user_profile)}项, "
  300. f"历史={len(self.memory.entity_history)}条, 对话={len(self.memory.short_term_messages)}条")
  301. except Exception as e:
  302. logger.warning(f"数据库连接初始化失败: {e}")
  303. self.memory.set_db_connection(None)
  304. async def _fetch_weather_for_city_service(self, query_str: str) -> str:
  305. """
  306. 为城市接待建议查询目的地天气,返回简要天气信息。
  307. 如果无法确定目的地,返回空字符串。
  308. 参数:
  309. query_str (str): 用户的接待资源查询内容
  310. 返回值:
  311. str: 天气信息摘要,或空字符串
  312. """
  313. # 【主流程 6:城市建议子分支,内部步骤 1】提取目的地;未知城市直接返回空天气。
  314. # 调用者:两个 _call_agent_intent 版本中的 city_service 分支。
  315. # 输入:用户接待需求文本;输出:天气代理产物文本或空字符串。
  316. # 顺序:先让模型提取城市 → 判断城市是否存在 → 构造天气任务 → 等待代理 → 取回产物。
  317. # 该函数自身不会生成接待建议,结果作为 weather_info 交给外层的建议模板。
  318. try:
  319. # 从查询中提取目的地城市
  320. chain = self._city_extract_prompt | self.llm
  321. city = chain.invoke({
  322. "conversation_history": self.memory.get_short_term_text(),
  323. "query": query_str
  324. }).content.strip()
  325. if city == "未知" or not city:
  326. logger.info(f"无法从查询中提取目的地城市: {query_str}")
  327. return ""
  328. logger.info(f"从接待资源查询中提取到城市: {city}")
  329. # 通过天气代理查询该城市天气
  330. agent = self.agent_network.get_agent("WeatherQueryAssistant")
  331. if agent is None:
  332. return ""
  333. # 保留接待需求里的日期,让天气助手按用户要求查询;没给日期才默认明天。
  334. # 【内部步骤 2】保留需求中的日期,构造天气 Task,发给 WeatherQueryAssistant。
  335. weather_query = (
  336. f"请查询{city}的天气,日期以用户接待需求为准;仅在需求未提供日期时查询明天。\n"
  337. f"用户接待需求:{query_str}"
  338. )
  339. msg = Message(content=TextContent(text=weather_query), role=MessageRole.USER)
  340. task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
  341. raw_response = await asyncio.get_event_loop().run_in_executor(
  342. None, lambda: asyncio.run(agent.send_task_async(task))
  343. )
  344. # 【内部步骤 3】任务完成且有产物时取回文本;未完成或没有产物时返回空串。
  345. # 产物本身仍可能描述“无数据”,不能将非空文本等同于有效天气数据。
  346. if raw_response.status.state == 'completed' and raw_response.artifacts:
  347. return raw_response.artifacts[0]['parts'][0]['text']
  348. return ""
  349. except Exception as e:
  350. logger.warning(f"查询接待资源天气失败: {e}")
  351. return ""
  352. def update_user_profile(self, profile: dict, replace: bool = False):
  353. """
  354. 更新用户偏好设置,由前端 API 直接调用
  355. 参数:
  356. profile (dict): 用户偏好键值对,如 {"seat_type": "二等座", "cabin_type": "经济舱"}
  357. 默认合并;replace=True 时以网页提交的完整偏好替换旧值
  358. """
  359. # 【旁路:偏好更新】交给 memory 合并或替换,并尝试持久化;完成后由 API 返回状态。
  360. # 调用者:POST /api/memory/profile。输入:偏好字典和是否整体替换。
  361. # 数据交给 ConversationMemory.update_profile;后续 intent_agent 再将偏好文本放入模型输入。
  362. self.memory.update_profile(profile, replace=replace)
  363. logger.info(f"更新用户偏好: {profile}")
  364. def intent_agent(self, user_input: str):
  365. """
  366. 意图识别:分析用户输入,判断用户想做什么
  367. 工作流程:
  368. 1. 将用户输入、对话历史、用户偏好等上下文组装后发送给大模型
  369. 2. 大模型返回 JSON,包含识别到的意图列表、改写后的查询、追问消息
  370. 支持的意图类型:
  371. - weather: 天气查询
  372. - flight: 航班查询
  373. - train: 高铁票查询
  374. - event: 会展门票查询
  375. - order: 票务预定
  376. - city_service: 城市接待建议
  377. - out_of_scope: 超出系统能力范围
  378. 参数:
  379. user_input (str): 用户本次的输入内容
  380. 返回值:
  381. tuple: (intents, user_queries, follow_up_message)
  382. - intents (list): 识别到的意图列表,如 ["weather", "flight"]
  383. - user_queries (dict): 改写后的查询,如 {"weather": "北京明天天气", "flight": "北京到上海航班"}
  384. - follow_up_message (str): 追问消息,当意图不明确时用于追问用户
  385. """
  386. # 组装 Prompt 模板 + 大模型,形成完整的处理链
  387. # 【主流程 4.1】获取提示模板并与模型组成调用链;此时还没有查天气或票务。
  388. # 调用者:chat 或 chat_stream,每轮正常处理先调用一次。
  389. # 输入:user_input 字符串,例如“北京明天天气怎样”;输出:三个值组成的元组。
  390. # 示意:(["weather"], {"weather": "北京某日期天气"}, ""),具体内容由模型生成。
  391. # 调用方用同样顺序接到 intents、user_queries、follow_up_message 三个变量中。
  392. chain = SmartBizTripPrompts.intent_prompt() | self.llm
  393. # 计算上海时区的当前日期,填入 intent_prompt 的 current_date 占位符。
  394. current_date = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d')
  395. # 调用大模型,传入所有上下文信息
  396. # 【主流程 4.2】实际请求模型,结合模板引用的历史、偏好、任务上下文和本轮问题。
  397. # 这是同步 invoke;即使外层是 async 方法,这次调用也不会自动变成非阻塞。
  398. intent_response = chain.invoke({
  399. "conversation_history": self.memory.get_short_term_text(), # 短期对话历史
  400. "query": user_input, # 用户本次输入
  401. "current_date": current_date, # 供模板解析今天、明天等相对日期
  402. "user_profile": self.memory.get_profile_text(), # 用户偏好(如"二等座"、"经济舱")
  403. "task_context": json.dumps(self.memory.current_task, ensure_ascii=False) # 当前任务上下文
  404. }).content.strip() # .content 获取文本内容,.strip() 去掉首尾空白
  405. logger.info(f"意图识别原始响应: {intent_response}")
  406. # 大模型有时会返回 ```json ... ``` 格式的代码块,需要用正则去掉
  407. # 清理的是字符串边缘的 Markdown 代码围栏,便于 json.loads 解析。
  408. # 这不校验 JSON 字段类型,也不会自动修复漏引号、漏逗号等结构错误。
  409. intent_response = re.sub(r'^```json\s*|\s*```$', '', intent_response).strip()
  410. logger.info(f"清理后响应: {intent_response}")
  411. # 将 JSON 字符串解析为 Python 字典
  412. # 【主流程 4.3】把模型文本解析成意图、改写查询和追问;无效 JSON 交由对话入口捕获。
  413. intent_output = json.loads(intent_response)
  414. # dict.get(key, default) 在字段缺失时使用默认值;字段存在但类型不对时不会自动纠正类型。
  415. # intents 用列表保留多个需求,user_queries 用字典按意图找到对应的查询内容。
  416. intents = intent_output.get("intents", []) # 意图列表,如 ["weather", "flight"]
  417. user_queries = intent_output.get("user_queries", {}) # 改写后的查询字典
  418. follow_up_message = intent_output.get("follow_up_message", "") # 追问消息
  419. # 先确认是字符串才 strip 去空白;模型若给了其他类型,这里将追问归为空串。
  420. # 这样外层可以统一用 follow_up_message != "" 判断是否需要先回复用户。
  421. follow_up_message = follow_up_message.strip() if isinstance(follow_up_message, str) else ""
  422. # 两项条件都满足才补充默认问候:识别为范围外,而且模型没有生成可用回复。
  423. # 已有追问时保留模型原文;不会覆盖每一条普通业务问题。
  424. if "out_of_scope" in intents and not follow_up_message:
  425. follow_up_message = (
  426. "你好!我是企业差旅助手,可以协助查询天气、航班、高铁票、会展门票,"
  427. "以及商务用车、会务套餐和保险,并提供城市接待建议。"
  428. "查询和预订功能需要相应服务及数据源可用。"
  429. "你可以先告诉我出发地、目的地和出行日期。"
  430. )
  431. logger.info(f"intents: {intents}||user_queries: {user_queries}||follow_up_message: {follow_up_message} ")
  432. return intents, user_queries, follow_up_message
  433. def _should_skip_planning(self, intents: list) -> bool:
  434. """
  435. 启发式判断:是否可以跳过 planning_agent,直接执行
  436. 判断规则(满足任一即跳过):
  437. 1. 未包含 complex,且意图数不超过一个时直接执行
  438. 2. 多意图全部命中下方 independent_intents 集合时直接执行;
  439. 当前直接执行分支使用 for + await,按意图顺序串行调用
  440. 需要进入 planning 的情况:
  441. - complex 意图无论有几项都进入规划
  442. - 多意图中出现不在 independent_intents 集合内的类型(如 order)
  443. - 单独的 order 或 trip_order 仍会跳过规划;本方法不直接分析自然语言依赖
  444. 优化收益:省去 1 次 LLM 调用(planning_agent),大幅降低简单请求的响应时间
  445. 参数:
  446. intents (list): 识别到的意图列表
  447. 返回值:
  448. bool: True 表示跳过 planning_agent,False 表示需要调用 planning_agent
  449. """
  450. # complex 必须进入规划,即使意图列表只有一项。
  451. # 【主流程 5.1】按意图标签做规则判断。规则返回 True 才跳过 planning_agent。
  452. # 依赖是否表达为 complex 取决于前面的意图识别,不是这个函数再次理解用户原话。
  453. # 输入:意图字符串列表。输出:布尔值,True=直接执行,False=还要询问规划模型。
  454. # 注意这里不是在生成 plan,返回值也不表示任务已完成;它仅决定下一段代码走哪条路。
  455. # 第一个判断优先级最高:即使列表只有 ["complex"],也在这里返回 False。
  456. # return 会直接离开整个函数,不会再去检查下面的“单意图”规则。
  457. if "complex" in intents:
  458. return False
  459. # 规则1:单意图直接跳过
  460. if len(intents) <= 1:
  461. return True
  462. # 规则2:多意图但全部为独立查询类,无需分步规划
  463. independent_intents = {"weather", "flight", "train", "event", "city_service",
  464. "car_rental", "meeting_package", "insurance", "trip_order"}
  465. # 逐项检查:只要发现一个不在集合里的意图就返回 False。
  466. # 所有项都检查完仍没提前返回,才执行循环后的 return True;集合成员检查不调用模型。
  467. for intent in intents:
  468. if intent not in independent_intents:
  469. # 存在需要分步的意图(如 order),不跳过
  470. # TODO 如果大模型认为意图非常复杂,使用complex。这里命中False
  471. return False
  472. return True
  473. def planning_agent(self, intents: list, user_queries: dict) -> dict:
  474. """
  475. 任务规划:直接进行任务的拆解
  476. 注意:在调用此方法前会先通过 _should_skip_planning 做启发式判断,
  477. 只有复杂任务才会进入此方法,省去不必要的 LLM 调用。
  478. 参数:
  479. intents (list): 识别到的意图列表,如 ["weather", "flight", "city_service"]
  480. user_queries (dict): 改写后的查询字典,如 {"weather": "...", "flight": "..."}
  481. 返回值:
  482. dict: 规划结果,格式为:
  483. - 简单任务:{"need_plan": false, "reason": "单意图,直接查询即可", "steps": []}
  484. - 复杂任务:{"need_plan": true, "reason": "多意图需要分步执行",
  485. "steps": [{"step": 1, "action": "...", "intent": "weather", "depends_on": 0}, ...]}
  486. """
  487. # 【主流程 5.2:可选】只有规则未跳过时才调用规划模型,返回 need_plan 和 steps。
  488. # 即使调用了规划模型,模型仍可能返回 need_plan=False,随后走直接执行分支。
  489. # 调用者:chat/chat_stream 的规划分支。输入:意图列表和按意图保存的改写查询。
  490. # 输出:Python 字典,例如 {"need_plan": True, "steps": [{"step": 1, "intent": "weather", "depends_on": 0}]}。
  491. # step 是步骤编号;intent 用来找代理;action 描述动作;depends_on 指要先拿到哪个步骤的结果。
  492. chain = SmartBizTripPrompts.planning_prompt() | self.llm
  493. # 调用参数中的 query 从 messages 最后一条取;对话入口刚加入了本轮用户消息。
  494. # json.dumps 把意图列表/查询字典变成提示词中的文本,并不是向子代理发送请求。
  495. planning_response = chain.invoke({
  496. "conversation_history": self.memory.get_short_term_text(),
  497. "query": self.messages[-1]["content"] if self.messages else "",
  498. "intents": json.dumps(intents, ensure_ascii=False),
  499. "user_queries": json.dumps(user_queries, ensure_ascii=False)
  500. }).content.strip()
  501. logger.info(f"规划响应: {planning_response}")
  502. planning_response = re.sub(r'^```json\s*|\s*```$', '', planning_response).strip()
  503. # 这里只将模型文本变成字典;步骤编号、依赖存在性和循环依赖要到 _execute_plan 再检查。
  504. plan = json.loads(planning_response)
  505. return plan
  506. async def _call_agent_intent(self, intent: str, query_str: str) -> str:
  507. """
  508. 统一调用 agent 的底层逻辑
  509. 这是一个私有方法(以 _ 开头),不直接对外暴露,供以下两个场景复用:
  510. 1. 简单任务直接执行时(chat 方法的 else 分支)
  511. 2. ReAct 循环中的步骤执行时(execute_step 方法)
  512. 这样做的好处:避免了代码重复,修改 agent 调用逻辑时只需改一处。
  513. 处理逻辑说明:
  514. - 城市接待建议先尝试调用天气子代理,再让主模型根据天气生成建议
  515. - 如果是其他意图,通过 A2A 协议调用对应的子代理
  516. - 收到结果后,根据代理类型做适当的总结(天气/票务需要总结,其他直接返回)
  517. 参数:
  518. intent (str): 意图类型,如 "weather"、"flight"、"city_service"
  519. query_str (str): 查询内容,如 "北京明天天气"
  520. 返回值:
  521. str: agent 返回的结果文本
  522. """
  523. # 根据意图类型,从配置中查找对应的 agent 名称
  524. # 【主流程 6.1】把 intent 映射成子助手名称;city_service 走单独的城市建议分支。
  525. # 调用者:非流式 chat 的直接分支,或复杂计划的 execute_step。
  526. # 输入:intent 决定交给谁,query_str 告诉对方具体做什么;输出:一次性返回一个字符串。
  527. # 例如 intent="train" 时,配置映射得到 TicketAssistant,再创建 Task 发给票务服务。
  528. # 查的是本地映射字典。例如 weather→WeatherQueryAssistant,train/flight→TicketAssistant。
  529. # 没有映射时得到 None;city_service 虽不在映射里,仍会被下方单独的 if 分支处理。
  530. agent_name = conf.intent.get(intent)
  531. # ========== 情况1:城市接待建议 ===========
  532. # 城市建议没有独立的接待代理;先尝试查天气,再由主模型生成建议。
  533. # 天气是否可用取决于子代理和数据源,不能仅凭调用就认定查询成功。
  534. if intent == "city_service":
  535. chain = SmartBizTripPrompts.city_service_prompt() | self.llm
  536. weather_info = await self._fetch_weather_for_city_service(query_str)
  537. return chain.invoke({"query": query_str, "weather_info": weather_info}).content.strip()
  538. # ========== 情况2:需要调用子代理 ===========
  539. elif agent_name:
  540. # 对于这些查询意图,保存意图类型和整段查询文本到记忆中
  541. # 这样后续对话可以引用这些实体(多轮对话场景)
  542. # extract_entities 内部已自动持久化到数据库
  543. if intent in ["flight", "train", "event", "car_rental", "meeting_package", "insurance"]:
  544. # 这里先记查询历史,再更新当前任务,随后才实际查询。
  545. # 因此“查询历史里有一条记录”不代表该次业务查询已成功。
  546. self.memory.extract_entities(intent, query_str)
  547. self.memory.update_task_context({"type": intent, "query": query_str})
  548. # 从代理网络中获取对应的 agent 实例
  549. # 【主流程 6.2】取得 A2A 客户端,准备携带历史和本轮查询的 Task。
  550. agent = self.agent_network.get_agent(agent_name)
  551. # 检查是否取到了客户端对象;即使对象存在,远端服务也可能未启动,通信时仍可能抛异常。
  552. if agent is None:
  553. # agent 未注册或不可用时,返回友好提示
  554. logger.warning(f"未找到代理:{agent_name}")
  555. return f"抱歉,{agent_name} 暂时不可用,请稍后重试。"
  556. # 构建发送给 agent 的消息:结构化传递对话历史和当前查询
  557. # 给子助手的 TextContent 内部放一段 JSON 字符串,包含 history 和 query。
  558. # 这两个字段是业务内容;外层 A2A Message/Task 另外负责协议封装。
  559. agent_input = json.dumps({
  560. "history": self.memory.get_short_term_text(),
  561. "query": query_str
  562. }, ensure_ascii=False)
  563. msg = Message(content=TextContent(text=agent_input), role=MessageRole.USER)
  564. # Message 用角色和 TextContent 包装文本,to_dict 将它转换为 Task 所接收的消息字典。
  565. # UUID 给每次代理任务生成独立标识,便于区分请求;它不是订单号或用户会话号。
  566. task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
  567. # 通过 A2A 协议异步发送任务给子代理
  568. # 线程池运行独立事件循环来等待 A2A;当前协程用 await 等待其返回。
  569. # 这里只把这段 A2A 调用移入线程池,并未把所有同步模型/数据库调用异步化。
  570. try:
  571. # 【主流程 6.3 → 7】经 A2A 把任务送到独立子助手进程,等待其完成工具调用。
  572. raw_response = await asyncio.get_event_loop().run_in_executor(
  573. # None 表示使用默认线程池;lambda 是稍后由线程执行的无参数函数。
  574. # 线程内 asyncio.run 创建事件循环并运行 send_task_async;外层 await 等待线程结果。
  575. # 两层返回最终都汇合到 raw_response,也就是带状态和产物的 A2A Task。
  576. None, lambda: asyncio.run(agent.send_task_async(task))
  577. )
  578. logger.info(f"{agent_name} 原始响应: {raw_response}")
  579. # 解析 agent 返回结果
  580. # 【主流程 9】取回 task.artifacts 中的文本,或读取状态里的追问/失败信息。
  581. # COMPLETED 只代表代理处理结束;仍要看返回内容,不能据此认定有数据或已真实下单。
  582. if raw_response.status.state == 'completed' and raw_response.artifacts:
  583. # 任务成功完成,从 artifacts 中提取结果文本
  584. # 数据路径:artifacts 是产物列表 → 第一个产物的 parts 列表 → 第一个 part 的 text。
  585. # 当前只取第一个文本 part;不会在这里遍历、合并所有产物。
  586. agent_result = raw_response.artifacts[0]['parts'][0]['text']
  587. elif raw_response.status.message:
  588. # 有消息但可能失败了,尝试从消息中提取文本
  589. agent_result = raw_response.status.message.get('content', {}).get('text', str(raw_response.status.message))
  590. else:
  591. agent_result = f"查询失败:{raw_response.status.message or '未知错误'}"
  592. except Exception as e:
  593. # agent 服务不可用(如网络不通、服务崩溃),返回友好错误提示
  594. logger.error(f"{agent_name} 调用异常: {str(e)}")
  595. return f"{agent_name} 服务暂时不可用:{str(e)}"
  596. # 根据代理类型,使用对应的总结 prompt 处理结果
  597. # 【主流程 10】按代理类型整理结果。流式版本在 astream 时持续产出文本片段。
  598. if agent_name == "WeatherQueryAssistant":
  599. chain = SmartBizTripPrompts.summarize_weather_prompt() | self.llm
  600. return chain.invoke({"query": query_str, "raw_response": agent_result}).content.strip()
  601. # 非流式版本此处仅匹配这两个名称;当前 TicketAssistant 会走下面的原文返回分支。
  602. # 流式版本则将 TicketAssistant 也纳入票务总结,两个入口的处理细节并不完全相同。
  603. elif agent_name == "TicketQueryAssistant" or agent_name == "TripAssistant":
  604. chain = SmartBizTripPrompts.summarize_ticket_prompt() | self.llm
  605. return chain.invoke({"query": query_str, "raw_response": agent_result}).content.strip()
  606. else:
  607. # 其他代理直接返回原始结果
  608. return agent_result
  609. # ========== 情况3:未识别的意图 ===========
  610. else:
  611. return "暂不支持此意图。"
  612. async def execute_step(self, step: dict, user_queries: dict,
  613. previous_results: list | None = None) -> str:
  614. """
  615. 执行单个计划步骤(供 ReAct 循环调用)
  616. 这个方法负责将规划中的单个步骤转化为实际的执行:
  617. 1. 从步骤中提取意图和查询内容
  618. 2. 调用 _call_agent_intent 执行实际的查询
  619. 参数:
  620. step (dict): 计划中的单个步骤,格式如:
  621. {"step": 1, "action": "查询天气", "intent": "weather", "depends_on": 0}
  622. user_queries (dict): 改写后的查询字典
  623. 返回值:
  624. str: 该步骤的执行结果
  625. """
  626. # 【计划执行 P4】取出当前步骤的意图和查询;必要时将动作及前置结果一起传给子助手。
  627. # 调用者:_execute_plan 批量调度时执行。
  628. # 输入:step 是当前步骤字典,user_queries 是意图→查询的映射,previous_results 是祖先步骤结果列表。
  629. # 输出:当前步骤的结果字符串。它不会自行决定再增加步骤,只执行规划里已有的一步。
  630. intent = step.get("intent", "")
  631. # 优先从 user_queries 中获取改写后的查询,如果没有则用用户原始输入
  632. # 按当前步骤 intent 查找改写查询;没有该键时退回最后一条消息文本,再没有则用空串。
  633. # 同一意图的多个计划步骤可能取到同一段查询,后面的 action 用于区分各步要完成的动作。
  634. query_str = user_queries.get(intent, self.messages[-1]["content"] if self.messages else "")
  635. logger.info(f"执行步骤:{step.get('action', '')},意图:{intent}")
  636. # 将执行动作和前置结果传给子代理,不额外增加 Thought 模型调用。
  637. # 有前置结果或有动作说明时,把查询升级为结构化 JSON 文本。
  638. # previous_results 是已执行步骤的结果,不是尚未执行的计划;由子助手阅读并决定工具参数。
  639. if previous_results or step.get("action"):
  640. query_str = json.dumps({
  641. "query": query_str,
  642. "action": step.get("action", ""),
  643. "previous_results": previous_results or [],
  644. "instruction": "依据前置结果完成当前动作;结果不足或失败时请说明或追问,不得编造参数。",
  645. }, ensure_ascii=False)
  646. # 委托给统一的 agent 调用方法
  647. # await 等待一个步骤完成,return 把它的文本交回 _execute_plan 的 gather 收集。
  648. return await self._call_agent_intent(intent, query_str)
  649. async def _execute_plan(self, steps: list, user_queries: dict) -> list:
  650. """先校验依赖,再按就绪批次执行;后续步骤携带前置结果。"""
  651. # 【计划执行 P1】先校验所有步骤号:必须是唯一正整数;depends_on=0 表示没有前置步骤。
  652. # 当前每步只接收一个直接前置步骤编号,后续会沿这条依赖链向前收集结果。
  653. # 调用者:react_loop 或 _react_loop_stream;两种入口共用同一个执行计划的方法。
  654. # 输入:steps 列表与 user_queries 字典;输出:含 step/description/result 的结果列表。
  655. # 这里分为两个阶段:先完整排批次,再实际跑任务。pending/scheduled 属于排批次阶段,completed 属于执行阶段。
  656. # 例:1 无依赖,2 依赖 1,3 无依赖 → 第一批 1 和 3,第二批 2;步骤 2 会收到步骤 1 的结果。
  657. # pending 以步骤号为键,方便后面快速判断依赖是否存在,以及删除已经排入批次的步骤。
  658. pending = {}
  659. for step in steps:
  660. number = step.get("step")
  661. # 用 type(...) is int 也会拒绝 True/False;在 Python 中 bool 是 int 子类,但这里不允许把它当步骤号。
  662. # 编号小于等于 0 或重复也会直接抛错;0 专门保留为“无依赖”的标记。
  663. if type(number) is not int or number <= 0 or number in pending:
  664. raise ValueError("计划步骤序号必须为不重复的正整数")
  665. pending[number] = step
  666. for step in steps:
  667. dep = step.get("depends_on", 0)
  668. # 依赖为 0 可以通过;非 0 依赖必须在本次计划里找到对应步骤。
  669. # 此时 pending 已收集所有步骤,所以前置步骤写在输入列表后面也可以被找到。
  670. if type(dep) is not int or (dep != 0 and dep not in pending):
  671. raise ValueError("计划包含不存在的前置步骤")
  672. # 完整校验后才执行,避免发现循环依赖前已执行部分动作。
  673. # 【计划执行 P2】只计算执行批次,尚未调用代理。scheduled 记录已安排的步骤。
  674. # 例如 1→2,另有独立步骤 3,则批次为 [1,3]、[2];不存在的依赖和环会提前报错。
  675. batches = []
  676. # 集合先放 0:这样所有 depends_on=0 的步骤在第一轮就满足“前置已安排”。
  677. scheduled = {0}
  678. # 每轮选出一批就绪步骤;只要还有未安排步骤就继续。
  679. # 此阶段不执行业务,只修改 pending/scheduled/batches 三个排程容器。
  680. while pending:
  681. # 列表推导式逐个查看尚未安排的步骤,只保留其直接前置已在 scheduled 中的项。
  682. # 同一轮 ready 生成后才更新 scheduled,所以链条 1→2 不会在这一轮同时被加入 ready。
  683. ready = [step for step in pending.values()
  684. if step.get("depends_on", 0) in scheduled]
  685. # pending 还有步骤却没有任何一步可安排,说明剩余依赖形成环,例如 1→2→1 或自己依赖自己。
  686. # 抛出异常退出,避免一边执行一边才发现计划根本无法完成。
  687. if not ready:
  688. raise ValueError("计划存在循环依赖")
  689. batches.append(ready)
  690. for step in ready:
  691. scheduled.add(step["step"])
  692. # 删除的是“待安排字典”里的键,不删除原始 steps 列表,也不是删除业务结果。
  693. del pending[step["step"]]
  694. # 【计划执行 P3】依赖全部验证通过后才真正执行;completed 保存已执行结果。
  695. completed = {}
  696. results = []
  697. # 重新建立完整步骤索引;前面的 pending 已经排空,追溯依赖时要用这份完整索引。
  698. by_number = {step["step"]: step for step in steps}
  699. # 外层循环按批次串行;本批 await gather 返回后才会进入下一批。
  700. for batch in batches:
  701. tasks = []
  702. for step in batch:
  703. # 每个步骤单独创建祖先列表,避免把另一个并行步骤的前置结果混进来。
  704. ancestors = []
  705. dep = step.get("depends_on", 0)
  706. # 向前追溯所有祖先,再反转为“最早前置 → 直接前置”的顺序传给当前步骤。
  707. while dep:
  708. # 先拿直接前置的结果,再把 dep 改为它的前置;一直追到 0 结束。
  709. # 由于批次已正确排列,执行到这里时这些前置结果都已放进 completed。
  710. ancestors.append(completed[dep])
  711. dep = by_number[dep].get("depends_on", 0)
  712. # 调用 async def 先得到协程对象,append 只收集它;真正调度在后面的 gather。
  713. # reversed 把“最近前置→最早前置”倒过来,list 将反向迭代器变成可传递的列表。
  714. tasks.append(self.execute_step(step, user_queries, list(reversed(ancestors))))
  715. # 【计划执行 P4 → 主流程 6~9】同一批并发调度,整批返回后才进入下一批。
  716. # 列表中的协程在 gather 时才被调度;返回结果与 tasks 的位置对应,不按完成先后排序。
  717. # return_exceptions=True:普通子任务异常作为结果项返回,便于同批其他任务完成。
  718. # 它不是在此处自动重试,也不会把数据库/同步模型调用全部变成并行计算。
  719. outputs = await asyncio.gather(*tasks, return_exceptions=True)
  720. # 【计划执行 P5】异常转成失败文本并记录;后续依赖步骤仍会收到该文本继续执行。
  721. # 这里没有依据失败内容自动取消后续步骤,也没有动态重规划。
  722. # zip 按位置把原步骤和对应返回值配对,即便步骤 3 比步骤 1 更早完成也不会配错。
  723. for step, output in zip(batch, outputs):
  724. if isinstance(output, Exception):
  725. output = f"执行失败:{output}"
  726. result = {"step": step["step"],
  727. "description": step.get("description", step.get("action", "")),
  728. "result": output}
  729. # 同一份 result 一方面按步骤号保存给后续依赖读取,一方面加入 results 用于最终汇总。
  730. completed[step["step"]] = result # completed 给后续步骤查找前置结果
  731. results.append(result)
  732. return results
  733. async def react_loop(self, steps: list, user_queries: dict) -> str:
  734. """
  735. ReAct 循环:按规划步骤逐步执行,直接行动(Action),跳过 Thought 推理
  736. 执行流程:
  737. 1. 按依赖关系分组步骤
  738. 2. 无依赖的步骤并行执行
  739. 3. 记录每步结果(Observation)
  740. 4. 最终汇总生成连贯回复
  741. 优化:省略 Thought LLM 调用(plan 已确定动作,Thought 无额外决策价值),
  742. 省去每步 1 次 LLM 调用,多步骤场景下显著减少响应时间。
  743. 参数:
  744. steps (list): 计划步骤列表,如:
  745. [{"step": 1, "action": "...", "intent": "flight", "depends_on": 0},
  746. {"step": 2, "action": "...", "intent": "weather", "depends_on": 0}]
  747. user_queries (dict): 改写后的查询字典
  748. 返回值:
  749. str: 综合所有步骤结果的最终回复
  750. """
  751. # 【主流程 5:复杂分支 → 6~9】先完成整个计划;每步通过非流式代理调用获得结果。
  752. # 随后进入主流程 10:多个结果由模型汇总,单个结果直接返回;并非数据库逐条推送。
  753. # 调用者:非流式 chat 的 need_plan=True 分支。
  754. # 输入:计划步骤和查询字典;输出:完整最终回复字符串。执行步骤并不在本方法内逐个展开,而是交给 _execute_plan。
  755. step_results = await self._execute_plan(steps, user_queries)
  756. # 提取每步文本供单步骤返回;step_results 本身仍保留编号和说明,供多步骤汇总使用。
  757. observations = [item["result"] for item in step_results]
  758. # ========== 所有步骤完成后的最终汇总 ==========
  759. # 多个步骤才额外调用汇总模型;一步直接返回该结果,零步返回“暂无结果”。
  760. if len(step_results) > 1:
  761. # 多个步骤:使用专门的汇总 prompt,将各步骤结果整合成一条连贯回复
  762. summary_chain = SmartBizTripPrompts.react_summary_prompt() | self.llm
  763. # 把编号、动作说明和结果拼成多行文本,让汇总模型知道每段结果来自哪一步。
  764. all_obs = "\n".join([f"步骤{s['step']} ({s['description']}): {s['result']}" for s in step_results])
  765. final_response = summary_chain.invoke({
  766. "query": self.messages[-1]["content"] if self.messages else "",
  767. "all_observations": all_obs
  768. }).content.strip()
  769. else:
  770. # 只有一个步骤:直接使用该步骤的结果
  771. final_response = observations[0] if observations else "暂无结果"
  772. return final_response
  773. async def chat(self, user_input: str) -> str:
  774. """
  775. 处理用户输入的主方法 —— 整个对话流程的入口
  776. 完整流程:
  777. 1. 记录用户消息到记忆中
  778. 2. 意图识别:判断用户想做什么
  779. 3. 处理特殊情况(超出范围 / 需要追问)
  780. 4. 任务规划:判断是简单任务还是复杂任务
  781. 5. 执行:简单任务直接执行,复杂任务进入 ReAct 循环
  782. 6. 记录回复到记忆中
  783. 7. 返回最终回复
  784. 参数:
  785. user_input (str): 用户的输入内容
  786. 返回值:
  787. str: 助手的回复内容
  788. 使用示例:
  789. service = ChatService()
  790. response = await service.chat("北京明天天气怎么样?")
  791. """
  792. # 将用户消息保存到记忆中(短期记忆、对话历史等)
  793. # 【主流程 3】先保存本轮用户消息,让意图识别能读取最新历史。
  794. # short_term_messages 有长度限制;messages 和 conversation_history 在此处持续追加。
  795. # 调用者:POST /api/chat。输入:用户文本;输出:return 一个完整字符串。
  796. # 正常路径中 response 最终总是汇总后的回复;异常路径改为返回 error_message。
  797. # 与流式版本不同,这个方法不使用 yield,HTTP 调用方必须等全部业务处理结束。
  798. # 这一步位于下方 try 之前;先记录用户消息,再做意图识别。
  799. # 后续读取短期历史时已经包含当前问题;写入历史不意味着后面的模型调用一定成功。
  800. self.memory.add_message("user", user_input)
  801. self.messages.append({"role": "user", "content": user_input})
  802. self.conversation_history += f"\nUser: {user_input}"
  803. try:
  804. # ========== 步骤1:意图识别 ==========
  805. # 【主流程 4】调用意图识别,取得意图列表、各意图查询和追问。
  806. intents, user_queries, follow_up_message = self.intent_agent(user_input)
  807. # ========== 步骤2:处理特殊情况 ==========
  808. if "out_of_scope" in intents:
  809. # 用户输入超出系统能力范围,返回追问消息
  810. response = follow_up_message
  811. elif follow_up_message != "":
  812. # 意图不明确,需要追问用户
  813. response = follow_up_message
  814. else:
  815. # ========== 步骤3:任务规划 ==========
  816. # 启发式判断:单意图或独立多意图跳过 planning_agent,省去1次LLM调用
  817. # 【主流程 5】先用规则判断是否需要规划,再按 need_plan 选择执行分支。
  818. if self._should_skip_planning(intents):
  819. plan = {"need_plan": False, "reason": "启发式判断:任务简单,可直接执行", "steps": []}
  820. logger.info(f"跳过规划: {plan['reason']}")
  821. else:
  822. plan = self.planning_agent(intents, user_queries)
  823. # 只读取规划结论;缺字段时按 False 处理。后面 if need_plan 才决定是否执行 steps。
  824. need_plan = plan.get("need_plan", False)
  825. logger.info(f"规划结果: need_plan={need_plan}, reason={plan.get('reason', '')}")
  826. if need_plan:
  827. # ========== 复杂任务:进入 ReAct 循环 ==========
  828. # 获取计划步骤,逐步执行
  829. steps = plan.get("steps", [])
  830. response = await self.react_loop(steps, user_queries)
  831. else:
  832. # ========== 简单任务:直接执行 ==========
  833. # 对每个识别到的意图,直接调用对应的处理逻辑
  834. responses = [] # 收集每个意图的回复
  835. routed_agents = [] # 记录路由到了哪些 agent
  836. # 【主流程 6:直接分支】逐个意图等待处理完成;这里是串行,不是并发。
  837. for intent in intents:
  838. logger.info(f"处理意图:{intent}")
  839. # 获取改写后的查询内容
  840. # 用当前意图取对应查询;当前代码缺键时退回空字典,并没有在这里自动补齐查询。
  841. query_str = user_queries.get(intent, {})
  842. # 统一调用 agent 处理
  843. result = await self._call_agent_intent(intent, query_str)
  844. responses.append(result)
  845. # 记录配置直接映射的代理;城市建议内部调用的天气代理不计入此列表。
  846. if intent != "city_service" and conf.intent.get(intent):
  847. routed_agents.append(conf.intent[intent])
  848. # 将多个意图的回复用空行拼接成最终回复
  849. response = "\n\n".join(responses)
  850. if routed_agents:
  851. logger.info(f"路由到代理:{routed_agents}")
  852. # 将助手回复也保存到记忆中
  853. # 【主流程 11】已有完整回复后再存助手消息,为下一轮提供上下文。
  854. # 成功走完分支后保存整段 response;只保存最终拼接文本,不为每个流式片段新增一条消息。
  855. self.memory.add_message("assistant", response)
  856. self.conversation_history += f"\nAssistant: {response}"
  857. self.messages.append({"role": "assistant", "content": response})
  858. return response
  859. # 【异常返回】意图或规划 JSON 无法解析时记录并返回提示,不继续后面的业务步骤。
  860. except json.JSONDecodeError as e:
  861. # 大模型返回的不是有效 JSON(虽然概率低,但偶尔会发生)
  862. logger.error(f"意图识别JSON解析失败")
  863. error_message = f"意图识别JSON解析失败:{str(e)}。请重试。"
  864. self.memory.add_message("assistant", error_message)
  865. self.messages.append({"role": "assistant", "content": error_message})
  866. return error_message
  867. except Exception as e:
  868. # 捕获其他所有异常(网络错误、服务不可用等)
  869. logger.error(f"处理异常: {str(e)}")
  870. error_message = f"处理失败:{str(e)}。请重试。"
  871. self.memory.add_message("assistant", error_message)
  872. self.messages.append({"role": "assistant", "content": error_message})
  873. return error_message
  874. def get_agent_cards(self) -> list:
  875. """
  876. 获取所有代理的卡片信息
  877. Agent Card 是 A2A 协议中描述一个 agent 能力的方式,
  878. 包含:名称、技能列表、描述、地址、状态等。
  879. 返回值:
  880. list: 代理卡片列表,每个卡片格式为:
  881. {
  882. "name": "WeatherQueryAssistant",
  883. "skills": ["天气查询: 根据城市查询天气"],
  884. "description": "天气查询助手",
  885. "url": "http://localhost:5005",
  886. "status": "在线"
  887. }
  888. """
  889. # 【旁路:代理卡片】读取每个已登记代理的卡片,转换成网页需要的字典列表。
  890. # status 的“在线”是这里写入的展示文本,不能证明该代理下游的 MCP/数据库可用。
  891. # 调用者:/api/agents 接口或 get_available_tools。
  892. # 输入:已注册的代理名称;输出:可被 JSON 序列化的字典列表,供网页绘制卡片。
  893. cards = []
  894. for agent_name in self.agent_network.agents.keys():
  895. agent_card = self.agent_network.get_agent_card(agent_name)
  896. agent_url = self.agent_urls.get(agent_name, "未知地址")
  897. cards.append({
  898. "name": agent_name,
  899. # 将每个技能对象格式化为 "技能名: 技能描述" 的字符串
  900. "skills": [s.name + ": " + s.description for s in agent_card.skills],
  901. "description": agent_card.description,
  902. "url": agent_url,
  903. "status": "在线"
  904. })
  905. return cards
  906. def get_memory_state(self) -> dict:
  907. """
  908. 获取当前记忆状态的摘要信息(用于调试和展示)
  909. 返回值:
  910. dict: 包含以下信息:
  911. - short_term_messages: 最近5条对话消息
  912. - user_profile: 用户偏好
  913. - current_task: 当前任务上下文
  914. - entity_history: 最近5条实体记录
  915. """
  916. # 【旁路:记忆展示】只取最近 5 条消息和查询记录,返回给网页侧栏;不请求模型。
  917. # 调用者:GET /api/memory。输出包含四个字段:最近对话、偏好、当前任务、最近查询历史。
  918. # 这里把角色标记 user/assistant 转成“用户/助手”供展示,底层记忆中仍保留原始角色标记。
  919. return {
  920. "short_term_messages": [
  921. {"role": "用户" if m["role"] == "user" else "助手", "content": m["content"], "timestamp": m["timestamp"]}
  922. for m in self.memory.short_term_messages[-5:]
  923. ],
  924. "user_profile": self.memory.user_profile,
  925. "current_task": self.memory.current_task,
  926. "entity_history": self.memory.entity_history[-5:]
  927. }
  928. def clear_memory(self):
  929. """
  930. 清空所有记忆(短期记忆、用户偏好、任务上下文等)
  931. 网页调用 /api/memory/clear 接口时调用,用于重置对话状态。
  932. memory.clear() 内部会自动清理数据库中的持久化数据。
  933. """
  934. # 【旁路:清空】先清理 memory 管理的数据,再清理 ChatService 自身维护的两份历史。
  935. # 调用者:POST /api/memory/clear。没有聊天文本输入,也不返回回复内容。
  936. # self.memory、self.messages、self.conversation_history 是三处状态,所以需要分别清理。
  937. self.memory.clear()
  938. self.messages.clear()
  939. self.conversation_history = ""
  940. async def chat_stream(self, user_input: str):
  941. """
  942. 流式处理用户输入的主方法 —— 决策主线与 chat 相同,最终回复通过 yield 分片返回
  943. 完整流程:
  944. 1. 记录用户消息到记忆中
  945. 2. 意图识别:判断用户想做什么
  946. 3. 处理特殊情况(超出范围 / 需要追问)
  947. 4. 任务规划:判断是简单任务还是复杂任务
  948. 5. 执行:简单任务直接执行,复杂任务进入 ReAct 循环
  949. 6. 最终回复通过 yield 分片返回,再保存拼接后的完整回复
  950. 参数:
  951. user_input (str): 用户的输入内容
  952. Yields:
  953. str: 助手回复的文本片段
  954. """
  955. # 【主流程 3】先保存本轮用户消息,让意图识别能读取最新历史。
  956. # short_term_messages 有长度限制;messages 和 conversation_history 在此处持续追加。
  957. # 调用者:api_server.sse_generator 的 async for。输入:本轮用户文本;输出:多次 yield 的字符串。
  958. # 顺序:记用户消息 → 识别意图 → 追问或规划/执行 → 边产出边收集回复 → 记助手消息。
  959. # yield 只是把当前片段交给调用者,不表示本函数结束;外层继续取值时会从下一行接着运行。
  960. # 片段传递共三层:模型/下层生成器 → 本方法 → API 的 SSE 生成器 → 浏览器。
  961. # 这一步位于下方 try 之前;先记录用户消息,再做意图识别。
  962. # 后续读取短期历史时已经包含当前问题;写入历史不意味着后面的模型调用一定成功。
  963. self.memory.add_message("user", user_input)
  964. self.messages.append({"role": "user", "content": user_input})
  965. self.conversation_history += f"\nUser: {user_input}"
  966. try:
  967. # 【主流程 4】调用意图识别,取得意图列表、各意图查询和追问。
  968. intents, user_queries, follow_up_message = self.intent_agent(user_input)
  969. # 【主流程 5:提前返回分支】超出范围或需要补信息时直接 yield 回复,跳过代理/工具。
  970. if "out_of_scope" in intents or follow_up_message != "":
  971. response = follow_up_message
  972. # 追问分支只产出一次整段文字;继续取值时仍会向后运行到保存助手消息的部分。
  973. yield response
  974. else:
  975. # 【主流程 5】先用规则判断是否需要规划,再按 need_plan 选择执行分支。
  976. if self._should_skip_planning(intents):
  977. plan = {"need_plan": False, "reason": "启发式判断:任务简单,可直接执行", "steps": []}
  978. logger.info(f"跳过规划(stream): {plan['reason']}")
  979. else:
  980. plan = self.planning_agent(intents, user_queries)
  981. # 只读取规划结论;缺字段时按 False 处理。后面 if need_plan 才决定是否执行 steps。
  982. need_plan = plan.get("need_plan", False)
  983. logger.info(f"规划结果: need_plan={need_plan}, reason={plan.get('reason', '')}")
  984. if need_plan:
  985. steps = plan.get("steps", [])
  986. # 【主流程 10】一边转发片段,一边保留副本;结束后拼成 response 供第 11 步保存。
  987. parts = []
  988. async for chunk in self._react_loop_stream(steps, user_queries):
  989. parts.append(chunk)
  990. yield chunk
  991. response = "".join(parts)
  992. else:
  993. responses = []
  994. # 【主流程 6:直接分支】逐个意图等待处理完成;这里是串行,不是并发。
  995. for intent in intents:
  996. # 已有前一个意图结果时先输出两个换行;这里的 if 不会跳出整个意图循环。
  997. if responses:
  998. yield "\n\n"
  999. logger.info(f"处理意图:{intent}")
  1000. # 用当前意图取对应查询;当前代码缺键时退回空字典,并没有在这里自动补齐查询。
  1001. query_str = user_queries.get(intent, {})
  1002. # parts 收集当前意图片段;responses 收集各意图全文。每次进入新意图都要新建 parts。
  1003. parts = []
  1004. async for chunk in self._call_agent_intent_stream(intent, query_str):
  1005. parts.append(chunk)
  1006. yield chunk
  1007. # 空串 join 把当前意图的片段按顺序拼成全文,加入 responses 后才处理下一个意图。
  1008. responses.append("".join(parts))
  1009. # 所有意图结束后用空行连接,形成待保存的完整助手回复。
  1010. response = "\n\n".join(responses)
  1011. # 【主流程 11】已有完整回复后再存助手消息,为下一轮提供上下文。
  1012. # 成功走完分支后保存整段 response;只保存最终拼接文本,不为每个流式片段新增一条消息。
  1013. self.memory.add_message("assistant", response)
  1014. self.conversation_history += f"\nAssistant: {response}"
  1015. self.messages.append({"role": "assistant", "content": response})
  1016. # 【异常返回】意图或规划 JSON 无法解析时记录并返回提示,不继续后面的业务步骤。
  1017. except json.JSONDecodeError as e:
  1018. logger.error("意图识别JSON解析失败")
  1019. error_message = f"意图识别JSON解析失败:{str(e)}。请重试。"
  1020. self.memory.add_message("assistant", error_message)
  1021. self.messages.append({"role": "assistant", "content": error_message})
  1022. yield error_message
  1023. except Exception as e:
  1024. logger.error(f"处理异常: {str(e)}")
  1025. error_message = f"处理失败:{str(e)}。请重试。"
  1026. self.memory.add_message("assistant", error_message)
  1027. self.messages.append({"role": "assistant", "content": error_message})
  1028. yield error_message
  1029. async def _call_agent_intent_stream(self, intent: str, query_str: str):
  1030. """
  1031. 流式版本的 _call_agent_intent,仅在生成最终回复时使用流式输出
  1032. 调用方需要自行收集 yield 的片段拼接为完整回复
  1033. """
  1034. # 【主流程 6.1】把 intent 映射成子助手名称;city_service 走单独的城市建议分支。
  1035. # 调用者:chat_stream 的直接分支。输入:意图与查询;输出:异步生成器产生的若干字符串片段。
  1036. # 这是含 yield 的 async def,调用后得到生成器;外层通过 async for 才让它逐段运行。
  1037. # A2A 请求在这里仍先等完整 Task 返回,随后主模型 astream 整理时才分片输出。
  1038. # 查的是本地映射字典。例如 weather→WeatherQueryAssistant,train/flight→TicketAssistant。
  1039. # 没有映射时得到 None;city_service 虽不在映射里,仍会被下方单独的 if 分支处理。
  1040. agent_name = conf.intent.get(intent)
  1041. if intent == "city_service":
  1042. chain = SmartBizTripPrompts.city_service_prompt() | self.llm
  1043. weather_info = await self._fetch_weather_for_city_service(query_str)
  1044. async for chunk in chain.astream({"query": query_str, "weather_info": weather_info}):
  1045. yield chunk.content if hasattr(chunk, 'content') else str(chunk)
  1046. elif agent_name:
  1047. if intent in ["flight", "train", "event", "car_rental", "meeting_package", "insurance"]:
  1048. # 这里先记查询历史,再更新当前任务,随后才实际查询。
  1049. # 因此“查询历史里有一条记录”不代表该次业务查询已成功。
  1050. self.memory.extract_entities(intent, query_str)
  1051. self.memory.update_task_context({"type": intent, "query": query_str})
  1052. # 【主流程 6.2】取得 A2A 客户端,准备携带历史和本轮查询的 Task。
  1053. agent = self.agent_network.get_agent(agent_name) # 按名称取出已注册的 A2A 客户端
  1054. # 检查是否取到了客户端对象;即使对象存在,远端服务也可能未启动,通信时仍可能抛异常。
  1055. if agent is None:
  1056. logger.warning(f"未找到代理:{agent_name}")
  1057. yield f"抱歉,{agent_name} 暂时不可用,请稍后重试。"
  1058. else:
  1059. # 流式版本拼接历史文本;非流式版本使用包含 history/query 的 JSON 文本。
  1060. # 本路径把历史和当前改写查询直接拼成对话文本,而不是非流式版本的 history/query JSON。
  1061. # 原始用户消息已在记忆中,因此追加的是用于本意图处理的改写查询。
  1062. chat_history = self.memory.get_short_term_text() + f'\nUser: {query_str}'
  1063. msg = Message(content=TextContent(text=chat_history), role=MessageRole.USER)
  1064. # Message 用角色和 TextContent 包装文本,to_dict 将它转换为 Task 所接收的消息字典。
  1065. # UUID 给每次代理任务生成独立标识,便于区分请求;它不是订单号或用户会话号。
  1066. task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
  1067. try: ## 异步调用的调度安排
  1068. # 【主流程 6.3 → 7】经 A2A 把任务送到独立子助手进程,等待其完成工具调用。
  1069. raw_response = await asyncio.get_event_loop().run_in_executor(
  1070. # None 表示使用默认线程池;lambda 是稍后由线程执行的无参数函数。
  1071. # 线程内 asyncio.run 创建事件循环并运行 send_task_async;外层 await 等待线程结果。
  1072. # 两层返回最终都汇合到 raw_response,也就是带状态和产物的 A2A Task。
  1073. None, lambda: asyncio.run(agent.send_task_async(task))
  1074. )
  1075. logger.info(f"{agent_name} 原始响应: {raw_response}")
  1076. # 【主流程 9】取回 task.artifacts 中的文本,或读取状态里的追问/失败信息。
  1077. # COMPLETED 只代表代理处理结束;仍要看返回内容,不能据此认定有数据或已真实下单。
  1078. if raw_response.status.state == 'completed' and raw_response.artifacts:
  1079. # 数据路径:artifacts 是产物列表 → 第一个产物的 parts 列表 → 第一个 part 的 text。
  1080. # 当前只取第一个文本 part;不会在这里遍历、合并所有产物。
  1081. agent_result = raw_response.artifacts[0]['parts'][0]['text']
  1082. elif raw_response.status.message:
  1083. agent_result = raw_response.status.message.get('content', {}).get('text', str(raw_response.status.message))
  1084. else:
  1085. agent_result = f"查询失败:{raw_response.status.message or '未知错误'}"
  1086. except Exception as e:
  1087. logger.error(f"{agent_name} 调用异常: {str(e)}")
  1088. agent_result = f"{agent_name} 服务暂时不可用:{str(e)}"
  1089. # 【主流程 10】按代理类型整理结果。流式版本在 astream 时持续产出文本片段。
  1090. if agent_name == "WeatherQueryAssistant":
  1091. chain = SmartBizTripPrompts.summarize_weather_prompt() | self.llm
  1092. elif agent_name in ("TicketQueryAssistant", "TicketAssistant", "TripAssistant"):
  1093. chain = SmartBizTripPrompts.summarize_ticket_prompt() | self.llm
  1094. else:
  1095. yield agent_result
  1096. return
  1097. # astream 返回模型消息片段;取 content 后交给外层,外层再封装 SSE。
  1098. # hasattr 用于判断片段是否有 content 属性,没有时才退回 str(chunk)。
  1099. async for chunk in chain.astream({"query": query_str, "raw_response": agent_result}):
  1100. yield chunk.content if hasattr(chunk, 'content') else str(chunk)
  1101. else:
  1102. yield "暂不支持此意图。"
  1103. async def _react_loop_stream(self, steps: list, user_queries: dict):
  1104. """
  1105. 流式版本的 ReAct 循环,直接行动(跳过 Thought 推理),最终汇总时流式输出
  1106. 优化:与 react_loop 一致,省略 Thought LLM 调用,减少每步 1 次 LLM 请求。
  1107. 调用方需要自行收集 yield 的片段拼接为完整回复。
  1108. """
  1109. # 【主流程 5:复杂分支 → 6~9】先完成整个计划;每步通过非流式代理调用获得结果。
  1110. # 随后进入主流程 10:多个结果由模型汇总,单个结果直接返回;并非数据库逐条推送。
  1111. # 调用者:chat_stream 的 need_plan=True 分支。
  1112. # 输入:计划步骤和查询字典;输出:供外层消费的文本片段。
  1113. # 先 await 全部步骤结果,再由汇总模型输出;只有一步时直接 yield 那一步的完整文本。
  1114. step_results = await self._execute_plan(steps, user_queries)
  1115. # 提取每步文本供单步骤返回;step_results 本身仍保留编号和说明,供多步骤汇总使用。
  1116. observations = [item["result"] for item in step_results]
  1117. # 多个步骤才额外调用汇总模型;一步直接返回该结果,零步返回“暂无结果”。
  1118. if len(step_results) > 1:
  1119. summary_chain = SmartBizTripPrompts.react_summary_prompt() | self.llm
  1120. # 把编号、动作说明和结果拼成多行文本,让汇总模型知道每段结果来自哪一步。
  1121. all_obs = "\n".join([f"步骤{s['step']} ({s['description']}): {s['result']}" for s in step_results])
  1122. async for chunk in summary_chain.astream({
  1123. "query": self.messages[-1]["content"] if self.messages else "",
  1124. "all_observations": all_obs
  1125. }):
  1126. yield chunk.content if hasattr(chunk, 'content') else str(chunk)
  1127. else:
  1128. yield observations[0] if observations else "暂无结果"