model_factory.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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 typing import Any
  14. import config
  15. from obs import get_logger
  16. logger = get_logger(__name__)
  17. # 已知模型的上下文窗口(token)。来源是各家的公开文档;命中不了就用兜底值。
  18. KNOWN_CONTEXT_WINDOWS: dict[str, int] = {
  19. "deepseek-flash": 128_000,
  20. "deepseek-v4-flash": 128_000,
  21. "deepseek-v4-pro": 128_000,
  22. "deepseek-chat": 64_000,
  23. "deepseek-reasoner": 64_000,
  24. "gpt-4o": 128_000,
  25. "gpt-4o-mini": 128_000,
  26. # 用户提供的平台上限:非思考最大输入 260K,总上下文 262K;声明输入额度。
  27. "qwen3.5-35b-a3b": 260_000,
  28. }
  29. # 兜底窗口:按最保守的假设给,宁可早点压缩也不要超窗被服务端截断
  30. FALLBACK_CONTEXT_WINDOW = 64_000
  31. VALID_THINKING_LEVELS = ("off", "high", "highest")
  32. def context_window(model_name: str | None = None) -> int:
  33. """解析模型的上下文窗口。
  34. ``CONTEXT_LIMIT`` 显式配置时优先——这是给「模型真实窗口比文档小」或
  35. 「想留更多余量」的场景留的手动闸。
  36. """
  37. if config.CONTEXT_LIMIT > 0:
  38. return config.CONTEXT_LIMIT
  39. name = model_name or config.MODEL
  40. if name in KNOWN_CONTEXT_WINDOWS:
  41. return KNOWN_CONTEXT_WINDOWS[name]
  42. # 前缀匹配:deepseek-xxx-v5 这种没登记过的新版本,沿用同族窗口
  43. for known, window in KNOWN_CONTEXT_WINDOWS.items():
  44. family = known.split("-")[0]
  45. if name.startswith(family):
  46. return window
  47. logger.warning(
  48. "模型 %s 未登记上下文窗口,按兜底值 %d 处理(可用 CONTEXT_LIMIT 显式覆盖)",
  49. name, FALLBACK_CONTEXT_WINDOW,
  50. )
  51. return FALLBACK_CONTEXT_WINDOW
  52. def normalize_thinking(level: str | None) -> str:
  53. """把思考级别归一到合法值;非法值回落 off 并告警。"""
  54. value = (level or config.THINKING_LEVEL or "off").strip().lower()
  55. if value not in VALID_THINKING_LEVELS:
  56. logger.warning("非法思考级别 %r,回落 off", level)
  57. return "off"
  58. return value
  59. def _thinking_payload(level: str) -> dict[str, Any] | None:
  60. """思考级别 → 透传给 provider 的 extra_body。
  61. ``off`` 返回 None(**什么都不发**):不同 provider 对「关闭思考」的
  62. 表达方式不一致,发错字段会被服务端拒;不发才是真正中立的默认。
  63. """
  64. if level == "off":
  65. return None
  66. if level == "high":
  67. return {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}
  68. return {"thinking": {"type": "enabled"}, "reasoning_effort": "highest"}
  69. def build_model(
  70. model_name: str | None = None,
  71. thinking: str | None = None,
  72. timeout: int | None = None,
  73. context_limit: int | None = None,
  74. api_key: str | None = None,
  75. base_url: str | None = None,
  76. max_tokens: int | None = None,
  77. temperature: float | None = None,
  78. extra_body: dict | None = None,
  79. ) -> Any:
  80. """构造一个聊天模型实例。
  81. deepseek 系列优先用 ``langchain-deepseek``(它解析 ``reasoning_content``,
  82. 官方 OpenAI 兼容层会把它丢掉);其余走 ``init_chat_model`` 的 openai 通道。
  83. """
  84. name = model_name or config.MODEL
  85. level = normalize_thinking(thinking)
  86. window = context_limit or context_window(name)
  87. provider_options = _thinking_payload(level)
  88. # 当前 DeepSeek 默认开启思考;off 必须显式关闭才能支持框架的强制工具选择。
  89. if name.lower().startswith("deepseek") and level == "off":
  90. provider_options = {"thinking": {"type": "disabled"}}
  91. if extra_body is not None:
  92. provider_options = extra_body
  93. if name.lower() == "qwen3.5-35b-a3b":
  94. # 千问使用 enable_thinking;文档提取的 JSON 格式参数仍须保留。
  95. provider_options = {"enable_thinking": level != "off", **(extra_body or {})}
  96. common: dict[str, Any] = {
  97. "model": name,
  98. "api_key": (config.OPENAI_API_KEY if api_key is None else api_key) or "sk-placeholder",
  99. "timeout": timeout or config.OPENAI_TIMEOUT,
  100. "max_retries": 2,
  101. # 框架靠这个字段决定何时压缩上下文,必须显式给
  102. "profile": {"max_input_tokens": window},
  103. }
  104. if name.lower() == "qwen3.5-35b-a3b":
  105. # 业务工具存在先后依赖,最终结构化结果也应单独提交。
  106. common["model_kwargs"] = {"parallel_tool_calls": False}
  107. endpoint = config.OPENAI_BASE_URL if base_url is None else base_url
  108. if endpoint:
  109. common["base_url"] = endpoint
  110. if provider_options:
  111. common["extra_body"] = provider_options
  112. if max_tokens is not None:
  113. common["max_tokens"] = max_tokens
  114. if temperature is not None:
  115. common["temperature"] = temperature
  116. if common["api_key"] == "sk-placeholder":
  117. logger.warning("OPENAI_API_KEY 未配置,模型实例无法真正发起请求(仅可用于结构验证)")
  118. model = _instantiate(name, common)
  119. logger.info(
  120. "模型就绪 model=%s thinking=%s window=%d base_url=%s",
  121. name, level, window, endpoint or "(默认)",
  122. )
  123. return model
  124. def _instantiate(name: str, common: dict[str, Any]) -> Any:
  125. """按模型名选择集成包。导入失败时逐级回落,并说明回落原因。"""
  126. if name.lower().startswith("deepseek"):
  127. try:
  128. from langchain_deepseek import ChatDeepSeek
  129. return ChatDeepSeek(**common)
  130. except ImportError:
  131. logger.warning("langchain-deepseek 未安装,回落到 ChatOpenAI(会丢失 reasoning_content)")
  132. try:
  133. from langchain_openai import ChatOpenAI
  134. return ChatOpenAI(**common)
  135. except ImportError:
  136. pass
  137. from langchain.chat_models import init_chat_model
  138. init_kwargs = {
  139. "api_key": common["api_key"],
  140. "timeout": common["timeout"],
  141. "max_retries": common["max_retries"],
  142. }
  143. if common.get("base_url"):
  144. init_kwargs["base_url"] = common["base_url"]
  145. for option in ("max_tokens", "temperature", "extra_body", "model_kwargs"):
  146. if option in common:
  147. init_kwargs[option] = common[option]
  148. return init_chat_model(f"openai:{name}", **init_kwargs)
  149. # ============================================================
  150. # 进程级缓存
  151. # ============================================================
  152. # 没有「摘要模型」这一档:压缩用的 SummarizationMiddleware 由 deepagents 内部
  153. # 构造,拿到的就是传进 create_deep_agent 的那个实例,没有注入另一个模型的入口。
  154. # 所以思考级别同样作用于压缩调用;要单独给压缩配便宜模型,得先能替换掉那个
  155. # 中间件(同名冲突见 AGENTS.md 不变式 5)。
  156. @functools.lru_cache(maxsize=8)
  157. def _cached_main(model_name: str, thinking: str) -> Any:
  158. return build_model(model_name, thinking)
  159. def get_model(model_name: str | None = None, thinking: str | None = None) -> Any:
  160. """取主模型实例(按 model+thinking 缓存,同配置复用同一实例)。"""
  161. name = model_name or config.MODEL
  162. level = normalize_thinking(thinking)
  163. return _cached_main(name, level)
  164. def clear_cache() -> None:
  165. """丢弃模型实例缓存(思考级别切换后调用)。"""
  166. _cached_main.cache_clear()
  167. def describe() -> dict[str, Any]:
  168. """当前模型配置摘要,供 GET /api/model 展示。"""
  169. name = config.MODEL
  170. return {
  171. "model": name,
  172. "thinking": normalize_thinking(None),
  173. "thinking_levels": list(VALID_THINKING_LEVELS),
  174. "context_window": context_window(name),
  175. "context_limit_override": config.CONTEXT_LIMIT,
  176. "base_url": config.OPENAI_BASE_URL or "(默认)",
  177. "configured": bool(config.OPENAI_API_KEY),
  178. }