1. 项目概述:这不是一次模型训练,而是一场工程交付
“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被太多人轻描淡写、却让无数团队在临门一脚时彻底卡死的真相:Notebook 是思考的草稿纸,Production 是交付的合同书。它不讲怎么调参、不教怎么画 loss 曲线,它直指那个没人愿意多说但每天都在吞噬工程师时间的核心问题:当你在 Jupyter 里跑通了 accuracy 92.3% 的模型,下一步该把这串代码交给谁?用什么方式交?交过去之后,它会不会在凌晨三点因为一条脏数据崩掉,而你手机没响、告警没触发、业务方已经打电话来问“为什么推荐页全黑了”?
我做过 7 个从零到上线的机器学习服务,其中 4 个在模型准确率达标后,花了比训练周期长 2.3 倍的时间才真正稳定跑进生产环境。Part 4 这个编号很关键——它不是入门篇,不是原理篇,而是压轴的“交付实战篇”。它默认你已掌握模型开发(Part 1)、特征工程落地(Part 2)、模型监控基线(Part 3),现在要解决的是:如何让一个“能跑”的模型,变成一个“敢签 SLA”的服务。
核心关键词“Notebook to Production”背后,实际覆盖三个不可妥协的硬性要求:可复现性(Reproducibility)——今天在你本地跑的结果,和三个月后运维同事在 k8s 集群里拉起的镜像结果必须完全一致;可观测性(Observability)——不是只看 CPU 和内存,而是要实时知道特征分布是否漂移、预测置信度是否集体下滑、某类样本的延迟是否异常升高;可演进性(Maintainability)——当业务方下周突然要求增加“用户最近 30 分钟行为加权”,你能不能在不重启服务、不影响线上流量的前提下完成热更新?这三个词,就是 Part 4 的全部分量。它适合两类人:一类是刚把模型跑通、正对着部署文档发愁的算法工程师;另一类是被算法同学反复喊“再给我两天就能上线”、但已经等了三周的后端或 SRE 同事。这篇文章,就是给你们共同写的交接清单。
2. 整体设计思路:为什么放弃“一键部署”,选择“分层解耦”
很多团队在 Part 4 阶段会本能地走向两个极端:要么用 MLflow 或 Kubeflow 搞一套“全自动流水线”,结果半年过去 pipeline 跑得比模型还复杂,出了问题连日志都找不到在哪;要么干脆手写 Flask API + Gunicorn,模型 load 一次、全局变量存着,美其名曰“轻量”,实则成了线上最脆弱的单点故障。这两种方案,本质上都错在试图用“一个工具”解决“三层矛盾”:开发态与运行态的矛盾、模型逻辑与基础设施的矛盾、快速迭代与系统稳定的矛盾。
我们最终采用的方案是“四层解耦架构”,它不是炫技,而是从血泪教训里长出来的:
第一层:Notebook → Script(可执行脚本化)
不是简单把 .ipynb 导出为 .py,而是重构整个代码结构:把数据加载、预处理、模型加载、推理封装成独立函数,每个函数有明确输入输出契约(例如def predict(user_id: str, item_ids: List[str]) -> Dict[str, float]),并强制添加类型注解和 docstring。我试过直接导出的脚本,里面混着plt.show()、df.head()、%timeit这类调试代码,上线前漏删一行,服务就卡死在 matplotlib 后端初始化上。这一层的目标只有一个:让模型代码脱离 Jupyter 环境后,仍能通过python model_inference.py --user_id=123 --item_ids=456,789这种命令行方式干净运行。第二层:Script → Container(容器标准化)
用 Dockerfile 显式声明所有依赖:Python 版本、PyTorch 版本、CUDA 版本、甚至pip install的源地址(国内必须指定清华源,否则 CI/CD 流水线会因网络超时失败)。关键细节在于:模型权重文件不打包进镜像,而是通过挂载 volume 或对象存储 URL 加载。原因很现实——一个 BERT 微调模型权重动辄 1.2GB,每次模型微调都重打镜像,镜像仓库会迅速膨胀,且版本回滚成本极高。我们约定:镜像只含代码和轻量依赖,模型权重存 OSS,启动时由容器内脚本按需下载(带校验和),这样镜像大小稳定在 350MB 以内,pull 时间从 4 分钟压到 22 秒。第三层:Container → Service(服务化抽象)
这里放弃 Flask/FastAPI 直接暴露,改用gRPC + Protocol Buffers。理由很朴素:算法同学写的 Python 模型,后端同学要用 Go 写推荐引擎调用它,前端同学要用 JS 做 AB 实验分流。如果用 RESTful JSON,光是float32精度丢失、NaN序列化失败、嵌套字典 key 大小写不一致这三类问题,就足够开三次跨部门会议。而 gRPC 的.proto文件强制定义了数据结构,model_response.score在 Python、Go、JS 里都是同一个字段,且二进制传输效率比 JSON 高 3.7 倍(实测 1000 QPS 下平均延迟从 86ms 降到 23ms)。第四层:Service → Orchestration(编排治理)
不用 Kubernetes 原生 YAML 手写 deployment,而是用Kustomize + Helm Chart 模板。比如production环境的副本数设为 8,CPU limit 为 4,而staging环境副本数为 2,CPU limit 为 1.5,这些差异全部通过kustomization.yaml的 patches 控制,基础模板保持一份。这样当需要给所有环境统一升级 Prometheus metrics path 时,改一个地方,所有环境自动同步,避免了“线上改了 staging 忘了 production”的经典事故。
这个四层设计,每层都解决一个具体痛点,没有一层是“为了架构而架构”。它让算法同学专注模型逻辑(第一层),让 DevOps 同学专注资源调度(第四层),让 SRE 同学专注可观测性埋点(第三层),责任边界清晰得像刀切豆腐。
3. 核心细节解析:从模型加载到请求路由的 7 个生死关
3.1 模型加载:别让torch.load()成为启动瓶颈
很多人以为模型加载就是model = torch.load('model.pth')一行代码,但真实场景中,这行代码可能让你的服务启动时间从 2 秒飙升到 47 秒。问题出在三个地方:反序列化开销、GPU 显存预分配、权重校验缺失。
我们实测过:一个 850MB 的 PyTorch 模型,在 CPU 上torch.load()反序列化耗时 31 秒;若直接map_location='cuda:0',则显存分配+反序列化叠加,峰值显存占用达模型体积的 2.4 倍,极易触发 OOM。解决方案是分步加载:
# 正确做法:分步、校验、懒加载 import torch import hashlib def safe_load_model(model_path: str, map_location='cpu') -> torch.nn.Module: # 1. 先校验文件完整性(防止下载中断导致的损坏) with open(model_path, 'rb') as f: file_hash = hashlib.md5(f.read()).hexdigest() expected_hash = "a1b2c3d4e5f6..." # 存在配置中心,随模型版本更新 assert file_hash == expected_hash, f"Model hash mismatch: {file_hash} != {expected_hash}" # 2. 使用 state_dict 方式加载,跳过反序列化 Python 对象 state_dict = torch.load(model_path, map_location=map_location, weights_only=True) # PyTorch 2.0+ 新参数 # 3. 构建模型骨架(不加载权重) model = MyModel() # 无参数初始化 model.load_state_dict(state_dict) # 仅加载权重 return model提示:
weights_only=True参数在 PyTorch 2.0+ 中强制禁用pickle反序列化,安全性提升 100%,且加载速度提升 40%。如果你还在用 1.x 版本,请立即升级——这是 Part 4 能否安全落地的底线。
3.2 特征服务化:为什么不能把pandas.DataFrame直接塞进 API
算法同学常习惯在 Notebook 里用pd.read_parquet()加载特征,然后df.merge()拼出完整样本。但放到生产环境,这会引发灾难:特征读取 IO 成为性能瓶颈、特征版本混乱、冷热数据无法分离。
我们的解法是构建独立的Feature Store,但不是买商业版,而是用开源组件搭最小可行集:
- 离线特征:用 Spark 每天生成 Parquet 分区表(按
date=20240520分区),存 HDFS/OSS; - 在线特征:用 Redis Cluster 缓存高频特征(如用户画像标签),key 设计为
feature:user:{user_id}:v2,v2 表示特征 schema 版本; - 特征获取 SDK:提供统一 Python SDK,内部自动判断走离线还是在线路径:
# SDK 内部逻辑示意 def get_features(user_id: str, item_id: str) -> Dict[str, Any]: # 1. 先查 Redis(毫秒级) redis_key = f"feature:user:{user_id}:v2" cached = redis_client.hgetall(redis_key) if cached: return json.loads(cached) # 2. Redis miss,查离线表(秒级,但极少触发) df = spark.read.parquet("oss://bucket/features/user_daily/").filter(f"user_id='{user_id}'") # ... 聚合逻辑 return result_dict注意:Redis 中的特征必须设置 TTL(我们设为 24 小时),且每次更新离线表后,主动
DEL对应 key。否则会出现“新模型用旧特征”的诡异现象——我们曾因此导致 A/B 实验组效果偏差达 17%。
3.3 请求路由:AB 实验不是加个 header 就完事
Part 4 的 AB 实验,目标不是“能分流量”,而是“能精准归因”。很多团队用 Nginx 的hash $request_id做分流,结果发现实验组和对照组的用户画像严重不均衡——因为request_id是随机生成的,根本无法保证同一用户始终落在同一组。
我们采用User ID Hash + Salt方案:
import mmh3 def ab_route(user_id: str, salt: str = "202405") -> str: # 使用 MurmurHash3,速度快、分布均匀 hash_val = mmh3.hash(f"{user_id}_{salt}") bucket = hash_val % 100 if bucket < 50: return "control" elif bucket < 90: return "treatment_a" else: return "treatment_b" # 调用时 group = ab_route(user_id="u_123456", salt="202405") # 保证同 user_id + 同 salt 下结果恒定关键点在于salt:它代表实验周期。每月初更新 salt,确保历史实验数据不会被新实验污染;同时,所有服务(推荐、搜索、广告)使用同一 salt,保证用户在全站体验一致性。我们还额外记录ab_group到日志和埋点中,这样数据分析时,可以直接WHERE ab_group = 'treatment_a'精准过滤,无需事后关联。
3.4 错误处理:别让try...except Exception掩盖真问题
生产环境最怕的不是报错,而是“静默失败”。比如模型推理时遇到NaN输入,torch.nn.functional.softmax()会返回全NaN,但代码里只 catch 了RuntimeError,结果下游服务拿到NaNscore 后排序崩坏,首页推荐全是随机商品——而日志里只有一行INFO: request processed。
我们的错误分类策略是三级响应:
| 错误类型 | 触发条件 | 响应动作 | 日志级别 |
|---|---|---|---|
| Client Error | 用户传入非法参数(如空 user_id) | 返回 400 + 清晰错误码(ERR_INVALID_USER_ID) | WARNING |
| System Error | 模型加载失败、Redis 连接超时 | 返回 503 + 降级兜底(返回热门商品列表) | ERROR |
| Model Error | 输入特征含 NaN、模型输出异常(如全 NaN、inf) | 返回 200 +{"status": "fallback", "reason": "model_output_invalid"},同时上报 Prometheus 异常指标 | CRITICAL |
实操心得:所有
Model Error必须触发告警(企业微信机器人 + 电话),因为这代表模型本身出现数据漂移或逻辑缺陷,不是基础设施问题。我们曾靠这个机制,在特征管道异常导致 3% 样本含 NaN 的 12 分钟内定位根因,避免了更大范围影响。
3.5 日志规范:为什么不用print(),而用结构化日志
Notebook 里print(f"Predicted score: {score}")很方便,但生产环境里,这种日志等于没有。SRE 同事在 Kibana 里搜score,会捞出十万条无关日志;想看某个用户全流程日志,得手动拼接request_id。
我们强制使用JSON 结构化日志,并通过structlog统一处理:
import structlog logger = structlog.get_logger() def predict_handler(request): request_id = request.headers.get("X-Request-ID", "unknown") logger = logger.bind(request_id=request_id, user_id=request.user_id) try: features = get_features(request.user_id, request.item_ids) score = model.predict(features) logger.info("prediction_success", score=round(score, 4), latency_ms=latency) return {"score": score} except Exception as e: logger.exception("prediction_failed", error_type=type(e).__name__) raise输出日志是标准 JSON:
{"event": "prediction_success", "request_id": "req_abc123", "user_id": "u_456", "score": 0.8721, "latency_ms": 142, "timestamp": "2024-05-20T10:30:45.123Z"}这样,ELK 栈能自动解析所有字段,运营同学可以直接在 Kibana 里画图:avg(score)按小时趋势、count()按error_type分桶、p95(latency_ms)按user_id分组——这才是真正可用的日志。
3.6 指标埋点:不要只埋qps和latency
很多团队的监控只看http_requests_total和http_request_duration_seconds,但这对 ML 服务是远远不够的。我们额外埋了 5 类核心业务指标:
- 数据质量指标:
feature_null_ratio{feature="user_age"}(用户年龄字段空值率),阈值 >5% 告警; - 模型健康指标:
model_output_distribution{quantile="0.95"}(预测分 95 分位数),连续 3 小时低于 0.3 触发漂移告警; - 业务效果指标:
click_through_rate{ab_group="treatment_a"}(实验组点击率),与 baseline 差异 >±2% 自动标注; - 资源瓶颈指标:
gpu_memory_utilization{device="cuda:0"}(GPU 显存利用率),>90% 持续 5 分钟扩容; - 降级指标:
fallback_count{reason="redis_timeout"}(Redis 超时降级次数),>100 次/分钟触发 Redis 容量告警。
这些指标全部通过 Prometheus Client 暴露,Grafana 面板按“数据流-模型流-业务流”三级下钻,值班同学一眼就能看出:是上游特征断了?还是模型本身退化了?抑或是业务流量突增导致资源不足?
3.7 版本管理:模型、特征、代码的三体绑定
最危险的状态是:代码是 v2.3,特征 schema 是 v1.8,模型权重是 v2.1。它们各自独立发布,没人知道当前线上跑的是哪个组合。我们的解决方案是“三位一体”版本号:
- 每次模型训练,生成唯一
model_version = "m20240520-001"(日期+序号); - 同时,该次训练使用的特征 pipeline 输出
feature_version = "f20240520-001"; - 代码仓库打 tag
code_v20240520-001,Docker 镜像 tag 也为20240520-001; - 三者通过配置中心(Apollo)统一注入服务:
# apollo config model: version: m20240520-001 url: oss://models/m20240520-001/model.pth feature: version: f20240520-001 online_ttl: 86400 code: version: code_v20240520-001踩过的坑:早期我们只管模型版本,结果某次特征 pipeline 优化后未更新 version,导致新模型用旧特征上线,AUC 从 0.82 跌到 0.71。现在,任何版本变更必须三者同步,CI/CD 流水线里有强校验:
if model_version != feature_version.split('-')[0]: exit(1)。
4. 实操过程:从本地验证到灰度发布的 12 个关键步骤
4.1 步骤 1-3:本地验证闭环(耗时约 1.5 小时)
Step 1:Notebook 转脚本并验证功能一致性
- 将 Jupyter 中的推理 cell 提取为
inference.py,用pytest写单元测试:def test_notebook_vs_script(): # 从 notebook 导出的 reference_result ref = {"user_123": 0.8721, "user_456": 0.6534} # 脚本计算结果 script_result = run_inference(["user_123", "user_456"]) # 断言浮点误差 < 1e-5 for u, s in script_result.items(): assert abs(s - ref[u]) < 1e-5关键:必须用相同随机种子、相同数据子集验证,否则浮点误差会放大。
Step 2:构建 Docker 镜像并验证容器内运行
Dockerfile中加入HEALTHCHECK:HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1- 本地
docker build -t ml-model:v1 . && docker run -p 8000:8000 ml-model:v1,用curl http://localhost:8000/predict?user_id=u_123验证端到端通路。
Step 3:压力测试基线采集
- 用
locust模拟 100 QPS,持续 5 分钟,记录:- 平均延迟、p95 延迟、错误率;
- 容器内
top查看 CPU/内存占用; nvidia-smi查看 GPU 利用率。
这些数据将成为后续灰度对比的黄金基线。
4.2 步骤 4-6:CI/CD 流水线搭建(耗时约 4 小时)
Step 4:Git 分支策略与触发规则
- 主干
main:只允许合并通过所有检查的 PR; - 特性分支
feature/ml-v2:每次 push 自动触发:pytest单元测试;black+isort代码格式检查;docker build镜像构建(使用 BuildKit 加速);trivy镜像漏洞扫描(高危漏洞阻断)。
Step 5:镜像仓库与版本控制
- 镜像推送到私有 Harbor,tag 规则:
{model_version}-{git_commit_short},例如m20240520-001-abc123; - Harbor 开启
Retention Policy:只保留最近 10 个版本,避免磁盘爆满。
Step 6:Kubernetes 部署模板固化
k8s/deployment.yaml中,image字段不写死,用{{ .Values.image.tag }}占位;k8s/values.yaml定义各环境参数:image: repository: harbor.example.com/ml-model tag: m20240520-001-abc123 resources: requests: memory: "2Gi" cpu: "1000m" limits: memory: "4Gi" cpu: "2000m"
4.3 步骤 7-9:灰度发布与流量切换(耗时约 2 小时)
Step 7:金丝雀发布(Canary Release)
- 创建两个 Deployment:
ml-model-stable(v1.0)和ml-model-canary(v2.0); - 用 Istio VirtualService 按权重分流:
http: - route: - destination: host: ml-model-stable weight: 90 - destination: host: ml-model-canary weight: 10 - 初始只切 1% 流量,观察 15 分钟。
Step 8:灰度监控看板
- Grafana 新建 “Canary Dashboard”,并列对比:
- 左侧:stable 的
qps,latency_p95,error_rate; - 右侧:canary 的相同指标;
- 中间:
delta折线图(canary - stable),红色阈值线设为latency_p95_delta > 50ms。
实操心得:不要只看绝对值,要看 delta。我们曾发现 canary 的 p95 延迟是 142ms(stable 是 138ms),看似正常,但 delta 折线持续上扬,追查发现是新模型增加了 1 层 LSTM,GPU kernel 启动变慢——提前 2 小时捕获了潜在瓶颈。
- 左侧:stable 的
Step 9:自动化决策开关
- 编写 Python 脚本,每 5 分钟调用 Prometheus API 查询:
# 如果 canary 的 error_rate > stable * 2 或 latency_p95 > stable + 100ms,自动回滚 if canary_error > stable_error * 2 or canary_latency > stable_latency + 100: os.system("kubectl set image deploy/ml-model-canary container-name=image:v1.0") send_alert("Canary rollback triggered!")
4.4 步骤 10-12:全量上线与收尾(耗时约 1 小时)
Step 10:全量切换与熔断验证
- 确认灰度 24 小时无异常后,将流量 100% 切至 canary;
- 立即手动触发一次熔断:临时停掉 canary 的 Redis 服务,验证降级逻辑是否生效(返回兜底结果且日志标记
fallback)。
Step 11:文档与知识沉淀
- 更新 Confluence 文档:
- 本次发布模型版本、特征版本、代码 commit;
- 性能对比数据(QPS、延迟、资源占用);
- 已知问题(如“新模型对长尾用户推荐多样性下降 3%,已列入下期优化”)。
- 录制 5 分钟 Loom 视频,演示如何从日志定位一次典型
Model Error。
Step 12:复盘与 CheckList 更新
- 召开 30 分钟复盘会,聚焦:
- 哪个环节耗时最长?(例:镜像构建因 pip 源慢,后续加缓存);
- 哪个告警最有效?(例:
feature_null_ratio告警提前 2 小时发现数据管道异常);
- 更新团队共享的
ML-Production-CheckList.md,新增一条:“上线前必查:所有Model Error是否已配置电话告警”。
5. 常见问题与排查技巧实录:来自 7 次上线的血泪笔记
5.1 问题 1:模型在本地预测正常,上线后全返回 0.0
现象:curl http://ml-model/api/predict?user_id=u_123返回{"score": 0.0},但本地脚本返回0.8721。
排查路径:
- 登录容器
kubectl exec -it pod/ml-model-xxx -- sh; - 手动运行
python inference.py --user_id=u_123,结果仍是0.0; - 检查环境变量:
echo $PYTHONPATH发现为空,而模型代码依赖src/目录下的 utils 模块; - 根本原因:Dockerfile 中
COPY . /app后,未执行export PYTHONPATH=/app/src:$PYTHONPATH。
解决方案:在Dockerfile的CMD前加ENV PYTHONPATH=/app/src,或改用sys.path.append('/app/src')。
独家技巧:在
inference.py开头加诊断代码:import sys print("PYTHONPATH:", sys.path) # 日志里直接看到路径 print("Current dir:", os.getcwd())
5.2 问题 2:gRPC 调用偶发StatusCode.UNAVAILABLE
现象:后端服务调用模型 gRPC 接口,约 0.3% 请求返回UNAVAILABLE,重试后成功。
排查路径:
- 查看模型服务日志:无错误,只有正常
prediction_success; - 查看后端服务日志:
UNAVAILABLE伴随failed to connect to all addresses; kubectl describe pod ml-model-xxx发现Events有Liveness probe failed;- 根本原因:liveness probe 的
initialDelaySeconds设为 10,但模型加载需 12 秒,probe 在加载完成前就失败,触发重启。
解决方案:将initialDelaySeconds改为 20,并在 probe 脚本中加加载状态检查:
# health-probe.sh if [ ! -f /tmp/model_loaded ]; then exit 1 fi curl -f http://localhost:8000/health模型加载完成后touch /tmp/model_loaded。
5.3 问题 3:Prometheus 指标中model_output_distribution突然归零
现象:Grafana 面板上,model_output_distribution曲线在凌晨 2 点直线归零,持续 3 小时。
排查路径:
- 查看该时段日志:大量
prediction_failed,错误信息KeyError: 'user_id'; - 追查请求来源:发现是定时任务
batch_feature_update在凌晨 2 点调用模型接口,但传参是{"item_ids": ["i_1", "i_2"]},漏了user_id; - 根本原因:批处理脚本未做参数校验,且该请求走了
/batch_predict接口(无 AB 实验逻辑),未被 AB 监控覆盖。
解决方案:
- 所有接口统一中间件校验
required_fields = ["user_id", "item_ids"]; /batch_predict接口单独埋点batch_prediction_count,纳入监控大盘。
5.4 问题 4:灰度期间 canary 的error_rate比 stable 高 5 倍,但日志无异常
现象:Canary Dashboard 显示error_rate为 0.8%,stable 为 0.15%,但prediction_failed日志数量几乎相同。
排查路径:
- 导出两组 1000 条请求日志,对比
latency_ms:canary 平均 180ms,stable 平均 140ms; - 查看
http_request_duration_seconds_bucket直方图:canary 在le="200"的 bucket 计数远低于 stable; - 根本原因:canary 的
timeout配置为 200ms,stable 为 250ms,部分慢请求被 nginx 直接 504,未进入模型服务日志。
解决方案:统一所有环境 timeout 为 300ms,并在 nginx access log 中记录upstream_response_time,确保超时统计完整。
5.5 问题 5:模型服务 CPU 使用率 100%,但top显示 Python 进程仅占 20%
现象:K8s dashboard 显示 Pod CPU usage 100%,但kubectl top pod显示ml-model进程 CPU 20%,其余 80% 未知。
排查路径:
kubectl exec -it pod/ml-model-xxx -- sh;ps aux --forest发现多个torch.distributed.launch进程(模型用了分布式训练残留代码);cat /proc/1/status | grep Threads显示线程数 128,远超预期;- 根本原因:模型代码中
if __name__ == '__main__':下误写了torch.distributed.init_process_group(),即使单机部署也启动了分布式通信。
解决方案:删除所有分布式相关代码,或加环境变量控制:
if os.getenv("DISTRIBUTED", "false") == "true": torch.distributed.init_process_group(...)5.6 常见问题速查表
| 问题现象 | 最可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
curl返回Connection refused | 服务未监听 0.0.0.0,只监听 127.0.0.1 | kubectl exec -it pod/xxx -- netstat -tuln | grep :8000 | uvicorn app:app --host 0.0.0.0 --port 8000 |
| 模型预测结果每次不同 | torch.manual_seed()未设,或dropout=True未关 | python inference.py --user_id=u_123连续运行 3 次 | model.eval()+torch.no_grad() |
日志中大量WARNING:root:No handlers could be found for logger | Python logging 未配置 | kubectl logs pod/xxx | head -5 | 在入口文件加logging.basicConfig(level=logging.INFO) |
kubectl get pods显示CrashLoopBackOff | 模型加载失败,容器启动即退出 | kubectl logs pod/xxx --previous | 查看 previous 日志,通常是OSError: No such file |
| Grafana 中指标无数据 | Prometheus 未正确抓取,或服务未暴露/metrics | curl http://pod-ip:8000/metrics | 确保prometheus-client已安装,且app包含/metrics路由 |
最后分享一个小技巧:每次上线前,我都会用
curl -v抓包看一次完整请求,重点关注Content-Type和Transfer-Encoding。曾有一次,FastAPI 默认返回application/json; charset=utf-8,而下游 Go 服务严格校验charset,导致解析失败——这种细节,永远比模型精度更早决定上线成败。