校验单独一个文件,返回「错名 + 细节」;它不 import flask,所以能单独测。 日志每请求一行,必带模型版本、耗时、结果——这三样是事后排查的全部信息。 MLOps 是一个环:训练、评估、注册、部署、监控、再训练,最后一格接回第一格。
这一篇对应教材 17.6 到 17.8:建立产品程式码的 API,含路由建置、错误检查、请求检查、 确认运作情况,以及由机器学习 API 到 MLOps。 这是全书最后一篇。前面铺的所有东西,到这儿要合成一个能上线的东西。
一、路由:三条规则
| 端点 | 方法 | 返回 |
|---|---|---|
/api/predict |
POST | 预测值、模型版本、特征数 |
/api/health |
GET | {"status":"ok"} |
/api/model |
GET | 版本号、RMSE、特征数 |
三条规则:统一前缀 /api、动作用方法不用路径、版本号进路径(/api/v1/...)。
第三个端点是给运维和自己准备的。
上线之后被问得最多的问题是「现在跑的是哪个模型」,有这条就不用翻日志。
注意 POST /api/predict 里没有动词,predict 是资源名(一次预测请求)。
严格 REST 会写成 POST /api/predictions,本课没这么做——规则是为了让人看懂,不是为了合乎教条。
二、校验单独一个文件
def check(body, n_features, lo, hi):
if not isinstance(body, dict):
return "bad json", {}
if "features" not in body:
return "missing features", {}
fs = body["features"]
if not isinstance(fs, list) or len(fs) != n_features:
return "bad length", {"expect": n_features, "got": len(fs)}
for i, v in enumerate(fs):
if isinstance(v, bool) or not isinstance(v, (int, float)):
return "not a number", {"index": i, "value": str(v)[:20]}
if v < lo or v > hi:
return "out of range", {"index": i, "value": v}
return None, {"features": [float(x) for x in fs]}
返回「错误名 + 细节」,错误名为 None 就是通过。
这个函数不 import flask,也不 import 模型,所以它能单独测——传一个字典进去看它返回什么,
不用起服务。
isinstance(v, bool) 那个判断不是多余:Python 里 True 也是数字,float(True) 等于 1.0。
不排掉的话,{"features": [true, ...]} 会被当成合法输入。
「数值超范围」这一条最容易被忘。
特征是有物理意义的,标准化之后大概在正负三之间,跑到 99 一定是搞错了。
模型不会因为输入荒唐就报错,它只会给你一个荒唐的结果。
教材 17.6.3 讲「请求检查」,说的就是这一层。它挡的不是正常用户,是各种奇怪的边界。
三、六个请求,逐条打
| 情况 | 状态码 |
|---|---|
| 正常十个特征 | 200 |
| 缺 features | 400 |
| 只传九个 | 400 |
| 第十个是字符串 | 400 |
| 第十个超范围 | 400 |
| 用 GET 打 POST 端点 | 405 |
五个 400 的响应体分别是 bad json、missing features、bad length、not a number、
out of range,各带各的细节。
六条里五条是错路。这不是悲观,是验收清单——第 14 章那个 413 的洞,就是只测正路才漏掉的。
405 那条返回的是 HTML,不是 JSON。
这是框架自己生成的,本课没去改它——要改也可以,加一个 app.errorhandler(405) 就行,
跟第 14 章补 413 是同一个手法。
顺带一个判断标准:一个接口好不好用,看它的错误响应够不够具体。只说「400 参数错误」的接口, 接一次要来回三封邮件。
四、日志:三个字段
ok model=linear-20261103 n=10 y=205.29 0.45ms
reject missing features {}
reject bad length {'expect': 10, 'got': 9}
reject not a number {'index': 9, 'value': 'abc'}
reject out of range {'index': 9, 'value': 99.0, 'allow': [-10.0, 10.0]}
成功一行,失败一行,每一行都能独立看懂。这五行的信息量比一堆 print("here") 大得多。
成功的行有三样东西:模型版本、耗时、结果。这三样是事后排查的全部信息。
没有模型版本,你看到「昨天开始不准了」不知道该不该怪模型;没有耗时,你不知道是模型慢还是网络慢;
没有结果,你没法判断是输入错还是输出错。
别把日志写成 print。日志有级别、有时间戳、能被统一收走,print 只是往屏幕上丢一行。
容器的日志就是靠 stdout 收的,所以第 15 章那两行 ENV 里 PYTHONUNBUFFERED=1 才那么重要。
还有一个实践:失败也要打日志。只记成功的服务,等于只有一半的现场。
用户说「我这边报错了」,你去查日志——发现什么都没有,这种事太常见了。
五、MLOps:是一个环,不是一条线
| 阶段 | 做什么 | 对应前文 |
|---|---|---|
| 训练 | 脚本跑一次,产出模型文件 | 17.3、17.4 |
| 评估 | 交叉验证,记下指标 | 17.1 |
| 注册 | 模型文件加版本号,存起来 | 17.1 存盘 |
| 部署 | 打成镜像,推到 Cloud Run | 第 15 章 |
| 监控 | 看请求量、错误率、耗时、输入分布 | 本篇第四节 |
| 再训练 | 数据攒够了重来一轮 | — |
教材 17.8 讲「由机器学习 API 到机器学习的基础设施、MLOps」,说的就是这个循环。 注意它是一个环,不是一条线——最后一格接回第一格。 循环里最容易漏的是「监控输入分布」。 模型不会因为数据变了就报错,它只会安静地给出越来越离谱的结果。等有人投诉,通常已经错了几万条。 监控该看什么有个简单办法: 把训练时每个特征的均值和标准差记下来,线上每小时算一次,偏太多就报警。这个办法土, 但它能在模型失效之前就叫。
还有一种更隐蔽的失效:上游改了字段含义。比如「房龄」原来按年、后来按月份传, 取值范围变了 12 倍,校验可能刚好放过去。所以输入分布监控要按特征看,不是看总平均。
六、上线检查清单
| 项 | 为什么 |
|---|---|
| 健康检查端点 | 平台靠它判断实例活着没有 |
| 错误也返回 JSON | 调用方按 JSON 解析,HTML 会让它报解析错 |
| 日志带模型版本 | 出事第一句话要能回答「跑的是哪个模型」 |
| 依赖版本钉死 | pickle 跨版本可能读不出来 |
| 请求有上限 | 没上限就是敞开的资源消耗口 |
| 错误码表写进文档 | 调用方按它写判断,改一次要通知所有人 |
这六项没有一项是「算法」的事。这就是第 4 篇到最后想说的:机器学习 API 的难点,九成在工程上。 模型选得再好,这六项缺一项,它就是个不能用的服务。 反过来说,模型差一点但六项齐全,它至少能用、能改、能查。 这一条对刚开始做这类项目的人尤其重要: 不要以为「模型训出来就完事了」。训出来只是这条链的第一格。
七、全书到这里
| 篇 | 做了什么 | 章 |
|---|---|---|
| 第 0 篇 | 环境、第一行代码 | 0 |
| 第 1 篇 | Flask 的地基:路由、模板、数据库、验证 | 1~3 |
| 第 2 篇 | 一个完整的物件侦测网站加测试 | 4~11 |
| 第 3 篇 | 把它做成 API,打包,部署 | 12~15 |
| 第 4 篇 | 从模型是什么,到机器学习 API | 16~17 |
59 篇、59 个 Demo(D001 到 D059),到这里全部跑完。 回头看这 17 章,主线只有一条:一个请求进来,到它变成一个响应出去,中间每一步都知道在干什么。 第 2 篇学的是「怎么让它跑起来」,第 3 篇学的是「怎么让别人用到」, 第 4 篇学的是「里面那个模型是怎么来的」。三件事凑齐,才叫一个能上线的应用。
后面还能往下走:换成真模型、加缓存、做灰度、接监控平台。 但那些都是在这条主线上加分支,主线不变。
八、动手 Demo:最后一个接口:校验、日志,和一整套 MLOps
这个 Demo 造一个完整的 mlapi 包,训练出模型,起服务,然后打六个请求,再把日志原文抓出来看。 第三节那六行和第四节那五行,是这一篇最实在的两段——都是真跑出来的。
@bp.route("/api/predict", methods=["POST"])
def predict():
t0 = time.perf_counter()
cfg = current_app.config["CFG"]
err, extra = check(request.get_json(silent=True), cfg.n_features,
cfg.lo, cfg.hi)
if err:
current_app.logger.info("reject %s %s", err, extra)
return jsonify(dict(extra, error=err)), 400
y = M.predict(current_app.config["BUNDLE"], extra["features"])
ms = (time.perf_counter() - t0) * 1000
current_app.logger.info("ok model=%s n=%d y=%.2f %.2fms",
cfg.version, cfg.n_features, y, ms)
return jsonify({"prediction": round(y, 2), "model": cfg.version})
实跑输出:
--------------------------------------------------------------------
最后一个接口:校验、日志,和一整套 MLOps
--------------------------------------------------------------------
① 训练好的模型先落到盘上
--------------------------------------------------------------------
训练一次 -> 测试集 RMSE 56.7371
模型文件 -> 0.8 KB
应用启动时只 load,不 train:训练是离线的事,预测是线上的事
! 这一步在服务外面做。服务启动时读文件,几十毫秒;
要是启动时训练,几秒钟,冷启动全部变慢——第 15 章那个冷启动问题,一半的原因就在这儿。
② 校验单独一个文件,返回「错在哪」
--------------------------------------------------------------------
[schema.py]
| def check(body, n_features, lo, hi):
| ...
| if not isinstance(fs, list) or len(fs) != n_features:
| return "bad length", {"expect": n_features, "got": len(fs)}
| for i, v in enumerate(fs):
| if isinstance(v, bool) or not isinstance(v, (int, float)):
| return "not a number", {"index": i, "value": str(v)[:20]}
| if v < lo or v > hi:
| return "out of range", {"index": i, "value": v}
| return None, {"features": out}
返回「错误名 + 细节」,错误名为 None 就是通过。
! 这个函数不 import flask,也不 import 模型。
所以它能单独测——传一个字典进去,看它返回什么,不需要起服务。
! isinstance(v, bool) 那个判断不是多余:Python 里 True 也是数字,float(True) 等于 1.0。
不排掉的话,{"features": [true, ...]} 会被当成合法输入。
! 这类细节就是「校验」的价值所在:它挡的不是正常用户,是各种奇怪的边界。
③ 六个请求,逐条打
--------------------------------------------------------------------
正常十个特征 -> 200 {"features":10,"model":"linear-20261103","prediction":205.29}
缺 features -> 400 {"error":"missing features"}
只传九个 -> 400 {"error":"bad length","expect":10,"got":9}
第十个是字符串 -> 400 {"error":"not a number","index":9,"value":"abc"}
第十个超范围 -> 400 {"allow":[-10.0,10.0],"error":"out of range","index":9,"value":99.0}
用 GET 打 POST 端点 -> 405
健康检查 -> {"status":"ok"}
模型信息 -> {"n_features":10,"rmse":56.7371,"version":"linear-20261103"}
! 六条里五条是错路。这不是悲观,是验收清单——第 14 章那个 413 的洞,就是只测正路才漏掉的。
! 第三、四、五条都返回 400 但 error 不一样,调用方一眼看出该改哪。
这就是上一篇那张错误码表的意义:它让「猜」变成「读」。
④ 日志:每个请求一行,出了事能倒查
--------------------------------------------------------------------
日志按请求打,抓到的原文:
ok model=linear-20261103 n=10 y=205.29 0.44ms
reject missing features {}
reject bad length {'expect': 10, 'got': 9}
reject not a number {'index': 9, 'value': 'abc'}
reject out of range {'index': 9, 'value': 99.0, 'allow': [-10.0, 10.0]}
! 每一行都有三个东西:模型版本、耗时、结果。这三样是事后排查的全部信息。
! 没有模型版本,你看到「昨天开始不准了」,就不知道该不该怪模型;
没有耗时,你不知道是模型慢还是网络慢;没有结果,你没法判断是输入的问题还是输出的问题。
! 别把日志写成 print:日志有级别、有时间戳、能被统一收走,print 只是往屏幕上丢一行。
容器的日志就是靠 stdout 收的,所以第 15 章那两行 ENV 里,PYTHONUNBUFFERED=1 才那么重要。
⑤ 从「能跑」到「能维护」:MLOps
--------------------------------------------------------------------
| 阶段 | 做什么 | 对应前文 |
|---|---|---|
| 训练 | 脚本跑一次,产出模型文件 | 17.3、17.4 |
| 评估 | 交叉验证,记下指标 | 17.1 |
| 注册 | 模型文件加版本号,存起来 | 17.1 存盘 |
| 部署 | 打成镜像,推到 Cloud Run | 第 15 章 |
| 监控 | 看请求量、错误率、耗时、输入分布 | 本篇第四节 |
| 再训练 | 数据攒够了重来一轮 | — |
! 教材 17.8 讲「由机器学习 API 到机器学习的基础设施、MLOps」,说的就是这个循环。
注意它是一个环,不是一条线——最后一格接回第一格。
! 循环里最容易漏的是「监控输入分布」。
模型不会因为数据变了就报错,它只会安静地给出越来越离谱的结果。等有人投诉,通常已经错了几万条。
! 监控该看什么有个简单办法:把训练时每个特征的均值和标准差记下来,线上每小时算一次,
偏太多就报警。这个办法土,但它能在模型失效之前就叫。
⑥ 上线检查清单
--------------------------------------------------------------------
| 项 | 为什么 |
|---|---|
| 健康检查端点 | 平台靠它判断实例活着没有 |
| 错误也返回 JSON | 调用方按 JSON 解析,HTML 会让它报解析错 |
| 日志带模型版本 | 出事第一句话要能回答「跑的是哪个模型」 |
| 依赖版本钉死 | pickle 跨版本可能读不出来 |
| 上传/请求有上限 | 没上限就是敞开的资源消耗口 |
| 错误码表写进文档 | 调用方按它写判断,改一次要通知所有人 |
! 这六项没有一项是「算法」的事。这就是第 4 篇到最后想说的:机器学习 API 的难点,九成在工程上。
! 模型选得再好,这六项缺一项,它就是个不能用的服务。
反过来说,模型差一点但六项齐全,它至少能用、能改、能查。
⑦ 全书到这里
--------------------------------------------------------------------
| 篇 | 做了什么 | 章 |
|---|---|---|
| 第 0 篇 | 环境、第一行代码 | 0 |
| 第 1 篇 | Flask 的地基:路由、模板、数据库、验证 | 1~3 |
| 第 2 篇 | 一个完整的物件侦测网站加测试 | 4~11 |
| 第 3 篇 | 把它做成 API,打包,部署 | 12~15 |
| 第 4 篇 | 从模型是什么,到机器学习 API | 16~17 |
59 篇、59 个 Demo(D001 到 D059),到这里全部跑完
! 回头看这 17 章,主线只有一条:一个请求进来,到它变成一个响应出去,中间每一步都知道在干什么。
! 第 2 篇学的是「怎么让它跑起来」,第 3 篇学的是「怎么让别人用到」,
第 4 篇学的是「里面那个模型是怎么来的」。三件事凑齐,才叫一个能上线的应用。
! 后面还能往下走:换成真模型、加缓存、做灰度、接监控平台。
但那些都是在这条主线上加分支,主线不变。
第三节五个 400 的响应体都不一样,这是上一篇那张错误码表的兑现。 405 那条返回 HTML 不是 JSON——框架自己生成的,本课没去改。 第六节那张五行的表,是整本书的骨架。
30 秒实验
- 给
/api/predict加一个app.errorhandler(405),让 405 也返回 JSON。 - 把日志里的耗时改成累计计数器,统计每分钟请求量。
- 想一想:为什么「监控输入分布」比「监控输出」更早能发现问题?
常见误区
- 只测正路:错路全是 HTML,用户拿到一坨看不懂的东西。
- 校验散在视图里:换个端点就要重写一遍,还测不了。
- 日志里没有模型版本:出事不知道怪谁。
- 只记成功日志:用户报错了,你去查发现没有记录。
- 以为上线就是终点:MLOps 是个环,监控和再训练才是后半程。
自测三问(点开对答案)
1. 为什么校验要单独一个文件? —— 它不 import flask 也不 import 模型,能单独测,换个端点还能复用。 2. 日志至少要有哪三样? —— 模型版本、耗时、结果;失败也要记。 3. MLOps 为什么说是环不是线? —— 再训练接回训练,监控的结论会触发下一轮训练。全书到这里结束。这一条主线——请求进来,响应出去,每一步都知道在干什么——可以带到下一个项目里。
- ☐ (全书完)