Agent检索架构:分片与分散检索实战
1. 单体Agent检索架构为什么扛不住生产压力做Agent落地的朋友应该都有这种感觉Demo阶段一切都很顺Query进来向量检索召回TopK塞进Prompt大模型回答得漂漂亮亮。可一旦把数据量从几千条文档涨到百万级切片再把QPS从个位数提到几十上百问题就开始密集冒出来了。先说我自己遇到的一个典型场景。某个知识密集型的Agent项目承载的是企业内部的产品文档、工单记录、历史方案总量大概1.2亿字离线切成48万多个索引块。刚开始是单体架构——一个向量库实例、一套全局索引、一个检索服务Agent的规划模块每次要查知识库就全量打过去。上线两周压测数据很难看P95延迟从最初的800ms一路爬到将近1.8秒高峰期甚至超过3秒。更麻烦的是召回质量的抖动同一个问题上午答得还行下午就开始漏关键细节。当时我的判断是问题不在模型也不在Prompt而在检索底座本身。单体架构意味着三件事——所有数据耦合在一个索引里所有查询共享同一份资源所有故障都是单点的。这三点叠加在Agent场景下会被无限放大。1.1 单点瓶颈集中式索引的吞吐上限集中式索引最直接的问题就是吞吐瓶颈。向量检索不是简单的点查它需要对全量向量做近似最近邻搜索。即便用HNSW这类索引数据量大了之后内存占用、图遍历深度、候选集大小都会一起涨。实测下来当索引块数量超过30万单实例的QPS天花板就非常明显了——不是计算资源不够而是索引结构本身决定了每次查询都要在全局图上走不少步。我后来做过一个对比实验同样的48万条切片单体索引的P95延迟在1.6秒左右按业务域拆成8个索引后每个索引只有6万条切片单查询延迟直接降到400毫秒。原因很简单——图索引的搜索复杂度跟图上节点数量强相关数据切片规模降一个量级搜索步数就降一个量级。这里有个很容易被忽略的点很多人以为加机器就能解决吞吐但单体索引加机器只能增加副本副本之间的数据同步、一致性协调、资源消耗都是额外成本。真正的解法是把数据拆开让每个实例只处理自己那一份。1.2 长尾质量问题全局TopK检索对Agent的致命伤单体索引还有个隐蔽问题召回质量的长尾抖动。当数据只来自一个业务域时全局TopK基本靠谱。可一旦多业务域混在一个索引里高频相似片段会把真正需要的信息挤出去。举个例子。Agent需要回答“某个线上故障的处理流程”这个答案其实分散在好几份文档里一份运维手册、一份历史工单、一份变更记录。如果这三份内容各自的相似度分都比不上海量产品FAQ里的常见问答片段那全局TopK召回的10个片段里可能只有1个是用户真正需要的。这种情况在单体架构里几乎无法根治——你只能调召回数量但召回多了噪音也多了大模型的处理效果反而更差。这也是为什么我做设计决策时优先考虑的从来不是“检索快不快”而是“该检索的东西能不能被检索到”。分片的意义不只是性能更是把检索约束在正确的语义空间里。1.3 可用性缺位一个上游故障拖垮整个Agent单体架构的可用性风险在Agent场景里尤其致命。Agent是多轮交互的一轮对话可能要触发多次检索。检索链路里任何一个环节出问题——向量库CPU飙高、连接池占满、某个离线任务把IO打满——整个Agent就没法正常工作。我踩过印象很深的一次某个离线文档更新任务在凌晨跑的时候产生了大量临时索引文件把磁盘IO占满了。第二天早高峰所有查询都积压在向量库里Agent连续两小时处于不可用状态。事后排查的时候发现单体架构下根本没有做故障隔离的手段——所有数据都在一个实例上实例出问题就是整体出问题。这次事故直接推动了我做架构改造。核心思路就是我接下来说的这两件事分片Sharding与分散检索Scatter-Gather。2. 分片设计数据拆开只是第一步语义边界才是关键分片这个词在数据库领域很常见但在Agent场景下分片逻辑和传统中间件分片有很大区别。数据库分片追求的是均匀分布和事务边界而Agent的检索分片核心诉求是“语义边界清晰”和“路由准确”。如果你只是把数据均匀地打散到不同实例上性能问题确实能缓解一部分但召回质量大概率会下降——因为一条完整知识被打散到多个分片里任何单片都拿不到全貌。所以我强调一个观点分片维度要跟着业务语义走而不是跟着数据量走。2.1 分片维度的取舍哈希、时间还是业务主题常见的分片维度有三类我分别说下适用场景和坑。第一类是哈希分片。按文档ID或者内容Hash分到不同桶里好处是数据均匀坏处是语义被彻底打散。一个主题的相关文档可能散在10个分片里查询时要么全量扇出Scatter要么路由不准。我的建议是哈希分片只适合做底层存储层的负载均衡不适合直接作为Agent检索分片。第二类是时间分片。按文档的创建时间、更新时间分片适合流式数据场景比如工单流水、日志分析、变更记录。这类数据天然有热区——最近三个月的数据被查得最多历史数据很少被检索。时间分片可以搭配冷热分层策略热分片放高性能节点冷分片放到成本更低的存储上。第三类是业务主题分片这也是我认为最适合Agent场景的方式。按业务域、文档类型、知识类别来切比如一个企业知识库可以拆成“产品文档”“运维手册”“工单记录”“研发规范”四个分片。每个分片内的内容语义高度内聚查询路由到正确的片之后召回精度会显著提升。我最终采用的是混合方案顶层按业务主题分片主题片内部再按哈希散列到多个物理节点。这样既保证语义内聚又获得均匀分布。2.2 文档内部切片的粒度控制chunking与父子分片分片是架构层面的切分文档内部的切片Chunking则是质量层面的切分。这个细节如果做不好前面的一切都白搭。我在实践中试过很多种切片策略最终留下来的是“父子分片”方案——召回用细粒度块重排和上下文组装用父级段落。具体做法是这样先把文档按照自然段落切分每个段落作为一个父节点再把过长的段落继续切成300到500个token的细块作为子节点。子节点用于向量检索召回命中后通过子节点找到父节点用整个父节点的内容去组装Prompt。这个设计解决了两个问题。一是细粒度块能精准命中关键信息不会因为段落太长导致向量被稀释二是父节点保证了上下文完整性大模型拿到的是完整段落而不是一个孤立的残缺片段。切片的参数我也给出一组实测值细块大小400 token左右重叠50到80 token重叠的目的是避免关键句子恰好在切分边界被截断。段落边界优选从Markdown标题、句子结束符这些自然边界切不要硬按token数切。2.3 分片路由的元数据设计分片确定之后还需要一套路由元数据来告诉检索服务查询应该去哪些分片。我维护了一张路由表每个分片记录以下信息分片ID、业务主题标签、数据范围描述比如时间范围、文档类型、物理节点地址、副本列表、健康状态、版本号。这里有个关键设计——路由表的更新不能做成强一致。一旦采用强一致同步延迟会直接影响查询可用性。我选择的是异步传播加版本校验路由表变更先写到元数据中心然后异步推送到各个检索节点节点本地缓存一份路由表每次查询先用本地缓存后台任务定期比对版本号拉取更新。这个方案的优点是查询链路不依赖元数据中心元数据中心挂了缓存的路由表还能撑一段时间缺点是会出现短暂的路由不一致比如某个分片刚做了迁移但节点缓存里还是旧地址。解决方式是查询失败后做一次路由重试从元数据中心重新拉取路由信息再打一次。幂等性做好这个重试逻辑是安全的。3. 分散检索执行链路从查路由到并发扇出再到归并分片做完之后检索链路就从“查一个全局索引”变成了“查多个分片再汇总”。这就是分散检索Scatter-Gather的核心把查询请求广播到多个分片各分片并行执行检索最后在聚合层完成结果归并。这个模式看起来简单实际落地时每一步都有不少细节。我拆成三部分来讲。3.1 查询意图与路由决策哪些片该参与检索分散检索不是所有查询都无脑打到所有分片。如果每次查询都全量扇出那分片就失去了意义性能和召回质量都会打折扣。正确的路由流程分两步。第一步是“定域路由”根据查询内容判断它属于哪个业务主题。这一步可以做得简单一点——用一组关键词规则做初筛再用一个轻量分类模型做兜底。比如查询里出现“服务器”“故障”“宕机”这些词就优先路由到“运维手册”分片出现“报销”“请假”“入职”就路由到“行政制度”分片。第二步是“散列路由”在确定业务主题之后把查询继续分发到该主题下的多个物理分片。这里有一个参数需要调参与检索的分片数量。我的经验值是主题内的物理分片如果少于4个可以全量扇出如果更多可以按分片负载情况选一部分或者用一致性哈希只路由到2到3个分片。路由决策里最容易翻车的地方是跨主题查询。比如用户问“数据库连接超时导致的线上故障处理”既涉及“产品文档”又涉及“运维手册”。这时候单一主题路由会丢失信息我的做法是增加一个“相关主题扩展”策略——先做主体判定再根据主题关联关系把相关的2到3个分片一起纳入查询。3.2 并发扇出限制、超时与动态探测路由确定之后查询会以并发方式打到多个分片。这里的核心是控制并发度和超时预算。先说并发度。很多人以为并发越高越快实际上扇出请求的延迟取决于最慢的那个分片并发太高反而会占用大量连接资源。我实测下来扇出数量在8到16路之间是性价比最高的区间再多收益很小反而容易触发连接池排队。再看超时预算。整体检索链路必须有一个硬性时间预算比如700毫秒。分配到每个分片请求的单项超时通常是200毫秒但这里要注意不允许等所有分片都返回。正确做法是“多数派返回即继续”——比如8个分片里收到6个结果就进入归并阶段剩余两个直接放弃。放弃不是简单的超时丢弃要做好日志标记。我当时在Trace日志里专门加了“partial response”标记记录哪些分片超时、超时原因是什么。这些数据是后续做分片容量规划的重要依据。最后是动态探测。我每个分片维护了三个状态健康、亚健康、故障。健康分片正常参与检索亚健康分片降低权重甚至临时摘除故障分片直接不参与路由。状态更新不是静态的而是根据最近一分钟的成功率、延迟、错误率动态计算。这个机制帮了我们大忙——有一次某个分片的磁盘IO异常动态探测在30秒内把它标记为亚健康查询自动绕开它整体服务没有任何感知。3.3 结果归并分数对齐、去重与重排分片并行返回结果之后最考验工程质量的就是归并层。归并不仅仅是“把所有TopK拼在一起再排一次序”里面有三个必踩的坑。第一个是分数对齐问题。不同的分片可能用了不同的向量模型版本或者索引参数不同导致返回的相似度分数不在一个量纲上。如果不做对齐直接归并排序某些分片的分数天然偏高就会一直霸榜。解决方式有两种一种是统一全分片模型版本另一种是在归并层做Min-Max归一化或者采用RRFReciprocal Rank Fusion这类基于排名而不是分数的融合算法。我强烈推荐RRF——它只依赖排名顺序天然免疫分数不可比的问题而且实现极其简单。第二个是去重问题。父子分片策略下同一内容的父节点和子节点可能在不同分片里都被召回如果直接一起放进Prompt既浪费token又会产生重复信息。我的方案是在归并层做一个相似度检测标题完全相同的直接去重正文相似度超过0.85的保留更长的那一条。第三个是重排问题。向量检索召回的是“语义相似”的片段但Agent需要的是“能回答问题”的片段两者并不等价。所以归并后通常还要接一个精排模型。我用的方案是向量召回粗排Top20到Top30归并去重后用一个轻量级cross-encoder或者干脆让大模型在Prompt里做二次筛选。精排层的存在能大幅度提升最终回答的准确率但也意味着多一次模型调用需要把这段时间计入整体延迟预算。我实测下来增加精排层后答案的可接受率从68%提升到了81%代价是P95延迟多了约150毫秒。对于Agent场景来说这个代价是值得的。4. 高可靠兜底工程熔断、降级与状态粘性分片和分散检索解决的是性能和召回质量问题但高可靠性还需要一套兜底机制。这部分的内容来自我踩过的好几个坑每一个都是真实事故换来的经验。4.1 分片故障时的降级链路分片故障之后最忌讳的做法是让整个请求失败。正确思路是设计一条降级链路让效果损失可控而不是直接放弃回答问题。我的降级链路分了三层。第一层是缓存兜底如果目标分片故障先查语义缓存看最近有没有相似的Query和答案。第二层是范围扩展故障分片不参与检索同时把查询改写为更宽的OR检索去其他分片里捞配合一个“召回数量放大系数”弥补精度损失。第三层才是明确告知用户“部分知识源不可用回答可能不完整”。这三层降级在实际运营中很有价值。有一次某个分片因为磁盘损坏离线了6个小时期间Agent的成功率只降了几个百分点而不是直接归零。如果没有降级链路这6个小时的业务影响会大得多。4.2 缓存策略与缓存穿透缓存是检索链路里性价比最高的优化手段但也是问题最多的地方。我采用的缓存方案是语义缓存不是简单的Query字符串完全匹配。具体做法是给每个Query生成Embedding计算与缓存条目Embedding的余弦相似度超过0.92就认为是同一个问题直接返回缓存结果。这样做的好处是用户换个说法提问也能命中缓存。缓存能走通缓存穿透就一定要防。所谓缓存穿透就是大量无法命中缓存的查询直接打到分片把检索服务打垮。我的做法是两层防护第一层对于空结果查询也做缓存缓存一个“无结果”标记短时间内避免重复查询第二层用布隆过滤器做前置过滤特别适合那些确实不存在结果的查询能拦截大部分无效流量。这里还要提一个重要细节缓存的失效策略。Agent场景里知识库会频繁更新如果缓存不失效用户会持续拿到旧答案。我的方案是给每个知识库维护一个版本号版本号变化时通过消息通知把所有相关缓存条目标记为过期而不是等待TTL自然过期。这个机制能保证知识更新后缓存最迟30秒内全部失效。4.3 多轮对话的状态粘性难题分片在物理上把数据拆开了但Agent的多轮对话状态是整体的这就产生了一个“状态粘性”问题。想象一个场景用户第一轮问的是产品功能Agent经过路由把对话状态和检索上下文暂存到了分片A所在节点第二轮用户接着问同一个功能的配置细节Agent的规划模块又把查询路由到了分片B。这时候分片B不知道前面的对话历史很可能回答得上下文不连贯。我尝试过两种解法。第一种是会话级一致性哈希整个会话的所有查询都路由到同一个分片组保证状态本地可达。这个方法实现简单但会导致某个分片组负载过高——热门会话都堆在同一个分片上。第二种是状态与检索解耦。把对话历史和Agent状态集中存储在一个独立的状态服务里检索分片只负责纯粹的文档检索不保存任何会话状态。每次查询时Agent先拉取会话状态再拼上当前问题一起发到检索服务。这个方案彻底解决了粘性问题代价是多了一次状态服务的访问延迟但对整体架构的可靠性提升是巨大的。最终我选了第二种。因为集中式状态服务可以用一个高可用的Redis或者类似存储来做它的可靠性远远好过把状态散落在各个无状态检索分片里。这个取舍在架构设计上很重要——无状态的分片可以随便扩缩容有状态的东西越少越好。5. 实测调参与避坑记录架构改造不是一蹴而就的中间经历了好几轮调参和排障。这一节我把最有参考价值的实测数据和踩坑记录分享出来。5.1 分片数与并发度的匹配分片数量不是越多越好它要和并发度、IO能力匹配。我遇到过这么个情况最初设计了48个分片分布在24个节点上每个节点跑两个分片。结果压测时发现延迟反而比32个分片时更高。排查原因后发现分片太多导致每次查询的扇出数量也跟着增多聚合层的归并开销变大而且每个分片的请求量变小之后无法充分利用锁页缓存。最终调优结果是6个节点、24个分片每节点4个分片扇出数控制在8到12路。这个配置下P95延迟稳定在280毫秒左右相比单体架构的1.8秒提升了约6倍。分片数和节点数的比值我的经验是控制在1:2到1:4之间比较合适——既能利用多核并行又不至于让分片太碎。5.2 跨分片打分一致性一个隐蔽的质量事故这是我最想提醒大家的一个坑。某次迭代我优化了某个分片的Embedding模型只改了A和B两个分片其他分片还是旧模型。上线后很多用户反馈“答案跟以前不一样了”而且某些片召回的答案明显质量偏低。排查了很久问题出在跨片打分不一致上——新模型的向量空间和旧模型不完全一致导致A、B两个分片的相似度分数普遍偏高归并排序时它们的内容总是排在最前面把其他分片更优的结果挤掉了。这个问题的根因是模型版本不统一但更深层的教训是分片之间的打分可比性是归并逻辑正确性的前提。如果你没法保证全分片模型版本一致那就一定要用基于排名融合的算法比如RRF而不是直接加和相似度分数。用完RRF之后这个问题就再也没出现过。5.3 热点分片与动态分裂业务主题分片有一个天然缺陷不同主题的热度差异极大。我们线上有个现象“产品文档”分片组的QPS是“研发规范”分片组的15倍以上。热点本身不是问题问题是热点导致的连锁反应。热分片的延迟会上升进而拖累整个检索延迟的平均值。如果热分片集中在某个节点上那个节点的CPU和内存都会告警。我做的处理是引入动态分裂机制——当某个分片的QPS连续10分钟超过阈值就把它分裂成两个分片把文档按照主题子类或者时间再切一次两个新分片路由到不同节点。同时在路由表更新之前查询打到旧分片时做一层代理转发保证一致性。动态分裂对工程能力有要求核心难点是分裂过程中的数据迁移和路由切换不能中断服务。我的实现方式是先复制数据生成新分片新分片追上数据进度之后通过路由表切换流量最后销毁旧分片。整个过程大约耗时3到5分钟期间服务无感知。5.4 冷启动预热与分片缓存最后一个提醒是分片冷启动的问题。新加入的分片在开始阶段性能非常差因为内存里的向量索引还没有完全加载锁页缓存也是空的。如果直接把流量打过去会导致大量慢查询。我添加了一个预热流程新分片在启动后先从路由表里标为“预热中”状态不参与流量分发后台跑一个预热任务把一部分历史高频Query轮流打过去把索引和缓存“暖”起来预热完成后状态自动转为“就绪”开始承接流量。这个流程大概需要5到10分钟但对稳定性的提升非常明显。踩过的坑是之前有几次分片迁移后没有做预热导致迁移后的半小时内延迟翻了三倍。加上预热流程之后迁移和扩容操作基本做到对业务无感。6. 写在最后的实践体会做完整套分片与分散检索改造之后我再回头看最大的感触是这些技术本身都不是新东西分片在数据库里用了几十年Scatter-Gather在搜索引擎里也是老模式但把它们组合在一起用在Agent的检索底座上还是有不少值得琢磨的设计决策。我的实践体会是Agent的检索架构设计和传统中间件设计有本质区别——传统中间件关注的是吞吐量和一致性而Agent检索必须同时兼顾性能、语义边界和上下文完整性。分片维度必须跟着业务语义走分散检索必须做好分数对齐和降级链路状态粘性必须放到架构层面来解决。如果你现在做的Agent还处在Demo阶段数据量不大我觉得没必要过早引入这套复杂度。单体架构先跑通等发现延迟和质量真的撑不住了再按这篇文章的思路去改造也来得及。反倒是那些数据量大、对可用性要求高的生产级Agent分片加分散检索这套组合几乎是必经之路。最后分享一个后续的扩展方向——目前分片和路由策略还是静态配置的我在尝试让Agent的规划模块自己参与路由决策比如根据当前对话目标动态选择合适的语义分片而不是依赖预先定义的主题规则。这条路如果走通Agent在面对开放域知识时会灵活得多但这已经是另一个话题了。