1. 从零搭建AI工程链路:我为什么坚持“from scratch”
“ai-engineering-from-scratch”这个标题,乍看像是又一个“三天入门机器学习”的噱头。但真正做过AI项目落地的人会明白,这四个英文单词背后是一种很少有人愿意走的路:不依赖任何全托管平台,从环境、数据、模型训练到部署,每一层都用双手去搭一遍。我这几年带过不少AI项目,踩过的坑比写过的代码还多,今天就用一篇完整的实战记录,聊聊从零构建一套AI工程体系到底要经历什么,以及为什么我强烈建议你至少完整走一遍这条路。
先说清楚这套内容能帮你解决什么问题。如果你是刚入行的算法工程师、想要转型AI方向的后端开发、或者正在负责公司内部AI能力建设的技术负责人,你会发现市面上90%的教程都在教你怎么调库、怎么用别人封装好的API,却很少有人告诉你:模型精度上不去时怎么排查数据问题?训练好的模型怎么高效地部署成高可用的服务?线上效果衰减了该如何定位是数据漂移还是模型过期?这些恰恰是“AI工程”最核心的命题,也是从“能跑通”到“能落地”之间的巨大鸿沟。
我始终认为,不亲手搭一遍全链路,你对AI系统的理解永远停留在“调参侠”的层面。所以这篇文章我会以一套完整的实操为线索,把从零搭建AI工程的每个关键环节都拆开揉碎。工程领域有一句老话:你没法管理你无法测量的东西。这句话在AI工程里同样成立,甚至更加致命——因为比起传统软件工程,AI系统里多了一个完全不透明的黑盒子:模型本身。
这套经验的适用范围非常广。无论是做自然语言处理、计算机视觉、推荐系统还是时序预测,底层逻辑都是一致的:数据管线的可靠性、实验追踪的规范性、模型服务的高性能、监控告警的完整性。理解了这些共性,你在任何一个细分方向上都能快速搭建起一套属于自己的AI工程能力。下面我就按一条完整工程链路的推进顺序,逐个环节复盘。
2. 环境与基础设施:如何搭出一套可复现的AI开发地基
2.1 从Python环境到依赖锁定:每个包版本都是一次事故现场
几乎所有从零开始的AI项目,第一个翻车点都出在Python环境上。我见过太多团队,代码在A机器上跑得好好的,换到B机器上直接ImportError,最后发现是numpy版本不同导致二进制接口不兼容。这种问题最恶心的地方在于,它不会在你开发时爆发,而是在上线部署的那天准时引爆。
所以我的第一道防线是Python虚拟环境加依赖锁定。具体操作上,我会要求所有项目必须使用venv或conda创建独立环境,然后把关键版本号写死。这里有个细节很多人忽略:requirements.txt里如果不锁传递依赖的子版本,几天后重装环境就会得到一批不同的依赖树。我在实际项目中会生成一份requirements-lock.txt,用pip freeze冻结完整环境,配合pip-tools的pip-compile命令来维护这份文件。
# 创建独立环境 python3 -m venv .venv source .venv/bin/activate # 安装核心依赖 pip install numpy==1.24.3 pandas==2.0.3 scikit-learn==1.3.0 # 生成完整锁文件(包含所有传递依赖) pip freeze > requirements-lock.txtGPU驱动的坑也非常经典。有一次我们在一台新买的服务器上训练模型,torch.cuda.is_available()返回False,排查半天发现是CUDA驱动版本和PyTorch的CUDA运行时不匹配。从那以后,我会把CUDA版本、cuDNN版本、显卡驱动版本直接写在项目的README开头,任何新机器第一次配置环境,必须按照文档逐步核对。这套流程看起来笨拙,但真实项目中80%的环境问题都能靠文档和锁文件消解。
2.2 容器化:让“在我机器上能跑”这句话彻底成为历史
虚拟环境解决了Python层面的隔离,但解决不了操作系统、CUDA驱动这类系统级依赖的问题。容器化是我从零搭建AI工程时坚持的第二层隔离方案。Docker在这里不只是“部署用的工具”,它更是开发环境一致性的保证。
一个典型的AI项目Dockerfile,我不会把整个CUDA镜像直接拉下来用,而是基于nvidia/cuda官方镜像自己分层构建。这样做的原因是,完整CUDA镜像体积动辄几个GB,而很多模型的推理根本用不到全部组件。我常用的做法是把基础镜像、依赖安装、代码拷贝、模型下载分成四层,每一层单独做缓存:
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu20.04 WORKDIR /app # 先拷贝依赖文件,充分利用Docker缓存层 COPY requirements-lock.txt . RUN pip install --no-cache-dir -r requirements-lock.txt # 再拷贝代码,只有代码变化时才会触发后续层重建 COPY src/ ./src/ COPY models/ ./models/ CMD ["python", "src/serve.py"]这里有个非常实用的小技巧:如果模型文件很大,不要把模型直接打进镜像。镜像仓库通常有大小限制,而且每次模型更新都要重新build镜像,效率极低。我现在的做法是,只在镜像里放下载模型的脚本,启动时通过环境变量指定模型版本,从对象存储拉取。这样模型迭代完全不需要重建镜像,部署速度从“分钟级”降到“秒级”。
2.3 实验追踪:别让实验结果变成一场金鱼记忆
很多从零起步的AI项目,最大的浪费不是算力,而是重复实验。我有段时间做模型调优,上午试了一个学习率,觉得效果还行;下午换了个batch size,再调回来的时候已经不记得上午那组参数是不是真的“效果还行”。直到我把所有实验都记下来,才发现所谓的“还行”和“更好”之间,差距往往只在1%以内的指标波动。
工具层面我强烈推荐MLflow或者Weights & Biases。我自己用得最多的是MLflow,因为它够轻、够简单,而且支持本地部署,不需要把任何训练数据传到第三方平台。每个实验自动记录的内容包括:代码版本(Git commit ID)、超参数、训练指标曲线、模型产物。这些元数据的价值,在你训练了上百个实验版本之后才能真正体会。
# 启动MLflow服务 mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri sqlite:///mlflow.db # 训练脚本中记录参数和指标 import mlflow with mlflow.start_run(): mlflow.log_param("learning_rate", 0.001) mlflow.log_param("batch_size", 32) mlflow.log_metric("val_accuracy", 0.876) mlflow.log_artifact("model.pt")搭建这套体系之后的第一个明显变化是,团队里每个人都能在实验记录里看到所有历史版本的效果对比,再也不会出现“我上次跑出来比你好,但忘了用的什么参数”这种荒唐事。神经网络这种极度依赖调参的工作,如果连历史都不能精确回放,本质上就是在原地打转。
3. 数据管线和特征工程:AI项目的重资产环节
3.1 数据获取与校验:垃圾进,垃圾出的第一道闸门
我在项目复盘时发现,后期花在修数据上的时间远超过训练模型的时间。训练集里出现了空值、重复样本、标签错乱、字段类型漂移,这些问题在模型开发阶段往往无感知,但会直接反映到线上效果上。所以从零搭建AI工程时,数据校验应该放在pipeline的最前面,而不是等模型出了问题再回头查数据。
我一般会在数据管线的入口加一层校验规则,用pydantic或者Great Expectations来做。最基础的检查必须包括:必填字段是否存在、字段类型是否符合预期、值域是否在合理范围、分类特征是否有未知类别。举个例子,如果特征是用户的年龄段,那值域必须是合理的整数区间;如果出现了-1这种占位符,就得在清洗阶段专门处理,而不是靠模型自己学。
from pydantic import BaseModel, Field class UserFeature(BaseModel): age: int = Field(ge=0, le=120) income: float = Field(ge=0) city: str = Field(min_length=2, max_length=64) is_active: bool这套校验逻辑的另外一个好处是,它能在数据分布发生变化时快速报警。线上数据一旦出现大量校验失败,说明数据源或者埋点逻辑变了,这种问题越早发现损失越小。我在生产环境里会把校验失败率作为一个核心监控指标,阈值超过5%就直接触发告警。
3.2 特征存储:为什么线上推理和线下训练必须特征一致
特征不一致是AI工程里最隐蔽的坑之一。训练的时候用的特征是从全量历史数据算出来的,部署的时候模型接收到的特征是从线上实时请求里抽的,如果两边算的不是一回事,模型效果绝对会崩。我在一个招聘推荐项目上吃过一次大亏:训练时用“用户近30天的浏览职位数”,线上却用“用户近7天的浏览职位数”,结果模型上线后准确率掉了十几个百分点,花了整整一周才定位到问题。
解决这个问题的标准做法,是把特征计算固化成一个独立的特征服务。训练时管线下线算一批特征写入离线存储,线上推理时调用同一套特征服务实时取数。关键就在于两个场景共用同一份特征计算代码和同一个特征定义,而不是各写一套。
特征存储的选型方面,规模不大时用Redis也够,QPS高或者特征维度很大时,我推荐用Feast这类开源特征存储框架。它本质上做了一件事:把特征定义、特征来源、特征服务接口统一管理起来,避免散落在各个业务代码里。
3.3 数据切片分析:精度指标之下的隐性问题
很多项目只看整体准确率或者AUC,觉得指标还行就急着上线。但这类单一指标很会骗人。有一次我们做一个文本分类模型,整体准确率92%,看起来相当漂亮。结果把测试集按文本长度切片一看,短文本准确率只有61%,长文本却高达95%。原来模型根本就是在偷懒,对短文本一律预测高频类别,反正这类样本占比大,把整体指标拉起来了。
从那以后,我的数据分析环节必做切片测试,而且切片维度要结合业务场景来定。对于分类任务,我会看:每个类别的精确率和召回率、按文本长度分桶的效果、按来源渠道分桶的效果、按时间窗口分布的效果。只有所有这些切片都达到预期,模型才敢放上线。
做切片分析的时候,混淆矩阵要按切片维度分开打印,不要把所有样本混在一起看。我曾经用sklearn.metrics.classification_report打印过一次,觉得结果很好,后来发现这个报告的准确率是全量样本的,根本看不出模型在特定人群上的糟糕表现。
4. 模型训练与开发:重现性比高性能更重要
4.1 基线模型:不要在垃圾堆上盖一座精装房
到这一步,很多新手会直接上BERT、上GPT、上各种超大模型。我每次都要拉住团队:先花半天时间搭一个基线,再谈花哨的东西。这个基线可以很简单,逻辑回归也好,浅层GBDT也好,它的意义不是刷新SOTA,而是给你一个“最低下限”的参照。
有了基线之后,每一次模型升级都要回答一个问题:比基线好多少?如果复杂模型只比简单模型提升了0.5%个点,那这个提升能不能抵消模型复杂度带来的部署成本和维护成本?这种事必须用数据说话。我在很多项目里发现,逻辑回归加好的特征工程,在实际业务中完全不输后来那些花了大价钱训练的深度模型。复杂不是目的,有效才是。
4.2 训练脚本的工程化:一键复现是底线
模型训练代码要能一键复现。这句话说起来容易,做起来却很难。我们组里训练的代码要求必须具备以下几个能力:设置随机种子、记录精确的数据集版本、记录代码版本、记录超参数、输出完整指标报告。任何一条缺失,实验结果不可重放,这种实验的价值就要打折扣。
import random import numpy as np import torch def set_seed(seed: int = 42): """统一设置随机种子,保证实验可复现""" random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) # 训练入口 set_seed(args.seed)随机种子这个细节看着不起眼,但影响很大。神经网络初始化、数据加载顺序(DataLoader的shuffle)、Dropout等操作都依赖随机数,只要有一处随机没控制住,同一份代码、同一份数据,两次训练出来的结果可能差好几个点。这给调优带来的困惑是灾难性的——你以为改了一个参数产生了效果,实际上只是随机波动。
4.3 训练管线的分层:数据预处理、训练、评估解耦
我把训练系统拆成三个独立模块:数据准备模块、训练模块、评估模块。每个模块通过中间产物交互,而不是在一个脚本里从头跑到尾。这样做的理由是,三个模块的迭代频率不同、资源需求不同,混在一起会让代码急剧膨胀。
数据准备模块输出经过清洗和特征工程的训练集/验证集/测试集,落盘为parquet或tfrecord格式。训练模块只读取准备好的数据,负责模型训练和checkpoint保存。评估模块加载checkpoint,输出完整评估报告。这个结构的最大好处是,任何一步出问题,都能明确锁死在某一层,而不需要把整个流程跑一遍才知道哪里挂了。
有一个细节我必须提醒:验证集和测试集要在数据准备阶段一次性切好,存成固定版本。如果每次都重新切,验证集会逐渐“泄漏”到训练过程中,导致最终评估结果虚高。我在实践里会用时间戳或者业务ID做分层采样,确保时间分布合理,避免未来数据泄漏到训练集里。
4.4 GPU训练和分布式训练:从单卡到多卡的第一个门槛
单卡训练到后期会撞上两个问题:显存不够、速度太慢。先解决显存,再解决并行策略。显存不够时,最直接的做法是减小batch size,但batch size太小会导致batchnorm统计不稳定,训练效果变差。我一般会开启混合精度训练,用torch.cuda.amp,既能省显存又能提速。
多卡训练是另一个深坑。DataParallel用起来方便,但性能瓶颈非常明显。我强烈建议直接上DistributedDataParallel,虽然初期配置麻烦一点,但扩展性和性能都远优于前者。最需要注意的问题是所有进程的数据加载要对齐,即每个进程拿到不同的数据分片,确保没有重复抽样。用DistributedSampler加上固定的seed,每个进程的shuffle就会不一致,这一步做错会让分布式训练结果比单卡还差。
5. 模型评估:别让指标选择毁掉你的AI产品
5.1 离线评估的指标体系:从单一指标到多维度衡量
我见到太多团队“唯AUC论”,AUC高就开心,AUC低就调参,完全无视业务目标。如果你是做推荐系统的,点击率预估的AUC再高,用户不转化又有什么意义?所以我的做法是,每次模型评估都拉一张完整的指标体系表,包括离线指标和业务模拟指标。
对于分类模型,最基本的要覆盖准确率、精确率、召回率、F1分数、AUC-ROC。但更重要的是业务侧的自定义指标。比如做反欺诈,关注的不是准确率,而是“在保住了95%正常用户的前提下,能捞出多少欺诈用户”——这是一个典型的带约束的最优化问题。这些指标的定义必须在项目开始之前就和业务方对齐,否则模型做完了都不知道评分的标准是什么。
5.2 线上回放和影子测试:上线前试一次真刀真枪的痕迹
离线跑分无论多好,都和线上真实环境有差距。我第一次做上线评估的时候,上线后效果和离线差了整整8个点,找了一圈才发现是线上特征缺失率远高于离线测试集。从那以后,我坚持要求每次发版前做“线上回放”:把过去几天生产的真实请求数据重新灌入新模型,对比新旧模型的输出分布和效果。
更进阶的做法是影子测试,也叫暗发布。新模型部署在边上,同样接收线上请求,但结果不直接使用,只是记录下来与生产模型做对比。积累一段时间后,可以在离线评估和影子数据对齐的基础上,再决定要不要全量切换。这套机制能在不伤害线上业务的前提下,近乎零风险地评估模型效果。
5.3 线上线下不一致的系统性排查清单
线上和线下效果不一致,原因通常集中在几个固定环节:
- 特征计算链路不一致:训练时用的特征服务版本和线上不一致
- 样本选择不一致:训练用的是历史数据,线上遇到的是实时数据,分布已有漂移
- 数据缺失处理不一致:训练时填充均值,线上却是默认值
- 模型输入形式不一致:训练时padding的方式和线上推理时的方式不同
- 后处理逻辑不一致:训练集里标签是规则算出来的,线上却走了另一个规则
说实话,我在复盘多个项目的过程中发现,80%的线上效果滑坡都是上面这几条中的某一条。所以我建议每次上线前,直接拿着这张清单逐项核对,能省掉大量排查时间。
6. 部署与服务化:模型只是AI系统的起点
6.1 模型转换和优化:从训练权重到可推理的格式
模型训练好后不是直接丢给Flask或者FastAPI就完事了,尤其当你要上生产环境的时候,必须先考虑推理效率。PyTorch的模型需要先转换成TorchScript或者ONNX格式。ONNX的好处是跨框架通用,模型可以很方便地被ONNX Runtime加载,推理速度比纯Python调用要快很多。
转换过程中最容易踩的坑是动态形状问题。如果模型输入序列长度不固定,在ONNX导出时需要显式指定动态轴的维度。我导出之前的文本模型时,因为忘了指定sequence length为动态维度,导致线上模型只能处理固定长度的文本,长度一超直接报错。
# ONNX导出示例 import torch import torch.onnx dummy_input = torch.randn(1, 128, dtype=torch.long) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input_ids"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch_size", 1: "seq_len"}, "logits": {0: "batch_size", 1: "seq_len"}, }, opset_version=14 )6.2 推理服务的高性能设计:批处理是吞吐提升的第一功臣
推理服务部署最核心的性能指标是吞吐量和延迟的平衡。单独一条请求喂给模型,延迟很低但吞吐很差;一次喂大量请求,吞吐上去了但单条延迟又会飙升。我的做法是实现动态批处理,也叫continuous batching。简单来说,服务端先把请求缓存到队列里,每隔一小段时间或者攒够固定数量,再一次性喂给模型推理。
工程实现上,我用FastAPI配合一个异步队列。请求进来到队列等待,推理进程从队列取数据,凑满一个batch后执行forward,再返回结果。这里面要考虑的还是格式对齐问题——动态batch的输入必须padding到当前batch内的最大长度,输出再按原长度截断。如果不做截断,会白白增加计算量甚至导致输出错位。
6.3 模型版本管理和灰度发布:回滚是永远的底牌
模型服务和普通web服务一个很大差异在于:模型是“统计性”的,任何一次替换都可能带来不可预知的效果变化。所以我从来不允许直接全量替换生产模型,最少也要走“金丝雀发布”。先让新模型承担5%的流量,观察指标无异常,逐步扩大到20%、50%、100%。
模型版本管理我使用MLflow Model Registry,每个注册的模型会有一个唯一的版本号,并标注当前状态(Staging、Production、Archived)。服务端启动时通过环境变量指定加载哪个版本,这样发布和回滚都只需要改环境变量再重启进程,速度非常快。
7. 监控、告警与持续迭代:AI系统的下半场
7.1 模型监控的核心指标:不只是看精度掉没掉
模型上线不是终点,而是运维的起点。传统软件只要代码不出bug,行为就是稳定的;模型不一样,外部世界一直在变,昨天还表现完美的模型今天可能就开始犯错。这种会“缓慢腐烂”的特性,让AI工程的监控环节显得尤为重要。
我在监控面板上固定展示四组指标:服务性能指标(请求量、P99延迟、错误率)、数据质量指标(缺失率、校验失败率)、模型输出分布指标(输出分数均值、离散度、类别分布)以及业务结果指标(转化率、点击率等)。组合监控的意义在于,这四组指标可以互相印证。比如第一组指标正常,但第二组指标飙高,说明数据源在变;第二组正常,第三组指标异常,大概率是模型需要重训的预警。
7.2 数据漂移检测的具体做法:别等模型崩了才发现
数据漂移是模型效果衰减的最常见原因。具体检测方法,我一般用两种:PSI(Population Stability Index)和KS检验。PSI计算的是模型输入特征分布在某个时间窗口内和训练窗口内的差异程度,一般超过0.2就认为显著漂移,需要人工介入。
def calculate_psi(expected, actual, bins=10): """计算PSI指标,衡量两个分布的差异""" expected, _ = np.histogram(expected, bins=bins) actual, _ = np.histogram(actual, bins=bins) expected = expected / expected.sum() actual = actual / actual.sum() psi = 0 for e, a in zip(expected, actual): e = max(e, 1e-6) a = max(a, 1e-6) psi += (e - a) * np.log(e / a) return psi漂移检测定时跑,每天一次就够了,不建议实时跑。理由是最小粒度上实时流的数据分布波动大,容易频繁误报,真正有意义的还是天级别的趋势变化。报警之后的操作流程我团队已有规范:先确认是那一组特征漂移了,再排查这个特征对应的业务环节是否有变化,最后才决定是不是需要重新采集数据、重新训练。
7.3 CI/CD与自动重训:数据、代码、模型三条流水线的协作
成熟的AI工程链路,最终会演化成三套自动化的流水线:数据流水线、训练流水线、部署流水线。数据流水线负责定时更新训练数据,训练流水线监听到新数据就启动训练,部署流水线在新模型评估通过后自动推送到线上。
我用的是GitHub Actions或GitLab CI来编排这三套流程。代码仓库的main分支一旦有新的提交,CI会自动跑一轮训练和评估。评估结果如果超过当前生产模型的指标,就会生成一个新模型版本并自动提交到Model Registry。在这套体系下,人工干预的点被压缩到了只有两个:模型注册时的审批和发布流量的控制。
这套体系跑通之后,“重训模型”这个动作的成本几乎变为零。新数据一到,模型自动迭代,评估自动把关,发布自动完成。团队的时间被释放出来做一些算法研究、特征探索这类真正创造价值的事情。
7.4 成本治理:算力账单也是AI工程的一部分
AI项目跑到后期,GPU机器的账单往往会让人心跳加速。算力成本管控是我强烈建议在工程体系一开始就要考虑的问题。GPU实例在闲置的时候照样计费,非常浪费。我会对所有训练任务做定期清理和生命周期管理。长期不用的开发机直接释放,训练任务加大对checkpoint频率的控制,避免每次意外中断都要从头再跑。
更实用的一点是,推理服务容量不要一次性拉满,用Kubernetes的HPA配置一个合理的伸缩策略,低峰期自动缩容。我在实际项目里做过一次成本自查,发现那台24小时跑着的GPU推理节点,真实负载高峰期一天只有4个小时。做了弹性伸缩之后,GPU账单降了将近一半。
8. 实战问题排查:从零搭建AI工程那些年踩过的坑
8.1 训练Loss不下降的排查顺序
模型训练时Loss不降反升,或者直接NaN,这是最让人头皮发麻的情况。我列一个排查顺序表,按顺序检查基本能定位90%以上的问题:
| 检查项 | 排查方法 | 常见原因 |
|---|---|---|
| 数据标签 | 单独抽几条数据人工检查 | 标签错乱、类别不平衡极端 |
| 学习率 | 从1e-4开始逐步降低 | 学习率过大导致震荡 |
| 输入数据 | 检查是否有NaN或inf值 | 特征中包含脏数据 |
| 模型初始化 | 检查最后几层参数范围 | 初始化方差过大 |
| 梯度 | 打印梯度范数 | 梯度爆炸或梯度消失 |
8.2 推理延迟突增的定位手段
排查线上推理延迟问题,我的习惯是先看服务链路拆解:请求进来后的排队时间、数据预处理时间、模型推理时间、后处理时间、网络传输时间。这五个环节分开埋点,分别统计P50、P95和P99。
有一次我们线上延迟从50ms飙到300ms,全组人都在怀疑是不是模型推理太慢。查了链路才知道,耗时大头根本不在模型上,而在数据预处理环节——一个特征工程函数里嵌套了多层循环,数据量一涨复杂度爆炸。所以排查问题时,一定先用数据说话,别凭感觉。
8.3 模型上线后效果差的紧急回滚方案
模型效果不符合预期时,回滚是第一优先级。我建议在任何新模型上线时,服务端保留加载旧模型的能力,并且发布脚本里必须包含一键回滚的操作。不要搞那种需要改代码才能回滚的方案,生产环境里每一秒钟的迟疑都意味着业务损失。
我个人的习惯是用环境变量控制模型版本,比如MODEL_VERSION=v2.1.0,新版本出问题直接把环境变量改回v2.0.0然后重启。这个操作30秒内就能完成,是所有后续排查工作的前提。
9. 最后分享一点个人体会
从零搭建AI工程,说到底是搭一套对抗复杂度的体系。算法再精巧,如果数据是不可信的,训练是不可复现的,部署是不可回滚的,监控是缺失的,那整套系统依然是沙上之塔。我在这个过程中最有价值的收益,其实不是某一次模型上线效果提升了多少,而是团队沉淀出了一套稳定的做事方法:任何一次改动都能被追踪,任何一个问题都能被定位,任何一次发布都能被回滚。这套方法论放之任何一个技术团队都适用。
如果你的项目正在从“实验”走向“产品”,我建议你也尝试把每个环节都亲手写一遍、搭一遍、踩一遍坑。这个过程会很痛苦,但完成之后,你收获的将不仅仅是一个能跑的模型,而是一整套能长期演进的AI工程能力。这套能力才是真正稀缺的东西。