1. 大多数AI项目,不是死在算法上,而是死在“我电脑上跑得好好的”
如果你待过任何一个做深度学习或AI应用开发的团队,你一定听过这句话。某个模型训练好了、推理脚本也调通了,可一旦换个机器——同事的Windows、另一台GPU服务器、新买的Mac——整个项目立刻变成一场灾难。import报错、CUDA版本不匹配、numpy和你装的PyTorch吵得不可开交。过去这一年,我相当一部分所谓的“写代码时间”,其实是在修环境。
这也正是“AI时代的软件工程”里最容易被低估的一块:我们天天讨论模型结构、训练策略、评测指标,却很少有人把开发环境本身当成一个需要设计、维护、持续演化的系统来对待。大部分人对待环境的方式是“撞运气式”的:装的时候凭感觉,坏了就重装,重装还坏就换解释器版本,最后也不知道是哪个操作把它救活的。
这篇文章不讲算法,不讲调参。我要讲的是怎么把一个AI项目的环境,从“碰巧能跑”改造成“可持续运行”。所谓可持续运行,我给它一个朴素的定义:新成员能在合理时间内复现开发环境;三个月后还能用同一套流程重建;依赖升级不会在周末把你叫醒。为了不让讨论飘在空中,我会用我们团队的一个实际项目线来贯穿全文——姑且叫它“某图像处理Demo”,它从训练到部署完整走过一遍环境从腐烂到重建的过程。你可以把它当作一套可以直接抄作业的思路。
1.1 一个典型项目的环境腐化全过程
某图像处理Demo最开始是负责算法的同学在本机跑的。他用了Python 3.10,虚拟环境里装了最新版PyTorch、OpenCV、若干图像增强库,加上一堆实验用的notebook依赖。代码在我的机器上也能跑,只是需要我“手动处理”一些差异:显卡驱动不同,得换一个CUDA版本的torch;他用的某个OpenCV接口在我的安装版本里行为不一样;还有两个仓库间的私有依赖路径,他写的是绝对路径。
当时所有人都不觉得这是问题。真正开始崩溃是第二个月,项目要部署成在线服务,需要把图像增强部分单独拆成微服务。部署的时候我面对一台全新的GPU服务器,试着按照README里的requirements.txt装环境——结果装了整整一个下午,跑起来之后推理速度和本机差了15%以上,一查发现是底层数学库的版本完全不同。那一刻我开始意识到,这个项目的“环境”已经不是一个人能维护的复杂系统了,它是一个缓慢演化的混沌体:没有记录决策、没有锁定状态、没有验证手段。
后来我们把环境彻底重做了一遍,过程中整理出了一份“环境腐烂路线图”,大概分四个阶段:
- 可用阶段:本机能跑,但依赖版本靠“当时最新”决定。
- 勉强可用阶段:在几台机器上验证过,但每台机器的安装步骤都不一样。
- 不可复现阶段:换一台新机器,README里的流程无法复现,必须靠口口相传的补丁。
- 不可修复阶段:没人说得出当前环境到底是什么状态,唯一的信心来源是“别动它”。
98%的AI项目环境问题,都处在第二到第三阶段。而大多数团队在“不可复现”阶段还硬扛着继续开发,环境问题像滚雪球一样越滚越大。
1.2 为什么AI环境比传统Web项目脆弱得多
传统软件开发也讲依赖管理,但复杂度完全不是一个量级。我总结下来,AI环境脆弱主要有四个原因:
第一,依赖链条又深又宽。一个深度学习项目的基础依赖动辄几十上百个,而且很多包不是纯Python,底层连着C++库、BLAS库、图像编解码库。你装一个OpenCV,它可能顺带编译一堆原生依赖,任何一个系统库版本变了,都可能让某个功能悄悄劣化。
第二,GPU栈是垂直耦合的。驱动、CUDA runtime、cuDNN、框架(PyTorch/TensorFlow),每一层都有自己的版本规则,且中间逻辑复杂。驱动老了不行,CUDA太新了框架不认识,cuDNN和框架版本不对就直接给你一个摸不着头脑的报错。这部分不像pip那样有个统一的lockfile能解决,它横跨操作系统和硬件驱动,普通项目根本不会碰到。
第三,AI包的发布节奏快且激进出奇。很多顶流框架保持周更甚至日更,版本之间API变动频繁,热门包之间的适配矩阵永远在变。今天你装最新版是“明智”,三个月后同一个版本就成了“历史遗留问题”。
第四,使用习惯太随意。notebook里直接pip install、临时需求就用--no-cache-dir硬装源码,这类操作在团队里太常见了。环境是“操作历史”而不是“版本声明”,聊可持续运行的时候,我觉得最大的敌人不是环境本身,而是这种“我装过就行”的思维方式。
1.3 可持续环境的定义:不只是一个能跑的镜像
先说一个容易踩的误区:“有Docker镜像就等于环境可持续”不太对。镜像只能保证容器内部的一致性,但迭代过程中的依赖变化、训练和推理环境的差异、镜像体积膨胀问题,都会让镜像一个星期之后变臭。可持续运行要求的不只是“能跑”,而是“可复现、可验证、可演进”。
可复现:同样的输入(代码版本+依赖声明+基础镜像),能产出同样的环境,不含任何隐性操作。可验证:环境是否健康是能被脚本检查出来的,而不是靠“我觉得没问题”。可演进:升级某个依赖时,冲突能在变更提交之前被暴露,而不是等到线上事故才被发现。
围绕这三个要求,我把那套改造思路拆成了四根支柱:依赖锁定、声明式重建、容器化、验证与巡检。下面几节我会逐一展开,每一条都有踩过的坑和修正后的做法。
2. 第一根支柱:依赖锁定,把“运气”变成“结论”
2.1 requirements.txt的“阶梯式灾难”
几乎所有Python项目都会有一份requirements.txt,但大多数一份文件的写法,放到AI项目里就是定时炸弹。最典型的写法是:
torch transformers opencv-python numpy scikit-learn不写版本号,等别人装的时候,pip会解析出“当时最新”的版本。问题在于,“当时最新”不是静止的。三个月前你装的时候torch是2.0.1,三个月后别人装的时候已经变成了2.1.0,而你的代码可能依赖了2.0.1才有的某个行为——于是“同样的代码,不同的环境,不同的结果”就发生了。
还有一种更隐蔽的“阶梯式灾难”:你写numpy==1.24.4,但某个间接依赖比如pandas内部对numpy的最低版本要求是1.26,pip解析的时候会强制升级numpy。结果你的锁定文件形同虚设,环境里实际运行的numpy已经变了。我在某图像处理Demo上第一次做依赖锁定就遇到了这个情况:requirements.txt里明明写着numpy==1.24.4,但conda list一看实际装的是1.26.3——追查下来,是某个图像增强库的依赖约束把它顶上去了。只锁直接依赖,等于没锁。
2.2 引入锁定文件:以某图像处理Demo为例
正确的思路是像npm的package-lock.json、Rust的Cargo.lock那样:锁定文件不光记录直接依赖的版本,还要记录整棵依赖树中每一个包的精确版本和哈希值。Python生态里能做这件事的工具不少,pip-tools是经典选择,现在更推荐的是uv,它把一切操作压到了“快得不像Python”的程度。
我们重构某图像处理Demo时的目录结构大致是这样:
project-root/ ├── requirements.in # 直接依赖(人工维护,注释写明为什么需要) ├── requirements-dev.in ├── requirements.txt # pip-compile / uv lock 生成,不准手改 ├── requirements-dev.txt └── .venv/requirements.in里不写具体版本,或者只写宽松的上限和下限:
# 图像处理核心 opencv-python>=4.8,<5.0 Pillow>=10.0,<11.0 # 深度学习推理框架 torch>=2.0,<2.3 # 数值计算 numpy>=1.24,<2.0然后用锁定机制生成resolved的完整清单。用pip-tools的话是这样:
pip-compile --generate-hashes requirements.in -o requirements.txt pip-compile --generate-hashes requirements-dev.in -o requirements-dev.txt如果用uv,命令更短:
uv pip compile requirements.in -o requirements.txt --generate-hashes这套做法的核心价值在于:有人问“这个包现在该装哪个版本”,答案不是来自某人的记忆,而是来自那份锁文件。锁文件提交到Git里,任何一次依赖变更都走diff,谁在什么时候把torch升到了2.1.0,一目了然。
安装的时候也推荐用强同步模式,而不是“缺哪个装哪个”。pip-tools对应的命令是pip-sync,uv对应的是uv pip sync requirements.txt。它会先把虚拟环境里多余的包卸载掉,再把缺失的补上,保证环境状态和锁文件完全一致。这一步很多人忽略,结果环境里堆了大量历史遗留包,时间久了又会引发版本的明争暗斗。
2.3 版本不是越新越好:锁定之后被“钉子户”反噬的教训
锁定有一个副作用:它容易让你变成“版本钉子户”。某图像处理Demo锁定依赖之后,我们舒服了半年多,因为环境从不崩。但有一次我想加一个新图像增强库,一解析发现它依赖的scipy版本和我们锁定的版本冲突,升级scipy又会让一个已经训练好的pipeline行为发生变化——因为SciPy 1.9之后ndimage某些滤波函数的数值行为有调整。
那次我差点被逼出“妥协方案”:新版库里搞一个独立的虚拟环境来跑,两套环境并存。后来冷静想了想,这个方案会让整个项目分叉,最终选择小幅升级scipy并重跑了所有回归测试,发现数值差异对最终指标的影响在误差范围内,才把升级合并进去。
这个教训让我定了一条规矩:锁定不等于冻结,锁住的是“当前验证过的组合”,不是“永远不变”。每次要动依赖,都要把它当一次小型的、有测试保障的变更来对待。锁文件的意义是让版本变化被识别、被审阅、被测试,而不是让版本变化消失。
3. 第二根支柱:声明式重建,环境从“操作记录”变成“图纸”
3.1 conda与pip混用的历史债
把Python依赖锁好,只是解决了一部分问题。AI项目还有一个绕不开的麻烦:很多底层依赖并不在pip生态里。以某图像处理Demo为例,图像增强环节需要系统级的libgl、libglib2.0,某些格式的编解码库,以及CUDA工具链。你用pip装这些会撞得头破血流,因为它们不属于Python世界。
所以很多人会引入conda——它的确能解决非Python依赖的分发问题。但随之而来的是另一个陷阱:conda和pip混用时,conda并不知道pip到底装了些什么。我们在某图像处理Demo上真实发生过:conda list显示opencv是4.7.0,pip list显示另一个opencv-python-headless是4.8.1,两个包同时存在于一个环境里。import的时候,Python会取决于site-packages的搜索顺序——而这几乎是随机的,可能和安装先后顺序有关。某天早上同事说“OpenCV怎么突然找不到haarcascade了”,一查才发现是环境里两个包打架,把文件结构搞乱了。
这种问题用锁文件是补不上的。你需要的不是更好的锁,而是一个声明式的环境定义,让环境从一开始就能完整地重建。
3.2 environment.yml的重构与迷雾问题
我们决定把环境定义统一到conda的environment.yml,但并不是简单地把当前环境导出。当时用conda env export导出的文件有一千多行,里面记录了大量隐式依赖和平台特定信息——拿到另一台机器上,第一行就让你装一个只有某些系统才有的包。我管这叫“环境迷雾”:文件看着很完整,但因为依赖了太多平台相关的隐式状态,实际上谁也别想复现。
正确的做法是先导出“历史最小集”——conda env export --from-history,它会忽略全局隐式依赖,只保留用户显式install过的包。然后在这个基础上人工整理出一份精简的environment.yml,把依赖分成明显的几组:
name: image-demo channels: - conda-forge - pytorch dependencies: # 系统级库 - libgl=1.6 - glib=2.78 # Python运行时 - python=3.10.12 - pip=23.2.1 # 数值与图像 - numpy=1.24.4 - opencv=4.7.0 - pillow=10.0.0 # 推理框架 - pytorch::pytorch=2.1.2 # pip 无法通过conda管理的那部分 - pip: - -r requirements.txt这里有几个细节值得展开。一是channel顺序很重要。conda会按channel顺序解析优先级,我们要求pytorch相关包必须明确加pytorch::前缀,防止它跑到conda-forge里去找一个普通版本,结果和CUDA解耦。二是environment.yml里保留pip段,但同时要求pip段的依赖也必须由requirements.txt锁定——这样conda负责系统级和conda生态内的包,pip负责剩下的Python包,双方各管一段,边界清楚。
重建一条命令就能完成:
conda env create -f environment.yml如果速度快一点,可以考虑优先使用micromamba。某图像处理Demo后来换成了micromamba,环境创建速度从三四分钟降到十几秒,而且它不吃conda那套臃肿的激活逻辑,更贴近“操作系统的package manager”的感觉。但底层原则不变:声明式定义,一次执行,处处可复现。
3.3 一条命令重建环境的实现思路
光有environment.yml还不够,团队里总有人记不住流程。我们把环境相关的所有操作收敛到一个入口,做了一个Makefile,这样任何成员只需要执行:
make setup # 创建环境并安装全部依赖 make doctor # 检查环境健康状况 make test # 跑回归测试Makefile的核心就是几条短脚本,但有一个容易被忽略的设计:幂等性。make setup必须能被反复执行而不产生破坏。因此脚本开头通常是:
set -euo pipefail if command -v micromamba >/dev/null 2>&1; then MAMBA=micromamba elif command -v mamba >/dev/null 2>&1; then MAMBA=mamba else echo "请先安装 micromamba" exit 1 fi if ! $MAMBA env list | grep -q "image-demo"; then $MAMBA env create -f environment.yml else $MAMBA env update -f environment.yml --prune fi关键在--prune参数,它会把环境里有但environment.yml里没有的包清掉——这正好解决了conda和pip混用时“历史残留包”的问题。环境的状态永远由声明文件决定,而不是由操作历史决定。这一句话,就是“声明式重建”和“操作记录式运维”最大的区别。
4. 第三根支柱:容器化,把环境变成可运输的单元
4.1 服务化阶段为什么不得不引入Docker
环境在本机能跑、在开发服务器上能跑,不等于在部署服务器上能跑。某图像处理Demo走到服务化那一步,我们要把训练好的模型封装成一个推理接口,部署到两台配置不同的GPU服务器上。如果我用conda环境直接拷到目标机器上,第一次就会踩到大坑:conda环境的很多路径是写死到创建时的绝对路径里的(比如/home/user/micromamba/envs/image-demo),换个用户目录就失效。
容器化是解决“环境和镜像搬迁”这个问题最成熟的手段,没有之一。Docker镜像本质上就是一个完整的文件系统快照,里面包含了你声明的所有依赖、系统库、运行时。容器带来的最大的改变,不是让环境“跑得更快”,而是让环境变成一个可以被Git管理、可以被CI构建、可以被签名验证的工件。
但这不意味着随便写一个Dockerfile就万事大吉了。我见过太多AI项目的Dockerfile,是一次性把整个conda环境、代码、模型文件全部糊进一层镜像里,体积动辄10GB甚至20GB,构建一次半小时,任何一点代码改动都要重新下载海量依赖。这种镜像虽然也能跑,但它完全不具备“可持续”的属性——每一次改代码都是一场噩梦。
4.2 Dockerfile依赖分层与层缓存
可持续的镜像构建,核心是分层和缓存命中的管理。Docker构建时,每一行指令如果输入没变化,就会命中缓存层。因此Dockerfile的顺序必须按照“变化频率从低到高”来排:先放系统依赖和Python依赖,再放代码,最后放模型文件。
某图像处理Demo重构后的Dockerfile大致是这个样子:
FROM nvcr.io/nvidia/cuda:12.2.1-runtime-ubuntu22.04 AS runtime # 系统级依赖(最稳定的一层) RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 \ libglib2.0-0 \ ffmpeg \ && rm -rf /var/lib/apt/lists/* # Python运行时 RUN apt-get install -y python3.10 python3-pip # 先拷贝锁定文件,单独安装依赖层 WORKDIR /opt/app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷贝代码(代码变更时只重建这一层) COPY src/ ./src/ # 模型文件单独放最后一层,方便后面替换 COPY models/ ./models/ CMD ["python", "src/serve.py"]有几个细节值得展开。一是基础镜像选择:我用了CUDA官方提供的runtime镜像,而不是python开头的基础镜像,这样能确保CUDA库版本和镜像底层的驱动逻辑匹配,避免在容器里再装一套CUDA toolkit;二是**pip install --no-cache-dir,不仅是为了减小镜像体积,更是为了避免缓存带来的不可复现;三是模型文件单独分层**,实际部署中模型会频繁更新,放在最后一层意味着换模型只需要重新构建最后一步,而依赖层缓存能直接命中,构建时间大幅缩短。
层缓存不是银弹,它有一个很现实的坑:如果依赖安装步骤读取了一个经常变化的文件(比如把整个项目目录COPY进去再安装),缓存的命中率会极低。正确的做法是只COPY锁定文件,等依赖装好,再COPY剩下的代码。这看似是个小顺序问题,实际会改变整个镜像构建的体验。
4.3 本地开发与容器开发不割裂:devcontainer的实操配置
容器化容易带来一个新的割裂:本地开发环境是conda的、生产环境是Docker的,两个环境不完全一致,最后总有一些“开发时没问题、部署时炸”的bug。解决这个割裂的现代做法是devcontainer——用VS Code的Remote-Containers扩展,让本地开发也跑进容器里。
某图像处理Demo的devcontainer配置长这样:
{ "name": "image-demo-dev", "build": { "dockerfile": "Dockerfile.dev", "context": ".." }, "runArgs": [ "--gpus", "all", "--shm-size=8g" ], "mounts": [ "source=${localWorkspaceFolder}/data,target=/workspace/data,type=bind" ], "customizations": { "vscode": { "extensions": ["ms-python.python", "ms-toolsai.jupyter"] } }, "postCreateCommand": "make setup" }Dockerfile.dev和部署镜像不同,它基于同一个基础镜像,但额外安装了开发用依赖(调试器、notebook、代码格式化工具等)。主目录挂载到容器,代码改动实时可见,GPU通过--gpus all直通进去。
这样做的核心收获是:开发环境、测试环境、生产环境之间的差异被压缩到了“只有代码”这个层面。本地跑不出来的bug,容器里一样复现;容器里通过了,生产环境基本也不会有意外。当然,devcontainer也不是没有代价——它要求团队所有人都愿意使用这套工作流,且网络条件能接受镜像拉取。但相比环境割裂带来的隐性返工,这点代价我觉得相当值。
这里还必须提一个经常被忽略的点:使用GPU的容器,宿主机的GPU驱动版本必须足够新。容器内的CUDA版本可以跟宿主机驱动不完全一致,但宿主机驱动必须支持容器所需的最低CUDA版本。我们在某图像处理Demo上线前就踩过一次:一台服务器的驱动是495系列,容器里用的CUDA运行时要求驱动>=525,结果容器一跑就报“CUDA driver version is insufficient”。这个不是Dockerfile能解决的,上线前一定记得nvidia-smi确认驱动版本。
5. 第四根支柱:验证与巡检,让环境一直“活着”
5.1 环境健康检查脚本,不只是pip check
环境建好了,锁定了,容器化也做了,是不是就“可持续运行”了?还差最后一步——验证。一个环境没有验证手段,你就只能靠“跑一下看看”来判断它好不好;而很多依赖问题根本不会立即报错,而是悄悄改变行为,潜伏几周后在某个边界case突然爆发。
某图像处理Demo后来维护了一套“环境健康检查”脚本,我习惯叫它doctor。它的职责不是检查代码,而是检查环境本身。每次环境变更、每次新成员加入、每次部署前,都先跑一遍。核心检查项包括:
- Python版本是否与声明一致;
- 关键包(torch、opencv、numpy)版本是否与锁文件一致;
pip check是否报出依赖冲突;- GPU是否可用,
torch.cuda.is_available()是否返回预期结果; - 推理冒烟测试:加载一个最小的模型,跑一遍前向,输出shape是否正确;
- 检查环境变量、临时目录权限、共享内存大小等基础设施是否够用。
脚本长这样(精简版):
#!/usr/bin/env bash set -euo pipefail # 1. 核对Python版本 PY_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") if [[ "$PY_VERSION" != "3.10" ]]; then echo "Python版本不符合预期: $PY_VERSION (需要3.10)"; exit 1 fi # 2. 核对关键依赖版本 python - <<'PY' import torch, numpy, cv2 from packaging.version import Version assert Version(torch.__version__) >= Version("2.0"), torch.__version__ assert Version(numpy.__version__) >= Version("1.24"), numpy.__version__ print(f"torch={torch.__version__}, numpy={numpy.__version__}, opencv={cv2.__version__}") PY # 3. GPU检查 python -c "import torch; assert torch.cuda.is_available(), 'GPU不可用'" # 4. 推理冒烟 python -m pytest tests/smoke/test_inference.py -x -q echo "环境健康检查通过。"这套脚本的启发是:环境健康检查要像代码测试一样成为项目的一部分。没人指望没有测试的代码能保持质量,环境也是一样的道理。更妙的是,所有检查项都可以直接在CI里跑,这样“环境腐化”就不再是事后事故,而是能在合并前被拦截住。
5.2 团队协作中的环境变更纪律
再好的工具,如果没有团队纪律兜底,也会慢慢废掉。某图像处理Demo从一次线上事故里总结出来的几条硬规矩,我觉得有普适性:
任何环境变更必须走代码评审。改环境不是“装个包试一下”,所有依赖变更(requirements.in、environment.yml、Dockerfile)都要在MR/PR里展示,合并前跑一遍环境健康检查和全量测试。有人会说这太重了,但对比“线上跑着好好的,谁偷偷升了个包结果第二天接口超时”这个场景,这点重量实在不算什么。
禁止在共享服务器上直接改环境。我们在云上GPU服务器上设了铁律:任何人不得在服务器上手动conda install或者pip install。确需试验的,进dev容器或自己的开发环境里试;要在服务器上的服务环境里变更,只能通过重新构建镜像或执行make setup做声明式更新。直接改共享环境的结果只有一个——环境进入“不可知状态”,再也没人知道那台机器上到底装了什么。
依赖变更时的分级审批。我们把依赖变更分成了两个级别:间接依赖的小版本升级(比如某个传递依赖的patch版本),可以按常规流程走;但框架主版本升级、Python版本升级、CUDA变更这种“动地基”的操作,必须另行评估,清点所有受影响模块,做一轮专项回归。之前某图像处理Demo想从PyTorch 1.x升到2.x,我们花了整整一周做兼容性测试,才敢合并那个变更。
5.3 CI里的环境冒烟测试和遗留风险
有了健康检查脚本之后,下一步一定是把它塞进CI。某图像处理Demo的CI流程大致分三步:第一步构建环境(用environment.yml + requirements.txt),第二步跑环境健康检查(就是上一节的make doctor),第三步跑模型相关的回归测试集。
实际上CI不会完全等价于本地环境:它会暴露一批“本地藏着没暴露”的问题。最典型的就是平台差异化——本机是macOS或Windows,CI是Linux,CUDA构建产生的共享库、ffmpeg的编解码后端都可能不同。因此CI环境要尽量贴近生产:要么直接跑在同样的CUDA基础镜像里,要么用GitHub Actions的GPU runner。跑不起GPU runner的话,至少也要保证CPU路径的健康检查和依赖一致性检查在跑,GPU相关的测试留到部署前的单独验证阶段。
还有一个容易忽略的环节:构建产物和模型工件的完整性校验。模型文件动辄几百MB,用Git管理不方便,很多团队就直接扔在共享盘或对象存储里。但如果不给模型文件记录一个SHA256哈希,你很难知道部署时拉下来的到底是哪个版本的模型。我们后来引入了一个简单的“模型注册表”(一个JSON清单,记录模型文件名、版本号、SHA256、基础环境版本),部署脚本先校验哈希再启动服务。这个操作看起来很小,但在“环境可持续运行”这件事上,它把模型和环境的耦合关系显式化了——每次模型的生成环境和推理环境是否匹配,现在是一份可以检查的数据,而不是一段猜。
6. 让环境随项目一起成长:升级策略与长期维护
6.1 依赖刷新日
环境建好之后最怕的一件事,就是从此没人再碰它。某图像处理Demo在下半年遇到过一个问题:一个图像增强库的底层依赖有安全漏洞,官方在两个月前就发了修复版,但因为我们的锁文件一直没动,修补永远没进来。等到安全扫描工具报警,我们才意识到“锁定”变成了“冻结”,环境不再安全。
后来我们定了一个“依赖刷新日”的节奏:每个月的固定一天,专门用一个工作session做依赖升级,不干别的。流程是先用工具把锁文件整体解开升级一遍(比如uv pip compile --upgrade),然后跑全量测试和健康检查。升级不是一次全升——因为一次全升的风险和排错成本太高。我们按依赖的层次分组升级:
| 升级级别 | 范围 | 频率 | 风险控制 |
|---|---|---|---|
| P0 安全补丁 | 锁文件中所有有CVE公告的包 | 紧急,随时 | 单包升级+针对性测试 |
| P1 间接依赖 | 锁定文件中非直接声明的传递依赖 | 每月 | 跑全量测试,关注数值差异 |
| P2 直接依赖 | requirements.in里显式依赖 | 每月 | 先升级再跑全量回归,必要时分步 |
| P3 地基级 | Python版本、框架主版本、CUDA | 按需 | 专门评估、专项回归、灰度发布 |
这个节奏最大的好处是,版本升级不再和需求开发抢时间,它是一个有固定节奏、有风险兜底的小项目。三个月下来,环境一直保持在一个“不算最新、但足够近”的状态,既避免了安全风险堆积,也避免了升级步子太大导致的兼容性灾难。
6.2 安全漏洞扫描与模型工件版本管理
“可持续运行”的最后一层含义,是安全可持续。环境里的依赖不是只有功能问题,还有安全问题。某图像处理Demo用了两条扫描链路:第一条是pip-audit,扫Python依赖的已知漏洞;第二条是镜像扫描,用容器镜像扫描工具检查系统库层面的漏洞。这两条链路都接进了CI,发现高危漏洞直接阻塞合并。
值得一提的坑是,很多AI项目用的依赖不是PyPI官方版本,而是直接从GitHub源安装的(比如git+https://...@some-branch)。这类依赖的漏洞扫描基本是盲区——因为pip-audit无法获取它的版本元数据。我们处理这类依赖的原则是:尽量固定到具体的commit或tag,而不是main分支;同时定期手动检查上游的更新动态。虽然多了一步手工,但总比“不知道依赖了什么代码版本”强。
模型工件的版本管理我们也单独做了。最初模型文件放进共享盘、用model_v4_final_real_v2.pkl这种文件名管理,结果有一次部署时发现拉下来的是旧版本,推理结果和指标报告对不上。现在的做法是给每个模型生成一个元数据文件,记录版本号、哈希、训练数据的代码版本、依赖环境版本和评测指标。这样模型和环境、代码之间形成了一条可追溯的链,任何一个环节都可以单独回溯。
6.3 可持续运行,说到底是在管理“环境熵增”
回到标题。我为什么强调“把环境搭建成一个可持续运行的系统”?因为环境这个东西,天然是会腐烂的。它像一条河:你不管它,泥沙会不断沉积,河道会慢慢改向,最后你会面对一条完全陌生的水系。所谓“可持续运行”,核心是在对抗环境熵增——通过锁定、声明、容器化、验证和巡检,把混乱度增长的速度压到可控的水平。
我们重建某图像处理Demo环境的那一个月里,团队一度很憋屈:“我们到底在搞工程还是在搞运维?”但后来所有人的认知统一了:在AI时代,环境就是软件工程的一部分,而且是前期投入回报率最高的一部分。一次修好,之后的每次变更测试、每次新人加入、每次部署上线,都在省时间;而如果不好好搭,你省下的那些时间会在某天深夜的部署事故里连本带利地还回去。
我个人最大的体会其实是心态上的变化:以前我遇到环境问题,第一反应是“赶紧找workaround把它跑起来”;现在我的第一反应是“这个环境为什么坏了?我的声明定义里为什么没有挡住它?”把环境当成系统来维护之后,很多问题其实都能在设计阶段被预防,而不是在运行阶段被救火。环境健康不是运气,它是一套工程纪律的自然结果。