创建时间: 2026-08-28最后更新: 2026-08-28

1. 工具

FastAPI、LangChain 和 LangGraph 都大量使用类型标注。项目规模增大后,只靠人工检查,很难持续发现下面这些问题:

  • 引用了未定义的名称;
  • 忘记处理可能为 None 的 API 密钥;
  • 调用异步函数后忘记 await;
  • 路由声明返回 dict[str, str],实际却返回数字;
  • 一个模块使用单引号,另一个模块使用双引号;
  • 导入顺序和未使用变量不断积累。

这些问题可以拆给不同工具:

工具主要职责
Ruff Formatter统一代码排版
Ruff Linter检查未使用变量、导入顺序、部分常见错误和代码规范
Mypy 或 Pyright根据类型标注检查跨函数、跨模块的数据契约

格式化、Lint 和类型检查解决的是三类问题,不能互相替代。Ruff 能覆盖大量常见问题,但它不会完整推导每个 Pydantic 模型和 LangChain Runnable 之间的类型关系;类型检查器也不能替代 Ruff 的导入排序和代码风格检查。

先把开发工具放进独立依赖组:

install-quality-tools.bash
1
uv add --group lint ruff mypy

对应配置会类似:

quality-dependencies.toml
1
[dependency-groups]
2
lint = [
3
"mypy",
4
"ruff",
5
]

这里省略版本约束,只是为了突出依赖分组。实际执行 uv add 时,uv 会按项目的依赖约束策略更新 pyproject.toml 和 uv.lock。质量工具升级后,规则和类型推导结果也可能变化,因此同样要经过 CI 验证。

2. Ruff

Ruff 可以从 pyproject.toml 的 [tool.ruff] 读取通用配置:

ruff-base.toml
1
[tool.ruff]
2
target-version = "py313"
3
line-length = 100
4
src = ["."]
5
extend-exclude = [
6
".venv",
7
"build",
8
"dist",
9
]

常用字段含义如下:

字段含义
target-versionRuff 按哪个 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 前缀:

ruff.toml
1
target-version = "py313"
2
line-length = 100

如果同一目录同时存在多个 Ruff 配置文件,优先级是 .ruff.toml、ruff.toml、pyproject.toml。普通项目选择一种即可,不要在多个文件中重复维护同一套配置。

3. Lint

Lint 规则放在 [tool.ruff.lint]:

