0. 系列闭环(不公开源码也能跟读)

端到端链路:Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。
本篇:第 17/17 篇 · 配置环 · settings

阶段 用户可见 代码入口 对应篇
建会话 欢迎语 POST /api/sessions 09
多轮对话 SSE 流式 chat/stream → run_guide_single_turn 06, 14
信息充分 开始分析 _run_analysis_background 05, 07
履历解析 进度 30% run_resume_parser 12
画像/RIASEC 进度 50% run_profile_analyzer 03, 13
职业匹配 进度 70% run_career_matcher 02
报告 进度 90% run_reporter 11
下载 PDF 文件 GET …/report/pdf 11, 15
说明
读本篇前 第 08/15 篇环境变量
读完本篇 能写出 .env 如何覆盖 Settings 默认值
下一环 回到第 01 篇:闭环复盘(回到第 01 篇复盘)

全系列闭环索引:SERIES-LOOP.md

1. 要解决什么问题

iCan 的配置分散在多处会迅速失控:LLM 密钥、MySQL URL、Embedding 路径、Chroma 目录、对话轮次上限——本地 .env、Docker environment、CI 注入的变量可能同时存在。还要避免把完整 LLM_API_KEY 打进日志。

项目把所有项收敛到 config.py 一个 Settings,配合模块级 settings 单例,供 llm/providers.py、Agent、Docker compose 统一读取。本文只讲 iCan 这份实现,不是 pydantic-settings 通用教程。


2. 实现位置

模块 与配置的关系
config.py _ENV_FILE 定位、dotenv 预加载、Settingsget_settings()settings 单例
llm/providers.py from ican.config import settings,读 LLM_* 创建 ChatOpenAI
agents/resume_parser.py 间接通过 get_light_model()LLM_MODEL_LIGHT
.env.example 仓库推荐的本地/DeepSeek 示例值
docker-compose.yml 容器内环境变量覆盖(默认 Ollama + MySQL DSN)

pydantic-settings 配置加载


3. _ENV_FILE 查找:两层路径

config.py 在 import Settings 之前就计算 .env 绝对路径:

1
2
3
4
5
_ENV_FILE = os.path.normpath(os.path.join(
os.path.abspath(os.path.dirname(__file__)), "..", "..", ".env"))
if not os.path.exists(_ENV_FILE):
_ENV_FILE = os.path.normpath(os.path.join(
os.path.abspath(os.path.dirname(__file__)), "..", "..", "..", ".env"))

解释(__file__src/ican/config.py):

  1. 首选 项目根/.env(向上两级到 iCan/ 仓库根);
  2. 备选 再向上一级(兼容 src 外层还有一层目录的 checkout 布局)。

若两个路径都不存在,_ENV_FILE 仍指向最后一个候选路径;pydantic 读不到文件时不会报错,字段走代码默认值。

Settings.model_config 显式绑定该路径:

1
2
3
4
5
6
model_config = SettingsConfigDict(
env_file=_ENV_FILE,
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)

case_sensitive=True 表示环境变量名必须与字段名一致(LLM_API_KEY,不是 llm_api_key)。extra="ignore" 允许 .env 里有多余键而不 ValidationError。


4. dotenv 预加载:先于 pydantic 写入 os.environ

Settings 类定义之前,若找到 .env,会用 python-dotenv 手动灌进 os.environ

1
2
3
4
5
6
7
8
if os.path.exists(_ENV_FILE):
try:
from dotenv import dotenv_values
for k, v in dotenv_values(_ENV_FILE).items():
if v is not None and k not in os.environ:
os.environ[k] = v
except Exception:
pass

设计意图:

  • 不覆盖已有环境变量k not in os.environ)——Docker/K8s 注入的值优先级高于 .env 文件;
  • 在 pydantic 解析前就让 os.environ 可见,避免部分库只读 environ、不读 env_file 的边缘问题;
  • except Exception: pass:缺 python-dotenv 或文件损坏时静默跳过,仍可用纯环境变量启动。

有效优先级(与注释一致):

