聊聊我在小红书做推荐算法时踩过的性能优化坑

云计算Code
2026-07-22 17:54
阅读 322

凌晨两点半,上海张江的夜风从窗户缝里钻进来,吹得我后脖颈有点凉。我裹了裹那件洗得发白的公司文化衫,盯着屏幕上跳动的监控曲线,灌了一口已经凉透的瑞幸。

这是我来小红书的第二年,推荐算法组的。租的房子离公司骑车十分钟,当初选这里就是图个方便——毕竟搞算法的,谁还没经历过半夜被oncall叫醒看线上指标的日子呢。比起通勤一小时挤地铁,我宁愿把时间花在写代码上,深夜的效率是真的比白天高太多了,整个楼层就剩我和保安大叔,安静得能听见服务器风扇的嗡嗡声。

今天想聊的,不是什么高大上的模型架构,而是这一年多来我在性能优化上踩过的坑、做过的一些技术探索。说实话,这些东西在论文里看不到,在技术大会上也很少有人讲,但恰恰是每天真实在发生的事。

从一次线上事故说起

去年双十一大促前夕,大概是11月8号,晚上十点多,我正窝在家里用Bolt.new搭一个个人项目的原型——对,就是那个最近很火的AI全栈开发工具。突然钉钉疯狂弹消息,值班群里炸了。

"推荐接口P99延迟飙到800ms了!"

"用户侧反馈刷笔记卡顿!"

我当时脑子嗡的一下,赶紧打开电脑连VPN。查了一圈发现,是我们新上的一个内容理解模块,在处理用户实时行为序列的时候,因为特征拼接的逻辑写得有问题,导致每次请求都要做一次全量的序列重组。平时流量低的时候不明显,大促流量一上来,直接就把CPU打满了。

那个晚上我改到凌晨四点,临时把特征计算逻辑从请求链路里拆出来,改成异步预计算+缓存的方式,才把延迟压回200ms以内。

第二天复盘会上,leader没骂我,但说了一句让我印象很深的话:"性能优化不是出了事才做的事,它应该是一种习惯。"

从那以后,我就开始系统性地梳理我们推荐链路上的性能瓶颈,也顺便研究了一波业界的做法。这篇文章,就是想把这些东西整理出来,跟大伙儿聊聊。

推荐系统的性能瓶颈到底在哪

很多人觉得推荐系统嘛,核心就是模型,把AUC搞上去就完事了。但实际工程中,模型推理可能只占整个链路耗时的20%-30%,剩下的时间全花在了数据准备、特征工程、结果排序、后处理这些环节上。

我们当时的链路大概是这样的:

用户请求 → 召回(多路) → 粗排 → 精排 → 重排 → 结果返回

每一路都有性能问题,但最要命的是精排阶段。精排模型用的是一个多目标融合的DIN变体,特征维度大概有3000多维,其中用户行为序列特征占了大头。问题就出在这个行为序列上——用户的笔记浏览历史、点赞历史、收藏历史,这些序列长度是不固定的,有的活跃用户序列能到上千条。

在请求进来的时候,我们需要实时去Redis里拉这些序列数据,然后在内存里做特征拼接、padding、masking,最后才送进模型。这个过程,在序列比较长的时候,耗时能到100ms以上。

100ms什么概念?我们整个精排的预算是150ms,光特征处理就吃掉了一大半,留给模型推理的时间就很紧张了。

特征工程的性能优化:从实时到预计算

第一个动刀的地方就是特征工程。

思路其实不复杂:把能提前算的东西提前算好,别等到请求来了再算。但说起来容易,做起来全是坑。

用户行为序列的预计算

我们做了一个离线任务,每隔5分钟跑一次,把每个活跃用户的行为序列提前拼接好,存成protobuf格式扔进Redis。请求来的时候,直接拿过来用,省掉了实时拼接的开销。

