model_factory.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """模型实例构建。
  2. 三个要点:
  3. 1. **思考级别烧进实例**——级别是构造参数,运行时改级别等于换模型,需要
  4. 重建 agent(见 agents/agent_registry.py 的失效重建)。
  5. 2. **必须显式声明上下文窗口**——内置 SummarizationMiddleware 的
  6. ``trigger=("fraction", x)`` 依赖模型 ``profile["max_input_tokens"]``;
  7. 不声明则框架在首次触发压缩时直接报错,而不是降级。
  8. 3. **不信任进程环境**——用 ``config`` 读 .env 的值,而不是让 SDK 自己去
  9. 捡 ``OPENAI_API_KEY`` 等环境变量,避免被外部环境污染。
  10. """
  11. from __future__ import annotations
  12. import functools
  13. from ipaddress import ip_address
  14. from typing import Any
  15. from urllib.parse import urlsplit
  16. import config
  17. from obs import get_logger
  18. logger = get_logger(__name__)
  19. # 已知模型的上下文窗口(token)。来源是各家的公开文档;命中不了就用兜底值。
  20. KNOWN_CONTEXT_WINDOWS: dict[str, int] = {
  21. "deepseek-flash": 128_000,
  22. "deepseek-v4-flash": 128_000,
  23. "deepseek-v4-pro": 128_000,
  24. "deepseek-chat": 64_000,
  25. "deepseek-reasoner": 64_000,
  26. "gpt-4o": 128_000,
  27. "gpt-4o-mini": 128_000,
  28. # 用户提供的平台上限:非思考最大输入 260K,总上下文 262K;声明输入额度。
  29. "qwen3.5-35b-a3b": 260_000,
  30. }
  31. # 兜底窗口:按最保守的假设给,宁可早点压缩也不要超窗被服务端截断
  32. FALLBACK_CONTEXT_WINDOW = 64_000
  33. VALID_THINKING_LEVELS = ("off", "high", "highest")
  34. def _is_qwen35_model(model_name: str) -> bool:
  35. """兼容组织前缀和 FP8 等部署后缀,保留实际请求中的完整模型名。"""
  36. return model_name.strip().rsplit("/", 1)[-1].lower().startswith("qwen3.5-")
  37. def _qwen_thinking_payload(level: str, extra_body: dict | None = None) -> dict[str, Any]:
  38. """Qwen3.5/vLLM 使用模板思考开关;high 和 highest 均开启思考。"""
  39. options = dict(extra_body or {})
  40. template_kwargs = dict(options.get("chat_template_kwargs") or {})
  41. template_kwargs["enable_thinking"] = level != "off"
  42. # 顶层 enable_thinking 是另一种接口的参数,避免同时发送相互冲突的开关。
  43. options.pop("enable_thinking", None)
  44. options["chat_template_kwargs"] = template_kwargs
  45. return options
  46. def context_window(model_name: str | None = None) -> int:
  47. """解析模型的上下文窗口。
  48. ``CONTEXT_LIMIT`` 显式配置时优先——这是给「模型真实窗口比文档小」或
  49. 「想留更多余量」的场景留的手动闸。
  50. """
  51. if config.CONTEXT_LIMIT > 0:
  52. return config.CONTEXT_LIMIT
  53. name = model_name or config.MODEL
  54. if name in KNOWN_CONTEXT_WINDOWS:
  55. return KNOWN_CONTEXT_WINDOWS[name]
  56. # 前缀匹配:deepseek-xxx-v5 这种没登记过的新版本,沿用同族窗口
  57. for known, window in KNOWN_CONTEXT_WINDOWS.items():
  58. family = known.split("-")[0]
  59. if name.startswith(family):
  60. return window
  61. logger.warning(
  62. "模型 %s 未登记上下文窗口,按兜底值 %d 处理(可用 CONTEXT_LIMIT 显式覆盖)",
  63. name, FALLBACK_CONTEXT_WINDOW,
  64. )
  65. return FALLBACK_CONTEXT_WINDOW
  66. def normalize_thinking(level: str | None) -> str:
  67. """把思考级别归一到合法值;非法值回落 off 并告警。"""
  68. value = (level or config.THINKING_LEVEL or "off").strip().lower()
  69. if value not in VALID_THINKING_LEVELS:
  70. logger.warning("非法思考级别 %r,回落 off", level)
  71. return "off"
  72. return value
  73. def _thinking_payload(level: str) -> dict[str, Any] | None:
  74. """思考级别 → 透传给 provider 的 extra_body。
  75. ``off`` 返回 None(**什么都不发**):不同 provider 对「关闭思考」的
  76. 表达方式不一致,发错字段会被服务端拒;不发才是真正中立的默认。
  77. """
  78. if level == "off":
  79. return None
  80. if level == "high":
  81. return {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}
  82. return {"thinking": {"type": "enabled"}, "reasoning_effort": "highest"}
  83. def build_model(
  84. model_name: str | None = None,
  85. thinking: str | None = None,
  86. timeout: int | None = None,
  87. context_limit: int | None = None,
  88. api_key: str | None = None,
  89. base_url: str | None = None,
  90. max_tokens: int | None = None,
  91. temperature: float | None = None,
  92. extra_body: dict | None = None,
  93. ) -> Any:
  94. """构造一个聊天模型实例。
  95. deepseek 系列优先用 ``langchain-deepseek``(它解析 ``reasoning_content``,
  96. 官方 OpenAI 兼容层会把它丢掉);其余走 ``init_chat_model`` 的 openai 通道。
  97. """
  98. name = model_name or config.MODEL
  99. level = normalize_thinking(thinking)
  100. window = context_limit or context_window(name)
  101. if config.OPENAI_MAX_RETRIES < 0:
  102. raise ValueError("OPENAI_MAX_RETRIES 必须大于或等于 0")
  103. provider_options = _thinking_payload(level)
  104. # 当前 DeepSeek 默认开启思考;off 必须显式关闭才能支持框架的强制工具选择。
  105. if name.lower().startswith("deepseek") and level == "off":
  106. provider_options = {"thinking": {"type": "disabled"}}
  107. if extra_body is not None:
  108. provider_options = extra_body
  109. is_qwen35 = _is_qwen35_model(name)
  110. if is_qwen35:
  111. # 使用 vLLM 支持的模板参数;保留文档提取的 JSON 格式等其他选项。
  112. # 不发送通用 thinking/reasoning_effort=highest,后者不是 vLLM 的合法档位。
  113. provider_options = _qwen_thinking_payload(level, extra_body)
  114. common: dict[str, Any] = {
  115. "model": name,
  116. "api_key": (config.OPENAI_API_KEY if api_key is None else api_key) or "sk-placeholder",
  117. "timeout": timeout or config.OPENAI_TIMEOUT,
  118. # 由 SDK 对单次 HTTP 请求退避重试,流已开始后不会重放 Agent 或工具。
  119. "max_retries": config.OPENAI_MAX_RETRIES,
  120. # 框架靠这个字段决定何时压缩上下文,必须显式给
  121. "profile": {"max_input_tokens": window},
  122. }
  123. if not config.OPENAI_TOOL_CALL_STREAMING:
  124. # 部分兼容网关会丢失 SSE 中的 delta.tool_calls;完整 JSON 响应仍可正常解析。
  125. # 仅带工具的模型请求改走非流式,Agent 的消息和进度事件仍照常返回。
  126. common["disable_streaming"] = "tool_calling"
  127. if is_qwen35:
  128. # 业务工具存在先后依赖,最终结构化结果也应单独提交。
  129. common["model_kwargs"] = {"parallel_tool_calls": False}
  130. endpoint = config.OPENAI_BASE_URL if base_url is None else base_url
  131. if endpoint:
  132. common["base_url"] = endpoint
  133. hostname = urlsplit(endpoint).hostname or ""
  134. try:
  135. private_endpoint = ip_address(hostname).is_private
  136. except ValueError:
  137. private_endpoint = hostname == "localhost"
  138. if private_endpoint:
  139. # httpx在macOS也会读取系统代理;单位内网模型应直接连接。
  140. import httpx
  141. common["http_client"] = httpx.Client(trust_env=False)
  142. common["http_async_client"] = httpx.AsyncClient(trust_env=False)
  143. if provider_options:
  144. common["extra_body"] = provider_options
  145. if max_tokens is not None:
  146. common["max_tokens"] = max_tokens
  147. if temperature is not None:
  148. common["temperature"] = temperature
  149. if common["api_key"] == "sk-placeholder":
  150. logger.warning("OPENAI_API_KEY 未配置,模型实例无法真正发起请求(仅可用于结构验证)")
  151. model = _instantiate(name, common)
  152. logger.info(
  153. "模型就绪 model=%s thinking=%s window=%d base_url=%s max_retries=%d tool_call_streaming=%s",
  154. name, level, window, endpoint or "(默认)", common["max_retries"], config.OPENAI_TOOL_CALL_STREAMING,
  155. )
  156. if is_qwen35:
  157. logger.info(
  158. "Qwen3.5 思考请求参数 chat_template_kwargs.enable_thinking=%s;配置级别=%s,high/highest 均开启思考",
  159. provider_options["chat_template_kwargs"]["enable_thinking"], level,
  160. )
  161. return model
  162. def _instantiate(name: str, common: dict[str, Any]) -> Any:
  163. """按模型名选择集成包。导入失败时逐级回落,并说明回落原因。"""
  164. if name.lower().startswith("deepseek"):
  165. try:
  166. from langchain_deepseek import ChatDeepSeek
  167. return ChatDeepSeek(**common)
  168. except ImportError:
  169. logger.warning("langchain-deepseek 未安装,回落到 ChatOpenAI(会丢失 reasoning_content)")
  170. try:
  171. from langchain_openai import ChatOpenAI
  172. if common.get("base_url"):
  173. from agents.openai_compat import GatewayChatOpenAI
  174. return GatewayChatOpenAI(**common)
  175. return ChatOpenAI(**common)
  176. except ImportError:
  177. pass
  178. from langchain.chat_models import init_chat_model
  179. init_kwargs = {
  180. "api_key": common["api_key"],
  181. "timeout": common["timeout"],
  182. "max_retries": common["max_retries"],
  183. }
  184. if common.get("base_url"):
  185. init_kwargs["base_url"] = common["base_url"]
  186. for option in ("max_tokens", "temperature", "extra_body", "model_kwargs", "http_client", "http_async_client", "disable_streaming"):
  187. if option in common:
  188. init_kwargs[option] = common[option]
  189. return init_chat_model(f"openai:{name}", **init_kwargs)
  190. # ============================================================
  191. # 进程级缓存
  192. # ============================================================
  193. # 没有「摘要模型」这一档:压缩用的 SummarizationMiddleware 由 deepagents 内部
  194. # 构造,拿到的就是传进 create_deep_agent 的那个实例,没有注入另一个模型的入口。
  195. # 所以思考级别同样作用于压缩调用;要单独给压缩配便宜模型,得先能替换掉那个
  196. # 中间件(同名冲突见 AGENTS.md 不变式 5)。
  197. @functools.lru_cache(maxsize=8)
  198. def _cached_main(model_name: str, thinking: str) -> Any:
  199. return build_model(model_name, thinking)
  200. def get_model(model_name: str | None = None, thinking: str | None = None) -> Any:
  201. """取主模型实例(按 model+thinking 缓存,同配置复用同一实例)。"""
  202. name = model_name or config.MODEL
  203. level = normalize_thinking(thinking)
  204. return _cached_main(name, level)
  205. def clear_cache() -> None:
  206. """丢弃模型实例缓存(思考级别切换后调用)。"""
  207. _cached_main.cache_clear()
  208. def describe() -> dict[str, Any]:
  209. """当前模型配置摘要,供 GET /api/model 展示。"""
  210. name = config.MODEL
  211. level = normalize_thinking(None)
  212. return {
  213. "model": name,
  214. "thinking": level,
  215. "thinking_levels": list(VALID_THINKING_LEVELS),
  216. **({
  217. "thinking_parameters": _qwen_thinking_payload(level),
  218. "thinking_levels_note": "Qwen3.5 当前接口使用思考开关,high/highest 均对应开启思考。",
  219. } if _is_qwen35_model(name) else {}),
  220. "context_window": context_window(name),
  221. "context_limit_override": config.CONTEXT_LIMIT,
  222. "base_url": config.OPENAI_BASE_URL or "(默认)",
  223. "configured": bool(config.OPENAI_API_KEY),
  224. }