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

先写契约再写代码:端点、请求体、响应体、错误码,四样定死。 四类请求错误全用 400,靠 error 字段区分;关键是定了不许改。 拿一个假服务把契约打一遍,不用等模型训完就能发现设计问题。

📝 本章和前面几章的关系

这一篇对应教材 17.3 到 17.4.2:机器学习 API 的规格、准备开发、安装程式库、确认目录。 上一篇选定了模型,这一篇开始把它包起来。但第一行代码还不是 Python,是一张表。

一、先把三个端点定下来

方法 路径 做什么 成功
GET /api/health 健康检查 200
GET /api/model 模型版本与指标 200
POST /api/predict 给特征,返回预测值 200

路径用名词,动作用方法。/api/predict 是个动词,严格说不算 REST; 但预测这件事没有「资源」可指,业界普遍就这么写,不用纠结。 教材 17.6.1 讲「制定网址(端点)的命名规则」,核心就三条:统一前缀 /api、统一用复数名词、 版本号放进路径(/api/v1/predict)。 版本号那一层现在看着多余,等你要改返回格式的时候就值了:老客户端打 v1,新客户端打 v2, 两边同时活着。没有版本号,就只能让客户端升级。

/api/model 这个端点是给自己准备的。 线上出问题,第一句话要能回答「现在跑的是哪个模型、什么时候训的」——靠翻日志找这个答案太慢。

二、请求和响应各定一个样子

POST /api/predict
Content-Type: application/json

{"features": [0.038, 0.050, 0.061, 0.021, -0.044,
              -0.035, -0.043, -0.003, 0.019, -0.017]}
200 OK

{"prediction": 152.3, "model": "linear-20261103",
 "features": 10}

请求体是一个长度 10 的数组,对应 10 个特征。 为什么不带字段名,只给数组?因为模型要的就是这个形状, 加一层 {"age": 0.038, ...} 反而要在服务里再做一次转换。 响应里带上 model 那一项,是给自己留后路。 features 那一项是回显个数,看着多余,其实是最省事的一道自检:调用方一看「我传了 9 个, 它说 10 个」,立刻知道是自己的问题。 返回值的类型也要定死。prediction 是浮点数还是字符串?精度几位? 这些不定清楚,客户端就得自己猜,猜错了就是线上事故。

三、错误码一张表,先定死

情况 状态码 error 字段
不是 JSON 400 bad json
缺 features 400 missing features
长度不是 10 400 bad length
有非数字 400 not a number
数值超范围 400 out of range
路径不存在 404 not found
方法不对 405 method not allowed

四类请求错误全是 400,靠 error 字段区分。这是本课的选择; 也有人用 422 表示「格式对但内容不对」。两种都行,关键是定下来之后不许改,调用方按它写判断。 教材 17.6.2 讲「定义错误码与错误讯息」,重点不是码选哪个,是每个错误都要说清「错在哪」。 bad length 不如 features 长度应为 10,实际 9——后者省掉一轮来回。 第 14 章那个 detail 字段就是干这个的,这里把它变成制度。

「数值超范围」这一条最容易被忘。特征是有物理意义的:面积不可能是负数、房龄不可能是 3000 年。 模型不会因为输入荒唐就报错,它只会给你一个荒唐的结果。

四、目录架构:模型和接口分开

mlapi/
    __init__.py      create_app
    config.py        路径、版本号、上限
    data.py          读数据、切分
    features.py      特征处理(标准化)
    model.py         训练、存盘、加载、预测
    schema.py        请求校验与错误定义
    views/predict.py 路由
wsgi.py              入口

schema.py 这个文件专门放「请求长什么样、错误怎么报」, 它不 import 模型,也不 import flask——纯校验逻辑,能单独测。 views/ 只做三件事:收 JSON、交给 schema 校验、把结果交回去。 它不知道模型是怎么训的,也不知道特征怎么算——第 14 章那条分工,这里又用一次。 model.py 里塞四件事(训练、存盘、加载、预测)看着多了点,但它们共用同一份参数, 拆开反而要来回传。拆分的标准是「谁和谁一起变」,不是「一个文件一件事」。

五、依赖钉死,连 scikit-learn 也钉

flask==3.1.3
scikit-learn==1.9.1
numpy==2.5.2
gunicorn==23.0.0

