3. 算法模块的接口契约设计与版本兼容
为什么算法模块需要接口契约
算法模块被上下游调用时,真正稳定的不是内部实现,而是输入输出接口、异常语义和版本兼容策略。如果只靠口头约定,内部一次重构(例如把返回的 list 改成 numpy.ndarray、把缺失值从 None 改成 NaN)就可能让调用方静默出错。接口契约把这些约定固化成可测试的断言。
接口契约的四个要素
一个可复用的算法模块,接口契约至少覆盖以下四类约定,缺一不可:
- 输入契约:字段名、数据类型、形状、缺失值表示、取值范围、必填与可选参数。
- 输出契约:返回类型(
dict/DataFrame/ndarray)、字段顺序、概率与标签的区分、空结果的表示。 - 异常语义:什么情况抛异常、抛什么类型、消息里包含什么信息;什么情况返回空结果而不抛异常。
- 版本兼容:新增可选参数属于 minor 变更;删除或重命名字段属于 major 变更;弃用字段保留一个版本并给出告警。
用契约测试锁定接口
契约测试不验证算法精度,只验证接口行为是否与约定一致。下面给出一个最小可运行的契约测试示例,覆盖输入形状、异常语义和版本兼容三类断言。
# contract_test.py
import pandas as pd
import pytest
from algo_module import predict
def test_output_contract():
"""输出契约:返回 dict,含 label 和 score 两个键"""
df = pd.DataFrame({"age": [25, 40], "income": [3000, 8000]})
result = predict(df)
assert isinstance(result, dict)
assert set(result.keys()) == {"label", "score"}
assert len(result["label"]) == len(df)
def test_missing_field_raises():
"""异常语义:缺字段抛 ValueError,消息含字段名"""
df = pd.DataFrame({"age": [25]})
with pytest.raises(ValueError, match="income"):
predict(df)
def test_empty_input_returns_empty():
"""空输入返回空结果,不抛异常"""
df = pd.DataFrame({"age": [], "income": []})
result = predict(df)
assert result["label"] == []
def test_backward_compatible_signature():
"""版本兼容:旧调用签名仍可运行"""
df = pd.DataFrame({"age": [30], "income": [5000]})
result = predict(df) # 不传新参数
assert "label" in result
契约测试应放在 CI 中,与单元测试分开运行。当内部实现重构时,只要契约测试通过,上下游调用方就不需要改动。
版本兼容策略速查
| 变更类型 | 版本号变化 | 调用方影响 | 处理方式 |
|---|---|---|---|
| 新增可选参数(有默认值) | minor +1 | 无 | 直接发布 |
| 新增返回字段 | minor +1 | 无(调用方按需读取) | 文档标注 |
| 删除或重命名字段 | major +1 | 破坏性 | 保留旧字段一个版本并告警 |
| 修改异常类型 | major +1 | 破坏性 | 同时抛新旧异常或提前通知 |
| 修改默认值 | minor +1 | 可能改变行为 | 在 release note 中说明 |
不要用“内部重构”作为修改接口的理由。接口一旦发布,就应视为对外承诺;重构只能改变实现,不能改变契约。若必须破坏契约,走 major 版本并给调用方迁移窗口。