config.py 8.5 KB

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