1
进程环境变量 > .env 文件(经预加载 + pydantic env_file)> Settings 字段默认值

5. Settings 字段:代码默认 vs .env 示例

代码里的默认值(未设环境变量时生效):

字段 默认值 用途
LLM_MODEL_CHAT gpt-4o Guide / 分析 / 匹配 / Reporter
LLM_MODEL_LIGHT gpt-4o-mini ResumeParser 等
LLM_BASE_URL https://api.openai.com/v1 OpenAI 兼容根路径
LLM_API_KEY "" 空字符串,本地需自行填写
LLM_MAX_TOKENS 4096 最大生成 token(compose 默认 8192 会覆盖)
DB_URL sqlite:///./ican.db 开发 SQLite
DEBUG False 生产模式
MAX_CHAT_ROUNDS 15 Guide 循环上限
EMBEDDING_MODEL_PATH "" 空表示需外部挂载或本地配置
CHROMA_PERSIST_DIR "" 空表示使用项目相对路径逻辑

.env.example 展示的是常见 DeepSeek 部署示例,不是代码默认:

1
2
3
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL_CHAT=deepseek-v4-flash
LLM_MODEL_LIGHT=deepseek-v4-flash

文档里写「默认 DeepSeek」会与源码不符;应以 config.py 为准,.env / compose 为覆盖层。

Docker docker-compose.yml 又是第三套默认(Ollama qwen3.5:9bLLM_API_KEY=ollama),适合离线演示,与 .env.example 的云端 API 并不矛盾——都是环境覆盖,不是改 Settings 类默认值。


6. 便捷属性与 llm_config_dict 脱敏

Settings 上几个 @property 供业务侧使用:

  • is_debug / is_production:基于 DEBUG
  • log_level_value:把 LOG_LEVEL 字符串映射到 logging.DEBUG 等整数;
  • app_info:应用名、版本、调试开关字典。

LLM 相关聚合在 llm_config_dict

1
2
3
4
5
6
7
8
9
10
11
12
13
14
result = {
"api_key": self.LLM_API_KEY,
"base_url": self.LLM_BASE_URL,
"model_chat": self.LLM_MODEL_CHAT,
"model_light": self.LLM_MODEL_LIGHT,
"temperature": self.LLM_TEMPERATURE,
"max_tokens": self.LLM_MAX_TOKENS,
}
safe_result = {
**result,
"api_key": "***" + result["api_key"][-4:] if len(result["api_key"]) > 4 else "***",
}
logger.info(f"[llm_config_dict] 执行完成,返回(已脱敏): {safe_result}")
return result # 注意:返回的仍是含明文 key 的 result

要点:

  • 日志safe_result,只露末四位;
  • 返回值仍是完整 result(含明文 api_key),调用方若再 print 需自行脱敏;
  • key 长度 ≤4 时日志显示 ***,避免短 key 泄露过多。

llm/providers.py 不经过 llm_config_dict,直接读 settings.LLM_API_KEY 等字段构造 ChatOpenAI——配置入口单一,但日志脱敏只在访问 llm_config_dict 属性时触发。


7. 模块级单例:get_settings()settings

1
2
3
4
5
6
7
8
9
10
11
12
def get_settings() -> Settings:
settings = Settings()
logger.info(
f"[get_settings] 执行完成,应用: {settings.APP_NAME} v{settings.APP_VERSION},"
f"调试模式: {settings.DEBUG},数据库: {settings.DB_URL}"
)
return settings

try:
settings = get_settings()
except Exception:
settings = Settings() # 创建失败时用纯默认值兜底

模块 import 时执行一次,全进程共享 from ican.config import settings。FastAPI 若需要依赖注入,可包装 get_settings(),但当前代码库普遍直接 import settings

注意get_settings() 每次调用都会 Settings() 新建实例;只有模块级 settings 是单例。业务代码应 import settings,不要反复 get_settings() unless 测试隔离。

启动日志会打印 DB_URL 明文(含密码),与 API Key 不同,数据库连接串目前未做脱敏——生产环境应靠 LOG_LEVEL 控制或外层日志过滤。


