Max
搜索
返回故事会

在VSCode下构建基于Python的LLMOps工程

52 分钟阅读0Max ZhangLLMOps
PythonVSCode环境配置RuffFastAPI

那天晚上十一点,我坐在电脑前,盯着终端里一个空文件夹发呆。

事情是这样的。我接了个活——帮一个团队用大模型做一套自动客服系统。听起来简单对吧?调个 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 .venvsource .venv/bin/activatepip 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.tomluv 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.tomlsetup.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_idint 类型,但它保证不了这个 int 是不是一个真实存在的用户 ID。那是业务逻辑验证的活。

Pytest:你写的代码真的能跑吗

Lint 过了,类型对了。但代码逻辑对不对?你的 RAG 检索器在给定一段中文 query 的时候,能不能正确返回文档片段?LLM 调用在超时的时候会不会优雅降级?

uv add --dev pytest
pytest

测试不需要多复杂。但至少关键路径要有:

  • 模型调用能正常返回
  • 工具调用(Tool Calling)的参数解析正确
  • RAG 检索的召回率在可接受范围

Pytest 解决的是:逻辑正确性验证。它不解决的是:你根本没写测试的情况。

pre-commit 和 commitizen——别让烂代码溜进仓库

代码质量工具装好了。但你可能会忘记跑。赶需求的时候谁还记得 ruff checkmypy?先提交了再说,下次一定。

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 有意义。你依然可以写 fixupdatewip,然后三个月后你打开 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-commitcommit 前自动检查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.functionReturnTypesinlayHints.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 框架之一。它的卖点有三个:

  1. 原生异步。每个请求处理器可以是 async def,不阻塞主线程。等 OpenAI API 返回的时候,服务器可以同时处理其他请求。

  2. 自动 API 文档。你在代码里定义了一个 create_user 函数,FastAPI 自动生成 Swagger UI 和 ReDoc 文档页面。不需要手写 OpenAPI schema。

  3. 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 里,它的价值在几个地方:

  1. 环境变量管理。你把 .env 文件读进来,Pydantic 的 BaseSettings 帮你做类型验证——OPENAI_API_KEY 是不是字符串?MAX_TOKENS 是不是正整数?启动阶段就能发现配置错误。

  2. API 请求/响应验证。FastAPI 的请求体和响应体都是 Pydantic model,不合法的数据到不了你的业务代码。

  3. 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。它们不是散装的——每个工具在开发流程里都有自己固定的位置,而且彼此之间有依赖关系。

Loading diagram...

这张图从左到右就是你的日常开发流程:

  • 编码阶段:你打开 VSCode,uv 帮你管好虚拟环境和依赖,Pylance 实时给你类型提示。VSCode 的 Ruff 插件在你保存时自动格式化和修 lint。
  • 检查阶段:你 git commit 的时候,pre-commit 把 Ruff 和 Mypy 全量跑一遍,没过就不让提交。Commitizen 帮你写规范的 commit message,而不是 fixupdate 这种废话。
  • 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 shellpoetry 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 checkmypypytest

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 有三个逆天问题:

  1. 。pip 的依赖解析是 Python 写的,解析一个中等复杂度的项目能在"等咖啡泡好"和"等外卖送到"之间随机分布。uv 用 Rust 重写了整个解析器,快到什么鬼程度——你还没反应过来就跑完了。

  2. 没有锁文件requirements.txt 可以写 flask>=2.0,但每次安装出来的实际版本取决于你跑命令那一刻 PyPI 的状态。你同事三个月后安装,flask 已经是 3.0 了,API 不一样,代码炸了。uv.lock 锁定精确版本,杜绝"我这能跑啊"。

  3. 虚拟环境管理割裂。pip 只管装包,虚拟环境你得另外用 venv/virtualenv 创建、激活、管理。uv 一把梭——uv sync 自动创建 .venv、装依赖、锁定版本。你少记三个命令,少三次出错的机会。

给你一套判断框架

结尾了。我不想总结——总结你翻回目录就能看。

我给你几个问题。你在搭自己的 LLMOps 项目的时候,拿这几个问题掂量一下:

  1. 今天有没有人能一键启动你的项目? git clone 之后,需要几步才能跑起来?大于三步就太多了。

  2. 代码质量检查是自动的还是靠自觉? 如果靠自觉,你信不信总有那么一次赶需求的时候会跳过。

  3. 换一个模型要改几层代码? 如果换模型要动业务逻辑代码,你的 AI 能力层边界不够清晰。

  4. 你半年后能读懂你的 commit 历史吗? 打开 git log,如果全是 "fix" 和 "update",commitizen 值得你花十分钟装一下。

  5. 有人提 PR 的时候,你是不是得人肉 review 格式和类型? 如果是,你的 CI 少了 ruff 和 mypy。

  6. 这套工程化的成本值不值得? 如果你的项目生命周期不到一个月,不要搭这些。如果你的项目要跑一年以上,现在不搭以后更疼。

有问题就加,没问题就去写代码。

工程化的终点不是把东西搞得越来越复杂。是让你在复杂面前保持冷静。

读者来信

0/1000

暂无来信,期待你的分享。