四个就够了:web 框架、模型、数组、生产服务器。 注意 scikit-learn 那行是等号不是大于等于。理由上一篇说过:pickle 存的模型跨版本可能读不出来。 训练环境和运行环境必须是同一个版本。 教材 17.4.1 讲「安装程式库」,这一步最容易出的岔子是训练环境和运行环境装的版本不一样。 两边都从同一个 requirements 装,才有一致性。

顺手加一条:pip freeze 会把当前环境所有包都写进去,一百多行里有大半用不上。 手写这四行,比 freeze 出来那一片干净得多。

六、先写个假服务,把契约打一遍

在写真正模型代码之前,先写一个只会返回 0.0 的假服务,把三个请求打一遍。

请求 状态码 响应体
正常十个特征 200 {"prediction":0.0,"model":"stub"}
少了一个字段 400 {"error":"missing features"}
只传九个 400 {"error":"bad length","got":9}

这一步看着像在自娱自乐,其实很值:契约是调用方先看到的,拿假数据就能把它验一遍, 不用等模型训完。 而且它逼你回答一个问题: 空请求、缺字段、长度不对,各返回什么?这些问题在写假服务的时候最好回答, 等真模型接进来就来不及想了。 注意第三行那个 got:错误响应里带上实际收到的值,比只说「长度不对」有用得多。 这是上一篇那个 detail 的另一个应用场景。

七、这一篇之后

到这儿准备工作做完了:三个端点、两份 JSON 样例、一张错误码表、一棵目录树、四行依赖, 外加一个能跑通的假服务。 这些东西加起来比代码还长,但它们是先写的。后面三篇填肉的时候, 你会发现每一步都有个明确的目标——就是让那些表里的每一行变成真的。 下一篇处理最难的一块:把一份「分析脚本」改造成能上线的产品代码。

八、动手 Demo:定接口:先把契约写下来,再动代码

这个 Demo 先把端点、请求体、响应体、错误码四样摆出来,再造一遍目录树, 最后起一个假服务把契约真打三次。 第六节那三行是真的 HTTP 请求,状态码和响应体都是实测的。

@stub.route("/api/predict", methods=["POST"])
def stub_predict():
    body = request.get_json(silent=True)
    if not isinstance(body, dict) or "features" not in body:
        return jsonify({"error": "missing features"}), 400
    fs = body["features"]
    if not isinstance(fs, list) or len(fs) != 10:
        return jsonify({"error": "bad length", "got": len(fs)}), 400
    return jsonify({"prediction": 0.0, "model": "stub"})

实跑输出:

--------------------------------------------------------------------
定接口:先把契约写下来,再动代码
--------------------------------------------------------------------

  ① 三个端点,一张表
  --------------------------------------------------------------------

| 方法 | 路径 | 做什么 | 成功 |
|---|---|---|---|
| GET | /api/health | 健康检查 | 200 |
| GET | /api/model | 模型版本与指标 | 200 |
| POST | /api/predict | 给特征,返回预测值 | 200 |

  ! 路径用名词,动作用方法。/api/predict 是个动词,严格说不算 REST;
  但预测这件事没有「资源」可指,业界普遍就这么写。
  ! 教材 17.6.1 讲「制定网址(端点)的命名规则」,核心就三条:统一前缀 /api、统一用复数名词、
  版本号放进路径(/api/v1/predict)。
  ! 版本号那一层现在看着多余,等你要改返回格式的时候就值了:老客户端打 v1,新客户端打 v2,
  两边同时活着。

  ② 请求和响应,各定一个样子
  --------------------------------------------------------------------

  [契约原样]
  | POST /api/predict
  | Content-Type: application/json
  |
  | {"features": [0.038, 0.050, 0.061, 0.021, -0.044,
  |               -0.035, -0.043, -0.003, 0.019, -0.017]}
  请求体:一个长度为 10 的数组,对应 10 个特征。


  [契约原样]
  | 200 OK
  |
  | {"prediction": 152.3, "model": "linear-20261103",
  |  "features": 10}
  响应体:预测值、模型版本、用到的特征个数。

  ! 响应里带上 model 这一项,是给自己留后路:线上出问题时,
  第一句话要能回答「现在跑的是哪个模型」。
  ! features 那一项是回显个数。
  看着多余,其实是最省事的一道自检:调用方一看「我传了 9 个,它说 10 个」,立刻知道是自己的问题。

  ③ 错误码一张表,先定死
  --------------------------------------------------------------------

