agent学习Day11——多环境配置与完整JD分析接口
一、多环境配置:让配置跟环境走
三件事
一是 OS 环境变量优先级最高——export KEY=xxx盖过.env文件,生产敏感信息只放 OS 不落盘。
二是 env_file 分层——.env(通用默认,可进 git)+.env.development/.env.production(环境覆盖,gitignore)+.env.local(个人本地)。
三是 ENV 字段标识——告诉代码"我在哪个环境",据此选 env_file。
pydantic-settings 的一个坑
model_config = SettingsConfigDict(env_file=...)是类定义时求值的,那时self.ENV字段还没读到,没法写env_file=f".env.{self.ENV}"。解法:类定义前先从 OS 环境变量读 ENV 到模块级变量:
importosfrompydantic_settingsimportBaseSettings,SettingsConfigDict _env_name=os.environ.get("ENV","development")classSettings(BaseSettings):model_config=SettingsConfigDict(env_file=(".env",f".env.{_env_name}"),# 元组:后者覆盖前者env_file_encoding="utf-8",extra="ignore",)ENV:str="development"LOG_LEVEL:str="DEBUG"LOG_FILE:str="logs/app.log"# ... 其他字段env_file接收元组的语义:pydantic-settings 按顺序加载,后面的文件覆盖前面的——同名 key 后者生效。所以.env是底座默认值,.env.{env_name}是环境特定覆盖。
配置优先级全景
实例化参数 > OS 环境变量 > .env 文件 > 字段默认值生产环境你在 OS 设DEEPSEEK_API_KEY=正式key,本地用.env写DEEPSEEK_API_KEY=测试key——同一份代码、不同环境自动读到不同值,不用改代码。这就是多环境配置能跑起来的底层机制。
二、结构化日志:从 print 到 dictConfig
print 的五个毛病
- 没级别(INFO/WARN/ERROR 混在一起,没法过滤)
- 没时间戳、没文件名、没行号
- 不能同时输出到终端和文件
- 不能按级别动态切换(dev 想看 DEBUG,prod 只看 INFO)
- 关掉
print要改代码
logging模块解决以上所有问题。dictConfig是结构化配置——把"日志怎么打"用字典声明,比代码一行行logger.addHandler(...)配置更清晰、更易切换。
三件套:Logger / Handler / Formatter
- Logger(入口):你写
logger.info("xxx")就是给它发消息。它决定"这条消息要不要往下传"(按级别过滤)。 - Handler(出口):决定消息发到哪儿——终端(
StreamHandler)、文件(FileHandler)、网络、邮件。每个 Handler 各自管自己的级别过滤。 - Formatter(格式化):决定消息长啥样——加时间戳、文件名、行号、级别标签。
两层过滤(最容易绕晕的地方)
- Logger 设级别= “低于这个级别的消息我直接丢,不传给 Handler”
- Handler 设级别= “低于这个级别的消息我收到了但也不输出”
- 两者取交集——消息必须通过 Logger 级别 + Handler 级别两层才能输出
我自己的需求是"终端 DEBUG + 文件 INFO",靠的就是两层过滤:Logger 级别设 DEBUG(让 DEBUG 消息能传出去),两个 Handler 各自设级别(终端 DEBUG 全收、文件 INFO 起步)。
举例:Logger 级别=DEBUG,Handler 级别=INFO。
logger.debug("x")通过 Logger(DEBUG≥DEBUG)但被 Handler 拦下(DEBUG<INFO)→不输出logger.info("x")两层都通过 →输出
dictConfig 落地
importlogging.configfrompathlibimportPathfromapp.core.configimportSettingsdefsetup_logging(settings:Settings)->None:# 提前建日志目录,否则 FileHandler 第一次写文件时崩log_path=Path(settings.LOG_FILE)log_path.parent.mkdir(parents=True,exist_ok=True)logging.config.dictConfig({"version":1,"disable_existing_loggers":False,# 关键!不禁用已注册 logger(uvicorn 等)"formatters":{"default":{"format":"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s","datefmt":"%Y-%m-%d %H:%M:%S",},},"handlers":{"console":{"class":"logging.StreamHandler","level":"DEBUG","formatter":"default","stream":"ext://sys.stdout",# ext:// 协议引用 Python 对象},"file":{"class":"logging.FileHandler","level":"INFO","formatter":"default","filename":settings.LOG_FILE,"encoding":"utf-8",},},"loggers":{"app":{"level":settings.LOG_LEVEL.upper(),# 动态读配置,不写死"handlers":["console","file"],"propagate":False,# 切断向父 logger 传播,避免重复输出},},"root":{"level":"WARNING","handlers":["console"],},})几个关键点我特意记一下:
disable_existing_loggers: False—— 默认True会禁用所有已存在 logger(包括 uvicorn 的),导致 uvicorn 访问日志消失。一律写False。propagate: False—— 子 logger 处理完消息后默认会向父 logger 传播,父 logger 再处理一遍 → 重复输出。切断传播避免重复。ext://sys.stdout—— dictConfig 是纯字典配置,不能直接写 Python 对象,用ext://协议字符串引用。ext://sys.stdout=sys.stdout。settings.LOG_LEVEL.upper()—— 动态读配置,不写死 DEBUG。生产 OS 设LOG_LEVEL=INFO自动屏蔽 DEBUG,排查时改LOG_LEVEL=DEBUG立刻打开,不用动代码。
logger 继承
logging.getLogger("app.main")创建的 logger 名为"app.main",Python logging 把.当层级分隔符——"app.main"是"app"的子 logger。子 logger 没设级别 → 继承父 logger 的级别;没自己的 handler → 消息 propagate 到父 logger,由父的 handler 处理。"app"设了propagate=False→ 不再向 root 传播,链条到此为止。
三、完整接口整合:把散装能力串成链路
这一节是把前面几篇学的依赖注入、LlmService、async、异常处理、重试、mock 测试、日志全整合到一个接口里。前面那些知识点都是为这一刻铺路。
三层架构(这节的灵魂)
| 层 | 文件 | 职责 | 厚薄 |
|---|---|---|---|
| 路由层 | app/api/routes/jd.py | 接收请求、调服务、返回 | 薄(不写业务逻辑、不 try/except) |
| 服务层 | app/services/llm_service.py | 业务逻辑、调 LLM、解析、异常映射 | 厚 |
| 依赖层 | app/api/dependencies.py | 注入服务实例 | 薄 |
反模式:路由里直接OpenAI()调 LLM——三层混一起,无法测试、无法替换。
路由核心链路
@router.post("/analyze",response_model=JdAnalysisResult)asyncdefanalyze(req:JdAnalyzeRequest,llm_service:LlmService=Depends(get_llm_service),):logger.info("analyze called, jd_text length=%s",len(req.jd_text))result=awaitllm_service.analyze_jd(req.jd_text)logger.info("analyze success: job_title=%s, skills_count=%s",result.job_title,len(result.required_skills))returnresultresponse_model=JdAnalysisResult—— FastAPI 自动序列化 + 生成 /docs + 字段过滤req: JdAnalyzeRequest—— FastAPI 自动用 Pydantic 校验请求体,失败抛RequestValidationError→ 422Depends(get_llm_service)—— 依赖注入,路由不 new 服务,解耦- 路由不写 try/except—— 异常由全局处理器接
异常映射全景
| 场景 | 异常 | 状态码 | 语义 |
|---|---|---|---|
| 请求 jd_text 缺失/过短/过长 | RequestValidationError | 422 | 客户端错 |
| LLM 重试耗尽 | ServiceError | 503 | 上游不可用 |
| LLM 返回坏 JSON | BadGatewayError | 502 | 上游返回无效响应 |
| 其他未捕获 | Exception兜底 | 500 | 服务端 bug |
坏 JSON 的 502 映射是这节的核心。之前parse_jd_analysis抛ValueError(或 pydanticValidationError)走 Exception 兜底 → 500,语义错位——把上游的锅算成了我们的 bug。HTTP 状态码语义:
- 500 Internal Server Error—— “服务端自己出 bug”。是我们的错,不该重试,得修代码。
- 502 Bad Gateway—— “上游网关返回了无效响应”。上游返回了垃圾,不是我们的 bug,重试可能成功。
- 503 Service Unavailable—— “服务暂时不可用”。上游连不上/超时/限流,重试可能成功。
坏 JSON 是 LLM(上游)返回了垃圾——语义上该是 502,不是 500。修复:在analyze_jd内 try/except 捕获ValueError+PydanticValidationError,抛BadGatewayError(502)。
frompydanticimportValidationErrorasPydanticValidationErrorfromapp.core.exceptionsimportBadGatewayErrorasyncdefanalyze_jd(self,jd_text:str)->JdAnalysisResult:raw=awaitself.call_llm(...)try:returnparse_jd_analysis(raw)except(ValueError,PydanticValidationError)ase:raiseBadGatewayError(message="LLM 返回的内容无法解析为有效 JSON",detail=str(e),)起别名PydanticValidationError避免跟项目的ValidationError(422 那个)撞名——这是这节最容易踩的坑。两个ValidationError完全不同:
| 名字 | 来源 | 用途 | 是不是 AppException |
|---|---|---|---|
app.core.exceptions.ValidationError | 我们项目 | AppException 子类,code=422,路由层抛 | ✅ 是 |
pydantic.ValidationError | Pydantic 库 | model_validate失败时自动抛 | ❌ 不是 |
4 场景端到端测试
用 TestClient +dependency_overrides注入假 service,测 HTTP 链路 + 异常映射:
classFakeLlmService:"""鸭子类型假 service,不继承 LlmService 避免触发 AsyncOpenAI 初始化。"""def__init__(self,return_value=None,raise_exception=None):self.return_value=return_value self.raise_exception=raise_exceptionasyncdefanalyze_jd(self,jd_text:str):ifself.raise_exception:raiseself.raise_exceptionreturnself.return_value@pytest.fixture(autouse=True)defcleanup_overrides():yieldapp.dependency_overrides.clear()# 避免测试间污染deftest_analyze_success(client):app.dependency_overrides[get_llm_service]=lambda:FakeLlmService(return_value=_make_result())response=client.post("/api/v1/jd/analyze",json={"jd_text":VALID_JD})assertresponse.status_code==200deftest_analyze_short_jd_text_422(client):response=client.post("/api/v1/jd/analyze",json={"jd_text":"短"})assertresponse.status_code==422# FastAPI 自动校验,FakeLlmService 没被调deftest_analyze_llm_unavailable_503(client):app.dependency_overrides[get_llm_service]=lambda:FakeLlmService(raise_exception=ServiceError(message="LLM 超时"))response=client.post("/api/v1/jd/analyze",json={"jd_text":VALID_JD})assertresponse.status_code==503deftest_analyze_bad_json_502(client):app.dependency_overrides[get_llm_service]=lambda:FakeLlmService(raise_exception=BadGatewayError(message="无法解析"))response=client.post("/api/v1/jd/analyze",json={"jd_text":VALID_JD})assertresponse.status_code==502两种 mock 模式的区别(这个区分是测试设计的灵魂):
- 服务层单元测试(
test_analyze_jd_mock.py):mockclient.chat.completions.create(网络边界),业务逻辑真跑——验"加工过程对不对" - 端到端集成测试(
test_jd_analyze.py):mockLlmService整个(鸭子类型 FakeLlmService),测 HTTP 链路 + 异常映射——验"调度对不对"
比喻:服务层单元测试是验"工厂流水线对不对",端到端测试是验"整条供应链调度对不对"。两者互补,不能互相替代。
四、几个我踩过的坑
把变量名写成了字符串字面量
改logging_config.py时我把settings.LOG_LEVEL.upper()(调用对象属性方法)写成了"settings.LOG_LEVEL".upper()(字符串字面量.upper())。dictConfig 收到"SETTINGS.LOG_LEVEL"找不到对应级别抛 ValueError,pytest 加载 conftest 时炸。
教训:Python 里"xxx"是字符串字面量,xxx是变量/表达式,差一个引号语义完全不同。"settings.LOG_LEVEL".upper()是把字符串"settings.LOG_LEVEL"转大写 ="SETTINGS.LOG_LEVEL";settings.LOG_LEVEL.upper()是访问settings对象的LOG_LEVEL属性、再调它的.upper()方法。
改级别改错位置:handler 还是 logger
我说改 “app” logger 的 level(loggers段下),结果改到了 console handler 的 level(handlers段下)。console handler 级别保留 DEBUG(终端看 DEBUG 细节),“app” logger 级别才该读settings.LOG_LEVEL(控制全局消息能否传出)。
教训:改代码前先精确定位"改哪一行、改哪个字段",别凭印象改。两层过滤里,Logger 级别管"消息能不能往下传",Handler 级别管"消息能不能输出"——改 Logger 级别才影响全局,改 Handler 级别只影响一个出口。
pre-commit 因 import 排序挂掉
commit 时 ruff 检测到 import 顺序不规范(Organize imports),pre-commit restore 了改动,commit 失败。修法是 commit 前先ruff check --fix .修 import 排序,再 add + commit。
教训:pre-commit 是故意"不让坏代码进 git"的设计,改完再 commit 是正常流程不是失败。以后 commit 前养成"先ruff check --fix ."的习惯。
五、小结
多环境配置让代码不动配置跟环境走,结构化日志让print升级成有级别有格式的logging,三层架构 + 异常映射把散装能力串成完整接口。这三块是从"能跑的代码"往"工程化的代码"挪的核心动作。
路由薄、服务厚、依赖注入——这三层分离让代码可换、可测、可替换。两层过滤(Logger 级别 + Handler 级别取交集)+ 多环境优先级(实例化参数 > OS > .env > 默认值),是配置和日志能"跟环境走、可动态切"的底层机制。
后面做任何 LLM / Agent 接口,这三层架构 + 422/503/502/500 四种状态码语义 + 两层 mock 测试(服务层单元 + 端到端集成),都是底层认知,提前理清能少踩不少坑。
