1. 这不是调包,是亲手搭起AI工程的骨架
“AI Engineering from Scratch”——看到这个标题,我第一反应不是兴奋,而是下意识摸了摸键盘边沿那层被磨得发亮的漆面。过去三年,我带过17个团队落地AI项目,从智能客服到工业质检,从医疗影像辅助标注到供应链需求预测。几乎每个新来的工程师,开口第一句都是:“老师,用哪个框架?Hugging Face Model Hub里找一个差不多的微调一下行不行?”——然后掏出笔记本,准备抄几行from transformers import AutoModel。这不怪他们。现在的AI生态太丰盛了,丰盛到让人忘了锅灶怎么砌、柴火怎么劈、水温怎么控。
但真正卡住项目交付的,从来不是模型精度差0.3%,而是线上推理延迟突然飙升300ms、批量任务莫名OOM、AB测试流量切分后指标漂移、模型版本回滚时特征schema错位……这些都不是pip install能解决的。它们暴露的是整个AI工程链路的“地基松动”:数据管道没做血缘追踪,特征生成没固化计算逻辑,模型服务没定义SLA契约,监控告警没覆盖特征漂移阈值。而“from scratch”,不是让你从零写反向传播,而是亲手把数据、代码、配置、部署、监控这五根柱子,一根一根夯进地里,让它们彼此咬合、可追溯、可替换、可度量。
这个词组里的“AI Engineering”,不是AI+Engineering的简单拼接,它是一个独立工种——就像前端工程师不等于“会写HTML的程序员”,AI工程师也不等于“会跑PyTorch的算法同学”。它的核心能力图谱里,模型训练只占25%,剩下75%是:如何让数据在毫秒级完成清洗与对齐;如何让特征计算在不同环境(离线/在线/实时)保持语义一致;如何让模型服务像数据库一样提供确定性延迟和错误码;如何让一次模型更新,自动触发下游所有依赖服务的回归验证。而“from scratch”,就是拒绝黑盒封装,把每一层抽象的API背后,都拆解成可调试、可审计、可压测的原子模块。比如你用sklearn.Pipeline,得知道它内部怎么管理transformer的fit状态;你用MLflow,得清楚它记录的run_id和artifact_uri在S3上对应哪几个文件夹层级;你用Kubernetes部署模型,得明白livenessProbe的HTTP路径返回什么才算健康。
适合谁读?如果你正面临这些场景:团队里算法和工程总是互相甩锅;上线后模型效果和离线评估相差甚远;每次迭代都要重跑全量特征,耗时8小时;运维半夜打电话说GPU显存爆了但查不出哪个batch在泄漏内存——那么这篇内容就是为你写的。它不教你如何调参,但会告诉你为什么调参结果无法复现;不讲BERT原理,但会演示如何把BERT的tokenizer封装成无状态的gRPC服务;不推荐最佳实践清单,而是带你亲手敲出第一个可验证的特征注册中心。这不是速成课,而是一份“AI系统建造师”的施工日志。
2. 整体架构设计:为什么必须放弃“端到端黑盒”思维
2.1 拆解AI工程的五大不可妥协层
很多团队尝试“AI Engineering from Scratch”时,第一步就栽在架构选型上——直接照搬大厂开源方案,比如用Airflow搭数据管道、用Feast做特征存储、用KServe部署模型。结果半年后发现:Airflow DAG里嵌套了12层PythonOperator,没人敢改;Feast的online store和offline store schema不一致,导致线上预测和离线训练特征值偏差0.7%;KServe的custom predictor模板里硬编码了模型路径,版本回滚要手动改yaml。问题根源在于,他们把“from scratch”误解成了“用开源组件拼乐高”,而非“理解每块乐高的承重极限和连接方式”。
真正的从零构建,必须先确立五个刚性分层,且每一层都需满足“可独立演进、可单独测试、可明确Owner”:
数据接入层(Data Ingestion Layer):职责是无损、有序、可追溯地把原始数据拉进系统。关键约束:不在此层做任何业务逻辑清洗(如去重、补缺),只做协议转换(JSON→Parquet)、格式校验(Schema Check)、基础元数据打标(source_timestamp, ingestion_id)。我见过最典型的错误,是把用户行为日志的session_id生成逻辑放在这一层——结果线上session切分规则变更,导致历史数据无法重跑。
特征计算层(Feature Computation Layer):这是AI工程最易失控的环节。它的核心契约是确定性(Determinism):相同输入数据、相同代码版本、相同配置参数,必须产出完全一致的特征向量。为此必须禁用所有非确定性操作:
random.shuffle()、time.time()、依赖系统时钟的ID生成、未固定seed的numpy/torch操作。我们曾因一个pandas.DataFrame.sample(frac=0.1)没设random_state,导致A/B测试组间特征分布偏移,花了三天定位。模型服务层(Model Serving Layer):目标不是“让模型跑起来”,而是“让模型像银行ATM一样可靠”。这意味着必须定义清晰的SLA:P99延迟≤200ms、错误率<0.1%、冷启动时间<30s。实现上,我们放弃通用框架,选择gRPC + Protobuf作为通信协议——因为Protobuf的强类型约束,能天然防止前后端字段错位(比如前端传
user_age:int32,后端误读为user_age:string);gRPC的流式接口,让实时特征计算(如用户最近5次点击间隔)能自然支持。实验治理层(Experiment Governance Layer):解决“谁在什么时候用了什么数据、什么代码、什么超参训练了什么模型”这个元问题。关键不是记录
accuracy=0.87,而是记录feature_version=v2.3.1, data_slice=20240501-20240507, code_commit=abc1234, hyperparam_lr=0.001。我们用SQLite本地存储实验元数据(轻量、ACID、无需运维),每条记录绑定一个SHA256哈希值,该哈希由上述所有要素拼接后计算——任何一环变动,哈希值必变,杜绝“我以为用的是v2.3.1,其实是v2.2.9”的事故。可观测性层(Observability Layer):不是简单埋点
model_latency_ms,而是构建三维监控:数据维度(输入特征分布漂移、缺失率突增)、模型维度(预测置信度下降、类别概率熵值异常)、系统维度(GPU显存使用率、请求队列堆积)。我们用Prometheus采集指标,但关键创新是:把特征统计(如age_mean,click_rate_std)也作为指标暴露,这样当age_mean从35.2骤降到28.1时,监控告警能直接关联到上游数据源变更。
这五层不是瀑布式流程,而是网状依赖:特征计算层的输出,同时喂给模型训练(离线)和服务(在线);实验治理层的数据,驱动可观测性层的基线设定。放弃这种分层思维,所谓“from scratch”只会变成“from chaos”。
2.2 为什么拒绝端到端框架:以TensorFlow Extended(TFX)为例的深度解剖
TFX常被当作“AI Engineering from Scratch”的标杆方案,但它恰恰是需要警惕的典型。官方文档宣称“一站式解决数据验证、特征工程、模型训练、服务部署”,听起来完美。但实操中,我们用TFX落地一个电商推荐模型时,遭遇了三重结构性陷阱:
第一重陷阱:组件耦合度远超宣传。TFX的ExampleGen组件看似只负责读取数据,实则隐式绑定了tf.data的解析逻辑。当我们想把用户行为日志(JSON格式)和商品库(MySQL表)做Join生成训练样本时,ExampleGen无法直接处理SQL Join,必须先用BigQuery或Spark预处理——但TFX Pipeline又不原生支持Spark算子。最终我们被迫在ExampleGen前加一层Custom Executor,用subprocess调用spark-submit,结果Pipeline的DAG可视化里出现了一个黑色方块,标注着“UNKNOWN_EXECUTOR”,运维完全无法监控其资源消耗。
第二重陷阱:版本控制形同虚设。TFX用pipeline_root目录存放所有中间产物,但pipeline_root本身没有版本号。当你运行tfx pipeline update时,它只是把新代码覆盖到旧目录,旧版本的Transform组件输出(即特征统计)仍留在磁盘,新训练任务却可能意外读取到旧统计——因为TFX默认按文件名匹配,而非按commit hash。我们曾因此上线一个模型,其归一化参数仍是三个月前的数据分布,导致新用户年龄特征被错误压缩,CTR预估偏差达40%。
第三重陷阱:可观测性深度不足。TFX的StatisticsGen能生成特征分布报告,但它只在Pipeline运行结束时生成一次快照。而真实线上场景需要:当user_click_count_7d的P95值连续3分钟低于阈值10,立即触发告警并冻结模型更新。TFX没有提供流式统计接口,我们必须在ModelServer层额外开发一套特征采样器,定时从gRPC请求中抽样,再送入自研的DriftDetector——这本质上是在TFX之上又建了一套系统。
所以我们的结论很明确:TFX不是“from scratch”的起点,而是“from scratch”完成后,用来封装某一层(如特征计算)的胶水工具。真正的从零构建,应该先手写一个极简的FeatureCalculator类,明确其输入(raw_data: dict)、输出(features: dict)、契约(idempotent=True, deterministic=True),再逐步为其添加单元测试、性能压测、监控埋点。等这个类稳定运行三个月,再考虑是否用TFX的Transform组件替代它——而不是反过来,用TFX倒逼工程规范。
2.3 架构决策背后的成本计算:人力、时间与风险的三角平衡
所有架构选择,本质都是在三个维度上做权衡:人力成本(需要多少人掌握该技术)、时间成本(从0到可用需要多久)、风险成本(出问题时的排查难度和影响范围)。我们用一张实际项目中的决策表来说明:
| 技术选型 | 人力成本(人/月) | 时间成本(天) | 风险成本(典型故障) | 我们的最终选择 | 决策理由 |
|---|---|---|---|---|---|
| 数据存储:Delta Lake | 3(需懂Spark+Rust) | 15 | 事务冲突导致数据重复写入 | ✅ 采用 | 虽然学习曲线陡,但ACID保障让数据团队敢做高频更新,避免了“不敢上线”的隐形成本 |
| 特征存储:Redis Cluster | 1(运维熟悉) | 3 | 大Key导致节点阻塞,线上服务雪崩 | ❌ 放弃 | Redis的内存模型无法支撑千万级用户实时特征,单次故障影响全域,风险成本过高 |
| 特征存储:自研Key-Value Store(基于RocksDB) | 2(1后端+1DBA) | 22 | RocksDB compaction期间CPU飙升 | ✅ 采用 | 风险可控(compaction可限速),且完全掌控schema演进,避免了第三方服务的黑盒风险 |
| 模型部署:Flask REST API | 0.5(Python Web通) | 1 | GIL锁导致并发瓶颈,QPS卡在200 | ❌ 放弃 | 时间成本最低,但风险成本最高——一旦流量突增,整个服务不可用,无降级方案 |
| 模型部署:Triton Inference Server | 2(需懂CUDA+ONNX) | 8 | GPU显存碎片化,新模型加载失败 | ✅ 采用 | 风险成本虽存在,但NVIDIA提供了完善的诊断工具(tritonserver --model-repository),且支持模型热更新 |
这张表的关键启示是:最低时间成本≠最优选择。Flask部署看似1天搞定,但它把风险转嫁给了业务方——当促销活动流量翻10倍时,算法同学要熬夜调优,而本该专注模型迭代的精力,全耗在修Web服务器上。真正的工程效率,是让每个人在自己专业领域内,用最熟悉的工具解决最核心的问题。所以我们的原则是:基础设施层(数据、存储、网络)宁可多花2周,也要选成熟可控的方案;应用层(模型、业务逻辑)可以快速试错,但必须有熔断和降级兜底。
3. 核心模块实现:手把手搭建可验证的特征计算引擎
3.1 特征计算引擎的设计哲学:从“函数”到“契约”
在开始写代码前,必须确立一个铁律:每个特征,必须是一个有明确契约的函数。这个契约包含四个要素:
- 输入契约(Input Contract):规定输入数据的结构、类型、允许的空值比例。例如
user_profile_feature的输入必须是{"user_id": str, "age": Optional[int], "city": str},且age字段缺失率不能超过5%。 - 计算契约(Computation Contract):声明计算过程的确定性、幂等性、时间复杂度。例如
7d_click_rate必须满足:O(n)时间复杂度、idempotent=True(多次执行结果相同)、deterministic=True(无随机性)。 - 输出契约(Output Contract):定义输出特征的名称、类型、取值范围、业务含义。例如
7d_click_rate输出为float,范围[0.0, 1.0],含义是“过去7天内,该用户点击广告次数占曝光次数的比例”。 - 演化契约(Evolution Contract):约定版本升级规则。例如
v1.0到v1.1只能增加新字段,不能修改现有字段类型或含义;v2.0可破坏性升级,但必须提供迁移脚本。
我们用Python的dataclass和pydantic来强制实施这些契约。以下是一个生产级UserClickRateFeature的完整实现:
from dataclasses import dataclass from typing import Dict, Optional, List, Any from pydantic import BaseModel, validator, Field import hashlib import time class FeatureInput(BaseModel): user_id: str click_events: List[Dict[str, Any]] = Field(default_factory=list) impression_events: List[Dict[str, Any]] = Field(default_factory=list) @validator('click_events', 'impression_events') def check_event_list(cls, v): if len(v) > 10000: raise ValueError('event list too long, max 10000') return v @dataclass class UserClickRateFeature: """ 计算用户7天点击率特征 Input Contract: click_events/impression_events 必须按timestamp升序排列 Computation Contract: deterministic=True, idempotent=True, O(n) Output Contract: click_rate: float in [0.0, 1.0] Evolution Contract: v1.x -> v1.y 兼容,v2.0 不兼容 """ version: str = "1.0" def __post_init__(self): # 强制校验契约 assert self.version == "1.0", "Only v1.0 supported" def compute(self, input_data: FeatureInput) -> Dict[str, float]: # 输入契约校验 if not isinstance(input_data, FeatureInput): raise TypeError("Input must be FeatureInput instance") # 计算契约:确定性实现 click_count = 0 impression_count = 0 now = int(time.time()) window_start = now - 7 * 24 * 3600 # 7天前时间戳 for event in input_data.click_events: if event.get('timestamp', 0) >= window_start: click_count += 1 for event in input_data.impression_events: if event.get('timestamp', 0) >= window_start: impression_count += 1 # 输出契约:确保范围 click_rate = 0.0 if impression_count == 0 else min(1.0, click_count / impression_count) return {"7d_click_rate": round(click_rate, 6)} def get_signature(self) -> str: """生成特征计算签名,用于版本控制和缓存""" # 签名包含:代码逻辑哈希 + 输入契约哈希 + 版本号 code_hash = hashlib.sha256( f"{self.compute.__code__.co_code}{self.version}".encode() ).hexdigest()[:16] input_hash = hashlib.sha256( str(FeatureInput.schema()).encode() ).hexdigest()[:16] return f"UCRF-{code_hash}-{input_hash}"这个实现看似简单,但每个细节都有深意:
FeatureInput用pydantic校验,确保输入结构合法,且@validator限制事件列表长度,防止单次计算耗尽内存;compute方法里没有random、time.time()等非确定性调用,window_start用固定时间戳而非datetime.now();get_signature()方法生成唯一哈希,该哈希将作为特征缓存的key——当代码或输入契约变更时,哈希必变,旧缓存自动失效,杜绝“脏缓存”问题。
提示:不要在
compute方法里做I/O操作(如读数据库、调API)。特征计算必须是纯函数,所有依赖数据应由上游模块(如数据接入层)提前注入。这是我们踩过最大的坑:曾有一个特征在compute里实时查询用户画像API,结果API抖动导致模型服务整体超时。
3.2 实现特征注册中心:让每个特征都有身份证
特征散落在各处(Jupyter Notebook、SQL脚本、Python模块),是AI工程混乱的根源。我们构建了一个极简但有效的特征注册中心(Feature Registry),它不是复杂的微服务,而是一个带版本控制的YAML文件+校验脚本:
# features/registry.yaml - name: "7d_click_rate" description: "用户过去7天点击率" owner: "recommendation-team" type: "numerical" range: "[0.0, 1.0]" source: "kafka://user_behavior_topic" computation: module: "features.user_click_rate" class: "UserClickRateFeature" version: "1.0" tags: ["realtime", "user"] created_at: "2024-05-01" - name: "item_price_category" description: "商品价格区间分类(1:低价, 2:中价, 3:高价)" owner: "pricing-team" type: "categorical" values: ["1", "2", "3"] source: "mysql://product_db.items" computation: module: "features.item_price" class: "ItemPriceCategoryFeature" version: "2.1" tags: ["batch", "item"] created_at: "2024-04-15"配套的校验脚本validate_registry.py会做三件事:
- 语法校验:检查YAML格式、必填字段(
name,description,computation); - 代码校验:动态导入
computation.module,确认类存在且可实例化; - 契约校验:调用
get_signature(),比对YAML中记录的signature字段(需人工更新)是否匹配。
# 运行校验 python validate_registry.py --registry features/registry.yaml # 输出: # ✅ 7d_click_rate: signature match (UCRF-ab12cd34-ef56gh78) # ✅ item_price_category: signature match (UCRF-ij90kl12-mn34op56) # 🚨 total_features: 2 valid, 0 invalid这个注册中心的价值,在于它把“特征”从代码片段,变成了可管理的资产。当算法同学说“我要用7d_click_rate”,工程师立刻能查到:它由谁维护、数据源在哪、计算逻辑在哪个文件、当前版本是否稳定。更重要的是,它为自动化铺平道路——我们的CI流水线会在每次PR提交时,自动运行validate_registry.py,如果校验失败,PR直接被拒绝合并。
3.3 构建特征一致性验证:离线与在线的同一把尺子
最大的信任危机,是离线训练和在线服务用的不是同一套特征。我们曾遇到:离线AUC=0.85,线上CTR提升却为负。最终发现,离线特征工程用pandas的fillna(0),而在线服务用numpy的np.nan_to_num(),对inf值的处理逻辑不同,导致少量样本特征值偏差。
解决方案是:为每个特征编写一致性测试(Consistency Test),在离线和在线两个环境分别运行,比对输出。测试框架核心代码如下:
# tests/consistency/test_user_click_rate.py import pytest from features.user_click_rate import UserClickRateFeature from feature_registry import load_registry def test_7d_click_rate_consistency(): # 准备相同输入数据(JSON格式,确保跨环境一致) test_input = { "user_id": "u123", "click_events": [ {"timestamp": 1714567800, "ad_id": "a001"}, {"timestamp": 1714567860, "ad_id": "a002"} ], "impression_events": [ {"timestamp": 1714567800, "ad_id": "a001"}, {"timestamp": 1714567830, "ad_id": "a002"}, {"timestamp": 1714567860, "ad_id": "a002"} ] } # 离线环境计算 offline_feature = UserClickRateFeature(version="1.0") offline_result = offline_feature.compute(FeatureInput(**test_input)) # 在线环境模拟(调用gRPC服务) import grpc import feature_pb2 import feature_pb2_grpc with grpc.insecure_channel('localhost:50051') as channel: stub = feature_pb2_grpc.FeatureServiceStub(channel) request = feature_pb2.FeatureRequest( feature_name="7d_click_rate", input_json=json.dumps(test_input) ) online_response = stub.Compute(request) online_result = json.loads(online_response.result) # 断言一致性(允许浮点数微小误差) assert abs(offline_result["7d_click_rate"] - online_result["7d_click_rate"]) < 1e-6 if __name__ == "__main__": pytest.main([__file__, "-v"])这个测试被集成到两个地方:
- 离线训练流水线:每次模型训练前,自动运行所有特征的一致性测试,失败则中断训练;
- 在线服务CI/CD:每次模型服务代码更新,先部署到测试环境,再运行一致性测试,通过才发布到生产。
注意:测试数据
test_input必须是真实脱敏数据,而非随机生成。我们建立了一个“黄金测试集”(Golden Test Set),包含100个覆盖边界条件的样本(如空事件列表、全inf值、超长列表),并定期用线上流量采样更新。随机数据永远无法暴露nan/inf处理差异这类幽灵bug。
4. 实操全流程:从本地开发到生产部署的七步法
4.1 步骤1:初始化项目骨架与契约校验
一切始于一个干净的Git仓库。我们不用Cookiecutter等模板,而是手写Makefile和pyproject.toml,因为模板会隐藏关键决策。初始化命令如下:
# 创建项目 mkdir ai-engineering-from-scratch && cd ai-engineering-from-scratch git init # 初始化Python环境(强制指定版本,避免pipenv/poetry的隐式升级) echo "3.9.16" > .python-version pyenv install 3.9.16 pyenv local 3.9.16 # 创建最小依赖 cat > pyproject.toml << 'EOF' [build-system] requires = ["setuptools>=45", "wheel"] build-backend = "setuptools.build_meta" [project] name = "ai-engineering-core" version = "0.1.0" dependencies = [ "pydantic>=1.10.0,<2.0.0", "numpy>=1.21.0", "protobuf>=3.20.0", ] EOF # 创建契约校验入口 cat > validate_contracts.py << 'EOF' #!/usr/bin/env python3 """ 特征契约校验器:检查所有feature模块是否符合设计契约 """ import importlib import sys from pathlib import Path def validate_feature_module(module_path: str): try: module = importlib.import_module(module_path) # 检查是否存在Feature类 if not hasattr(module, 'Feature'): raise AttributeError(f"No Feature class in {module_path}") # 检查Feature类是否有compute方法 feature_class = getattr(module, 'Feature') if not hasattr(feature_class, 'compute'): raise AttributeError(f"Feature class missing compute method: {module_path}") # 检查compute方法签名 import inspect sig = inspect.signature(feature_class.compute) if len(sig.parameters) != 2: # self + input_data raise ValueError(f"compute method signature invalid: {sig}") print(f"✅ {module_path}: valid") return True except Exception as e: print(f"❌ {module_path}: {e}") return False if __name__ == "__main__": success = True for feature_file in Path("features").rglob("*.py"): if feature_file.name != "__init__.py": module_path = f"features.{feature_file.stem}" if not validate_feature_module(module_path): success = False sys.exit(0 if success else 1) EOF chmod +x validate_contracts.py # 第一次提交 git add . && git commit -m "chore: init project skeleton with contract validation"这个初始化脚本的精妙之处在于:
.python-version强制锁定Python版本,避免团队成员因pyenv global设置不同导致环境差异;pyproject.toml不引入任何AI框架(PyTorch/TensorFlow),因为特征计算层应与模型无关;validate_contracts.py是首个可执行的契约校验器,它不验证业务逻辑,只验证“每个feature模块是否具备基本契约”——这是工程化的第一道防线。
4.2 步骤2:构建本地开发环境:Docker Compose一键启停
本地开发环境必须“开箱即用”,且与生产环境尽可能一致。我们用Docker Compose编排三个核心服务:
# docker-compose.yml version: '3.8' services: # 特征计算服务(gRPC) feature-service: build: ./services/feature_service ports: - "50051:50051" environment: - FEATURE_REGISTRY_PATH=/app/features/registry.yaml volumes: - ./features:/app/features:ro - ./proto:/app/proto:ro # 模型服务(Triton) model-service: image: nvcr.io/nvidia/tritonserver:23.04-py3 ports: - "8000:8000" - "8001:8001" - "8002:8002" volumes: - ./models:/models command: tritonserver --model-repository=/models --log-verbose=1 # 数据模拟器(生成测试数据) ># services/feature_service/Dockerfile FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "main.py"]main.py只做一件事:加载registry.yaml,启动gRPC服务。关键点在于,所有环境变量(如FEATURE_REGISTRY_PATH)都通过Docker Compose注入,而非硬编码在代码里。这样,当切换到Kubernetes生产环境时,只需修改configmap,代码零改动。
实操心得:本地环境务必包含
>- name: "7d_click_rate" description: "用户过去7天点击率" owner: "ai-engineering-team" type: "numerical" range: "[0.0, 1.0]" source: "kafka://user_behavior" computation: module: "features.user_click_rate" class: "UserClickRateFeature" version: "1.0" tags: ["realtime"] signature: "UCRF-ab12cd34-ef56gh78" # 由validate_registry.py生成然后编写
feature-service的gRPC接口(services/feature_service/main.py):import grpc import time import features.user_click_rate from concurrent import futures import feature_pb2 import feature_pb2_grpc from features import load_registry class FeatureService(feature_pb2_grpc.FeatureServiceServicer): def __init__(self): self.registry = load_registry("features/registry.yaml") def Compute(self, request, context): try: # 从注册中心获取特征配置 feature_config = self.registry.get_feature(request.feature_name) if not feature_config: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f"Feature {request.feature_name} not found") return feature_pb2.FeatureResponse() # 动态导入特征类 module = __import__(feature_config['computation']['module'], fromlist=['']) feature_class = getattr(module, feature_config['computation']['class']) feature_instance = feature_class(version=feature_config['computation']['version']) # 解析输入JSON import json input_data = json.loads(request.input_json) # 执行计算 result = feature_instance.compute(input_data) return feature_pb2.FeatureResponse(result=json.dumps(result)) except Exception as e: context.set_code(grpc.StatusCode.INTERNAL) context.set_details(f"Compute error: {str(e)}") return feature_pb2.FeatureResponse() def serve(): server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) feature_pb2_grpc.add_FeatureServiceServicer_to_server(FeatureService(), server) server.add_insecure_port('[::]:50051') server.start() print("Feature service started on port 50051") server.wait_for_termination() if __name__ == '__main__': serve()最后,运行端到端测试:
# 启动所有服务 docker-compose up -d # 发送gRPC请求(用grpcurl工具) grpcurl -plaintext -d '{"feature_name":"7d_click_rate","input_json":"{\"user_id\":\"u123\",\"click_events\":[{\"timestamp\":1714567800}],\"impression_events\":[{\"timestamp\":1714567800}]}"} localhost:50051 feature.FeatureService/Compute # 返回:{"result":"{\"7d_click_rate\":1.0}"}这一步成功,意味着你已打通“数据输入 → 特征计算 → gRPC响应”的全链路。此时,
feature-service就是一个可独立部署、可单独压测的服务,它不依赖任何AI框架,甚至不关心最终模型是什么。4.4 步骤4:模型服务集成:Triton的轻量级封装
模型服务层,我们选择NVIDIA Triton,但不做全量功能启用,而是聚焦三个核心能力:模型版本管理、并发控制、健康检查。
models/config.pbtxt配置如下:name: "recommendation_model" platform: "pytorch_libtorch" max_batch_size: 8 input [ { name: "user_features" data_type: TYPE_FP32 dims: [ 128 ] # 128维特征向量 } ] output [ { name: "prediction" data_type: TYPE_FP32 dims: [ 1 ] } ] instance_group [ { count: 2 kind: KIND_CPU } ]关键配置解读:
max_batch_size: 8:Triton会自动批处理请求,提升GPU利用率,但必须与特征服务的