| 情况 | 状态码 | error 字段 |
|---|---|---|
| 不是 JSON | 400 | `bad json` |
| 缺 features | 400 | `missing features` |
| 长度不是 10 | 400 | `bad length` |
| 有非数字 | 400 | `not a number` |
| 数值超范围 | 400 | `out of range` |
| 路径不存在 | 404 | `not found` |
| 方法不对 | 405 | `method not allowed` |

  ! 四类请求错误全是 400,靠 error 字段区分。这是本课的选择;
  也有人用 422 表示「格式对但内容不对」。两种都行,关键是定下来之后不许改,调用方按它写判断。
  ! 教材 17.6.2 讲「定义错误码与错误讯息」,重点不是码选哪个,是每个错误都要说清「错在哪」。
  bad length 不如 features 长度应为 10,实际 9——后者省掉一轮来回。

  ④ 目录架构:模型和接口分开
  --------------------------------------------------------------------
  ├── mlapi/
  │   ├── __init__.py
  │   ├── config.py
  │   ├── data.py
  │   ├── features.py
  │   ├── model.py
  │   ├── schema.py
  │   └── views/
  │       ├── __init__.py
  │       └── predict.py
  ├── requirements.txt
  └── wsgi.py
  ! schema.py 这个文件专门放「请求长什么样、错误怎么报」,它不 import 模型,
  也不 import flask——纯校验逻辑,能单独测。
  ! views/ 只做三件事:收 JSON、交给 schema 校验、把结果交回去。
  它不知道模型是怎么训的,也不知道特征怎么算——第 14 章那条分工,这里又用一次。

  ⑤ 依赖钉死,连 scikit-learn 的版本也钉
  --------------------------------------------------------------------

  [requirements.txt]
  | flask==3.1.3
  | scikit-learn==1.9.1
  | numpy==2.5.2
  | gunicorn==23.0.0
  四个就够了:web 框架、模型、数组、生产服务器。

  ! 注意 scikit-learn 那行是等号不是大于等于。
  理由在上一篇说过:pickle 存的模型跨版本可能读不出来。
  ! 教材 17.4.1 讲「安装程式库」,这一步最容易出的岔子是训练环境和运行环境装的版本不一样。
  两边都从同一个 requirements 装,才有一致性。

  ⑥ 先写一个假服务,把契约打一遍
  --------------------------------------------------------------------
  正常十个特征 -> 200  {"features":10,"model":"stub","prediction":0.0}
  少了一个字段 -> 400  {"error":"missing features"}
  只传九个 -> 400  {"error":"bad length","expect":10,"got":9}
  ! 这一步看着像在自娱自乐,其实很值:契约是调用方先看到的,拿假数据就能把它验一遍,
  不用等模型训完。
  ! 而且它逼你回答一个问题:空请求、缺字段、长度不对,各返回什么?
  这些问题在写假服务的时候最好回答,等真模型接进来就来不及想了。
  ! 教材 17.4.2 讲「确认目录」,下一步才是把真的模型和真的特征计算填进去。
  填进去的时候不该改动 views/ 以外的任何东西。

第三节那张表里四类请求错误都是 400,只有路径错和方法错是 404/405。 第四节那棵树是当场造出来的,不是画的。 第六节三行响应里,第三行带了 got 字段,比只说长度不对有用。

30 秒实验

  1. 给假服务加一条「有非数字」的判断,让它返回 not a number。
  2. 把端点改成 /api/v1/predict,想想老客户端要怎么过渡。
  3. 想一想:为什么 /api/model 这个端点值得单独存在?

常见误区

  1. 先写代码后想接口:写到一半发现字段名不对,全改。
  2. 错误只返回状态码:调用方不知道错在哪,只能猜。
  3. 不校验数值范围:模型不报错,给你一个荒唐的结果。
  4. requirements 用 >=:某天自动升级,pickle 读不出来。
  5. 等模型训完才验接口:设计问题发现得太晚。
自测三问(点开对答案) 1. 契约里要定死哪四样? —— 端点、请求体、响应体、错误码。 2. 为什么响应里要带 model 字段? —— 线上出问题时能立刻回答「现在跑的是哪个模型」。 3. 先写假服务有什么好处? —— 不用等模型训完就能验契约,还能逼自己想清各种错误情况。

契约定完了,下一篇动真格:把一份脚本式的分析代码改造成能上线的产品代码。

相关笔记