llm_service.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """LLM 服务层 —— OpenAI 兼容接口封装与 HelloAgents LLM 适配."""
  2. import logging
  3. import os
  4. from functools import wraps
  5. from typing import Any
  6. from collections.abc import Callable
  7. from hello_agents import HelloAgentsLLM
  8. from ...settings import get_settings
  9. logger = logging.getLogger(__name__)
  10. def normalize_openai_compatible_chat_messages(messages: Any) -> Any:
  11. if not isinstance(messages, list):
  12. return messages
  13. out: list[Any] = []
  14. for m in messages:
  15. if not isinstance(m, dict):
  16. out.append(m)
  17. continue
  18. role = str(m.get("role") or "").strip().lower()
  19. content = m.get("content")
  20. if role == "summary":
  21. text = content if isinstance(content, str) else ("" if content is None else str(content))
  22. out.append({"role": "user", "content": ("[前文摘要]\n" + text).strip()})
  23. continue
  24. if role == "developer":
  25. text = content if isinstance(content, str) else ("" if content is None else str(content))
  26. nm = dict(m)
  27. nm["role"] = "system"
  28. nm["content"] = text
  29. out.append(nm)
  30. continue
  31. out.append(m)
  32. return out
  33. def _patch_hello_agents_llm_openai_chat_roles() -> None:
  34. marker = "_papergraph_openai_role_normalize_applied"
  35. if getattr(HelloAgentsLLM, marker, False):
  36. return
  37. def _wrap(orig: Callable[..., Any]) -> Callable[..., Any]:
  38. @wraps(orig)
  39. def inner(self: Any, *args: Any, **kwargs: Any) -> Any:
  40. if args and isinstance(args[0], list):
  41. args = (normalize_openai_compatible_chat_messages(args[0]),) + tuple(args[1:])
  42. elif isinstance(kwargs.get("messages"), list):
  43. kwargs = dict(kwargs)
  44. kwargs["messages"] = normalize_openai_compatible_chat_messages(kwargs["messages"])
  45. return orig(self, *args, **kwargs)
  46. return inner
  47. HelloAgentsLLM.invoke = _wrap(HelloAgentsLLM.invoke)
  48. if hasattr(HelloAgentsLLM, "invoke_with_tools"):
  49. HelloAgentsLLM.invoke_with_tools = _wrap(HelloAgentsLLM.invoke_with_tools)
  50. for _async_name in ("ainvoke", "async_invoke"):
  51. if hasattr(HelloAgentsLLM, _async_name):
  52. setattr(HelloAgentsLLM, _async_name, _wrap(getattr(HelloAgentsLLM, _async_name)))
  53. setattr(HelloAgentsLLM, marker, True)
  54. logger.debug("HelloAgentsLLM: patched invoke* for OpenAI-compatible message roles (summary→user)")
  55. _patch_hello_agents_llm_openai_chat_roles()
  56. def _patch_deepseek_disable_thinking() -> None:
  57. marker = "_papergraph_deepseek_thinking_disabled"
  58. if getattr(HelloAgentsLLM, marker, False):
  59. return
  60. import re as _re
  61. def _is_deepseek(llm_self: Any) -> bool:
  62. base = str(getattr(getattr(llm_self, "_adapter", None), "base_url", "") or "")
  63. return bool(_re.search(r"deepseek", base, _re.I))
  64. def _wrap(orig: Callable[..., Any]) -> Callable[..., Any]:
  65. @wraps(orig)
  66. def inner(self: Any, *args: Any, **kwargs: Any) -> Any:
  67. if _is_deepseek(self):
  68. kwargs = dict(kwargs)
  69. extra = dict(kwargs.get("extra_body") or {})
  70. if "thinking" not in extra:
  71. extra["thinking"] = {"type": "disabled"}
  72. kwargs["extra_body"] = extra
  73. return orig(self, *args, **kwargs)
  74. return inner
  75. HelloAgentsLLM.invoke = _wrap(HelloAgentsLLM.invoke)
  76. if hasattr(HelloAgentsLLM, "invoke_with_tools"):
  77. HelloAgentsLLM.invoke_with_tools = _wrap(HelloAgentsLLM.invoke_with_tools)
  78. for _async_name in ("ainvoke", "async_invoke"):
  79. if hasattr(HelloAgentsLLM, _async_name):
  80. setattr(HelloAgentsLLM, _async_name, _wrap(getattr(HelloAgentsLLM, _async_name)))
  81. setattr(HelloAgentsLLM, marker, True)
  82. logger.debug("HelloAgentsLLM: patched invoke* to disable thinking mode for DeepSeek")
  83. _patch_deepseek_disable_thinking()
  84. _llm_instance: HelloAgentsLLM | None = None
  85. def coerce_hello_agents_llm_output_to_str(out: Any) -> str:
  86. if out is None:
  87. return ""
  88. if isinstance(out, str):
  89. return out
  90. for attr in ("content", "text"):
  91. v = getattr(out, attr, None)
  92. if isinstance(v, str):
  93. return v
  94. msg = getattr(out, "message", None)
  95. if msg is not None:
  96. c = getattr(msg, "content", None)
  97. if isinstance(c, str):
  98. return c
  99. choices = getattr(out, "choices", None)
  100. if isinstance(choices, list) and choices:
  101. m = getattr(choices[0], "message", None)
  102. if m is not None:
  103. c = getattr(m, "content", None)
  104. if isinstance(c, str):
  105. return c
  106. return str(out)
  107. def _maybe_disable_proxy_for_llm(base_url: str) -> None:
  108. url = (base_url or "").strip()
  109. if not url:
  110. return
  111. disable = get_settings().llm_disable_proxy
  112. proxy_vars = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
  113. has_proxy = any(str(os.getenv(k) or "").strip() for k in proxy_vars)
  114. if not has_proxy:
  115. return
  116. from urllib.parse import urlparse
  117. host = ""
  118. try:
  119. host = (urlparse(url).hostname or "").strip()
  120. except Exception:
  121. host = ""
  122. if not host:
  123. return
  124. def _append_no_proxy(*extra_hosts: str) -> None:
  125. to_add = [h for h in (host, *extra_hosts) if h and str(h).strip()]
  126. if "deepseek.com" in host:
  127. for h in ("deepseek.com", "*.deepseek.com"):
  128. if h not in to_add:
  129. to_add.append(h)
  130. if "aihubmix.com" in host:
  131. for h in ("aihubmix.com", "*.aihubmix.com"):
  132. if h not in to_add:
  133. to_add.append(h)
  134. for env_key in ("NO_PROXY", "no_proxy"):
  135. cur = str(os.getenv(env_key) or "").strip()
  136. parts = [p.strip() for p in cur.split(",") if p.strip()]
  137. seen = {p.lower() for p in parts}
  138. for h in to_add:
  139. hl = h.lower()
  140. if hl not in seen:
  141. parts.append(h)
  142. seen.add(hl)
  143. os.environ[env_key] = ",".join(parts)
  144. if disable:
  145. for k in proxy_vars:
  146. os.environ.pop(k, None)
  147. _append_no_proxy()
  148. return
  149. _append_no_proxy()
  150. def is_llm_configured() -> bool:
  151. s = get_settings()
  152. key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or s.openai_api_key or "").strip()
  153. if not key:
  154. from dotenv import load_dotenv
  155. env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')
  156. if os.path.exists(env_path):
  157. load_dotenv(env_path, override=True)
  158. key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or "").strip()
  159. return bool(key)
  160. def _sync_env_from_settings() -> None:
  161. s = get_settings()
  162. if not os.getenv("LLM_API_KEY") and not os.getenv("OPENAI_API_KEY") and os.getenv("AIHUBMIX_API_KEY"):
  163. os.environ["LLM_API_KEY"] = str(os.getenv("AIHUBMIX_API_KEY") or "").strip()
  164. if not os.getenv("LLM_BASE_URL") and not os.getenv("OPENAI_BASE_URL") and os.getenv("AIHUBMIX_BASE_URL"):
  165. os.environ["LLM_BASE_URL"] = str(os.getenv("AIHUBMIX_BASE_URL") or "").strip()
  166. if not os.getenv("LLM_MODEL_ID") and not os.getenv("OPENAI_MODEL") and os.getenv("AIHUBMIX_MODEL_ID"):
  167. os.environ["LLM_MODEL_ID"] = str(os.getenv("AIHUBMIX_MODEL_ID") or "").strip()
  168. if not os.getenv("LLM_API_KEY") and not os.getenv("OPENAI_API_KEY") and s.openai_api_key:
  169. os.environ["LLM_API_KEY"] = s.openai_api_key
  170. if not os.getenv("LLM_BASE_URL") and not os.getenv("OPENAI_BASE_URL") and s.openai_base_url:
  171. os.environ["LLM_BASE_URL"] = s.openai_base_url
  172. if not os.getenv("LLM_MODEL_ID") and not os.getenv("OPENAI_MODEL") and s.openai_model:
  173. os.environ["LLM_MODEL_ID"] = s.openai_model
  174. def get_llm() -> HelloAgentsLLM:
  175. global _llm_instance
  176. if _llm_instance is None:
  177. _sync_env_from_settings()
  178. api_key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or "")
  179. base_url = (os.getenv("LLM_BASE_URL") or os.getenv("OPENAI_BASE_URL") or "")
  180. model = (os.getenv("LLM_MODEL_ID") or os.getenv("OPENAI_MODEL") or "")
  181. if not api_key:
  182. s = get_settings()
  183. api_key = s.openai_api_key
  184. if not base_url:
  185. base_url = s.openai_base_url
  186. if not model:
  187. model = s.openai_model
  188. if not api_key:
  189. raise RuntimeError("LLM 未配置:请设置 LLM_API_KEY(或在 backend/.env 中配置)")
  190. _maybe_disable_proxy_for_llm(base_url)
  191. kw = {}
  192. if model:
  193. kw["model"] = model
  194. if api_key:
  195. kw["api_key"] = api_key
  196. if base_url:
  197. kw["base_url"] = base_url
  198. logger.info("🔧 正在初始化 LLM...")
  199. logger.info(" Model: %s", model or "default")
  200. logger.info(" Base URL: %s", base_url or "default")
  201. _llm_instance = HelloAgentsLLM(**kw)
  202. logger.info("✅ LLM 已初始化")
  203. logger.info(" 实际模型: %s", getattr(_llm_instance, "model", "") or "unknown")
  204. logger.info(" Provider: %s", getattr(_llm_instance, "provider", None) or "unknown")
  205. return _llm_instance