先写契约再写代码:端点、请求体、响应体、错误码,四样定死。 四类请求错误全用 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 秒实验
- 给假服务加一条「有非数字」的判断,让它返回
not a number。 - 把端点改成
/api/v1/predict,想想老客户端要怎么过渡。 - 想一想:为什么
/api/model这个端点值得单独存在?
常见误区
- 先写代码后想接口:写到一半发现字段名不对,全改。
- 错误只返回状态码:调用方不知道错在哪,只能猜。
- 不校验数值范围:模型不报错,给你一个荒唐的结果。
- requirements 用
>=:某天自动升级,pickle 读不出来。 - 等模型训完才验接口:设计问题发现得太晚。
自测三问(点开对答案)
1. 契约里要定死哪四样? —— 端点、请求体、响应体、错误码。 2. 为什么响应里要带 model 字段? —— 线上出问题时能立刻回答「现在跑的是哪个模型」。 3. 先写假服务有什么好处? —— 不用等模型训完就能验契约,还能逼自己想清各种错误情况。契约定完了,下一篇动真格:把一份脚本式的分析代码改造成能上线的产品代码。
- ☐ (本篇完,下一篇 17.3 分析脚本产品化(上):读懂与拆函数)