8. 多环境怎么切换

场景 做法
本地开发 复制 .env.example.env,填 DeepSeek/OpenAI
Docker compose environment 块覆盖;宿主机 .env 可通过 ${VAR} 传入
仅 CI 密钥 不设 .env,流水线注入 LLM_API_KEY 等环境变量
SQLite → MySQL DB_URL,compose 默认已是 MySQL DSN

EMBEDDING_MODEL_PATHCHROMA_PERSIST_DIR 在 compose 里默认 /app/models/bge-m3/app/chroma_data,与第 15 篇 volume 挂载对应。空字符串默认值在代码里表示「需外部指定路径」(Chroma 注释写明默认可落在项目根 chroma_data)。

类型校验由 pydantic 完成:DEBUG=false 字符串会转为 FalseDEBUG=abc 启动即 ValidationErrorLLM_TEMPERATURELLM_MAX_TOKENS 同理。


9. 在流水线中的位置

1
2
3
4
5
6
7
.env / 环境变量
→ dotenv 预加载 (config.py 顶部)
→ Settings() 校验
→ settings 单例
→ llm/providers.py (ChatOpenAI)
→ agents/resume_parser.py / agents/* / api/routes/*
→ docker-compose environment 覆盖

换模型不改 Agent 代码,只改 LLM_BASE_URL + LLM_MODEL_*;这与第 8 篇「OpenAI 兼容接口 + 环境变量切换」一致。llm/providers.pycheck_ollama_available 同样读 settings.LLM_BASE_URLsettings.LLM_MODEL_CHAT 做探活。


10. 踩坑与边界

踩坑 1:误以为 .env.example 即运行时默认。 源码默认是 gpt-4o + OpenAI URL;DeepSeek 只是示例文件。写文档或截图时需区分「类默认值」和「部署示例」。

踩坑 2:llm_config_dict 脱敏只保护 logger。 返回 dict 仍含明文 key;若把返回值序列化到 API 响应会泄密。脱敏逻辑不应复制到对外接口,应单独设计 DTO。

踩坑 3:_ENV_FILE 路径与 cwd 无关。 配置按 config.py 位置算绝对路径,从 /app/src 启动 uvicorn 仍能找到仓库根 .env(若挂载进容器)。但若 .env 未 COPY 进镜像、又未在 compose 设变量,则只用代码默认 + compose 注入项。

踩坑 4:dotenv 预加载 silence。 except Exception: pass 吞掉 dotenv 失败,排查「变量未生效」时要同时检查 os.environ、compose、以及 case_sensitive 拼写。

踩坑 5:双模型字段只配一个。 .env.example 里 chat/light 常设相同 DeepSeek 模型;agents/resume_parser.py 仍走 LLM_MODEL_LIGHT。只改 LLM_MODEL_CHAT 不改 light 会导致两路模型不一致。


11. 小结

  1. _ENV_FILE 两级查找 + SettingsConfigDict(env_file=...) 绑定唯一 .env 路径。
  2. import 前 dotenv 预加载,且不覆盖已有环境变量,适配 Docker 注入。
  3. 代码默认 OpenAI 系;DeepSeek/Ollama 通过 .env 或 compose 覆盖,勿写死进 Settings 类。
  4. llm_config_dict 日志脱敏末四位,返回体仍含明文,调用方慎用。
  5. 使用模块级 settings 单例;get_settings() 主要用于启动日志与异常兜底。

本系列第 16 篇中间件、第 15 篇 Docker 中的环境变量,均以此模块为源头。


附录:关键源码(逐行注释)

以下代码摘自 iCan 实现,每行上方均有中文注释,不公开仓库也可跟读。
生成命令:python3 bin/build-ican-annotated-snippets.py

Settings LLM/DB 字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# ========== Settings LLM/DB 字段 ==========
# 源文件: config.py 行 32-88

# L32: 定义类(配置或 ORM 模型)
class Settings(BaseSettings):
# L34: 【文档】iCan 项目全局配置类。
# L36: 【文档】功能描述:
# L37: 【文档】集中管理所有配置项,支持从 .env 文件和环境变量读取配置。
# L38: 【文档】使用 pydantic-settings 进行类型校验和验证。
# L40: 【文档】入参说明:
# L41: 【文档】无(通过 BaseSettings 自动从环境变量/.env 文件读取)
# L43: 【文档】出参说明:
# L44: 【文档】Settings 实例,可通过属性访问各配置项
# (L33-45 为函数/模块文档字符串,已转为注释便于阅读)

# L47: ------------------------------------------------------------------
# L48: Pydantic Settings 配置
# L49: ------------------------------------------------------------------
# L50: 赋值:更新局部变量或 state 字段
model_config = SettingsConfigDict(
# L51: 赋值:更新局部变量或 state 字段
env_file=_ENV_FILE,
# L52: 赋值:更新局部变量或 state 字段
env_file_encoding="utf-8",
# L53: 赋值:更新局部变量或 state 字段
case_sensitive=True,
# L54: 赋值:更新局部变量或 state 字段
extra="ignore",
# L55: 执行该语句(细节见上文业务描述)
)

# L57: ------------------------------------------------------------------
# L58: 应用基础配置
# L59: ------------------------------------------------------------------
# L60: 赋值:更新局部变量或 state 字段
APP_NAME: str = "iCan"
# L61: 【文档】应用名称

# L63: 赋值:更新局部变量或 state 字段
APP_VERSION: str = "0.1.0"
# L64: 【文档】应用版本

# L66: 赋值:更新局部变量或 state 字段
DEBUG: bool = False
# L67: 【文档】调试模式开关

# L69: ------------------------------------------------------------------
# L70: LLM 大语言模型配置
# L71: ------------------------------------------------------------------
# L72: 赋值:更新局部变量或 state 字段
LLM_API_KEY: str = ""
# L73: 【文档】LLM API 密钥,从 .env 文件读取

# L75: 赋值:更新局部变量或 state 字段
LLM_BASE_URL: str = "https://api.openai.com/v1"
# L76: 【文档】LLM API 基础 URL

# L78: 赋值:更新局部变量或 state 字段
LLM_MODEL_CHAT: str = "gpt-4o"
# L79: 【文档】对话引导、分析、匹配使用的模型

# L81: 赋值:更新局部变量或 state 字段
LLM_MODEL_LIGHT: str = "gpt-4o-mini"
# L82: 【文档】解析、报告格式化使用的轻量模型

# L84: 赋值:更新局部变量或 state 字段
LLM_TEMPERATURE: float = 0.7
# L85: 【文档】LLM 生成温度参数

# L87: 赋值:更新局部变量或 state 字段
LLM_MAX_TOKENS: int = 4096
# L88: 【文档】LLM 最大生成 token 数

llm_config_dict 脱敏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# ========== llm_config_dict 脱敏 ==========
# 源文件: config.py 行 179-215

# L179: 装饰器
@property
# L180: 同步函数 llm_config_dict:路由决策或工厂方法
def llm_config_dict(self) -> dict:
# L182: 【文档】获取 LLM 相关配置的字典形式。
# L184: 【文档】功能描述:
# L185: 【文档】将 LLM 相关的配置项组装为字典,方便传递给 LLM 客户端。
# L187: 【文档】入参说明:
# L188: 【文档】无
# L190: 【文档】出参说明:
# L191: 【文档】dict: 包含 api_key, base_url, model_chat, model_light,
# L192: 【文档】temperature, max_tokens 的字典
# (L181-193 为函数/模块文档字符串,已转为注释便于阅读)
# L194: 开始 try 块,后续 except 负责兜底
try:
# L195: 导入依赖模块
from ican.core.logger import get_logger

# L197: 赋值:更新局部变量或 state 字段
logger = get_logger(__name__)
# L198: 记录日志,便于线上排查节点入参/出参
logger.info(f"[llm_config_dict] 开始执行,入参: 无")

# L200: 赋值:更新局部变量或 state 字段
result = {
# L201: 执行该语句(细节见上文业务描述)
"api_key": self.LLM_API_KEY,
# L202: 执行该语句(细节见上文业务描述)
"base_url": self.LLM_BASE_URL,
# L203: 执行该语句(细节见上文业务描述)
"model_chat": self.LLM_MODEL_CHAT,
# L204: 执行该语句(细节见上文业务描述)
"model_light": self.LLM_MODEL_LIGHT,
# L205: 执行该语句(细节见上文业务描述)
"temperature": self.LLM_TEMPERATURE,
# L206: 执行该语句(细节见上文业务描述)
"max_tokens": self.LLM_MAX_TOKENS,
# L207: 执行该语句(细节见上文业务描述)
}

# L209: 脱敏后记录日志
# L210: 赋值:更新局部变量或 state 字段
safe_result = {**result, "api_key": "***" + result["api_key"][-4:] if len(result["api_key"]) > 4 else "***"}
# L211: 记录日志,便于线上排查节点入参/出参
logger.info(f"[llm_config_dict] 执行完成,返回(已脱敏): {safe_result}")
# L212: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return result
# L213: 捕获异常,避免整图/整请求崩溃
except Exception as e:
# L214: 执行该语句(细节见上文业务描述)
print(f"[llm_config_dict] 获取 LLM 配置字典异常: {e}")
# L215: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return {}

get_settings 单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# ========== get_settings 单例 ==========
# 源文件: config.py 行 287-326

# L287: 同步函数 get_settings:路由决策或工厂方法
def get_settings() -> Settings:
# L289: 【文档】获取全局 Settings 单例实例。
# L291: 【文档】功能描述:
# L292: 【文档】创建并返回 Settings 配置实例。此函数可作为依赖注入的工厂方法,
# L293: 【文档】确保整个应用使用同一份配置。
# L295: 【文档】入参说明:
# L296: 【文档】无
# L298: 【文档】出参说明:
# L299: 【文档】Settings: 全局配置实例
# (L288-300 为函数/模块文档字符串,已转为注释便于阅读)
# L301: 开始 try 块,后续 except 负责兜底
try:
# L302: 导入依赖模块
from ican.core.logger import get_logger

# L304: 赋值:更新局部变量或 state 字段
logger = get_logger(__name__)
# L305: 记录日志,便于线上排查节点入参/出参
logger.info(f"[get_settings] 开始执行,入参: 无")

# L307: 赋值:更新局部变量或 state 字段
settings = Settings()

# L309: 记录日志,便于线上排查节点入参/出参
logger.info(
# L310: 执行该语句(细节见上文业务描述)
f"[get_settings] 执行完成,应用: {settings.APP_NAME} v{settings.APP_VERSION},"
# L311: 执行该语句(细节见上文业务描述)
f"调试模式: {settings.DEBUG},数据库: {settings.DB_URL}"
# L312: 执行该语句(细节见上文业务描述)
)
# L313: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return settings
# L314: 捕获异常,避免整图/整请求崩溃
except Exception as e:
# L315: 执行该语句(细节见上文业务描述)
print(f"[get_settings] 创建 Settings 实例异常: {e}")
# L316: 向上抛出异常,由调用方或 LangGraph 处理
raise


# L319: ----------------------------------------------------------------------
# L320: 全局配置实例(模块级单例)
# L321: ----------------------------------------------------------------------
# L322: 开始 try 块,后续 except 负责兜底
try:
# L323: 赋值:更新局部变量或 state 字段
settings = get_settings()
# L324: 捕获异常,避免整图/整请求崩溃
except Exception:
# L325: 如果创建失败(如缺少 .env 文件),使用默认值
# L326: 赋值:更新局部变量或 state 字段
settings = Settings()

系列导航

主题
1 系统全景
2 五 Agent 协作
3 霍兰德 RIASEC
4–7 状态 · 路由 · 嵌套 · 容错
8–11 LLM 层 · SSE/WS · DB 迁移 · PDF
12–14 JSON Prompt · RIASEC Prompt · Guide Prompt
15–17 Docker · 中间件 · 配置

← 返回 iCan 专题