🧪 第17章 机器学习 API 的开发程序与实践
📄 一句话概括

校验单独一个文件,返回「错名 + 细节」;它不 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 秒实验

  1. 给 /api/predict 加一个 app.errorhandler(405),让 405 也返回 JSON。
  2. 把日志里的耗时改成累计计数器,统计每分钟请求量。
  3. 想一想:为什么「监控输入分布」比「监控输出」更早能发现问题?

常见误区

  1. 只测正路:错路全是 HTML,用户拿到一坨看不懂的东西。
  2. 校验散在视图里:换个端点就要重写一遍,还测不了。
  3. 日志里没有模型版本:出事不知道怪谁。
  4. 只记成功日志:用户报错了,你去查发现没有记录。
  5. 以为上线就是终点:MLOps 是个环,监控和再训练才是后半程。
自测三问(点开对答案) 1. 为什么校验要单独一个文件? —— 它不 import flask 也不 import 模型,能单独测,换个端点还能复用。 2. 日志至少要有哪三样? —— 模型版本、耗时、结果;失败也要记。 3. MLOps 为什么说是环不是线? —— 再训练接回训练,监控的结论会触发下一轮训练。

全书到这里结束。这一条主线——请求进来,响应出去,每一步都知道在干什么——可以带到下一个项目里。

相关笔记