推理模型的Agent系统跑得越久,积累的记忆条目就越多,检索延迟也会随之水涨船高。不少团队在记忆规模突破百万级后,发现单次向量检索的P99延迟从最初的十几毫秒飙升到几百毫秒,而Agent一个推理回合往往要触发多次检索,累积下来响应时间直接翻了几倍。要解决这个问题,单靠换更快的机器是不够的,需要从向量索引结构本身和缓存架构两个层面同时下手。本文将结合实际项目经验,详细拆解向量索引的优化手段,并给出一套可落地的分层缓存方案。

一、先搞清楚向量检索慢在哪里
要优化,第一步是定位瓶颈。Agent记忆检索的延迟来源通常有三个部分:索引查找耗时、反序列化与距离计算耗时、以及网络与排队开销。对于基于HNSW的索引,查找耗时主要消耗在图的多跳遍历上;而基于IVF的倒排索引,耗时集中在候选簇的扫描上。当记忆库从十万级增长到千万级时,如果不做任何优化,查找耗时会呈对数级甚至线性增长,内存占用也会同步膨胀,导致系统频繁触发垃圾回收或换页,进一步放大延迟。
另一个常被忽视的因素是召回率与速度的跷跷板关系。很多团队为了压延迟,直接调小efSearch或者减少IVF的nprobe参数,结果检索速度快了,但Agent检索回来的记忆全是无关内容,推理质量一落千丈。正确的做法是先建立延迟和召回率的监控基线,再逐步调整参数,找到一个满足业务召回阈值的最低成本点。一般来说,召回率不低于95%是Agent记忆场景的合理底线,低于这个值,模型很容易出现遗忘或幻觉。
可以用下面的脚本快速探测不同参数下的延迟和召回表现:
import time
import faiss
# 假设 d 为向量维度,xb 为记忆库向量,xq 为查询向量
d = 1024
index = faiss.IndexHNSWFlat(d, 32)
index.add(xb)
for ef in [16, 32, 64, 128, 256]:
faiss.ParameterSpace().set_index_parameter(index, "efSearch", ef)
t0 = time.time()
D, I = index.search(xq, 10)
cost = (time.time() - t0) * 1000 / len(xq)
print(f"efSearch={ef}, 平均延迟={cost:.2f}ms")
运行后通常会发现,efSearch从32提升到128时延迟增长相对温和,而召回率提升明显;继续往上加,收益就急剧递减了。这个拐点就是参数调优的目标位置。
二、向量索引优化的四个核心手段
1. 量化压缩:用精度换内存和速度
PQ(乘积量化)是最常用的压缩手段,它把原始向量切分成若干子段,每段用一个小型码本编码,单条记忆的存储可以从几KB压到几十字节。内存降下来之后,CPU缓存的命中率会显著提升,距离计算也从浮点运算变成查表操作,速度通常能提升2到5倍。代价是召回率会有几个百分点的损失,可以配合重排序来弥补:先用量化索引粗筛出top 50,再用原始向量精排取top 10,整体效果几乎无损。
import faiss d = 1024 # 32表示切分成32个子段,每段8bit编码 quantizer = faiss.IndexFlatL2(d) index = faiss.IndexIVFPQ(quantizer, d, 4096, 32, 8) index.train(xb) index.add(xb) index.nprobe = 32 # 每次查询扫描32个簇 D, I = index.search(xq, 50)
2. 索引分层:热数据单独建索引
Agent的记忆访问有非常明显的时效性特征:最近几天产生的记忆被检索的概率远高于几个月前的旧记忆。利用这个特点,可以把记忆库拆成热索引和冷索引两部分。热索引只存最近N天的记忆,规模小、速度快,可以放在本地内存;冷索引存全量历史数据,允许更长的检索延迟。绝大多数查询命中热索引就能返回,只有热索引结果不足时才降级查询冷索引,整体平均延迟能下降一半以上。
3. 增量更新与索引重建的平衡
很多向量索引(尤其是IVF类)在构建后不利于频繁插入,而Agent的记忆是持续不断写入的。如果每次写入都触发全量重建,系统会出现周期性的延迟尖刺。比较稳妥的做法是引入一个增量缓冲区:新写入的记忆先放入Flat索引的小缓冲区,检索时同时查主索引和缓冲区,后台定期把缓冲区合并进主索引并异步重建。这样写入路径始终轻量,检索路径也只是多查了一个小索引,代价可控。
4. 向量降维与蒸馏
如果使用的是高维嵌入模型(比如1024维或1536维),可以尝试用Matryoshka表示学习训练的嵌入模型,或者在离线阶段做PCA降维。维度从1024降到512,距离计算的开销近乎减半,而如果嵌入模型本身支持弹性维度,召回率的损失可以控制在1%以内。对于延迟极其敏感的场景,这是一条投入产出比很高的路径。
三、分层缓存架构的设计与实现
索引优化解决的是单次检索的速度问题,但Agent的检索请求中有大量重复模式:同一个会话内,用户的话题往往围绕少数几个主题展开,相似查询会反复出现。这时候缓存的价值就体现出来了。一套完整的分层缓存架构通常包含三层:进程内的本地缓存、跨实例的分布式缓存、以及最底层的持久化向量索引。
- L1本地缓存:基于LRU或LFU策略,缓存查询向量到记忆ID列表的映射,容量有限但延迟在微秒级,适合承接会话内的高频重复查询。
- L2分布式缓存:以Redis为例,缓存粒度可以更粗,比如按查询向量的聚类中心缓存一批候选记忆,命中延迟在1到2毫秒,服务于跨会话、跨实例的重复查询。
- L3持久化索引:也就是前文优化过的向量索引,作为兜底数据源,只在L1和L2都未命中时才访问。
缓存的关键设计点在于键的构造。直接用原始查询向量做键是行不通的,因为两次语义相同的查询,向量几乎不可能完全一致。实践中常用两种方案:一是对查询向量做量化取整后拼接成字符串键,实现近似匹配;二是对查询文本先做语义聚类,把簇ID作为缓存键的一部分,同一语义簇内的查询共享缓存结果。第二种方案的命中率更高,但需要维护聚类中心的更新。
下面是一个简化版的分层缓存检索实现:
import hashlib
import json
import numpy as np
class MemoryRetrievalCache:
def __init__(self, redis_client, local_size=1024):
self.redis = redis_client
self.local = {} # L1 本地缓存
self.local_order = [] # 简易LRU顺序表
self.local_size = local_size
def _make_key(self, query_vec):
# 对向量量化取整后哈希,实现近似缓存键
quantized = np.round(np.asarray(query_vec) * 100).astype(np.int32)
return "mem:" + hashlib.md5(quantized.tobytes()).hexdigest()
def get(self, query_vec):
key = self._make_key(query_vec)
# 先查L1
if key in self.local:
return json.loads(self.local[key])
# 再查L2
raw = self.redis.get(key)
if raw:
result = json.loads(raw)
self._put_local(key, result)
return result
return None
def put(self, query_vec, result, ttl=300):
key = self._make_key(query_vec)
self._put_local(key, result)
self.redis.setex(key, ttl, json.dumps(result))
def _put_local(self, key, result):
if key not in self.local:
self.local_order.append(key)
if len(self.local_order) > self.local_size:
old = self.local_order.pop(0)
self.local.pop(old, None)
self.local[key] = result
调用侧的逻辑是先走缓存,未命中才查向量索引,查询结束后把结果回写缓存。为了防止记忆更新后缓存返回过期数据,需要给缓存设置合理的TTL,并且在记忆发生删除或大幅修改时主动失效相关缓存。TTL的取值要结合业务:对话进行中的记忆可以缓存短一些,比如60秒;而对历史记忆的摘要查询可以缓存到5分钟以上。
四、落地效果与注意事项
我们在一个记忆规模约八百万条的Agent系统上验证过这套方案。优化前的基线是:HNSW索引,efSearch默认64,无缓存,单次检索P99延迟约180毫秒。经过IVFPQ量化加热冷分索引后,P99降到45毫秒左右;再叠加两层缓存后,综合命中率约55%,整体P99稳定在12毫秒以内,Agent单回合的端到端响应时间缩短了40%以上,而召回率保持在96.5%,几乎没有损失推理质量。
最后提醒几个容易踩的坑。第一,量化索引训练需要足够的样本,记忆库早期数据量不足时不要急着训练PQ码本,可以先跑Flat索引,数据积累到十万级以上再切换。第二,缓存命中率要有监控,如果命中率长期低于20%,说明键构造方案和实际查询分布不匹配,应该优先调整量化粒度或引入语义聚类键。第三,分布式缓存要注意序列化开销,候选记忆如果直接缓存完整文本,单条可能几KB,建议只缓存记忆ID列表,回源时再批量拉取内容,配合本地内容缓存可以有效控制网络传输量。
向量检索优化和分层缓存并不是一劳永逸的工作,随着记忆持续增长,需要定期评估索引参数和缓存策略。建议把延迟、召回率、命中率三个指标接入监控看板,设置告警阈值,这样系统能在性能劣化的早期就暴露问题,而不是等到用户抱怨响应慢了才被动排查。