拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建AI工程:可运维、可诊断、可演进的生产级AI服务

从零构建AI工程:可运维、可诊断、可演进的生产级AI服务

1. 为什么“从零构建AI工程”不是口号,而是必须重走的路

最近帮三家公司做过AI落地评估,发现一个扎心的事实:90%的所谓“AI项目”,其实只是把现成模型API调用封装成一个网页表单。用户上传一张图,后台扔给某个云服务,返回个JSON结果——这叫AI应用?不,这叫API搬运工。真正卡住业务的,从来不是模型好不好,而是当你要把一个在Jupyter里跑通的demo,变成每天处理50万条请求、能扛住促销峰值、出错时自动降级、日志能精准定位到某一行代码、运维同事半夜能看懂告警含义的系统时,那套“from scratch”的能力,才真正决定生死。

“AI Engineering from Scratch”这个标题,表面看是讲技术栈搭建,实则是一场认知重构。它拒绝把AI当成黑盒调用,而是把它还原成可设计、可拆解、可测试、可运维的工程实体。关键词里没有“LLM”“Transformer”“Fine-tuning”,只有两个词:AI Engineering和From Scratch。前者定义了领域边界——它不是算法研究,不是论文复现,而是让AI能力稳定、可靠、可持续地嵌入业务流;后者划出了方法论底线——不依赖魔改版SDK,不迷信一键部署脚本,从Python环境隔离开始,到模型服务的健康探针设计,每一步都亲手踩过坑、验证过边界、记录过退路。

我见过太多团队在“快速上线”压力下跳过这一步:用全局conda环境装所有依赖,模型版本和PyTorch版本混在一起;用pickle序列化模型直接上线,结果生产环境Python小版本升级导致反序列化失败;把训练脚本和推理服务写在同一份代码里,一改全崩。这些不是“小问题”,是系统性脆弱的伏笔。而“from scratch”的核心价值,恰恰在于强制你面对每一个被封装层掩盖的细节:CUDA驱动和cudnn版本如何对齐?ONNX导出时哪些算子不支持?模型输入预处理的数值范围,在训练集、验证集、线上流量中是否一致?这些细节不会出现在API文档里,但会出现在凌晨三点的告警页面上。

所以这篇文章不教你怎么调用ChatGLM,也不讲如何微调Llama3。它只做一件事:带你亲手搭起一座桥——从本地跑通的.py文件,到能放进Kubernetes集群、被Prometheus监控、被业务方当“水电煤”一样稳定使用的AI服务。过程中你会亲手编译一个轻量级推理引擎,手动配置gRPC服务的超时熔断,用Wireshark抓包验证模型服务的二进制协议头。这些操作本身不难,但它们共同构成了一种肌肉记忆:当AI不再是个“调用即成功”的魔法,而是一串可追溯、可干预、可修复的工程链路时,你才算真正拿到了AI工程的入场券。

2. 环境筑基:为什么连Python虚拟环境都要亲手编译

很多人觉得“from scratch”就是写代码,其实第一步是重建信任基础——你得相信自己电脑上的每一行字节,都是可控、可复现、可审计的。这听起来很原始,但在AI工程里,恰恰是最容易被跳过的致命环节。我曾接手一个故障:模型在测试环境准确率98%,上线后跌到62%。排查三天,最后发现是Docker镜像里用的pip install torch默认装了CPU版本,而GPU节点上CUDA驱动版本太老,PyTorch自动fallback到CPU计算,但没报错,只默默变慢、变不准。这种问题,靠“重装一遍”解决不了,靠CI/CD流水线也救不回来——根子在环境不可信。

所以“from scratch”的第一课,是放弃conda create -n ai-env python=3.10这种便利。我们要亲手编译Python解释器,目的不是炫技,而是切断所有隐式依赖链。具体怎么做?用pyenv管理源码版本,下载CPython 3.10.12源码,打上两个补丁:一个是禁用--enable-shared(避免.so库路径污染),另一个是修改setup.py,强制sqlite3模块静态链接(防止不同Linux发行版SQLite版本差异导致DB读取异常)。编译命令不是./configure && make && make install,而是:

