dashscope_client.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. """
  2. DashScope客户端封装
  3. 封装与阿里云百炼平台的交互
  4. 需求: 2.2, 3.1, 3.2, 3.3, 3.4
  5. 支持: 流式输出、Token消耗输出、思考模式、图像输入、联网搜索
  6. 联网搜索需求: 1.1, 2.1, 2.2, 2.3, 5.1
  7. """
  8. import logging
  9. from typing import Dict, Generator, List, Optional, Union
  10. import dashscope
  11. from dashscope import Generation, MultiModalConversation
  12. from app.schemas.llm_schema import SearchOptions, SearchResult
  13. from app.services.vertical_domain_processor import VerticalDomainProcessor
  14. logger = logging.getLogger(__name__)
  15. # DashScope API基础URL
  16. DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1"
  17. class DashScopeClient:
  18. """百炼平台DashScope客户端"""
  19. def __init__(self, api_key: str):
  20. """
  21. 初始化客户端
  22. Args:
  23. api_key: 用户的API密钥(从用户数据动态加载)
  24. """
  25. self.api_key = api_key
  26. # 使用中国站Endpoint(如需国际站请改为 https://dashscope-intl.aliyuncs.com/api/v1)
  27. dashscope.base_http_api_url = DASHSCOPE_BASE_URL
  28. def _build_search_params(self, search_options: SearchOptions) -> Dict:
  29. """
  30. 构建搜索参数
  31. Args:
  32. search_options: 搜索配置选项
  33. Returns:
  34. 搜索参数字典
  35. """
  36. if not search_options or not search_options.enable_search:
  37. return {}
  38. search_params = {}
  39. # 基础搜索参数
  40. if search_options.forced_search:
  41. search_params["forced_search"] = True
  42. if search_options.enable_search_extension:
  43. search_params["enable_search_extension"] = True
  44. # 搜索策略和时效性
  45. search_options_dict = {
  46. "search_strategy": search_options.search_strategy
  47. }
  48. # 时效性参数(仅turbo策略支持)
  49. if (search_options.freshness is not None and
  50. search_options.search_strategy == "turbo"):
  51. search_options_dict["freshness"] = search_options.freshness
  52. # 自然语言搜索控制
  53. if search_options.intention_options:
  54. search_options_dict["intention_options"] = search_options.intention_options
  55. search_params["search_options"] = search_options_dict
  56. # 搜索来源和引用相关参数
  57. if search_options.enable_source:
  58. search_params["enable_source"] = True
  59. if search_options.enable_citation:
  60. search_params["enable_citation"] = True
  61. search_params["citation_format"] = search_options.citation_format
  62. if search_options.prepend_search_result:
  63. search_params["prepend_search_result"] = True
  64. logger.info(f"构建搜索参数: {search_params}")
  65. return search_params
  66. def _extract_search_info(self, response) -> Optional[Dict]:
  67. """
  68. 提取搜索信息
  69. Args:
  70. response: DashScope API响应
  71. Returns:
  72. 搜索信息字典,如果没有搜索信息则返回None
  73. """
  74. try:
  75. # 检查响应中是否包含搜索信息
  76. if hasattr(response, 'output') and hasattr(response.output, 'search_info'):
  77. search_info = response.output.search_info
  78. logger.info(f"提取到搜索信息: {len(search_info.get('search_results', []))} 个结果")
  79. # 处理垂直领域搜索结果
  80. enhanced_search_info = VerticalDomainProcessor.process_vertical_domain_search(search_info)
  81. return enhanced_search_info
  82. # 对于字典格式的响应
  83. if isinstance(response, dict):
  84. output = response.get('output', {})
  85. if 'search_info' in output:
  86. search_info = output['search_info']
  87. logger.info(f"提取到搜索信息: {len(search_info.get('search_results', []))} 个结果")
  88. # 处理垂直领域搜索结果
  89. enhanced_search_info = VerticalDomainProcessor.process_vertical_domain_search(search_info)
  90. return enhanced_search_info
  91. return None
  92. except Exception as e:
  93. logger.warning(f"提取搜索信息时出错: {e}")
  94. return None
  95. def _extract_search_results(self, search_info: Dict) -> List[SearchResult]:
  96. """
  97. 从搜索信息中提取搜索结果列表
  98. Args:
  99. search_info: 搜索信息字典
  100. Returns:
  101. 搜索结果列表
  102. """
  103. if not search_info or 'search_results' not in search_info:
  104. return []
  105. results = []
  106. for item in search_info['search_results']:
  107. try:
  108. result = SearchResult(
  109. index=item.get('index', 0),
  110. title=item.get('title', ''),
  111. url=item.get('url', ''),
  112. snippet=item.get('snippet')
  113. )
  114. results.append(result)
  115. except Exception as e:
  116. logger.warning(f"解析搜索结果项时出错: {e}, 项目: {item}")
  117. continue
  118. return results
  119. def _has_image_content(self, messages: List[Dict]) -> bool:
  120. """检查消息中是否包含图像内容"""
  121. for msg in messages:
  122. content = msg.get('content')
  123. if isinstance(content, list):
  124. for item in content:
  125. if isinstance(item, dict) and item.get('type') == 'image_url':
  126. return True
  127. return False
  128. def _should_use_multimodal_api(self, model: str) -> bool:
  129. """判断是否应该使用多模态API
  130. 根据模型名称判断是否需要使用MultiModalConversation API:
  131. - 名称含 -vl- 或 -vl 结尾的视觉语言模型
  132. - 名称含 -omni- 或 -omni 结尾的全模态模型
  133. - 名称含 multimodal 的模型
  134. - 显式维护的特殊模型名单
  135. """
  136. model_lower = model.lower()
  137. # 按命名规律匹配 VL / omni / 多模态模型
  138. if '-vl-' in model_lower or model_lower.endswith('-vl'):
  139. return True
  140. if '-omni-' in model_lower or model_lower.endswith('-omni'):
  141. return True
  142. if 'multimodal' in model_lower:
  143. return True
  144. # 显式列表(不符合上述命名规律的特殊多模态模型)
  145. explicit_multimodal = {
  146. 'qwen3.5-flash',
  147. 'qwen3.5-plus',
  148. 'qwen3.6-plus',
  149. 'kimi-k2.5',
  150. }
  151. return model in explicit_multimodal
  152. def _convert_to_multimodal_format(self, messages: List[Dict]) -> List[Dict]:
  153. """将消息转换为多模态格式"""
  154. converted = []
  155. for msg in messages:
  156. role = msg.get('role')
  157. content = msg.get('content')
  158. if isinstance(content, str):
  159. converted.append({'role': role, 'content': [{'text': content}]})
  160. elif isinstance(content, list):
  161. new_content = []
  162. for item in content:
  163. if isinstance(item, dict):
  164. if item.get('type') == 'text':
  165. new_content.append({'text': item.get('text', '')})
  166. elif item.get('type') == 'image_url':
  167. image_url = item.get('image_url', {})
  168. url = image_url.get('url', '')
  169. new_content.append({'image': url})
  170. elif isinstance(item, str):
  171. new_content.append({'text': item})
  172. converted.append({'role': role, 'content': new_content})
  173. else:
  174. converted.append({'role': role, 'content': [{'text': str(content)}]})
  175. return converted
  176. def call(
  177. self,
  178. model: str,
  179. messages: List[Dict],
  180. temperature: Optional[float] = None,
  181. top_p: Optional[float] = None,
  182. max_tokens: Optional[int] = None,
  183. enable_thinking: bool = False,
  184. thinking_budget: Optional[int] = None,
  185. search_options: Optional[SearchOptions] = None
  186. ) -> Dict:
  187. """
  188. 非流式调用
  189. Args:
  190. model: 模型名称
  191. messages: 对话消息列表
  192. temperature: 采样温度
  193. top_p: 核采样概率
  194. max_tokens: 最大输出token数
  195. enable_thinking: 是否启用思考模式
  196. thinking_budget: 思考过程的最大Token数
  197. search_options: 搜索配置选项
  198. Returns:
  199. API响应字典
  200. """
  201. has_image = self._has_image_content(messages)
  202. use_multimodal = self._should_use_multimodal_api(model)
  203. # 构建搜索参数
  204. search_params = self._build_search_params(search_options) if search_options else {}
  205. if has_image or use_multimodal:
  206. # 使用多模态API
  207. converted_messages = self._convert_to_multimodal_format(messages)
  208. kwargs = {
  209. 'api_key': self.api_key,
  210. 'model': model,
  211. 'messages': converted_messages
  212. }
  213. if max_tokens is not None:
  214. kwargs['max_tokens'] = max_tokens
  215. # 多模态API暂不支持搜索功能,记录警告
  216. if search_params:
  217. logger.warning("多模态API暂不支持联网搜索功能,将忽略搜索参数")
  218. response = MultiModalConversation.call(**kwargs)
  219. else:
  220. # 使用文本生成API
  221. kwargs = {
  222. 'api_key': self.api_key,
  223. 'model': model,
  224. 'messages': messages,
  225. 'result_format': 'message'
  226. }
  227. if temperature is not None:
  228. kwargs['temperature'] = temperature
  229. if top_p is not None:
  230. kwargs['top_p'] = top_p
  231. if max_tokens is not None:
  232. kwargs['max_tokens'] = max_tokens
  233. if enable_thinking:
  234. kwargs['enable_thinking'] = True
  235. if thinking_budget is not None:
  236. kwargs['thinking_budget'] = thinking_budget
  237. # 添加搜索参数
  238. if search_params:
  239. kwargs['enable_search'] = True
  240. kwargs.update(search_params)
  241. logger.info(f"启用联网搜索: model={model}, search_strategy={search_options.search_strategy}")
  242. response = Generation.call(**kwargs)
  243. return response
  244. def call_stream(
  245. self,
  246. model: str,
  247. messages: List[Dict],
  248. temperature: Optional[float] = None,
  249. top_p: Optional[float] = None,
  250. max_tokens: Optional[int] = None,
  251. enable_thinking: bool = False,
  252. thinking_budget: Optional[int] = None,
  253. search_options: Optional[SearchOptions] = None
  254. ) -> Generator:
  255. """
  256. 流式调用
  257. Args:
  258. model: 模型名称
  259. messages: 对话消息列表
  260. temperature: 采样温度
  261. top_p: 核采样概率
  262. max_tokens: 最大输出token数
  263. enable_thinking: 是否启用思考模式
  264. thinking_budget: 思考过程的最大Token数
  265. search_options: 搜索配置选项
  266. Yields:
  267. 流式响应块
  268. """
  269. has_image = self._has_image_content(messages)
  270. use_multimodal = self._should_use_multimodal_api(model)
  271. # 构建搜索参数
  272. search_params = self._build_search_params(search_options) if search_options else {}
  273. logger.info(f"DashScope流式调用: model={model}, has_image={has_image}, use_multimodal={use_multimodal}, enable_thinking={enable_thinking}, enable_search={bool(search_params)}")
  274. try:
  275. if has_image or use_multimodal:
  276. # 使用多模态API流式调用
  277. converted_messages = self._convert_to_multimodal_format(messages)
  278. kwargs = {
  279. 'api_key': self.api_key,
  280. 'model': model,
  281. 'messages': converted_messages,
  282. 'stream': True,
  283. 'incremental_output': True
  284. }
  285. if max_tokens is not None:
  286. kwargs['max_tokens'] = max_tokens
  287. # 多模态API暂不支持搜索功能,记录警告
  288. if search_params:
  289. logger.warning("多模态API暂不支持联网搜索功能,将忽略搜索参数")
  290. responses = MultiModalConversation.call(**kwargs)
  291. else:
  292. # 使用文本生成API流式调用
  293. kwargs = {
  294. 'api_key': self.api_key,
  295. 'model': model,
  296. 'messages': messages,
  297. 'result_format': 'message',
  298. 'stream': True,
  299. 'incremental_output': True
  300. }
  301. if temperature is not None:
  302. kwargs['temperature'] = temperature
  303. if top_p is not None:
  304. kwargs['top_p'] = top_p
  305. if max_tokens is not None:
  306. kwargs['max_tokens'] = max_tokens
  307. if enable_thinking:
  308. kwargs['enable_thinking'] = True
  309. if thinking_budget is not None:
  310. kwargs['thinking_budget'] = thinking_budget
  311. # 添加搜索参数
  312. if search_params:
  313. kwargs['enable_search'] = True
  314. kwargs.update(search_params)
  315. logger.info(f"启用流式联网搜索: model={model}, search_strategy={search_options.search_strategy}")
  316. responses = Generation.call(**kwargs)
  317. for response in responses:
  318. yield response
  319. except Exception as e:
  320. logger.error(f"DashScope流式调用异常: {type(e).__name__}: {str(e)}")
  321. raise
  322. def call_with_search(
  323. self,
  324. model: str,
  325. messages: List[Dict],
  326. search_options: SearchOptions,
  327. **kwargs
  328. ) -> Dict:
  329. """
  330. 支持搜索的非流式调用(便利方法)
  331. Args:
  332. model: 模型名称
  333. messages: 对话消息列表
  334. search_options: 搜索配置选项
  335. **kwargs: 其他参数
  336. Returns:
  337. API响应字典
  338. """
  339. return self.call(
  340. model=model,
  341. messages=messages,
  342. search_options=search_options,
  343. **kwargs
  344. )
  345. def call_stream_with_search(
  346. self,
  347. model: str,
  348. messages: List[Dict],
  349. search_options: SearchOptions,
  350. **kwargs
  351. ) -> Generator:
  352. """
  353. 支持搜索的流式调用(便利方法)
  354. Args:
  355. model: 模型名称
  356. messages: 对话消息列表
  357. search_options: 搜索配置选项
  358. **kwargs: 其他参数
  359. Yields:
  360. 流式响应块
  361. """
  362. return self.call_stream(
  363. model=model,
  364. messages=messages,
  365. search_options=search_options,
  366. **kwargs
  367. )
  368. def extract_search_results_from_response(self, response) -> List[SearchResult]:
  369. """
  370. 从响应中提取搜索结果(便利方法)
  371. Args:
  372. response: DashScope API响应
  373. Returns:
  374. 搜索结果列表
  375. """
  376. search_info = self._extract_search_info(response)
  377. if search_info:
  378. return self._extract_search_results(search_info)
  379. return []
  380. def has_search_results(self, response) -> bool:
  381. """
  382. 检查响应是否包含搜索结果
  383. Args:
  384. response: DashScope API响应
  385. Returns:
  386. 是否包含搜索结果
  387. """
  388. search_info = self._extract_search_info(response)
  389. return search_info is not None and len(search_info.get('search_results', [])) > 0