ruff-lint.toml
01
[tool.ruff.lint]
02
select = [
03
"E", # pycodestyle 错误
04
"F", # Pyflakes
05
"I", # 导入排序
06
"B", # flake8-bugbear
07
"UP", # Python 语法升级建议
08
"ASYNC", # 异步代码常见问题
09
]
10
ignore = [
11
"E501", # 行宽交给格式化器处理
12
]
13
14
[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,也可以针对特定目录添加例外:

per-file-ignores.toml
1
[tool.ruff.lint.per-file-ignores]
2
"tests/**/*.py" = ["S101"]
3
"scripts/**/*.py" = ["T201"]

这段配置只是演示语法,不需要现在就复制。只有同时启用了对应的 S、T 规则时,这些 ignore 才会生效。实际项目还应在代码审查中说明例外原因,避免目录级忽略逐渐掩盖真实问题。

执行检查:

ruff-check.bash
1
uv run ruff check app tests
2
uv run ruff check --fix app tests

--fix 默认只应用 Ruff 标记为安全的修复。可能改变程序行为的修复必须额外传入 --unsafe-fixes,不适合在不了解影响时直接开启。自动修改后仍应查看 Git diff,尤其要留意导入移动和语法升级。

需要确认某个文件最终使用了哪些设置,可以运行:

ruff-settings.bash
1
uv run ruff check --show-settings app/main.py

它对排查配置发现、父目录继承和命令行覆盖很有帮助。

4. 格式化

格式化设置放在 [tool.ruff.format]:

ruff-format.toml
1
[tool.ruff.format]
2
quote-style = "double"
3
indent-style = "space"
4
line-ending = "auto"
5
docstring-code-format = true

quote-style 控制普通字符串优先使用单引号还是双引号,indent-style 控制使用空格还是制表符缩进。line-ending = "auto" 会尽量保留已有的换行风格,docstring-code-format = true 则会格式化文档字符串中的 Python 示例。

格式化命令分为修改和检查两种:

ruff-format.bash
1
uv run ruff format app tests
2
uv run ruff format --check app tests

本地开发通常执行第一条;CI 执行 --check,只验证而不修改仓库。

line-length = 100 不是“超过 100 个字符就一定报错”。格式化器会尽量在目标宽度附近换行,但部分无法安全拆分的字符串仍可能更长。前面的配置已经忽略 E501;如果重新启用这条规则,格式化完成后仍可能出现少量需要人工处理的长行。

Ruff Formatter 的设计目标之一是与 Black 风格接近。项目选择 Ruff 后,一般不需要再同时运行 Black 和 isort,否则多个工具可能重复修改同一段代码。

5. 类型

Mypy 读取 [tool.mypy]:

mypy.toml
1
[tool.mypy]
2
python_version = "3.13"
3
files = ["app", "tests"]
4
warn_unused_configs = true
5
warn_return_any = true
6
warn_unused_ignores = true
7
check_untyped_defs = true
8
implicit_optional = false
9
show_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 可能为空:

app/agent/model.py
01
from langchain_openai import ChatOpenAI
02
03
from app.core.settings import Settings
04
05
06
def create_model(settings: Settings) -> ChatOpenAI:
07
api_key = settings.model.api_key
08
if api_key is None:
09
raise RuntimeError("模型 API Key 未配置")
10
11
return ChatOpenAI(
12
model=settings.model.name,
13
api_key=api_key.get_secret_value(),
14
base_url=str(settings.model.base_url),
15
)

显式判断既满足类型检查,也表达了真实业务规则。使用强制类型转换让错误消失,却不处理运行时空值,没有实际价值。

第三方库缺少类型信息时,可以对指定模块设置 override:

mypy-overrides.toml
1
[[tool.mypy.overrides]]
2
module = [
3
"legacy_package",
4
"legacy_package.*",
5
]
6
ignore_missing_imports = true

只忽略确认缺少类型声明的第三方库,不要全局开启 ignore_missing_imports = true。全局忽略后,拼错模块名也可能被当成“缺少类型信息”而跳过。

执行类型检查:

mypy-check.bash
1
uv run mypy

这里没有传入目录,是因为 Mypy 会读取前面 files = ["app", "tests"] 的配置。如果删除 files,就应把检查路径写在命令中,例如 uv run mypy app tests。

Pyright 或 BasedPyright 也能完成类型检查。一个项目通常选择一个主类型检查器,并以 CI 结果为准。编辑器可以使用另一套语言服务提供补全,但如果两个检查器都作为强制门禁,就需要明确解决规则差异的成本。

6. 工程实践

FastAPI 依赖 Pydantic 和类型标注生成请求校验与 OpenAPI,因此类型写法会同时影响编辑器、类型检查和运行行为:

app/api/routes/chat.py
01
from fastapi import APIRouter
02
from pydantic import BaseModel, Field
03
04
from app.api.dependencies import ChatServiceDep
05
06
router = APIRouter()
07
08
09
class ChatRequest(BaseModel):
10
message: str = Field(min_length=1, max_length=4_000)
11
12
13
class ChatResponse(BaseModel):
14
answer: str
15
16
17
@router.post("/chat", response_model=ChatResponse)
18
async def chat(
19
body: ChatRequest,
20
service: ChatServiceDep,
21
) -> ChatResponse:
22
answer = await service.answer(body.message)
23
return ChatResponse(answer=answer)

ChatServiceDep 是项目在依赖模块中定义的类型别名,它把 ChatService 与 Depends(get_chat_service) 组合在一起。路由文件只需要导入这个别名,不应该依赖示例中没有定义的名称。

这里有三层约束:

  1. Pydantic 在运行时校验请求;
  2. response_model 控制 FastAPI 响应文档与过滤;
  3. 返回类型让类型检查器验证函数实现。

它们互相补充,不能互相替代。Mypy 通过不代表恶意请求一定合法,Pydantic 校验通过也不代表每个内部函数类型都正确。

LangChain 返回的消息内容可能是字符串,也可能是内容块列表。不要为了满足 str 返回类型,直接写 str(response.content),因为这可能把列表转换成一段 Python 表示形式,而不是用户期望的文本。业务服务应按模型接口读取文本内容或遍历 content_blocks,再向路由返回已经规范化的字符串;文本回复、工具调用和多模态内容也要分别测试。

执行流程

本地开发可以按这个顺序运行:

quality-workflow.bash
1
uv run ruff format app tests
2
uv run ruff check --fix app tests
3
uv run mypy

CI 不应自动改代码,而是检查仓库是否已经符合规范:

quality-ci.bash
1
uv sync --locked --group lint
2
uv run ruff format --check app tests
3
uv run ruff check app tests
4
uv run mypy

--locked 会检查 uv.lock 是否仍与 pyproject.toml 一致;锁文件过期时,CI 会直接失败。--frozen 只是按现有锁文件安装而不检查它是否过期,更适合某些尚未复制完整项目元数据的 Docker 分层构建,不适合作为这里的默认 CI 示例。

提交前检查

编辑器保存时可以运行 Ruff,团队也可以使用 pre-commit 等工具在提交前执行同一组命令。无论采用哪种自动化方式,都应复用 pyproject.toml 中的配置,避免本地钩子和 CI 各维护一套规则。提交前钩子可以缩短反馈时间,但它可能被跳过,因此不能替代 CI。

配置变更也需要代码审查。扩大 ignore、缩小 files、排除整个目录,可能让 CI 变绿,却同时降低了检查覆盖范围。

引入质量工具时,可以先测量现状,再分批修复:

  1. 固定 Python 目标版本和扫描目录;
  2. 先启用 E、F、I 等基础规则;
  3. 修复现有问题并建立 CI;
  4. 再增加异步、安全或复杂度规则;
  5. 逐步提高类型检查严格度;
  6. 每条 ignore 都说明局部原因。

工具的目标是减少低价值争论和真实缺陷,不是让配置文件看起来复杂。

正在验证登录状态
请稍候,验证完成后将继续显示文章内容