./configure --prefix=$HOME/.pyenv/versions/3.10.12-scratch \ --without-ensurepip \ --enable-optimizations \ LDFLAGS="-static-libgcc -static-libstdc++" make -j$(nproc) make install

关键点在于-static-libgcc和-static-libstdc++。这意味着编译出的Python二进制文件,不依赖系统glibc版本。当你把这份Python打包进Docker镜像时,哪怕基础镜像是Alpine Linux(musl libc),它也能跑——因为所有C运行时都被静态链接进去了。这解决了跨平台部署最头疼的“libc不兼容”问题。而禁用ensurepip,是为了彻底剥离pip这个“黑盒”,后续所有包管理,都通过python -m venv创建干净虚拟环境,再用pip install --no-binary :all:强制源码编译安装关键包(如numpy、scipy),确保每个C扩展都针对当前CPU指令集(AVX2/SSE4.2)做了优化。

提示:--no-binary :all:不是为了性能,而是为了可审计性。当你看到Building wheel for numpy (pyproject.toml)时,你知道它正在用你的GCC编译;而Successfully built numpy后面跟着的SHA256哈希值,是你能验证的唯一凭证。这比任何“官方wheel包”都更可信。

接下来是CUDA工具链。别急着apt install nvidia-cuda-toolkit。去NVIDIA官网下载对应驱动版本的cuda_12.1.1_530.30.02_linux.run安装包,但不执行安装。用--extract参数解压出cuda-toolkit目录,然后手动设置环境变量:

export CUDA_HOME=$HOME/cuda-toolkit export PATH=$CUDA_HOME/bin:$PATH export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH

为什么不用nvidia-smi检测到的驱动自带toolkit?因为驱动自带的nvcc版本往往滞后,且与PyTorch源码要求的CUDA Toolkit版本不匹配。手动提取,才能精确控制nvcc --version输出的版本号。我曾遇到PyTorch 2.1.0源码编译时,nvcc报告error: unsupported GNU version,查了半天发现是Ubuntu 22.04默认GCC 11.3,而CUDA 12.1只认GCC 11.2。解决方案不是降级系统GCC(会破坏其他软件),而是用gcc-11.2单独编译CUDA Toolkit——这只有亲手拆解toolkit才能做到。

最后是cuDNN。别下.deb包,下.tar.xz源码包。解压后,把include和lib目录内容,硬链接到CUDA toolkit目录下(ln -sf $CUDNN_INCLUDE $CUDA_HOME/include/cudnn.h)。硬链接而非复制,是为了确保PyTorch编译时find_package(CUDA)能找到的cuDNN头文件,和最终链接的so文件,来自完全相同的二进制。这避免了“头文件版本新、so文件版本旧”导致的undefined symbol错误——这种错误在动态链接时才暴露,调试成本极高。

这套流程看起来繁琐,但它建立了一种工程纪律:所有依赖,必须有明确来源、明确版本、明确构建方式。当你在Kubernetes Pod里看到python --version输出3.10.12 (from source),nvcc --version输出Cuda compilation tools, release 12.1, V12.1.105,你就知道,这个环境不是“大概能跑”,而是“必然可控”。

3. 模型服务化:从PyTorch Module到gRPC服务的七层解剖

模型训练完,torch.save(model.state_dict(), 'model.pth'),这只是故事的开始。真正的挑战在于:如何让这个.pth文件,变成一个能被Java后端、Go网关、甚至嵌入式设备调用的稳定服务?很多团队直接用Flask搭个HTTP接口,这就像用自行车驮运集装箱——不是不能动,而是根本没设计承载力。而“from scratch”的服务化,本质是把模型推理过程,拆解成七层可独立演进、可单独压测、可精准监控的组件。

第一层:序列化协议层。别用pickle,它不安全、不跨语言、版本敏感。我们选Protocol Buffers(protobuf)。定义inference.proto:

