config.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. """全局配置:.env 读取与类型化访问的唯一入口。
  2. 设计取舍:不用 pydantic-settings,只用 ``python-dotenv`` + 三个 ``get_*``
  3. 帮助函数。理由是骨架阶段配置项还在快速增删,引入 settings 模型会让每次
  4. 加一个开关都要改三处;这里改一处即可,且每个配置项都带默认值——默认值
  5. 必须等于「什么都不配也能跑起来」的那套(沿用 1.5 的工程纪律:默认 = 可用的
  6. 最小配置,任何新开关的默认值都不应改变既有行为)。
  7. ## 关于环境变量与 .env 的优先级(踩过的坑,别改回去)
  8. 机器上若存在全局的 ``OPENAI_API_KEY``(实测遇到过一把无效的),进程环境会
  9. **静默盖掉项目 .env**。症状是:配好的密钥明明是对的,服务却报 401,且报错
  10. 里显示的密钥后几位和 .env 里的对不上——足以浪费半天。
  11. 所以优先级做成**显式可配**,而不是隐含假定某一方赢:
  12. =================== ====================================================
  13. ``DEEP_AGENT_ENV_PRIORITY`` 行为
  14. =================== ====================================================
  15. ``dotenv``(默认) 项目 .env 优先。防「被污染的 shell 静默改掉项目配置」
  16. ``process`` 进程环境优先。用于**跑变体实例**:不同端口、不同库、
  17. 不同方言(``DB_DIALECT=mysql PORT=8081 python main.py``)
  18. ``none`` 完全忽略 .env,只认进程环境与代码默认值(测试/CI 用)
  19. =================== ====================================================
  20. 无论哪一档,**两侧值不同都会打 WARNING 并把键名暴露给 /health**,
  21. 让「我改了配置为什么不生效」有迹可循。
  22. 为什么默认不是 ``process``:两种失败模式的代价不对等。``dotenv`` 的代价是
  23. 「有意的环境变量覆盖不生效」(一条命令行加个前缀就绕过了,且启动日志会告诉
  24. 你),``process`` 的代价是「本机某个无关的全局变量静默改掉项目配置」(表现为
  25. 身份诡异的 401 或连错数据库)。
  26. """
  27. from __future__ import annotations
  28. import os
  29. from pathlib import Path
  30. from dotenv import dotenv_values, load_dotenv
  31. # 项目根目录(本文件所在目录)。所有相对路径以此为基准解析,
  32. # 避免「从哪个目录启动服务」影响资源定位。
  33. ROOT = Path(__file__).resolve().parent
  34. APP_VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
  35. _ENV_PATH = ROOT / ".env"
  36. # dotenv(默认)| process | none
  37. _ENV_PRIORITY = (os.getenv("DEEP_AGENT_ENV_PRIORITY", "dotenv") or "dotenv").strip().lower()
  38. if _ENV_PRIORITY not in {"dotenv", "process", "none"}:
  39. _ENV_PRIORITY = "dotenv"
  40. # 直接读文件(不经 os.environ),用于检测冲突
  41. _FILE_VALUES: dict[str, str] = (
  42. {} if _ENV_PRIORITY == "none" else {k: v for k, v in dotenv_values(_ENV_PATH).items() if v}
  43. )
  44. # 冲突记录:键名 → 说明。供 log_config_sources() 与 /health 使用
  45. CONFLICTS: dict[str, str] = {}
  46. for _key, _file_value in _FILE_VALUES.items():
  47. _process_value = os.environ.get(_key)
  48. if _process_value and _process_value != _file_value:
  49. CONFLICTS[_key] = (
  50. "项目 .env 覆盖了同名进程环境变量"
  51. if _ENV_PRIORITY == "dotenv"
  52. else "进程环境变量覆盖了项目 .env"
  53. )
  54. # 见模块 docstring 的「环境变量与 .env 的优先级」一节
  55. if _ENV_PRIORITY != "none":
  56. load_dotenv(_ENV_PATH, override=(_ENV_PRIORITY == "dotenv"))
  57. def _mask(value: str) -> str:
  58. """给日志用的脱敏显示:只留前 6 后 4 位。"""
  59. if not value:
  60. return "(空)"
  61. if len(value) <= 12:
  62. return "*" * len(value)
  63. return f"{value[:6]}...{value[-4:]}"
  64. def log_config_sources() -> None:
  65. """把配置来源与冲突写进日志。须在 setup_logging() 之后调用。"""
  66. from obs import get_logger
  67. logger = get_logger("config")
  68. if _ENV_PRIORITY == "none":
  69. logger.warning(
  70. "DEEP_AGENT_ENV_PRIORITY=none:已忽略 %s,全部配置取自进程环境变量与代码默认值",
  71. _ENV_PATH,
  72. )
  73. elif _ENV_PATH.exists():
  74. logger.info(
  75. "配置来源:%s(%d 项)| 优先级=%s | 冲突 %d 项",
  76. _ENV_PATH, len(_FILE_VALUES), _ENV_PRIORITY, len(CONFLICTS),
  77. )
  78. else:
  79. logger.warning("未找到 %s,全部配置取自进程环境变量与代码默认值", _ENV_PATH)
  80. for key, reason in CONFLICTS.items():
  81. # 只暴露键名与脱敏后的值,不打印完整密钥
  82. effective = os.getenv(key, "")
  83. logger.warning(
  84. "配置冲突 %s:%s(生效值 %s)%s",
  85. key,
  86. reason,
  87. _mask(effective),
  88. ";如需让进程环境优先,加 DEEP_AGENT_ENV_PRIORITY=process"
  89. if _ENV_PRIORITY == "dotenv"
  90. else "",
  91. )
  92. def get_str(key: str, default: str = "") -> str:
  93. value = os.getenv(key)
  94. return default if value is None or value == "" else value
  95. def get_int(key: str, default: int = 0) -> int:
  96. raw = os.getenv(key)
  97. if raw is None or raw.strip() == "":
  98. return default
  99. try:
  100. return int(raw.strip())
  101. except ValueError:
  102. return default
  103. def get_float(key: str, default: float = 0.0) -> float:
  104. raw = os.getenv(key)
  105. if raw is None or raw.strip() == "":
  106. return default
  107. try:
  108. return float(raw.strip())
  109. except ValueError:
  110. return default
  111. def get_bool(key: str, default: bool = False) -> bool:
  112. """把 ``1/true/yes/on`` 视为真,``0/false/no/off`` 视为假,其余回落默认值。"""
  113. raw = os.getenv(key)
  114. if raw is None or raw.strip() == "":
  115. return default
  116. return raw.strip().lower() in {"1", "true", "yes", "on"}
  117. # ============================================================
  118. # 服务
  119. # ============================================================
  120. HOST: str = get_str("HOST", "0.0.0.0")
  121. PORT: int = get_int("PORT", 8080)
  122. LOG_LEVEL: str = get_str("LOG_LEVEL", "INFO")
  123. LOG_FORMAT: str = get_str("LOG_FORMAT", "text")
  124. # ============================================================
  125. # 模型
  126. # ============================================================
  127. MODEL: str = get_str("DEEPAGENT_MODEL", "deepseek-flash")
  128. OPENAI_BASE_URL: str = get_str("OPENAI_BASE_URL", "")
  129. OPENAI_API_KEY: str = get_str("OPENAI_API_KEY", "")
  130. OPENAI_TIMEOUT: int = get_int("OPENAI_TIMEOUT", 180)
  131. # 思考级别:off | high | highest,烧进模型实例(运行时切换需重建 agent)
  132. THINKING_LEVEL: str = get_str("DEEPAGENT_THINKING", "off")
  133. # ============================================================
  134. # 存储
  135. # ============================================================
  136. # SQLite 是默认业务库;保留 MySQL 兼容配置供旧部署显式使用。
  137. DB_DIALECT: str = get_str("DB_DIALECT", "sqlite").lower()
  138. DB_HOST: str = get_str("DB_HOST", "127.0.0.1")
  139. DB_PORT: int = get_int("DB_PORT", 3306)
  140. DB_USER: str = get_str("DB_USER", "root")
  141. DB_PASSWORD: str = get_str("DB_PASSWORD", "")
  142. DB_NAME: str = get_str("DB_NAME", "vent_agent_v2")
  143. DB_PING_IDLE_SECONDS: int = get_int("DB_PING_IDLE_SECONDS", 60)
  144. # sqlite 档位的库文件位置
  145. DB_SQLITE_PATH: str = get_str("DB_SQLITE_PATH", "data/vent_agent.sqlite")
  146. # ============================================================
  147. # 图状态(checkpoint)
  148. # ============================================================
  149. CHECKPOINTER: str = get_str("CHECKPOINTER", "sqlite").lower() # sqlite | memory
  150. CHECKPOINT_DB_PATH: str = get_str("CHECKPOINT_DB_PATH", "data/checkpoints.sqlite")
  151. # ============================================================
  152. # 上下文与压缩
  153. # ============================================================
  154. # 声明的上下文窗口(0 = 用模型登记值)。
  155. # 这是压缩的**唯一调节旋钮**:框架的摘要中间件在「声明窗口的 85%」处触发,
  156. # 所以想让它更早压缩,就把 CONTEXT_LIMIT 调到 (真实窗口 × 期望比例 / 0.85)。
  157. CONTEXT_LIMIT: int = get_int("CONTEXT_LIMIT", 0)
  158. # 自动压缩开关。关闭时通过 HarnessProfile.excluded_middleware 摘掉框架的
  159. # 摘要中间件——注意这会同时失去「淘汰历史自动落盘到 /conversation_history/」
  160. # 这个能力,长会话会硬撑到超出模型窗口后报错。
  161. CTX_COMPACT_ENABLED: bool = get_bool("CTX_COMPACT_ENABLED", True)
  162. # 冷启动时从会话历史补种的最近消息条数(热线程只喂当轮新消息)
  163. CTX_RECENT_LIMIT: int = get_int("CTX_RECENT_LIMIT", 30)
  164. # 框架固定的压缩触发比例。仅用于展示与断言,改它无效——真正的旋钮是
  165. # CONTEXT_LIMIT。放在这里是为了让 /health 能如实报出「几成触发」。
  166. FRAMEWORK_COMPACT_TRIGGER: float = 0.85
  167. # ============================================================
  168. # 并发闸
  169. # ============================================================
  170. MAX_CONCURRENT_TASKS: int = get_int("TFAGENTS_MAX_CONCURRENT_TASKS", 10)
  171. MAX_USER_TASKS: int = get_int("TFAGENTS_MAX_USER_TASKS", 2)
  172. TASK_WALL_TIMEOUT: int = get_int("TFAGENTS_TASK_WALL_TIMEOUT", 1800)
  173. # 独立算法:单图与批量共享计算并行上限;批量排队、结果保留。
  174. ALGORITHM_MAX_PARALLEL: int = max(1, get_int("ALGORITHM_MAX_PARALLEL", 2))
  175. ALGORITHM_MAX_PENDING_IMAGES: int = max(1, get_int("ALGORITHM_MAX_PENDING_IMAGES", 100))
  176. ALGORITHM_RESULT_TTL_HOURS: int = max(1, get_int("ALGORITHM_RESULT_TTL_HOURS", 24))
  177. ALGORITHM_JOB_DIR: str = get_str("ALGORITHM_JOB_DIR", "data/algorithm_jobs")
  178. # ============================================================
  179. # 计费
  180. # ============================================================
  181. DAILY_TOKEN_LIMIT: int = get_int("TFAGENTS_DAILY_TOKEN_LIMIT", 0) # 0 = 不限
  182. # ============================================================
  183. # 观测
  184. # ============================================================
  185. TRACE_MIDDLEWARE: bool = get_bool("TFA_TRACE_MIDDLEWARE", True)
  186. def resolve_path(relative: str) -> Path:
  187. """把配置里的相对路径解析为项目根下的绝对路径,并确保父目录存在。"""
  188. path = Path(relative)
  189. if not path.is_absolute():
  190. path = ROOT / path
  191. path.parent.mkdir(parents=True, exist_ok=True)
  192. return path