在VSCode下构建基于Python的LLMOps工程
那天晚上十一点,我坐在电脑前,盯着终端里一个空文件夹发呆。
事情是这样的。我接了个活——帮一个团队用大模型做一套自动客服系统。听起来简单对吧?调个 API,写几行 Python,起一个 FastAPI 服务,完事儿。
我他妈的也是这么想的。
然后我新建了项目目录。光标在终端里一闪一闪的。我突然不知道该干什么了。用 pip 还是 poetry?虚拟环境放项目里面还是外面?代码风格要不要统一?commit message 写什么?VSCode 配哪些插件?CI 怎么写?最卧槽的是——有没有人能告诉我,这堆东西哪件先做、哪件后做?
你多半也经历过这种时刻。LLMOps 听起来高大上,但落到地上,第一关既不是模型选型,也不是 prompt 优化,而是:我该怎么把这个项目"搭好"?
这不是一篇写"pip install 就完事"的文章。我想跟你聊的是,一个 Python LLMOps 工程,从你敲第一个命令开始,到代码 push 之后 CI 自动跑完 lint、type check、test 全流程,中间每一步应该怎么走。为什么这么走。以及——冷静地说——这么走也解决不了什么。
你他妈的到底在解决什么问题
先退一步。为什么 LLMOps 工程需要这么多"规矩"?
因为大模型应用天然不稳定。
你今天改了 prompt 的一个词,返回的 JSON 格式可能就变了。你升级了一个依赖包,某个模型调用的超时参数就炸了。更别提多人协作的时候——每个人在自己环境里能跑通的代码,推到仓库里就报 ImportError。
这不是危言耸听。我见过一个 LLM 项目上线三个月后,dev 分支的 requirements.txt 里有 47 个包,其中 12 个的直接依赖已经冲突了三个月没人知道。因为"代码能跑"。
工程化要解决的就是这个东西——把不确定性关进笼子里。
但它不解决什么?它不帮你选模型。它不帮你写 prompt。它不保证你的 AI 回答是正确的。工程化只是管好代码质量、依赖关系、自动化流程这一摊事。你指望靠一套规范化的项目结构来防止模型幻觉——醒醒吧,这是两回事。
好,现在你知道工程化要解决什么、不解决什么了。我们开始搭。
项目结构——先搭好你的厨房
想象你要开一家餐厅。你进到厨房里,锅碗瓢盆到处乱放,菜刀和砧板堆在水槽里,冰箱门卡住了打不开,煤气灶打不着火。你觉得你能做出什么好菜?
项目结构就是他妈的厨房布局。
llmops/
├── .editorconfig # 编辑器统一配置
├── .gitignore # Git 忽略文件
├── .pre-commit-config.yaml # pre-commit hooks 配置
├── .python-version # Python 版本锁定
├── .vscode/ # VS Code 配置
│ ├── extensions.json # 推荐插件列表
│ └── settings.json # 用户设置(最佳实践)
├── README.md # 项目文档
├── main.py # 入口文件
├── pyproject.toml # 项目元数据 + 依赖管理
└── uv.lock # 依赖锁定文件
一个一个说。
.editorconfig——这玩意是跨编辑器的。你用 VSCode,你同事用 PyCharm,另一个用 Vim。别笑,真有人用 Vim 写 Python 而且写得比你好。这个文件让你仨的缩进、换行符、编码保持一致。它不解决代码风格问题(那是 ruff 的活),它只解决"不同编辑器的默认行为不一样"这件事。
.gitignore——别把 .venv、__pycache__、.env 文件提交到仓库里。听起来是废话,但我见过至少十次生产环境密钥泄露就是因为 .env 没进 .gitignore。这个文件简单,但漏一个可能就是安全事故。
.pre-commit-config.yaml——后面细说。先记住:它等于一个安检门,每次 commit 之前会自动 scan 一遍你的代码。
.python-version——锁定 Python 版本。你用 3.12,你同事用 3.11,部署的服务器 pyenv 装的是 3.10。然后某天你用了 functools.cached_property(3.8 才有)或者 match 语句(3.10 才有),生产环境直接炸。这个文件就是防止这种惨案的。uv 和 pyenv 都会读它。
.vscode/——两个文件。extensions.json 告诉每个打开这个项目的队友"你最好装这几个插件"。settings.json 直接帮你把编辑器的行为配置好。新人拉下来就开写,不用折腾配置。这就是"工程化"的体验感——不是你自己知道怎么配,而是让下一个接手的家伙不用从头摸索。
pyproject.toml——以前 Python 项目需要一个 setup.py、一个 requirements.txt、一个 setup.cfg、可能还有一个 tox.ini。卧槽,写个 Python 项目而已,光配置文件就四个。PEP 517/518 之后,所有东西都可以塞进 pyproject.toml 里:项目元数据、依赖、ruff 配置、mypy 配置、pytest 配置。一个文件管所有。你不需要了,但你的队友需要。
uv.lock——依赖锁定文件。和 package-lock.json 一个道理。pip 的 requirements.txt 可以写版本范围(>=1.0),但每次 pip install 出来的实际版本可能不一样。uv.lock 锁定的是精确版本,你和你同事、你和你 CI 跑的依赖完全一致。杜绝"我这能跑啊"的困惑。
这些文件加在一起不超过 100 行你自己写的配置,但它们是整个工程的骨骼。
环境初始化——uv 这东西让我舒服了
说实话,Python 的包管理烂了很多年。
pip 的依赖解析慢得像在爬。poetry 想解决问题但自己又引入了一套自己的配置逻辑和 resolver,有些时候比我写业务代码还复杂。conda 太大了,对纯 Python 项目来说像用挖掘机铲花园的土。
然后 uv 来了。
uv 是 Rust 写的 Python 包管理器。Rust 意味着快——这不是快一点,是快一个数量级。你跑一次 uv sync 的感觉像在本地呼吸,不像 pip 那种等待 brew coffee 的感觉。更关键的是,它把"包管理"和"虚拟环境管理"合在了一个工具里。你不需要先 python -m venv .venv 再 source .venv/bin/activate 再 pip install -r requirements.txt。一个 uv sync 搞定所有。
# 安装 uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# 初始化项目
uv init # 创建 pyproject.toml
uv sync # 自动创建 .venv 并安装依赖
就这么两行。uv init 帮你生成一个干净的 pyproject.toml,uv sync 自动创建虚拟环境(默认 .venv 目录)并装好所有依赖。如果你想手动控制虚拟环境的名字和位置,也可以:
uv venv .venv # 手动创建虚拟环境
source .venv/bin/activate # 激活
deactivate # 退出
添加依赖也非常直觉:
uv add fastapi # 添加生产依赖
uv add --dev ruff # 添加开发依赖
uv sync # 同步依赖(根据 pyproject.toml)
uv add --dev 的包会被写入 pyproject.toml 的 [dependency-groups.dev] 而不是 [project.dependencies],生产环境可以通过 uv sync --no-dev 跳过开发依赖。这个"dev dependency"的区分非常重要——你总不想把 pytest 和 ruff 部署到生产服务器上。
但 uv 不解决什么?它不管你的代码质量。它不检查你的类型。它不帮你写测试。它只是一个包管理器。你可以用 uv 装好所有依赖,然后写一坨屎一样的代码——uv 会忠实地帮你管理这坨屎的依赖关系,不会有任何怨言。
代码质量工具——三个人守三道门
包管理搞定了。接下来你需要几个"门神"来看着你的代码质量。
Ruff:最快的 Python linter 和 formatter
先跟你说说以前 Python 社区有多分裂。Linting 用 flake8(慢)。格式化用 black(还行)。import 排序用 isort(又一个工具)。安全扫描用 bandit(又一个)。所有这些工具的配置文件散落在 pyproject.toml、setup.cfg、.flake8 里。
Ruff 把上面所有的活都干了,而且是用 Rust 写的,快到你根本感觉不到它跑过。
你想检查代码:ruff check .。你想让它自动修:ruff check --fix .。你想格式化:ruff format .。
但这还没完。你需要在 pyproject.toml 里告诉 Ruff 你要检查什么规则:
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C90", "S", "BLE", "T20", "ANN", "PTH", "RUF"]
ignore = ["S301", "T201"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
这里每一组字母是一类规则:
E/F:pycodestyle 和 pyflakes 的基础错误检查W:警告I:import 排序(取代 isort)N:命名规范UP:自动升级到新 Python 语法(比如把Optional[str]改成str | None)B:常见 bug 模式C90:圈复杂度检查S:安全检查(取代 bandit)ANN:强制类型注解RUF:Ruff 自己特有的规则
line-length = 88 是因为 black 社区默认 88,不是 80。这个数背后有一个有趣的数学论证——这里不展开,知道用 88 就行。
Ruff 解决的是:代码风格不统一、遗漏 import、不安全的写法、命名混乱。它不解决的是:你的代码逻辑是否正确、你的架构设计是否合理。Ruff 能告诉你 x = x + 1 应该写成 x += 1,但它没法告诉你这个函数其实不该存在。
Mypy:给 Python 加一层类型保险
Python 是动态类型语言——这意味着你可以在运行时给一个变量赋任何类型。这在写脚本的时候很爽,在维护一个 5 万行的项目的时候是噩梦。
你在一个函数里返回了 dict,调用方以为拿到的是 User 对象,然后 user.name 炸了个 AttributeError。你查了半小时才发现,原来是上游某个 if 分支里 return 的类型不对。
Mypy 在你跑代码之前就能发现这种问题:
mypy .
配置放在 pyproject.toml 里:
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
ignore_missing_imports = true 的意思是:第三方库如果没有类型标注,别报错。否则你装个 boto3,mypy 能给你报几百个错,全他妈的是误报。
Mypy 解决的是:类型不匹配、属性不存在、函数签名错误。它不解决的是:运行时变量值的合法性。Mypy 能保证 user_id 是 int 类型,但它保证不了这个 int 是不是一个真实存在的用户 ID。那是业务逻辑验证的活。
Pytest:你写的代码真的能跑吗
Lint 过了,类型对了。但代码逻辑对不对?你的 RAG 检索器在给定一段中文 query 的时候,能不能正确返回文档片段?LLM 调用在超时的时候会不会优雅降级?
uv add --dev pytest
pytest
测试不需要多复杂。但至少关键路径要有:
- 模型调用能正常返回
- 工具调用(Tool Calling)的参数解析正确
- RAG 检索的召回率在可接受范围
Pytest 解决的是:逻辑正确性验证。它不解决的是:你根本没写测试的情况。
pre-commit 和 commitizen——别让烂代码溜进仓库
代码质量工具装好了。但你可能会忘记跑。赶需求的时候谁还记得 ruff check 和 mypy?先提交了再说,下次一定。
pre-commit 就是来解决这个"下次一定"的。
uv add --dev pre-commit
pre-commit install
然后写一个 .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff-format
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.0.0
hooks:
- id: mypy
从现在开始,每次你 git commit,ruff 和 mypy 会在你面前跑一遍。没过?commit 失败。修好了再提交。
这是一种近乎暴力的代码质量保障机制。但它解决了一个很微妙的问题——人的意志力是有限的。你不需要靠"记得"来保证代码质量,它替你记得。
但 pre-commit 不解决什么?它不保证你的 commit message 有意义。你依然可以写 fix、update、wip,然后三个月后你打开 git log,发现你无法理解任何一次提交干了什么。
这时候 commitizen 出场:
uv add --dev commitizen
git add .
cz c
cz c 会弹出一个交互式界面,问你:这次提交是什么类型(feat/fix/docs/refactor/test...)?影响范围是什么?简短描述写什么?
它的产物是这样的:
feat(llm): add OpenAI function calling support
Added support for tool calling via OpenAI API,
including parameter validation with Pydantic models.
而不是:
update
Commitizen 解决的是:团队协作中的沟通成本。三个月后的你、你离职后的接手同事,都能读懂每次提交的意图。它不管你的代码质量——那是 pre-commit 的活。但有了规范的 commit message,changelog 可以自动生成,版本号可以自动升级,release 流程可以完全自动化。
工具链全景——一张表说清楚
你现在的工具箱里有这些东西了:
| 工具 | 作用 | 不管什么 |
|---|---|---|
| uv | 包管理 + 虚拟环境 | 代码质量、类型安全、逻辑正确 |
| ruff | 代码检查 + 格式化 | 类型安全、逻辑正确、架构设计 |
| mypy | 静态类型检查 | 运行时值合法性、逻辑正确 |
| pytest | 单元测试 | 你没写测试 |
| pre-commit | commit 前自动检查 | commit message 质量 |
| commitizen | 规范化 commit message | 代码质量 |
看到没?每件工具都有自己的边界。工具有边界是好事——你先得知道它不管什么,才知道什么时候需要别的工具来补位。
VSCode 配置——让你的编辑器比你更聪明
工具都装好了。现在需要让 VSCode 和这套工具体系深度配合。
光秃秃的编辑器就像一个没调好的乐器——能弹出声,但音是跑的。你把刚才装的 ruff、mypy、pytest 跟 VSCode 打通了,编辑器就能在你敲代码的瞬间告诉你哪里不对,而不是等你提交的时候才发现。
先装插件
打开 VSCode,装这四个插件:
- Pylance (Microsoft) — Python 语言服务器,智能补全加类型检查
- Ruff (charliermarsh) — 代码检查与格式化
- Prettier (esbenp) — JSON / YAML / Markdown 格式化
- Python (Microsoft) — Python 调试加测试支持
这四个不是"推荐装",是"必须装"。特别是 Pylance——没有它,VSCode 里写 Python 就像用记事本。
settings.json 逐段详解
下面这个配置很长。但我不想让你拷贝粘贴完事——每一段我都跟你讲清楚"为什么这么设"。理解了为什么,你以后自己调参数的时候心里才有底。
{
// --- 1. 智能分析与类型推断 (Pylance) ---
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.inlayHints.functionReturnTypes": true,
"python.analysis.inlayHints.variableTypes": true,
"python.analysis.diagnosticMode": "openFilesOnly"
}
typeCheckingMode: "basic" 打开了 Pylance 的基础类型检查。注意,这个和 mypy 不冲突——Pylance 在你敲代码的时候实时检查,mypy 在 pre-commit 和 CI 阶段全量检查。两个互补。
autoImportCompletions 让你输入 FastAPI 的时候编辑器自动补充 from fastapi import FastAPI。省键盘,省脑子。
inlayHints.functionReturnTypes 和 inlayHints.variableTypes 让编辑器在函数返回值和变量旁边用虚字显示类型提示。比如你写了一行 data = get_user(42),编辑器会在旁边显示 : User。这在你读别人代码(或者读自己三个月前的代码)的时候特别有用。
diagnosticMode: "openFilesOnly" 的意思是只检查打开的文件。如果不是这样,打开一个大型项目 Pylance 会全量扫描,CPU 干到 100%,风扇起飞。你牺牲了一点全局检查的覆盖面,换来了编辑器的流畅度。
// --- 2. 语言服务器 ---
"python.languageServer": "Pylance"
不设这个,VSCode 可能用 Jedi 语言服务器。Jedi 也不错,但跟 Pylance 比交互延迟和补全质量都差一档。
// --- 3. 环境配置 (uv + .venv) ---
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
第一行:打开内置终端时自动激活虚拟环境。你不用再敲 source .venv/bin/activate。
第二行:在当前已打开的终端里也激活环境。这个细节很容易被忽略——如果你在一个已经打开的终端里,VSCode 不会自动激活环境,除非打开这个选项。
第三行:指定解释器路径。${workspaceFolder} 是 VSCode 的变量,指当前项目的根目录。这行告诉 VSCode:"用我项目里的 .venv 里的 Python,不要用系统的。"
// --- 4. 代码质量与格式化 (Ruff) ---
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
},
"editor.rulers": [88]
}
这段是整个配置里最重要的。
formatOnSave: true 意思是每次你按 Ctrl+S,编辑器自动帮你格式化代码。你不用手动跑 ruff format。
source.fixAll.ruff 在保存时自动修复 Ruff 能修复的问题(比如无用 import、多余空白)。source.organizeImports.ruff 自动排序 import(PEP 8 标准:标准库在上,第三方库在中间,本地模块在下)。
editor.rulers: [88] 在编辑器里画一条竖线在 88 列的位置。提醒你别写太长的行。这就是写代码的"边界感"。
// --- 5. Markdown 格式化 ---
"[markdown]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
你的项目里肯定有 README.md,说不定还有文档目录。Prettier 格式化 Markdown 比你手动换行靠谱多了。
// --- 6. 单元测试 (pytest) ---
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": ["tests", "-v"]
打开 pytest 支持,VSCode 的侧边栏测试面板就可以直接发现并运行你的测试用例。pytestArgs 指定测试目录和 verbose 输出。
// --- 7. 编辑器优化 ---
"editor.guides.bracketPairs": "active",
"editor.parameterHints.enabled": true,
"editor.inlineSuggest.enabled": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true
bracketPairs 高亮当前括号对——当你的函数嵌套三层括号的时候,知道哪个 } 对应哪个 { 能救命。
parameterHints 在调用函数时显示参数名。比如你写 create_user(,编辑器弹出一个浮层告诉你这个函数的参数列表。
inlineSuggest 打开内联建议。注意这个和补全不是一回事——补全需要你主动触发,内联建议是幽灵文字直接出现在光标后面,按 Tab 就接受。
trimTrailingWhitespace 自动删除行尾空白。insertFinalNewline 在文件末尾加一个空行——这是 POSIX 标准,没有这个换行符有些工具会报 warning。
// --- 8. 文件排除 ---
"files.exclude": {
"**/__pycache__": true,
"**/.ruff_cache": true,
"**/.mypy_cache": true,
"**/.pytest_cache": true,
"**/*.pyc": true
}
把这些缓存目录从 VSCode 的文件浏览器里隐藏掉。你不需要看到它们,它们也不需要占用你的视野和搜索范围。这不是安全措施(.gitignore 才是),这纯粹是让界面清爽。
// --- 9. JSON / YAML 格式化 ---
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
JSON 和 YAML 也用 Prettier 格式化。你将来会写很多 YAML(pre-commit 配置、CI 配置、Docker Compose),一致性很重要。
把上面这些做成你项目 .vscode/settings.json 的默认内容,提交到仓库里。下一个人 git clone 之后打开 VSCode,一切都配好了。他就只用写代码。
这就是"工程化"的体感。
GitHub Actions CI——你的代码在你睡觉的时候被人检阅
本地跑 lint、type check、test 是一回事。但如果只有你自己在跑,团队里其他人不跑呢?
CI 的作用只有一句:强制执行。不是你跑不跑,是你不跑不行。
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
name: Test (Python ${{ "{{" }} matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python
run: uv python install ${{ "{{" }} matrix.python-version }}
- name: Install dependencies
run: uv sync --all-packages
- name: Run ruff
run: uv run ruff check .
- name: Run mypy
run: uv run mypy .
- name: Run pytest
run: uv run pytest tests -v
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --only-package
- name: Run ruff format check
run: uv run ruff format --check .
- name: Run ruff check
run: uv run ruff check .
这个配置做了两件事:
第一,一个叫 test 的 job。它在 Python 3.12 和 3.13 两个版本上分别跑一遍完整的流程:装依赖、ruff check、mypy、pytest。matrix 策略让它同时跑两个版本,其中一个挂了不影响另一个。
第二,一个叫 lint 的 job。它专门做格式和 lint 检查,和 test 并行跑。并行意味着你不需要等 test 跑完才能看到 lint 结果——两个同时跑,谁先挂谁先告诉你。
注意那个 astral-sh/setup-uv@v4 的 action。它不仅能装 uv,还能启用缓存。缓存意味着每次 CI 跑的时候不用重新下载所有依赖包,第二次开始速度巨快。
还有 --all-packages 和 --only-package 的区别。test job 需要所有依赖(包括 dev 依赖,因为要跑 pytest),所以用 --all-packages。lint job 只需要项目本身的包,不需要 dev 依赖,用 --only-package 更快。
CI 解决的是:代码质量在你 push 之后有一个客观的、不可绕过的检查机制。你不跑?CI 替你跑。跑不过?PR 合并不了。
但 CI 不解决什么?它不管你的代码设计好不好。你的测试覆盖率可能是 100%,但测试用例本身写得烂——mock 了所有东西,测的都是假的。CI 也不管你的架构是不是一坨屎。CI 管的是"能不能过",不是"好不好"。
架构分层——不是画图用的,是划清边界
前面聊的都是"怎么写代码"。现在聊"怎么组织代码"。
LLMOps 工程光有项目结构和 CI 还不够。你的代码需要按职责分层。层与层之间的边界越清晰,你将来改一层的时候越不用操心另一层。
我用的分层是六层——从上到下依次是:
┌─────────────────────────────────────────┐
│ 客户端层 │
│ Web App / 移动App / 小程序 / Open API │
└──────────────────┬──────────────────────┘
│ HTTP/gRPC
┌──────────────────▼──────────────────────┐
│ 接入层 API网关 · CDN · 负载均衡 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ 应用层 用户模块 · 权限模块 · 业务逻辑 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ AI能力层 LLM调用 · Agent · RAG · 嵌入 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ 数据层 PostgreSQL · Redis · 向量数据库 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ 基础设施层 Docker · K8s · Linux │
└─────────────────────────────────────────┘
每一层我都跟你说清楚它是什么、为什么有这么一层、它不管什么。
客户端层
这是用户能看到的东西。Web 应用(我用 Next.js 做)、管理后台、移动 App、小程序、以及 Open API。
客户端层只管渲染界面和发送请求。它不管请求到了后端怎么处理。前端收到 500 报错,它应该展示一个友好的错误页面,而不是去分析服务器日志。
为什么需要这一层? 因为如果你的 AI 功能直接跟前端耦合,换一个前端框架就等于重写整个系统。客户端层的独立性让你可以今天用 Next.js 做 Web,明天用 Swift 做 iOS,后天再开一个 gRPC 接口给合作伙伴——AI 能力层完全不动。
这一层不管的是: 权限校验、数据处理、模型调用。这些请求穿到下一层去处理。
接入层
API 网关(Kong / APISIX)、CDN、负载均衡(Envoy)。这一层像大楼的门禁和前台——所有请求统一从这里进来。
API 网关做的事:限流、鉴权、日志、路由。你的某个接口突然被刷了每秒 1000 次请求,网关在到达应用层之前就把它截住了。没有网关,你的 FastAPI 服务可能直接被流量打死。
CDN 加速静态资源——你的管理后台前端代码、文档页面的静态 HTML,这些不需要每请求一次就去服务器拿,CDN 边缘节点直接返回。
为什么需要这一层? 因为你不应该在每个微服务的代码里重复写限流和鉴权。接入层是横切关注点(cross-cutting concern)的集中处理点。
这一层不管的是: 业务逻辑。网关不知道你今天的 AI 回复应该用什么 prompt,它只管把请求正确地路由到下游。
应用层
用户模块、权限模块(RBAC + JWT)、业务逻辑。
这是你写 FastAPI 代码最多的一层。用户注册、登录、API Key 管理、用量统计、权限校验——都在这。
为什么需要这一层? 因为 AI 能力是做业务用的,不是业务本身。你的 AI 客服系统需要先验证用户身份、查出用户的历史对话、判断他有没有权限调用 GPT-4 还是只能用 GPT-3.5——这些是应用逻辑,不应该塞进 AI 能力层。
这一层不管的是: 怎么调用 LLM、怎么做 RAG。它把用户输入和上下文整理好,扔给下一层,拿回结果,返回给客户端。
AI 能力层
这一层是 LLMOps 的核心。多 LLM 集成(OpenAI / Anthropic / 本地模型)、Agent 框架(LangChain / LangGraph)、RAG 服务、文本嵌入、函数调用。
为什么需要这一层? 因为模型是会换的。今天用 GPT-4,明天可能切到 Claude 3.5,后天可能要接一个本地微调的 Llama。如果模型调用的逻辑散落在应用层的各个业务里,换一次模型等于重写半个系统。AI 能力层提供统一的大模型调用接口,应用层不管底层是哪个模型——它只管给输入、拿输出。
这一层不管的是: 用户是谁、用户有没有权限、这个请求该不该被限流。它只管"给定这个 prompt 和上下文,返回一个结果"。
数据层
PostgreSQL、Redis、向量数据库(Milvus / Qdrant)、对象存储(MinIO / S3)。
为什么需要这一层? 结构化数据放 PostgreSQL(用户表、订单表)。高频读写的数据放 Redis(Session、缓存、限流计数器)。向量数据放向量数据库(embedding 向量检索)。文件放对象存储(用户上传的图片、生成的报告)。
混着用?不存在的。你不可能用 PostgreSQL 做 embedding 检索——等数据量上来了,查询能慢到用户以为系统挂了。你也不可能用 Redis 做持久化存储数据——Redis 重启一下你就知道什么叫绝望。
这一层不管的是: 数据的业务含义。数据库只管 CRUD,不管这条记录在业务上代表什么。那是应用层的活。
基础设施层
Docker、Kubernetes、Linux 服务器。
为什么需要这一层? 因为你在本地能跑的服务,上生产不一定能跑。操作系统版本不同、Python 版本不同、缺少系统级依赖——Docker 把运行环境封进一个镜像里,从此"我本地能跑"和"生产环境报错"之间的鸿沟消失了。
K8s 管的是容器编排——服务挂了自动重启,流量大了自动扩容,滚动更新不停机。
这一层不管的是: 应用代码怎么写。Docker 不会让你的 RAG 检索变快,K8s 不会帮你选模型。
分层的本质
分层不是为了画图好看。是为了"改一层不动其他层"。你换模型不动业务代码,换数据库不动 AI 能力代码,换前端框架不动后端任何东西。边界是工程的命。
技术栈——每个选型背后都有一个"为什么"
分层搞清楚了,每一层用什么技术也就有答案了。
Python 3.12+
Python 是 LLM 生态的第一语言。绝大多数 LLM SDK、工具链、开源模型的服务代码都是 Python 写的。你用 Go 当然能调 OpenAI API,但 LangChain 的 Go 版功能连 Python 版的十分之一都不到。
3.12 相比 3.11 在性能上有明显提升——特别是解释器启动速度和 f-string 语法。而且 3.12 的类型系统支持更完善,配合 mypy 用效果更好。
Python 不解决的是:高并发下的 CPU 密集型任务。如果你要实时处理视频流,用 Python 是给自己找不痛快——那是 Go 或 Rust 的活。但 LLMOps 的主要瓶颈是 IO(等模型返回),不是 CPU,所以 Python 够用。
FastAPI
FastAPI 基于 Starlette(ASGI 框架)和 Pydantic(数据验证),是目前最快的 Python Web 框架之一。它的卖点有三个:
-
原生异步。每个请求处理器可以是
async def,不阻塞主线程。等 OpenAI API 返回的时候,服务器可以同时处理其他请求。 -
自动 API 文档。你在代码里定义了一个
create_user函数,FastAPI 自动生成 Swagger UI 和 ReDoc 文档页面。不需要手写 OpenAPI schema。 -
Pydantic v2 深度集成。你定义一个请求体 model,参数校验、类型转换、JSON 序列化全自动了。前端传来一个
{"age": "25"}(字符串),Pydantic 自动转成{"age": 25}(整数)。前端传来{"age": "二十五"}?422 错误直接返回,你的业务代码根本不会执行到那一行。
FastAPI 不解决的是:前端渲染(你再怎么用 Jinja2 模板都干不过 Next.js)、长连接通信(WebSocket 没问题,但要推流式 LLM 输出记得用 StreamingResponse)。
LangChain / LangGraph
LangChain 不是必须的。你完全可以直接调 openai.ChatCompletion.create(),如果只是做简单的 LLM 调用。
但当你需要这些能力的时候,LangChain 的价值就出来了:
- 把多个 LLM 调用串成一条链(Chain)
- Agent 自主决策——模型自己判断该不该调 tool,调哪个 tool
- RAG 的完整流程:文档加载、切分、向量化、检索、生成
- 对话记忆管理
LangGraph 更进一步——它让你构建有状态的、多步骤的 AI 工作流。比如一个客服 Agent,先理解用户意图,再查询订单系统,再调用 LLM 生成回复,中间任何一步失败都能回退重试。
LangChain 不解决的是:你提示词写得好不好。它能帮你组织 prompt template,但 template 里面写什么,仍然是你的事。也别指望 LangChain 能让你的模型变聪明。
Pydantic v2
Pydantic 是 Python 类型验证的事实标准。在 LLMOps 里,它的价值在几个地方:
-
环境变量管理。你把
.env文件读进来,Pydantic 的BaseSettings帮你做类型验证——OPENAI_API_KEY是不是字符串?MAX_TOKENS是不是正整数?启动阶段就能发现配置错误。 -
API 请求/响应验证。FastAPI 的请求体和响应体都是 Pydantic model,不合法的数据到不了你的业务代码。
-
LLM 输出结构提取。你让 GPT-4 返回一个 JSON,但你没法保证它一定返回合法的 JSON。用 Pydantic 的
model_validate()兜底,不合法的输出用正则尝试修复,还不行就返回默认值。
Pydantic 不解决的事:它验证的是"数据结构对不对",不是"数据内容对不对"。它知道 age 应该是 int,但它不知道 age: -5 合不合理——那是你的业务校验逻辑。
SQLAlchemy 2.0 + asyncpg
SQLAlchemy 2.0 支持原生 async,配合 asyncpg 驱动能发挥 FastAPI 的异步优势:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True)
你看这个代码风格——跟 Pydantic model 是不是有点像?融会贯通的感觉。用一个模式来处理数据验证和数据库映射,减少上下文切换。
SQLAlchemy 不解决的是:数据库查询性能优化。它帮你写 SQL,但写出来的 SQL 效率还是取决于你的模型设计。索引加不加、查询怎么 join——这些都是你自己的事。
Celery + Redis
LLM 调用可能很慢。GPT-4 生成一个 2000 字的回复可能需要 30 秒。你让用户在前端等 30 秒,他会关掉你的页面。
Celery 把"等模型回复"这件事从请求线程里拆出来:用户发请求,FastAPI 立即返回"任务已提交",然后后台 Celery worker 去调 LLM,完成后把结果存进 Redis,前端轮询拿结果。
Redis 在 Celery 的架构里同时当 message broker(传递任务)和 result backend(存储结果)。
Celery 不解决的是:任务优先级和复杂的编排。如果你需要"先调 embedding 服务,再调 LLM,如果 LLM 超时就降级用缓存结果"——这种复杂工作流用 Celery 也能做,但很痛苦。这时候你应该考虑 Temporal 或者用 LangGraph 自己管理状态。
Docker + K8s
Docker 解决环境一致性问题。你的 FastAPI 服务、Celery worker、PostgreSQL、Redis 各一个容器,用 docker-compose 在本地跑出一整套环境。
K8s 解决运维问题。服务挂了自动重启,CPU 超过 80% 自动扩容,滚动更新不停机,Ingress 统一管理所有服务的域名和 TLS。
Docker 不管你容器里跑的代码对不对。K8s 不管你的服务逻辑有没有 bug。它们只管"运行环境"和"资源编排"。
Prometheus + Grafana
LLMOps 里你最需要关注的几个指标:
- QPS:每秒请求数——知道你的系统在扛多少流量
- 延迟:P50 / P95 / P99 延迟——大部分用户等多久,最惨的那批用户等多久
- 错误率:4xx / 5xx 占比——系统是否健康
- Token 消耗:每次 LLM 调用花了多少 token——直接跟钱挂钩
- GPU 利用率:如果你自己部署模型
Prometheus 负责收集这些指标,Grafana 负责把它们画成你能一眼看懂的图表。
可观测性工具不解决的是:你知道出了问题,但你不知道怎么修。报警告诉你"P99 延迟突然从 2 秒飙升到 30 秒",你需要自己去排查是哪个组件导致的。是模型响应变慢了?是数据库连接池满了?还是某个下游服务挂了?可观测性能帮你定位,但它不会替你思考。
工程化的代价
你可能觉得这他妈的搞得太重了。确实重。
装这么多工具、配这么多文件、写 CI、分层、选技术栈——一个"hello world"级别的 LLM 调用,真的需要这么多东西吗?
不一定。如果你只是想写个脚本自己玩玩,pip install openai 然后一百行代码搞个命令行工具,完全没问题。不要过度工程化——这是另一个方向的坑。
但如果你在做的是一个团队协作的、需要长期维护的、用户在用真金白银付费的产品——那你现在搭的每一样东西,都会在未来的某天救你一次。
每次你改代码 push 上去 CI 告诉你类型不对,这就是一次生产事故被挡在了外面。
每次新同事 git clone 之后打开 VSCode 直接开始写代码不用配任何东西,这就是你花时间写配置的价值。
每次你需要换模型、换数据库、换部署方式,分层架构让你只改一层就能搞定,这就是当初设计边界的好处。
全景关系图——一张图看全工具链
前面你被塞了一堆工具:uv、ruff、mypy、pytest、pre-commit、commitizen、VSCode、CI、Docker。它们不是散装的——每个工具在开发流程里都有自己固定的位置,而且彼此之间有依赖关系。
这张图从左到右就是你的日常开发流程:
- 编码阶段:你打开 VSCode,uv 帮你管好虚拟环境和依赖,Pylance 实时给你类型提示。VSCode 的 Ruff 插件在你保存时自动格式化和修 lint。
- 检查阶段:你 git commit 的时候,pre-commit 把 Ruff 和 Mypy 全量跑一遍,没过就不让提交。Commitizen 帮你写规范的 commit message,而不是
fix和update这种废话。 - CI 阶段:代码 push 到 GitHub,Actions 自动在 3.12 和 3.13 两个版本上各跑一遍 Ruff check + Mypy + Pytest。uv.lock 保证 CI 环境装的依赖跟你本地一模一样。
- 部署阶段:CI 全绿之后,Docker 把运行环境打成镜像,推到 K8s 集群上线。
注意几个依赖关系:VSCode 依赖 uv 提供的 Python 解释器路径(settings.json 里配的 .venv/bin/python);pre-commit 依赖 Ruff 和 Mypy 已经通过 uv add --dev 安装好了;CI 各个 step 依赖 uv.lock 来保证依赖版本一致;Docker 构建依赖 CI 全绿——你当然可以跳过 CI 直接构建,但那是给自己挖坑。
这就是你工具箱的全景。每个工具不是孤岛——它们在流程上环环相扣。
FAQ——你心里在嘀咕的那些问题
uv 和 poetry 到底怎么选?
uv。除非你的项目已经深度绑定 poetry 的插件生态,或者公司有强制规范。
uv 更快(Rust 写的,解析依赖比 poetry 快一个数量级)、更简单(uv sync 一个命令,poetry 要 poetry shell 再 poetry install)、而且跟 pip 的兼容性更好——pyproject.toml 里可以直接写 pip 风格的依赖格式。
poetry 的革命性在于它是第一个试图正经解决 Python 依赖管理混乱的工具。但 uv 是站在 poetry 肩膀上做得更彻底的那个。如果你是全新项目、没有历史包袱,直接 uv。卧槽,这还用想?
ruff 和 mypy 到底各自管什么?
记一句话:ruff 管"代码写得丑不丑、安不安全",mypy 管"类型对不对"。
ruff 告诉你变量名不规范、import 没排序、有个 except: 裸奔了(可能吞掉 KeyboardInterrupt)、用了一个已知不安全的函数——这些是代码"长相"和"常见坑"的问题。
mypy 告诉你函数返回类型和声明不一致、你给 int 参数传了 str、你访问了对象上根本不存在的属性——这些是类型层面的 bug,ruff 完全不管。
这俩不重叠,互补的。傻逼的做法是只用 ruff 觉得够了——等线上因为类型错误炸了你就知道 mypy 是干什么的了。
CI 里哪些检查必须跑?
最少三个:ruff check、mypy、pytest。
ruff format --check 可以加上,但它更多是"统一风格"而非"阻止 bug"——你队友代码格式不对,你能忍,但 if 条件写反了,你不能忍。所以优先级是 pytest > ruff check > mypy > format check。
矩阵测试(多 Python 版本)不是必须的,但强烈建议。你写了 match 语句(3.10 才有),你同事的库只支持 3.11——CI 在 3.12 和 3.13 上都跑一遍就能抓出来,在你合并 PR 之前,而不是生产炸了之后。
为什么不用 pip 而要用 uv?
因为 pip 有三个逆天问题:
-
慢。pip 的依赖解析是 Python 写的,解析一个中等复杂度的项目能在"等咖啡泡好"和"等外卖送到"之间随机分布。uv 用 Rust 重写了整个解析器,快到什么鬼程度——你还没反应过来就跑完了。
-
没有锁文件。
requirements.txt可以写flask>=2.0,但每次安装出来的实际版本取决于你跑命令那一刻 PyPI 的状态。你同事三个月后安装,flask 已经是 3.0 了,API 不一样,代码炸了。uv.lock 锁定精确版本,杜绝"我这能跑啊"。 -
虚拟环境管理割裂。pip 只管装包,虚拟环境你得另外用 venv/virtualenv 创建、激活、管理。uv 一把梭——
uv sync自动创建 .venv、装依赖、锁定版本。你少记三个命令,少三次出错的机会。
给你一套判断框架
结尾了。我不想总结——总结你翻回目录就能看。
我给你几个问题。你在搭自己的 LLMOps 项目的时候,拿这几个问题掂量一下:
-
今天有没有人能一键启动你的项目? git clone 之后,需要几步才能跑起来?大于三步就太多了。
-
代码质量检查是自动的还是靠自觉? 如果靠自觉,你信不信总有那么一次赶需求的时候会跳过。
-
换一个模型要改几层代码? 如果换模型要动业务逻辑代码,你的 AI 能力层边界不够清晰。
-
你半年后能读懂你的 commit 历史吗? 打开 git log,如果全是 "fix" 和 "update",commitizen 值得你花十分钟装一下。
-
有人提 PR 的时候,你是不是得人肉 review 格式和类型? 如果是,你的 CI 少了 ruff 和 mypy。
-
这套工程化的成本值不值得? 如果你的项目生命周期不到一个月,不要搭这些。如果你的项目要跑一年以上,现在不搭以后更疼。
有问题就加,没问题就去写代码。
工程化的终点不是把东西搞得越来越复杂。是让你在复杂面前保持冷静。
读者来信
暂无来信,期待你的分享。