这里有个细节要注意:用户的行为是持续产生的,如果只靠离线任务,会有最多5分钟的数据延迟。对于推荐系统来说,5分钟可能意味着用户刚点赞了一个猫咪视频,但推荐流里还没反映出这个偏好变化。

我们的解法是做了一层增量更新:用户每次产生新行为,会通过消息队列触发一个轻量级的更新任务,只更新增量部分。这样既保证了时效性,又避免了全量重算的开销。

# 增量更新的核心逻辑
class BehaviorSequenceUpdater:
    def __init__(self, redis_client, max_seq_len=500):
        self.redis = redis_client
        self.max_seq_len = max_seq_len
    
    def append_behavior(self, user_id, behavior_item):
        """追加一条新行为到用户序列"""
        cache_key = f"user_seq:{user_id}"
        
        # 从缓存读取当前序列
        current_seq = self.redis.get(cache_key)
        if current_seq:
            seq = SequenceProto.FromString(current_seq)
        else:
            seq = SequenceProto()
        
        # 追加新行为
        seq.items.append(behavior_item)
        
        # 截断到最大长度
        if len(seq.items) > self.max_seq_len:
            seq.items[:] = seq.items[-self.max_seq_len:]
        
        # 写回缓存,设置过期时间
        self.redis.setex(cache_key, 7200, seq.SerializeToString())

这个改动上线后,特征处理阶段的耗时从平均85ms降到了12ms,效果还是很明显的。

特征哈希的优化

另一个耗时点在于特征哈希。3000多维特征,每维都要做hash映射到embedding table的索引。我们之前用的是Python原生的hash函数,在大规模特征下性能很差。

后来换成了murmurhash3,配合C++写的扩展模块,直接通过pybind11暴露给Python调用。这个改动比较trivial,但效果立竿见影:

优化项 优化前 优化后 提升
特征哈希耗时 35ms 4ms 8.7x
特征拼接耗时 50ms 8ms 6.2x
总体特征处理 85ms 12ms 7x

模型推理优化:TensorRT的坑与收获

特征处理优化完之后,下一个大头就是模型推理了。

我们用的是PyTorch训练的模型,线上部署用的是TorchScript。但TorchScript的性能说实话一般,特别是对于动态shape的输入(我们的行为序列长度就是动态的),优化空间很有限。

于是我开始调研TensorRT。这玩意是NVIDIA出的推理加速引擎,理论上能把推理速度提升2-5倍。但实际用起来,坑是真的多。

动态shape的支持

TensorRT对动态shape的支持一直是个痛点。我们的模型输入里,行为序列的长度是变化的,这就意味着input shape是动态的。在TensorRT 8.5之前,处理这种情况需要手动设置optimization profile,指定min/opt/max shape,然后在不同shape区间内做优化。

import tensorrt as trt

# 创建Builder和Network
builder = trt.Builder(TRT_LOGGER)
network = builder.create_network(
    1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
)

# 配置动态shape
config = builder.create_builder_config()
profile = builder.create_optimization_profile()

# 用户行为序列输入,batch_size=1-256, seq_len=1-500
profile.set_shape(
    "user_behavior_seq",
    min=(1, 1, 128),      # 最小shape
    opt=(64, 100, 128),   # 最优shape(最常见的情况)
    max=(256, 500, 128)   # 最大shape
)
config.add_optimization_profile(profile)

# 构建engine
engine = builder.build_engine(network, config)

这里有个经验:opt shape的设置很重要,要尽量贴近真实的请求分布。我们统计了线上数据,发现80%的请求序列长度在50-150之间,所以opt设的是100。如果opt设得太偏,实际推理性能会打折扣。

算子兼容性问题

另一个大坑是算子兼容性。我们模型里用了一些自定义的attention变体,TensorRT不支持,需要自己写plugin。写CUDA plugin这件事,说实话,对于算法工程师来说有点超纲了。

当时的解决方案是:把不支持的算子替换成等价的、TensorRT支持的标准算子组合。比如我们有一个custom softmax,用了一些trick来加速,但TensorRT不支持。最后改成用标准的softmax + 一些前置的数学变换来等价实现,精度损失在0.01%以内,但能跑TensorRT了。