syntax = "proto3"; package ai.serving; message PredictRequest { repeated float features = 1; // 归一化后的特征向量 uint32 batch_size = 2; // 显式声明batch size,避免shape推断错误 } message PredictResponse { repeated float scores = 1; // 模型输出logits uint32 status_code = 2; // 自定义状态码,非HTTP status string error_msg = 3; // 结构化错误信息 }

关键点在于batch_size字段。PyTorch模型的forward()方法不显式接收batch size,但生产环境必须知道——因为内存分配、CUDA stream调度、甚至模型内部的dropout行为,都依赖此信息。Protobuf强制你在协议层就声明它,堵死了“靠猜shape”的漏洞。

第二层:模型加载层。不直接torch.load(),而是封装成ModelLoader类:

class ModelLoader: def __init__(self, model_path: str, device: str = "cuda:0"): self.model_path = model_path self.device = device self.model = None self.lock = threading.Lock() def load(self) -> nn.Module: with self.lock: # 防止并发加载 if self.model is not None: return self.model # 1. 验证模型文件完整性 if not self._verify_checksum(): raise RuntimeError("Model checksum mismatch") # 2. 加载state_dict,但不立即to(device) state_dict = torch.load(self.model_path, map_location="cpu") # 3. 构建模型骨架(必须与训练时完全一致) model = MyModel() # 这里必须是训练时的原始类定义 model.load_state_dict(state_dict) # 4. 移动到device,并启用eval模式 self.model = model.to(self.device).eval() # 5. 预热:用dummy input触发CUDA kernel编译 dummy = torch.randn(1, 128, device=self.device) _ = self.model(dummy) return self.model

这里_verify_checksum()不是简单的MD5,而是用sha256sum计算模型文件+模型类源码(inspect.getsource(MyModel))的联合哈希。因为模型权重变了,但代码逻辑错了,同样会出错。这个校验,把“模型”和“代码”绑定为一个原子单元。

第三层:预处理管道层。不把预处理逻辑写死在forward()里,而是用sklearn.pipeline.Pipeline定义:

preprocessor = Pipeline([ ('scaler', StandardScaler()), ('pca', PCA(n_components=64)), ('to_tensor', Lambda(lambda x: torch.from_numpy(x.astype(np.float32))) ])

关键点在于Lambda步骤:它把NumPy数组转成Tensor,但不指定device。设备迁移交给下一层统一处理。这样预处理管道可以纯CPU运行,压测时能隔离I/O瓶颈。

第四层:设备调度层。这才是真正的“AI工程”分水岭。我们不用model.to(device)简单粗暴,而是实现DeviceManager:

class DeviceManager: def __init__(self): self.gpu_pool = [torch.device(f"cuda:{i}") for i in range(torch.cuda.device_count())] self.cpu_device = torch.device("cpu") def get_device(self, request: PredictRequest) -> torch.device: # 根据batch_size智能选择 if request.batch_size > 1000: return self.cpu_device # 大batch用CPU避免GPU OOM elif request.batch_size > 100: return self.gpu_pool[0] # 中batch用主GPU else: return self.gpu_pool[0] # 小batch也用主GPU,避免多卡通信开销 def move_to_device(self, tensor: torch.Tensor, device: torch.device) -> torch.Tensor: if device.type == "cpu": return tensor.cpu() else: return tensor.cuda(device.index)

这个逻辑背后是实测数据:在V100上,batch_size=512时GPU利用率92%,但batch_size=2048时显存溢出;而CPU处理batch_size=2000,耗时仅比GPU慢1.8倍,但稳定性100%。这就是工程权衡——不是“GPU一定快”,而是“在什么条件下GPU性价比最高”。

第五层:gRPC服务层。不用grpcio-tools自动生成代码,而是手写InferenceServicer:

class InferenceServicer(inference_pb2_grpc.InferenceServiceServicer): def __init__(self, model_loader: ModelLoader, preprocessor: Pipeline, device_manager: DeviceManager): self.model_loader = model_loader self.preprocessor = preprocessor self.device_manager = device_manager def Predict(self, request: inference_pb2.PredictRequest, context) -> inference_pb2.PredictResponse: try: # 1. 输入校验 if len(request.features) == 0: context.set_code(grpc.StatusCode.INVALID_ARGUMENT) context.set_details("Empty features") return inference_pb2.PredictResponse() # 2. 预处理(CPU) X_np = np.array(request.features).reshape(-1, 128) # 假设128维 X_processed = self.preprocessor.transform(X_np) # 3. 设备调度 device = self.device_manager.get_device(request) X_tensor = torch.from_numpy(X_processed).to(device) # 4. 模型推理 with torch.no_grad(): logits = self.model_loader.load()(X_tensor) # 5. 后处理(CPU) scores = logits.cpu().numpy().flatten().tolist() return inference_pb2.PredictResponse( scores=scores, status_code=0 ) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Predict failed: {str(e)}") return inference_pb2.PredictResponse( status_code=500, error_msg=str(e) )

注意context.set_code()的使用——gRPC的status code是协议层概念,比HTTP status更底层。当模型OOM时,context.set_code(grpc.StatusCode.RESOURCE_EXHAUSTED)比返回500 HTTP更精准,调用方能据此触发降级策略。

第六层:健康检查层。不依赖/healthz,而是gRPC原生HealthCheck服务:

# health_check.proto service Health { rpc Check(HealthCheckRequest) returns (HealthCheckResponse); rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); } # 在servicer中实现 def Check(self, request, context): # 1. 检查模型是否加载 if self.model_loader.model is None: return HealthCheckResponse(status=HealthCheckResponse.SERVING_STATUS_NOT_SERVING) # 2. 执行一次轻量级推理(不走完整pipeline) try: dummy = torch.randn(1, 128, device=self.device_manager.gpu_pool[0]) _ = self.model_loader.model(dummy) return HealthCheckResponse(status=HealthCheckResponse.SERVING_STATUS_SERVING) except: return HealthCheckResponse(status=HealthCheckResponse.SERVING_STATUS_NOT_SERVING)

第七层:可观测性注入层。在Predict方法开头,插入OpenTelemetry追踪:

with tracer.start_as_current_span("inference.predict") as span: span.set_attribute("request.batch_size", request.batch_size) span.set_attribute("device.type", device.type) # ... 推理逻辑 ... span.set_attribute("response.latency_ms", time.time() - start_time)

这七层不是理论模型,而是你部署时真实存在的七个代码文件。每一层都有独立的单元测试、独立的压测脚本、独立的监控指标。当Predict耗时飙升,你能立刻判断是预处理慢(CPU负载高)、还是模型推理慢(GPU显存不足)、还是gRPC序列化慢(网络带宽打满)。这种可诊断性,才是“from scratch”服务化的终极价值。

4. 持续交付:为什么CI/CD流水线要亲手写Makefile而不是用GitHub Actions模板

“AI工程”的持续交付,和Web开发有本质区别:它不仅要跑通单元测试,还要验证模型行为一致性、硬件兼容性、性能基线。一个git push触发的流水线,如果只做pytest tests/,那它只是个玩具。真正的“from scratch”CI/CD,必须亲手用Makefile定义每一个原子任务,因为只有Makefile能精确控制依赖关系、执行顺序、环境隔离、失败回滚——这些是YAML模板永远无法表达的工程细节。

先看核心Makefile结构:

# Makefile .PHONY: all clean test build-docker deploy # 全局变量 PYTHON := $(HOME)/.pyenv/versions/3.10.12-scratch/bin/python PIP := $(PYTHON) -m pip MODEL_DIR := ./models DOCKER_REGISTRY := registry.example.com # 目标:all - 完整验证流程 all: test build-docker # 目标:test - 四层验证 test: test-unit test-model-consistency test-hardware-compat test-performance-baseline # 目标:test-unit - 单元测试(纯Python) test-unit: $(PYTHON) -m pytest tests/unit/ -v # 目标:test-model-consistency - 模型行为一致性验证 test-model-consistency: $(PYTHON) scripts/validate_model_consistency.py \ --train-model $(MODEL_DIR)/train/model.pth \ --serve-model $(MODEL_DIR)/serve/model.pth \ --test-data $(MODEL_DIR)/test_data.npz # 目标:test-hardware-compat - 硬件兼容性验证 test-hardware-compat: @echo "Testing on CPU..." $(PYTHON) scripts/run_on_cpu.py --model $(MODEL_DIR)/serve/model.pth @echo "Testing on GPU..." $(PYTHON) scripts/run_on_gpu.py --model $(MODEL_DIR)/serve/model.pth --gpu-id 0 # 目标:test-performance-baseline - 性能基线验证 test-performance-baseline: $(PYTHON) scripts/benchmark.py \ --model $(MODEL_DIR)/serve/model.pth \ --batch-sizes 1 16 64 256 \ --thresholds '{"latency_ms": {"p95": 100}, "throughput_qps": {"min": 50}}' # 目标:build-docker - 构建Docker镜像 build-docker: Dockerfile docker build -t $(DOCKER_REGISTRY)/ai-service:$(shell git rev-parse --short HEAD) . # 目标:deploy - 部署到K8s deploy: build-docker kubectl set image deployment/ai-service ai-service=$(DOCKER_REGISTRY)/ai-service:$(shell git rev-parse --short HEAD)

这个Makefile的精妙之处,在于每个目标都是一个可独立执行、可组合的原子操作。比如make test-model-consistency,它调用的validate_model_consistency.py脚本,会做三件事:

  1. 加载训练模型(train/model.pth),用相同测试数据跑一次,记录输出logits;
  2. 加载服务模型(serve/model.pth),用相同测试数据跑一次,记录输出logits;
  3. 逐元素比较,计算np.max(np.abs(logits_train - logits_serve)),要求<1e-5。

为什么需要这个?因为模型导出(如ONNX)、量化(如FP16)、编译(如Triton)都会引入数值误差。这个测试不是为了“绝对相等”,而是为了确认误差在业务可接受范围内。我曾遇到ONNX导出后,softmax输出概率分布偏差0.3%,导致推荐排序错位——这个偏差在单元测试里根本测不出来,只有这种端到端一致性验证才能捕获。

再看test-hardware-compat。它不是简单跑个nvidia-smi,而是分别执行run_on_cpu.py和run_on_gpu.py。run_on_gpu.py的关键代码:

def test_gpu_compatibility(model_path: str, gpu_id: int): # 1. 设置CUDA_VISIBLE_DEVICES os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id) # 2. 初始化CUDA上下文 torch.cuda.set_device(gpu_id) torch.cuda.empty_cache() # 3. 加载模型到指定GPU model = torch.load(model_path, map_location=f"cuda:{gpu_id}") # 4. 执行一次推理,捕获CUDA错误 try: dummy = torch.randn(1, 128, device=f"cuda:{gpu_id}") _ = model(dummy) print(f"GPU {gpu_id} OK") except torch.cuda.OutOfMemoryError: raise RuntimeError(f"GPU {gpu_id} OOM on dummy input") except Exception as e: raise RuntimeError(f"GPU {gpu_id} failed: {e}")

这个测试能提前发现:驱动版本不匹配、CUDA Toolkit版本不兼容、甚至GPU显存碎片化等问题。它比任何“环境检查脚本”都更真实,因为它真的调用了CUDA API。

test-performance-baseline更狠。它不只是测“平均延迟”,而是用benchmark.py跑多个batch size,生成完整的性能曲线:

Batch SizeLatency (ms)Throughput (QPS)GPU Util (%)
112.381.215%
1618.7855.642%
6425.12540.278%
25642.85980.195%

然后对比预设阈值。如果batch_size=256时latency_ms.p95 > 100,或者throughput_qps.min < 50,整个CI就失败。这确保每次提交,都不会劣化服务性能基线。

Docker构建也不是docker build -t xxx .就完事。我们的Dockerfile严格遵循“多阶段构建+最小化基础镜像”:

# 第一阶段:构建环境 FROM nvidia/cuda:12.1.1-devel-ubuntu22.04 AS builder COPY --from=0 /usr/local/cuda-12.1 /usr/local/cuda RUN apt-get update && apt-get install -y python3.10-dev gcc g++ && rm -rf /var/lib/apt/lists/* # 第二阶段:运行环境 FROM ubuntu:22.04 # 只拷贝必要文件,不拷贝构建工具 COPY --from=builder /usr/local/cuda /usr/local/cuda COPY --from=builder /usr/bin/python3.10 /usr/bin/python3.10 # 手动安装Python标准库(不装pip) RUN python3.10 -m ensurepip --upgrade --default-pip && \ pip3 install --no-cache-dir --no-binary :all: torch==2.1.0+cu121 -f https://download.pytorch.org/whl/torch_stable.html # 第三阶段:最终镜像(无root权限) FROM scratch COPY --from=1 /usr/bin/python3.10 /usr/bin/python3.10 COPY --from=1 /usr/lib/x86_64-linux-gnu/libpython3.10.so.1.0 /usr/lib/libpython3.10.so.1.0 COPY --from=1 /usr/local/cuda /usr/local/cuda COPY --from=1 /app /app USER 1001:1001 ENTRYPOINT ["/app/inference_server"]

这个Dockerfile的每一行,都是对“最小化攻击面”的实践。scratch基础镜像意味着没有shell、没有包管理器、没有不必要的so库。USER 1001:1001强制非root运行。--no-binary :all:确保PyTorch也是源码编译,避免wheel包隐藏的CVE。

最后是部署。make deploy不调用kubectl apply -f k8s/deployment.yaml,而是用kubectl set image,因为这是幂等操作:无论deployment是否存在,它都只更新image字段。配合K8s的RollingUpdate策略,能保证零停机发布。更重要的是,它把“部署”和“配置”分离——deployment.yaml是基础设施代码,image tag是应用版本,两者独立演进。

这套Makefile CI/CD,不是为了替代GitHub Actions,而是为了在自动化之上,保留人类工程师的决策权。当test-performance-baseline失败时,流水线不会自动回滚,而是发Slack告警,附上性能曲线图,由工程师判断:是接受性能下降换功能?还是优化模型?还是扩容GPU?这个决策,必须由人来做。而Makefile,就是把这种决策逻辑,编码成可执行、可审计、可复现的工程契约。

5. 故障排查:一次GPU显存泄漏的七小时溯源实录

“from scratch”的最大价值,不是让你写出多漂亮的代码,而是当系统崩溃时,你有底气说:“我知道问题在哪,而且我能修好它。” 这种底气,来自对每一层抽象的亲手触摸。下面是我亲身经历的一次GPU显存泄漏排查,全程七小时,没有黑盒,只有层层剥茧。

现象:服务上线第三天,nvidia-smi显示GPU显存占用从初始1.2GB,缓慢爬升到12GB(V100显存16GB),第48小时OOM,Pod被K8s杀死重启。重启后重复此过程。

第一小时:确认现象,排除外部干扰
首先,kubectl top pods确认是ai-servicePod显存异常,不是其他Pod抢占。然后,kubectl exec -it ai-service -- nvidia-smi,确认是容器内显存泄漏,不是宿主机问题。接着,kubectl logs ai-service | grep -i "out of memory",发现大量CUDA out of memory错误,但时间戳集中在OOM前10分钟——说明泄漏是渐进式的,OOM是最终结果。

第二小时:隔离模型,确认泄漏源
停掉所有业务流量,只用curl发单条请求。watch -n 1 'nvidia-smi --query-gpu=memory.used --format=csv',观察显存变化。发现:每请求一次,显存+2MB,且不释放。这确认了泄漏在推理路径上。然后,注释掉模型推理代码,只留gRPC服务框架,显存稳定。再逐步放开:预处理→设备调度→模型加载→推理。定位到model(input)调用后显存上涨。

第三小时:检查PyTorch上下文
怀疑是torch.no_grad()没生效,或grad_fn残留。在Predict方法里加日志:

print(f"Before forward: {torch.cuda.memory_allocated()/1024/1024:.1f} MB") output = model(input) print(f"After forward: {torch.cuda.memory_allocated()/1024/1024:.1f} MB") print(f"Max memory: {torch.cuda.max_memory_allocated()/1024/1024:.1f} MB")

日志显示:Before: 1200.3 MB,After: 1202.5 MB,Max: 1202.5 MB。等等,max_memory_allocated没涨?说明不是模型内部泄漏,而是显存缓存未释放。PyTorch的CUDA缓存机制:torch.cuda.empty_cache()只清空未被引用的缓存,但model对象一直存活,其内部缓存(如cuBLAS工作区)可能被长期持有。

第四小时:验证缓存假设
写一个独立脚本test_cache.py:

import torch import gc model = torch.load("model.pth").cuda().eval() for i in range(100): x = torch.randn(1, 128).cuda() with torch.no_grad(): y = model(x) print(f"Iter {i}: {torch.cuda.memory_allocated()/1024/1024:.1f} MB") if i % 10 == 0: torch.cuda.empty_cache() gc.collect()

运行后,显存依然缓慢上涨。这证实了缓存泄漏。但empty_cache()无效,说明缓存被模型内部张量引用。

第五小时:深入模型源码
查看MyModel定义,发现一个可疑点:模型里有个self.register_buffer('running_mean', ...),用于BatchNorm统计。但推理时model.eval()应该禁用它。检查torch.nn.BatchNorm2d.forward()源码,发现当training=False时,它确实不更新running_mean,但running_mean张量本身仍驻留在GPU上。问题不在BatchNorm,而在模型里一个自定义的AttentionLayer,它有一个self.cache_k = None,在forward里:

if self.cache_k is None: self.cache_k = torch.zeros(...).cuda() # 每次forward都新建!

啊!这里self.cache_k在第一次调用时创建,但后续调用没复用,而是每次都torch.zeros(...).cuda()——这就在GPU上不断分配新内存,旧内存没被GC(因为self.cache_k被重新赋值,旧张量失去引用,但PyTorch的CUDA缓存没及时回收)。

第六小时:修复与验证
修复代码:

# 修复前 if self.cache_k is None: self.cache_k = torch.zeros(...).cuda() # 修复后 if self.cache_k is None: self.cache_k = torch.zeros(...).cuda() else: # 复用已有buffer,只重置值 self.cache_k.zero_()

重新构建Docker镜像,部署测试。nvidia-smi显示显存稳定在1.2GB,100次请求后无增长。但等等,torch.cuda.max_memory_allocated()依然在涨——说明还有别的泄漏。

第七小时:揪出gRPC的隐式引用
仔细看gRPC服务代码,在Predict方法里,logits = self.model_loader.load()(X_tensor)。self.model_loader.load()每次返回同一个模型实例,但load()方法里有model.to(device)。to(device)如果目标device和当前device不同,会返回新张量;如果相同,返回原张量。但model.to(device)内部会调用_apply(),可能创建临时张量。把model.to(device)移到ModelLoader.load()里,确保模型只加载一次,且to(device)只执行一次。再测,max_memory_allocated稳定。

最终结论:这是一个复合泄漏——主因是模型层cache_k反复分配,次因是gRPC服务层model.to(device)在每次请求中冗余调用。修复后,服务稳定运行30天,显存波动<50MB。

这次排查没有用任何“AI监控平台”,只靠nvidia-smi、print日志、gc.collect()、阅读PyTorch源码。它证明了一件事:“from scratch”不是为了炫技,而是为了在黑暗中,你手里有一盏自己点亮的灯。

返回列表