从能跑到能观察——AI 服务的工程化分层与日志
前几篇我们把 Agent 包成了 FastAPI 接口(第 4 篇),加了字段校验、接了错误处理。功能都齐了。但有两个问题一直卡着我: **一是看不清。** 有一次模型调用失败,前端只返回一句"模型调用失败",我盯着屏幕却不知道:请求到底进没进来?Service 执行到哪一步?具体炸在哪一行?服务能跑,但看不清它怎么跑。 **二是改不动。** 想给分析加个缓存,发现 Agent 调用、错误处理、日志全挤在一块,只能往 `routers.py` 里硬塞。能跑,但不等于好维护。 所以这一篇同时解决这两件事:把项目分层,再加日志。 > **能跑只是 Demo,能分层、能观察,才开始像可维护的服务。** --- ## 一、项目现在的分层结构 分层之后,目录是这样: ``` app/ ├── main.py 应用入口 ├── routers.py API 接口层 ├── schemas.py 请求和响应模型 └── services.py 业务服务层 resume_analyzer/ ├── agent.py PydanticAI Agent 调用 ├── models.py Agent 输出结构 └── tools.py Agent 工具函数 ``` `app/` 管 Web 服务和接口,`resume_analyzer/` 管 AI 能力本身。两条线分开,后面换模型、加工具都不动接口层。 --- ## 二、每一层分别管什么 ### 1. `main.py` —— 启动器 不碰业务。只做:创建 FastAPI app、挂载 router、配全局日志、提供 health check。 ### 2. `routers.py` —— 接口层 只关心 HTTP:地址是什么、请求/响应模型是什么、异常返回什么状态码。不该知道 Agent 内部细节。 ### 3. `schemas.py` —— 契约层 放 `AnalyzeRequest` / `AnalyzeResponse`。它告诉调用方:该传什么、会返回什么。前端对接就靠它。 ### 4. `services.py` —— 业务层 负责业务编排:调 Agent、记日志,以后还能加缓存、重试、数据库、权限。 ### 5. `agent.py` —— AI 能力层 只管 PydanticAI:定义 Agent、调模型、回结构化结果。 **一句话类比**:`main` 是门面,`routers` 是前台,`schemas` 是表单,`services` 是后台办事员,`agent` 是里面真正干活的专家。各司其职,谁也别越界。 --- ## 三、最该理解的一层:services.py 它可能现在只是这样: ``` def analyze_resume_service(resume_text: str) -> ResumeAnalysis: return analyze_resume(resume_text) ``` 看着只是"包了一层",没干啥。但它的价值不在今天,在以后: ``` 日志 → 放这 缓存 → 放这 重试 → 放这 数据库保存 → 放这 权限判断 → 放这 调用耗时 → 放这 ``` 它的存在,让 `routers.py` 永远只说"收到请求、返回结果",不用面对复杂业务。 --- ## 四、为什么需要日志 解决了分层,还得能看清服务内部。AI 服务跑起来后,出问题的地方比普通接口多: - API Key 没配置 - 模型调用失败 - 网络超时 - Agent 输出异常 - 用户输入异常 没有日志时,用户只看到: ``` { "detail": { "code": "MODEL_CALL_FAILED", "message": "模型调用失败" } } ``` 这对用户友好,对开发者不够。开发者还需要知道:哪一次请求失败了、失败前执行到了哪、具体异常堆栈是什么。日志就是干这个的。 > **API 返回给用户看,日志留给开发者看。** 两者面对的人不同,该说的内容也不同。 --- ## 五、main 定规则,services 记业务 ### 1. `app/main.py` —— 定规则,不写日志 `main.py` 是入口,在这里配置全局日志格式: ``` import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s - %(message)s", ) ``` 它规定的格式是:**时间 等级 模块名 - 内容**。比如一次真实运行: ``` 2026-08-14 10:20:01 INFO app.services - 开始分析简历,文本长度=61 ``` ### 2. `app/services.py` —— 记业务,才真正写日志 业务层拿到 logger 后,按执行过程记录: ``` import logging logger = logging.getLogger(__name__) ``` ``` logger.info("开始分析简历,文本长度=%s", len(resume_text)) # ... 调用 Agent ... logger.info("简历分析成功") ``` 失败处: ``` logger.exception("简历分析失败") ``` 这里有个容易混的点。`main.py` 的 `basicConfig(...)` **不是在写日志,是在定规则**——它告诉整个项目显示什么等级、长什么样、带不带模块名。而 `services.py` 的 `logger.info(...)` 才记录具体的事。 ``` main.py 配置日志规则(全局一次) services.py 记录业务日志(每次执行) ``` 这是 Python 服务端最常见的组织方式:规则集中定,业务分散记。 --- ## 六、别再用 print() 调试 `print()` 是临时调试,能看但不适合长期维护: ``` print("开始分析") ``` `logging` 是正式记录,优势是结构化的: ``` logger.info("开始分析简历,文本长度=%s", len(resume_text)) ``` 它有四样 `print` 没有的东西: - 日志等级(INFO/WARNING/ERROR) - 时间戳 - 模块名(知道谁打的) - 能记异常堆栈,以后可落文件或接日志平台 服务端项目里,优先用 `logging`。 --- ## 七、logger.exception() 是排查利器 这个方法必须放在 `except` 里: ``` try: result = analyze_resume(resume_text) except Exception: logger.exception("简历分析失败") raise ``` 它的特别之处:不仅记错误信息,还**自动带上 traceback**。逻辑是——先把详细错误写进日志,再把异常继续抛给上层。`routers.py` 再把它转成结构化的 API 错误。 一句记住:**`logger.exception**`** = 记错误 + 记堆栈 + 重新抛出。** --- ## 八、错误响应和日志的分工 今天最重要的理解就一句: ``` 错误响应:给用户 / 前端看 日志记录:给开发者看 ``` 模型调用失败时,前端看到的是干净的: ``` { "detail": { "code": "MODEL_CALL_FAILED", "message": "模型调用失败", "hint": "请检查 API Key、模型名称、网络连接或上游模型服务状态" } } ``` 开发者在终端看到的是原始的: ``` ERROR app.services - 简历分析失败 Traceback (most recent call last): ... ``` 这就是分工的价值:用户体验不破,排查线索不丢。 --- ## 九、今天的核心收获 > **一个 AI 服务不能只会调模型,还要有清晰的分层、稳定的错误边界、能观察运行过程的日志。** 回顾这 5 篇走过的路,项目已经具备: ``` 结构化输出 (1) output_type 字段校验 (2) field_validator 工具调用 (2) Tool Schema/Evidence (3) API 封装 (4) FastAPI 错误处理 (4) HTTPException 工程分层 (5) services.py 日志记录 (5) logging ``` 这已经是一个小型 AI 后端项目的雏形。