最终模型推理的耗时从原来的95ms(TorchScript)降到了28ms(TensorRT FP16),提升还是很可观的。不过整个过程花了大概三周,中间有好几个晚上改CUDA代码改到怀疑人生。

召回阶段的优化:向量检索的那些事

召回阶段我们用的是多路召回,其中向量召回是重要的一路。用的是双塔模型,用户侧和物品侧分别编码成向量,然后用ANN(近似最近邻)检索。

向量检索用的是Faiss,但线上部署的时候发现,当索引规模到了亿级别,单机Faiss的内存占用和检索延迟都不太理想。

索引分片与量化

我们把索引做了分片,分布在多台机器上,每台机器负责一部分向量。检索的时候并行查所有分片,然后merge结果。

同时用了IVF-PQ(倒排文件+乘积量化)来压缩向量存储。128维的float32向量,原始大小是512字节,PQ16压缩后只有16字节,内存直接省了32倍。精度上有一些损失,但在召回阶段是可以接受的——反正后面还有精排来兜底。

import faiss

# 构建IVF-PQ索引
dim = 128
nlist = 4096  # 聚类中心数
m = 16        # PQ子空间数
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8)

# 训练索引
index.train(training_vectors)
index.add(all_vectors)

# 设置nprobe,控制精度和性能的trade-off
index.nprobe = 64  # 搜索时检查的聚类中心数

nprobe的设置也是个学问。nprobe越大,召回率越高,但延迟也越大。我们做了个实验,画了nprobe和召回率、延迟的关系曲线,最后选了64,在这个点上召回率已经接近饱和,再增大nprobe收益很小但延迟还在涨。

nprobe Recall@100 延迟(ms)
16 78.3% 3.2
32 85.1% 5.8
64 89.7% 10.2
128 91.2% 18.5
256 91.8% 35.1

聊聊最近关注的一些新技术

说到技术探索,最近半年AI领域出了不少让人兴奋的东西,虽然跟我日常做的推荐算法不是直接相关,但有些思路是相通的,值得聊聊。

Sora带来的思考

Sora刚出来的时候,整个技术圈都炸了。作为一个搞算法的,我第一反应不是"卧槽好牛",而是"这玩意背后的工程架构得有多复杂"。

后来仔细看了OpenAI的技术报告,发现他们在视频生成的效率优化上做了很多工作。比如DiT(Diffusion Transformer)架构的选择,相比传统的U-Net,Transformer在并行计算上更有优势,更适合大规模GPU集群的分布式训练。

这其实跟我们推荐系统的优化思路是类似的:选择更适合并行化的架构,才能在大规模场景下把性能做上去。我们后来在精排模型上也尝试了一些Transformer化的改造,把原来串行的特征交叉逻辑改成并行的self-attention,推理效率提升了不少。

v0和Bolt.new:AI辅助开发的新范式

说到开发效率,最近用的两个工具让我感触挺深的。

v0是Vercor出的AI UI生成工具,给它一段描述,它能直接生成React组件代码。说实话,刚开始我觉得这就是个玩具,但用了几次之后发现,对于一些后台管理页面的原型搭建,效率确实高得离谱。

Bolt.new更猛,直接在浏览器里跑一个完整的开发环境,能生成全栈应用。上周五晚上我用它搭了一个数据可视化的dashboard,从描述需求到看到能跑的页面,大概花了20分钟。以前这种活我至少得搞半天。

当然,这些工具生成的代码质量嘛……emmm,只能说能跑。但作为一个快速验证想法的工具,已经非常够用了。

Prompt工程:被低估的技术能力

最后聊聊Prompt工程。很多人觉得写prompt就是"跟ChatGPT聊天",没什么技术含量。但实际工作中,我发现prompt设计的好坏,直接影响了很多环节的效率。

