model_factory.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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. }
  27. # 兜底窗口:按最保守的假设给,宁可早点压缩也不要超窗被服务端截断
  28. FALLBACK_CONTEXT_WINDOW = 64_000
  29. VALID_THINKING_LEVELS = ("off", "high", "highest")
  30. def context_window(model_name: str | None = None) -> int:
  31. """解析模型的上下文窗口。
  32. ``CONTEXT_LIMIT`` 显式配置时优先——这是给「模型真实窗口比文档小」或
  33. 「想留更多余量」的场景留的手动闸。
  34. """
  35. if config.CONTEXT_LIMIT > 0:
  36. return config.CONTEXT_LIMIT
  37. name = model_name or config.MODEL
  38. if name in KNOWN_CONTEXT_WINDOWS:
  39. return KNOWN_CONTEXT_WINDOWS[name]
  40. # 前缀匹配:deepseek-xxx-v5 这种没登记过的新版本,沿用同族窗口
  41. for known, window in KNOWN_CONTEXT_WINDOWS.items():
  42. family = known.split("-")[0]
  43. if name.startswith(family):
  44. return window
  45. logger.warning(
  46. "模型 %s 未登记上下文窗口,按兜底值 %d 处理(可用 CONTEXT_LIMIT 显式覆盖)",
  47. name, FALLBACK_CONTEXT_WINDOW,
  48. )
  49. return FALLBACK_CONTEXT_WINDOW
  50. def normalize_thinking(level: str | None) -> str:
  51. """把思考级别归一到合法值;非法值回落 off 并告警。"""
  52. value = (level or config.THINKING_LEVEL or "off").strip().lower()
  53. if value not in VALID_THINKING_LEVELS:
  54. logger.warning("非法思考级别 %r,回落 off", level)
  55. return "off"
  56. return value
  57. def _thinking_payload(level: str) -> dict[str, Any] | None:
  58. """思考级别 → 透传给 provider 的 extra_body。
  59. ``off`` 返回 None(**什么都不发**):不同 provider 对「关闭思考」的
  60. 表达方式不一致,发错字段会被服务端拒;不发才是真正中立的默认。
  61. """
  62. if level == "off":
  63. return None
  64. if level == "high":
  65. return {"thinking": {"type": "enabled"}, "reasoning_effort": "high"}
  66. return {"thinking": {"type": "enabled"}, "reasoning_effort": "highest"}
  67. def build_model(
  68. model_name: str | None = None,
  69. thinking: str | None = None,
  70. timeout: int | None = None,
  71. context_limit: int | None = None,
  72. ) -> Any:
  73. """构造一个聊天模型实例。
  74. deepseek 系列优先用 ``langchain-deepseek``(它解析 ``reasoning_content``,
  75. 官方 OpenAI 兼容层会把它丢掉);其余走 ``init_chat_model`` 的 openai 通道。
  76. """
  77. name = model_name or config.MODEL
  78. level = normalize_thinking(thinking)
  79. window = context_limit or context_window(name)
  80. extra_body = _thinking_payload(level)
  81. common: dict[str, Any] = {
  82. "model": name,
  83. "api_key": config.OPENAI_API_KEY or "sk-placeholder",
  84. "timeout": timeout or config.OPENAI_TIMEOUT,
  85. "max_retries": 2,
  86. # 框架靠这个字段决定何时压缩上下文,必须显式给
  87. "profile": {"max_input_tokens": window},
  88. }
  89. if config.OPENAI_BASE_URL:
  90. common["base_url"] = config.OPENAI_BASE_URL
  91. if extra_body:
  92. common["extra_body"] = extra_body
  93. if not config.OPENAI_API_KEY:
  94. logger.warning("OPENAI_API_KEY 未配置,模型实例无法真正发起请求(仅可用于结构验证)")
  95. model = _instantiate(name, common)
  96. logger.info(
  97. "模型就绪 model=%s thinking=%s window=%d base_url=%s",
  98. name, level, window, config.OPENAI_BASE_URL or "(默认)",
  99. )
  100. return model
  101. def _instantiate(name: str, common: dict[str, Any]) -> Any:
  102. """按模型名选择集成包。导入失败时逐级回落,并说明回落原因。"""
  103. if name.lower().startswith("deepseek"):
  104. try:
  105. from langchain_deepseek import ChatDeepSeek
  106. return ChatDeepSeek(**common)
  107. except ImportError:
  108. logger.warning("langchain-deepseek 未安装,回落到 ChatOpenAI(会丢失 reasoning_content)")
  109. try:
  110. from langchain_openai import ChatOpenAI
  111. return ChatOpenAI(**common)
  112. except ImportError:
  113. pass
  114. from langchain.chat_models import init_chat_model
  115. init_kwargs = {
  116. "api_key": common["api_key"],
  117. "timeout": common["timeout"],
  118. "max_retries": common["max_retries"],
  119. }
  120. if config.OPENAI_BASE_URL:
  121. init_kwargs["base_url"] = config.OPENAI_BASE_URL
  122. return init_chat_model(f"openai:{name}", **init_kwargs)
  123. # ============================================================
  124. # 进程级缓存
  125. # ============================================================
  126. # 没有「摘要模型」这一档:压缩用的 SummarizationMiddleware 由 deepagents 内部
  127. # 构造,拿到的就是传进 create_deep_agent 的那个实例,没有注入另一个模型的入口。
  128. # 所以思考级别同样作用于压缩调用;要单独给压缩配便宜模型,得先能替换掉那个
  129. # 中间件(同名冲突见 AGENTS.md 不变式 5)。
  130. @functools.lru_cache(maxsize=8)
  131. def _cached_main(model_name: str, thinking: str) -> Any:
  132. return build_model(model_name, thinking)
  133. def get_model(model_name: str | None = None, thinking: str | None = None) -> Any:
  134. """取主模型实例(按 model+thinking 缓存,同配置复用同一实例)。"""
  135. name = model_name or config.MODEL
  136. level = normalize_thinking(thinking)
  137. return _cached_main(name, level)
  138. def clear_cache() -> None:
  139. """丢弃模型实例缓存(思考级别切换后调用)。"""
  140. _cached_main.cache_clear()
  141. def describe() -> dict[str, Any]:
  142. """当前模型配置摘要,供 GET /api/model 展示。"""
  143. name = config.MODEL
  144. return {
  145. "model": name,
  146. "thinking": normalize_thinking(None),
  147. "thinking_levels": list(VALID_THINKING_LEVELS),
  148. "context_window": context_window(name),
  149. "context_limit_override": config.CONTEXT_LIMIT,
  150. "base_url": config.OPENAI_BASE_URL or "(默认)",
  151. "configured": bool(config.OPENAI_API_KEY),
  152. }