1. 工具
FastAPI、LangChain 和 LangGraph 都大量使用类型标注。项目规模增大后,只靠人工检查,很难持续发现下面这些问题:
- 引用了未定义的名称;
- 忘记处理可能为
None的 API 密钥; - 调用异步函数后忘记
await; - 路由声明返回
dict[str, str],实际却返回数字; - 一个模块使用单引号,另一个模块使用双引号;
- 导入顺序和未使用变量不断积累。
这些问题可以拆给不同工具:
| 工具 | 主要职责 |
|---|---|
| Ruff Formatter | 统一代码排版 |
| Ruff Linter | 检查未使用变量、导入顺序、部分常见错误和代码规范 |
| Mypy 或 Pyright | 根据类型标注检查跨函数、跨模块的数据契约 |
格式化、Lint 和类型检查解决的是三类问题,不能互相替代。Ruff 能覆盖大量常见问题,但它不会完整推导每个 Pydantic 模型和 LangChain Runnable 之间的类型关系;类型检查器也不能替代 Ruff 的导入排序和代码风格检查。
先把开发工具放进独立依赖组:
1uv add --group lint ruff mypy
对应配置会类似:
1[dependency-groups]2lint = [3"mypy",4"ruff",5]
这里省略版本约束,只是为了突出依赖分组。实际执行 uv add 时,uv 会按项目的依赖约束策略更新 pyproject.toml 和 uv.lock。质量工具升级后,规则和类型推导结果也可能变化,因此同样要经过 CI 验证。
2. Ruff
Ruff 可以从 pyproject.toml 的 [tool.ruff] 读取通用配置:
1[tool.ruff]2target-version = "py313"3line-length = 1004src = ["."]5extend-exclude = [6".venv",7"build",8"dist",9]
常用字段含义如下:
| 字段 | 含义 |
|---|---|
target-version | Ruff 按哪个 Python 版本检查和格式化语法 |
line-length | 格式化目标行宽,也是部分规则参考值 |
src | 用于识别项目内部导入的源码根目录 |
exclude | 替换默认排除列表 |
extend-exclude | 在默认排除列表基础上追加 |
include | 只包含匹配的文件模式 |
当前项目已经在 [project] 中声明 requires-python = ">=3.13"。没有显式配置 target-version 时,Ruff 通常会从同目录的 pyproject.toml 推断最低 Python 版本;直接写下 target-version = "py313",可以让工具目标更醒目。两处要保持一致,不能让项目声称支持 Python 3.11,Ruff 却把代码改写成只有 Python 3.13 才支持的语法。
这里的 src 不是扫描目录清单。当前采用扁平布局,Python 包 app/ 位于项目根目录下,所以源码根目录写成 .;如果目录结构是 src/app/,则应写成 src = ["src"]。Ruff 会据此判断导入属于标准库、第三方包还是项目内部模块。执行 ruff check app tests 时,命令参数才决定本次检查哪些路径。
Ruff 也支持独立 ruff.toml 或 .ruff.toml。使用独立文件时要去掉 tool.ruff 前缀:
1target-version = "py313"2line-length = 100
如果同一目录同时存在多个 Ruff 配置文件,优先级是 .ruff.toml、ruff.toml、pyproject.toml。普通项目选择一种即可,不要在多个文件中重复维护同一套配置。
3. Lint
Lint 规则放在 [tool.ruff.lint]:
01[tool.ruff.lint]02select = [03"E", # pycodestyle 错误04"F", # Pyflakes05"I", # 导入排序06"B", # flake8-bugbear07"UP", # Python 语法升级建议08"ASYNC", # 异步代码常见问题09]10ignore = [11"E501", # 行宽交给格式化器处理12]1314[tool.ruff.lint.per-file-ignores]15"**/__init__.py" = ["F401"]
规则代码按系列组织。select 决定启用哪些系列,ignore 再从中排除个别规则。这里忽略 E501,是因为 Ruff Formatter 只会尽力按目标行宽换行,并不能保证所有字符串、URL 和错误消息都不超长。如果团队确实要把行宽作为强制规则,可以重新启用 E501,再为无法合理换行的内容设置局部例外。
不要一开始就使用 select = ["ALL"],然后维护几十条自己也无法解释的忽略项。先选择团队能理解、能修复的规则,后续再逐步增加,通常更容易落地。
F401 表示导入后没有在当前文件使用。某些 __init__.py 会专门重新导出对象,因此示例只对它们忽略。更严格的项目也可以显式写 __all__,减少全局忽略。
如果以后启用了安全规则 S 或调试输出规则 T,也可以针对特定目录添加例外:
1[tool.ruff.lint.per-file-ignores]2"tests/**/*.py" = ["S101"]3"scripts/**/*.py" = ["T201"]
这段配置只是演示语法,不需要现在就复制。只有同时启用了对应的 S、T 规则时,这些 ignore 才会生效。实际项目还应在代码审查中说明例外原因,避免目录级忽略逐渐掩盖真实问题。
执行检查:
1uv run ruff check app tests2uv run ruff check --fix app tests
--fix 默认只应用 Ruff 标记为安全的修复。可能改变程序行为的修复必须额外传入 --unsafe-fixes,不适合在不了解影响时直接开启。自动修改后仍应查看 Git diff,尤其要留意导入移动和语法升级。
需要确认某个文件最终使用了哪些设置,可以运行:
1uv run ruff check --show-settings app/main.py
它对排查配置发现、父目录继承和命令行覆盖很有帮助。
4. 格式化
格式化设置放在 [tool.ruff.format]:
1[tool.ruff.format]2quote-style = "double"3indent-style = "space"4line-ending = "auto"5docstring-code-format = true
quote-style 控制普通字符串优先使用单引号还是双引号,indent-style 控制使用空格还是制表符缩进。line-ending = "auto" 会尽量保留已有的换行风格,docstring-code-format = true 则会格式化文档字符串中的 Python 示例。
格式化命令分为修改和检查两种:
1uv run ruff format app tests2uv run ruff format --check app tests
本地开发通常执行第一条;CI 执行 --check,只验证而不修改仓库。
line-length = 100 不是“超过 100 个字符就一定报错”。格式化器会尽量在目标宽度附近换行,但部分无法安全拆分的字符串仍可能更长。前面的配置已经忽略 E501;如果重新启用这条规则,格式化完成后仍可能出现少量需要人工处理的长行。
Ruff Formatter 的设计目标之一是与 Black 风格接近。项目选择 Ruff 后,一般不需要再同时运行 Black 和 isort,否则多个工具可能重复修改同一段代码。
5. 类型
Mypy 读取 [tool.mypy]:
1[tool.mypy]2python_version = "3.13"3files = ["app", "tests"]4warn_unused_configs = true5warn_return_any = true6warn_unused_ignores = true7check_untyped_defs = true8implicit_optional = false9show_error_codes = true
常用字段含义如下:
| 字段 | 含义 |
|---|---|
python_version | 按哪个 Python 版本分析语法和标准库 |
files | 未传路径时默认检查哪些目录 |
warn_return_any | 标注了具体返回类型却返回 Any 时提醒 |
warn_unused_ignores | 找出已经不再需要的忽略注释 |
check_untyped_defs | 检查没有类型标注的函数体,但不会强制补全函数签名 |
implicit_optional | 设为 false 后,默认值为 None 时仍要明确写出可空类型 |
show_error_codes | 输出错误规则代码,方便精确处理 |
当前 Mypy 默认已经把 implicit_optional 设为 false,这里显式写出,是为了让团队看见这项约束。类型检查可以从适度规则开始,再逐步提高,而不是第一天打开 strict = true,随后又给整个项目添加 # type: ignore。
例如,模型 API Key 可能为空:
01from langchain_openai import ChatOpenAI0203from app.core.settings import Settings040506def create_model(settings: Settings) -> ChatOpenAI:07api_key = settings.model.api_key08if api_key is None:09raise RuntimeError("模型 API Key 未配置")1011return ChatOpenAI(12model=settings.model.name,13api_key=api_key.get_secret_value(),14base_url=str(settings.model.base_url),15)
显式判断既满足类型检查,也表达了真实业务规则。使用强制类型转换让错误消失,却不处理运行时空值,没有实际价值。
第三方库缺少类型信息时,可以对指定模块设置 override:
1[[tool.mypy.overrides]]2module = [3"legacy_package",4"legacy_package.*",5]6ignore_missing_imports = true
只忽略确认缺少类型声明的第三方库,不要全局开启 ignore_missing_imports = true。全局忽略后,拼错模块名也可能被当成“缺少类型信息”而跳过。
执行类型检查:
1uv run mypy
这里没有传入目录,是因为 Mypy 会读取前面 files = ["app", "tests"] 的配置。如果删除 files,就应把检查路径写在命令中,例如 uv run mypy app tests。
Pyright 或 BasedPyright 也能完成类型检查。一个项目通常选择一个主类型检查器,并以 CI 结果为准。编辑器可以使用另一套语言服务提供补全,但如果两个检查器都作为强制门禁,就需要明确解决规则差异的成本。
6. 工程实践
FastAPI 依赖 Pydantic 和类型标注生成请求校验与 OpenAPI,因此类型写法会同时影响编辑器、类型检查和运行行为:
01from fastapi import APIRouter02from pydantic import BaseModel, Field0304from app.api.dependencies import ChatServiceDep0506router = APIRouter()070809class ChatRequest(BaseModel):10message: str = Field(min_length=1, max_length=4_000)111213class ChatResponse(BaseModel):14answer: str151617@router.post("/chat", response_model=ChatResponse)18async def chat(19body: ChatRequest,20service: ChatServiceDep,21) -> ChatResponse:22answer = await service.answer(body.message)23return ChatResponse(answer=answer)
ChatServiceDep 是项目在依赖模块中定义的类型别名,它把 ChatService 与 Depends(get_chat_service) 组合在一起。路由文件只需要导入这个别名,不应该依赖示例中没有定义的名称。
这里有三层约束:
- Pydantic 在运行时校验请求;
response_model控制 FastAPI 响应文档与过滤;- 返回类型让类型检查器验证函数实现。
它们互相补充,不能互相替代。Mypy 通过不代表恶意请求一定合法,Pydantic 校验通过也不代表每个内部函数类型都正确。
LangChain 返回的消息内容可能是字符串,也可能是内容块列表。不要为了满足 str 返回类型,直接写 str(response.content),因为这可能把列表转换成一段 Python 表示形式,而不是用户期望的文本。业务服务应按模型接口读取文本内容或遍历 content_blocks,再向路由返回已经规范化的字符串;文本回复、工具调用和多模态内容也要分别测试。
执行流程
本地开发可以按这个顺序运行:
1uv run ruff format app tests2uv run ruff check --fix app tests3uv run mypy
CI 不应自动改代码,而是检查仓库是否已经符合规范:
1uv sync --locked --group lint2uv run ruff format --check app tests3uv run ruff check app tests4uv run mypy
--locked 会检查 uv.lock 是否仍与 pyproject.toml 一致;锁文件过期时,CI 会直接失败。--frozen 只是按现有锁文件安装而不检查它是否过期,更适合某些尚未复制完整项目元数据的 Docker 分层构建,不适合作为这里的默认 CI 示例。
提交前检查
编辑器保存时可以运行 Ruff,团队也可以使用 pre-commit 等工具在提交前执行同一组命令。无论采用哪种自动化方式,都应复用 pyproject.toml 中的配置,避免本地钩子和 CI 各维护一套规则。提交前钩子可以缩短反馈时间,但它可能被跳过,因此不能替代 CI。
配置变更也需要代码审查。扩大 ignore、缩小 files、排除整个目录,可能让 CI 变绿,却同时降低了检查覆盖范围。
引入质量工具时,可以先测量现状,再分批修复:
- 固定 Python 目标版本和扫描目录;
- 先启用
E、F、I等基础规则; - 修复现有问题并建立 CI;
- 再增加异步、安全或复杂度规则;
- 逐步提高类型检查严格度;
- 每条 ignore 都说明局部原因。
工具的目标是减少低价值争论和真实缺陷,不是让配置文件看起来复杂。