比如我们在做内容理解的时候,需要用LLM来给笔记打标签。一开始prompt写得比较随意,准确率只有70%出头。后来我花了两天时间系统地优化prompt,包括:

  • 明确角色定义和输出格式
  • 加入few-shot examples
  • 用chain-of-thought引导推理过程
  • 针对不同品类的内容设计不同的prompt模板

优化之后准确率提到了91%,而且输出格式的稳定性也好了很多,下游解析不容易出错了。

# 优化前的prompt
"给这篇笔记打个标签"

# 优化后的prompt
"""
你是一个小红书内容分类专家。请根据以下笔记内容,从给定的标签体系中
选择最合适的1-3个标签。

标签体系:
- 一级分类:美妆、穿搭、美食、旅行、家居、母婴、健身、数码、其他
- 二级分类:(根据一级分类展开,此处省略)

要求:
1. 先分析笔记的核心内容和用户意图
2. 然后从标签体系中选择最匹配的标签
3. 输出格式:{"一级分类": "xxx", "二级分类": ["xxx", "xxx"], "置信度": 0.95}

示例:
笔记内容:"今天分享一个超简单的家常菜做法,10分钟搞定!"
输出:{"一级分类": "美食", "二级分类": ["家常菜", "快手菜"], "置信度": 0.92}

现在请对以下笔记进行分类:
{note_content}
"""

这件事让我意识到,prompt工程其实是一种"接口设计"能力。你怎么定义输入输出的规范,怎么约束模型的行为,怎么在准确率和灵活性之间做trade-off,这些都是实打实的技术活。

性能优化的一些心得

做了这么多优化,总结几条个人心得,不一定对,但都是自己踩过坑之后悟出来的:

1. 先测量,再优化

别凭直觉猜哪里慢。上profiler,看火焰图,看链路追踪。我们之前有个case,团队里一个同学花了一周优化特征计算逻辑,结果上线一看,整体延迟只降了2ms。后来一查,真正的瓶颈在下游的一个序列化步骤上。白干了一周。

2. 优化要分优先级

不是所有地方的优化都值得做。优先优化那些高频调用、耗时占比大的环节。一个只占整体耗时1%的模块,就算你把它优化10倍,整体也就提升1%。

3. 精度和性能的trade-off要量化

做量化、做剪枝、做近似计算,都会损失精度。但这个损失到底是多少,能不能接受,要用数据说话。我们做PQ量化的时候,专门跑了一组离线评估,确认召回率的损失在可控范围内,才敢上线。

4. 缓存是万恶之源,也是万善之源

用缓存能解决很多性能问题,但缓存一致性、缓存穿透、缓存雪崩这些问题也会随之而来。用缓存之前,一定要想清楚这些问题的应对方案。

5. 别过度优化

这条最重要。有时候一个方案能解决80%的问题,但要做到100%需要10倍的工程量。这时候要懂得取舍。工程不是科研,不需要追求极致,够用就好。

写在最后

写这篇文章的时候,窗外天已经蒙蒙亮了。看了一眼时间,早上六点半。

说实话,做性能优化这件事,很多时候是枯燥的。不像搞模型创新那样有发paper的成就感,也不像做产品功能那样能看到直观的用户反馈。它就是在一个个毫秒级的细节里死磕,把95ms变成90ms,再变成85ms。

但每次看到监控曲线上那条延迟线稳稳地压在目标值以下,每次大促流量洪峰来的时候系统扛得住不崩,心里还是很有成就感的。

这大概就是工程师的快乐吧——朴素,但真实。

好了,不说了,代码还没跑完,等结果出来我得看看今晚的AB实验数据。如果这次实验组的效果显著,明天就能推全量了。

对了,如果你也在做推荐系统或者对性能优化感兴趣,欢迎来找我交流。虽然我不一定都懂,但至少能陪你一起吐槽产品经理的需求有多离谱。

溜了溜了,继续写代码去了。🫡

评论 0

最热最新
暂无评论
云计算CodeLv.1
0
影响力
0
文章
0
粉丝