
在实际的本地化部署On-Premise环境中检索增强工厂智能体Retrieval-Augmented Factory Agents通常不是单个服务而是多个智能体实例分散在不同子网。子网之间的网络延迟、可用带宽和负载状态会直接影响检索质量和生成响应时间。这种场景下如何基于实时测量数据Measurement-Driven为每一次查询选择最合适的子网已经成为工程交付中不可回避的问题。本文围绕这一主题从设计思路、最小实现、运行验证到生产落地排错给出一条可复现的实践路径。1. 先理解“测量驱动”和“子网选择”要解决什么问题1.1 本地部署 RAG 智能体为什么会出现多个子网工厂环境中的 RAG 智能体通常用于设备维修知识问答、工艺文档检索、质量异常归因等场景。为了保证数据不出厂这类系统往往采用 On-Premise 方式部署。随着业务规模扩大智能体实例并不会集中在一台机器上而是会按照车间、厂区、业务线拆分到不同子网。每个子网内部可能包含一套完整的检索服务、向量数据库和生成服务。客户端发起一个问题时需要选择一个子网来处理。如果总是固定路由到某一个子网这个子网会变成热点其他子网资源闲置。更复杂的是即使各个子网硬件配置相同网络状况也会随时间变化某段时间流量突增、某台机器日志刷盘、某个交换机端口抖动都会造成延迟升高。1.2 子网选择不是“轮询”或“随机”这么简单最简单的方案是轮询即请求轮流发送到不同子网。但轮询无法感知“某个子网已经不可用”或者“某个子网当前延迟很高”。稍微好一点的做法是负载均衡比如加权轮询但权重仍然是静态配置无法适应动态变化。真正的问题在于RAG 请求对延迟和检索质量都比较敏感。如果所选子网检索模块响应变慢上游请求会超时如果长期把请求发送到吞叶量低、负载高的子网生成结果可能因为检索上下文不完整而变差。子网选择需要让选择策略随测量指标动态变化在多个可用的子网中挑选出当前综合质量最好的一个。1.3 核心目标和测量指标测量驱动的子网选择核心目标是建立一套“感知—评分—路由”的闭环。周期性测量各个子网的状态将状态转换成可比较的分数然后根据分数决定当前请求应该交给哪个子网。常见的测量指标包括可用性子网服务是否健康是否能够正常响应。网络延迟从路由器到子网内检索服务的一次探测往返时延。负载情况当前并发数、CPU 使用率或队列深度。带宽可用的吞吐能力影响大数据量检索结果返回速度。不同指标对 RAG 场景的影响并不相同。延迟影响首字响应时间负载影响稳定性带宽影响检索结果包的传输效率。实际项目中需要对这些指标做归一化处理再按业务目标分配权重。2. 一个最小可运行的测量驱动子网选择方案设计2.1 系统架构采集器、评分器、路由器为了把问题说清楚这里用一个最小系统演示核心逻辑。系统由三部分组成。测量采集器Collector负责按固定周期获取每个子网的状态指标。评分器Scorer把多个指标归一化后按权重计算出每个子网的得分。路由器Router根据得分选择最优子网把请求转发过去并在失败时回退到次优子网。实际工程中采集器可能部署在网关节点通过 HTTP、gRPC 或 ICMP 探测子网状态。这里为了演示使用模拟数据代替真实探测。2.2 数据结构子网状态快照每个子网在一次测量周期内的状态可以统一表示为一条记录字段类型含义subnet_idstr子网唯一标识latency_msfloat探测延迟单位毫秒availablebool是否可用loadfloat负载指数0 到 1 之间bandwidth_mbpsfloat可用带宽单位 Mbps在真实项目中latency_ms 可以从健康检查接口获取load 可以由服务端上报bandwidth_mbps 也可以通过传输一段固定大小的探测数据计算得出。这里选择简单的 dataclass 表示即可。2.3 评分策略先淘汰、再加权评分策略分成两步。第一步是硬性淘汰。如果 available 为 False则直接标记为不可选不参与后续评分。这样可以避免把请求发送到已经不可用的子网。第二步是计算加权得分。对于剩余子网需要把“延迟越低越好”“带宽越高越好”“负载越低越好”这三个方向统一成“得分越高越好”。一个常用做法是延迟分数 1 / latency_ms带宽分数 bandwidth_mbps负载分数 1 - load然后分别做 Min-Max 归一化使每个指标落在 0 到 1 区间避免量纲影响权重。综合得分公式可以写成score w_latency * norm_latency_score w_bandwidth * norm_bandwidth_score w_load * norm_load_score其中 w_latency、w_bandwidth、w_load 是权重三者之和为 1。默认情况下可以把延迟权重调高一些因为 RAG 请求对响应延迟更敏感。3. 环境准备和依赖3.1 开发环境要求下面的示例代码基于 Python 3.9 以上版本不需要第三方依赖仅使用标准库。这样可以方便地在一个干净环境中直接运行。项目建议Python 版本3.9 或更高操作系统Linux、macOS、Windows 均可第三方依赖无网络环境仅本机运行模拟不需要真实网络如果要在真实环境验证需要准备至少两个可控的子网环境以及一个可以发起 HTTP 探测的网关节点。生产环境建议使用 Python 3.11 或更高版本便于获得更好的异步和类型支持。3.2 目录结构建议按以下目录组织代码subnet-selector/ ├── metrics.py ├── selector.py ├── router.py └── simulate.pymetrics.py定义子网状态数据结构和模拟采集器。selector.py实现指标归一化、滑动窗口和分数计算。router.py实现最终选路和失败回退逻辑。simulate.py运行多轮模拟输出选择结果。如果只是学习可以放在一个脚本中。如果是工程化项目建议按模块拆分方便单元测试。3.3 依赖和版本确认由于没有第三方依赖运行前只需要确认 Python 版本python --version如果输出的是 Python 3.9 或以上版本就可以直接运行。如果版本过低建议通过系统包管理器或 pyenv 安装新版本避免使用过旧语法。4. 核心代码实现4.1 定义子网状态数据结构和模拟采集器首先在 metrics.py 中定义子网状态结构from dataclasses import dataclass dataclass class SubnetMetric: subnet_id: str latency_ms: float available: bool load: float bandwidth_mbps: float字段含义在前文已说明。这里有一个细节load 的语义要明确0 表示空闲1 表示负载饱和。不同服务对 load 的定义可能不同接入真实系统时需要在文档中统一。模拟采集器可以这样实现import random from typing import List def collect_metrics(subnet_ids: List[str]) - List[SubnetMetric]: metrics [] for sid in subnet_ids: available random.random() 0.1 metrics.append( SubnetMetric( subnet_idsid, latency_msrandom.uniform(20, 200), availableavailable, loadrandom.uniform(0.2, 0.9), bandwidth_mbpsrandom.uniform(50, 500), ) ) return metrics真实环境中collect_metrics需要替换为对子网健康检查接口的实际调用。这里的随机数据只用于验证选择逻辑。4.2 滑动窗口平滑测量数据测量数据会抖动一次过大的延迟值会导致子网被误判。为了减少抖动影响可以在评分前增加一个滑动窗口平滑器。在 selector.py 中实现from collections import deque from typing import Deque, Dict class MetricSmoother: def __init__(self, window_size: int 5): self.window: Dict[str, Deque[float]] {} self.window_size window_size def add(self, subnet_id: str, value: float) - float: if subnet_id not in self.window: self.window[subnet_id] deque(maxlenself.window_size) self.window[subnet_id].append(value) values self.window[subnet_id] return sum(values) / len(values)窗口大小默认为 5。窗口越大曲线越平滑但对真实变化的响应越慢。如果需要兼顾实时性和稳定性可以改用指数加权移动平均EWMA不过滑动窗口已经能满足大部分工厂内网场景。4.3 多指标归一化与加权评分评分器需要接收一组经过平滑处理后的子网指标输出每个子网的得分。归一化时Max 和 Min 可以在当前周期内取最大最小值也可以使用固定阈值。固定阈值更适合生产环境因为当前周期的 Max 可能受异常值影响。这里演示当前周期归一化from typing import Dict, List def _normalize(values: List[float]) - Dict[str, float]: min_val min(values) max_val max(values) span max_val - min_val if span 0: return {subnet_id: 1.0 for subnet_id in values} return {subnet_id: (v - min_val) / span for subnet_id, v in zip(values, values)} def compute_scores( metrics: List[SubnetMetric], weights: Dict[str, float], smoother: MetricSmoother, ) - Dict[str, float]: available [m for m in metrics if m.available] if not available: return {} for m in available: smoother.add(f{m.subnet_id}:latency, m.latency_ms) smoother.add(f{m.subnet_id}:bandwidth, m.bandwidth_mbps) smoother.add(f{m.subnet_id}:load, m.load) latency_map { m.subnet_id: smoother.window[f{m.subnet_id}:latency][-1] for m in available } bandwidth_map { m.subnet_id: smoother.window[f{m.subnet_id}:bandwidth][-1] for m in available } load_map { m.subnet_id: smoother.window[f{m.subnet_id}:load][-1] for m in available } latency_keys list(latency_map.keys()) latency_scores _normalize([latency_map[k] for k in latency_keys]) bandwidth_scores _normalize([bandwidth_map[k] for k in latency_keys]) load_scores _normalize([1 - load_map[k] for k in latency_keys]) scores {} for subnet_id in latency_keys: scores[subnet_id] ( weights[latency] * latency_scores[subnet_id] weights[bandwidth] * bandwidth_scores[subnet_id] weights[load] * load_scores[subnet_id] ) return scores需要注意的是_normalize的写法实际上有问题传入的是列表 values但返回的是 Dict[str, float]其中 key 是 values 的元素这很奇怪而且 zip(values, values) 是错误用法。上面代码只是为了演示思路实际需要修正。正确写法应该是传入一个 dict 或两个列表。为了避免误导我们重新设计一个更准确的实现from typing import Dict, List, Tuple def _normalize_dict(data: Dict[str, float]) - Dict[str, float]: values list(data.values()) min_val min(values) max_val max(values) span max_val - min_val if span 0: return {k: 1.0 for k in data} return {k: (v - min_val) / span for k, v in data.items()}然后在 compute_scores 中latency_dict {m.subnet_id: smoother.add(f{m.subnet_id}:latency, m.latency_ms) for m in available} bandwidth_dict {m.subnet_id: smoother.add(f{m.subnet_id}:bandwidth, m.bandwidth_mbps) for m in available} load_dict {m.subnet_id: smoother.add(f{m.subnet_id}:load, m.load) for m in available} latency_scores _normalize_dict(latency_dict) bandwidth_scores _normalize_dict(bandwidth_dict) load_inverted {k: 1 - v for k, v in load_dict.items()} load_scores _normalize_dict(load_inverted)这样逻辑才正确。我们会在最终代码中给出完整实现。这里先说明归一化的用途延迟越小得分越高带宽越大得分越高负载越低1-load 越大得分越高。4.4 路由选择与失败回退路由器拿到评分结果后选择得分最高的子网作为目标。同时为了处理“选择后调用失败”的情况需要保留次优候选。在 router.py 中实现from typing import Dict, List def select_best(scores: Dict[str, float]) - List[str]: if not scores: return [] sorted_subnets sorted(scores, keyscores.get, reverseTrue) return sorted_subnets class Router: def __init__(self, weights: Dict[str, float]): self.weights weights self.smoother MetricSmoother() def route(self, metrics: List[SubnetMetric]) - Dict[str, float]: scores compute_scores(metrics, self.weights, self.smoother) return scoresselect_best返回一个按得分降序排列的子网列表。调用方先取第一个如果调用失败再取第二个以此类推。这样可以防止因为一次选择失误导致整个请求失败。4.5 模拟多轮请求在 simulate.py 中运行多轮模拟import random from metrics import collect_metrics from router import Router, select_best def main(): subnet_ids [subnet-1, subnet-2, subnet-3] weights {latency: 0.5, bandwidth: 0.2, load: 0.3} router Router(weights) selection_count {sid: 0 for sid in subnet_ids} random.seed(42) for round_no in range(20): metrics collect_metrics(subnet_ids) scores router.route(metrics) candidates select_best(scores) selected candidates[0] if candidates else None if selected: selection_count[selected] 1 print(fround {round_no 1}: scores{scores}, selected{selected}) print(selection distribution:, selection_count) if __name__ __main__: main()运行后可以观察到每一轮得分最高且被选中的子网。由于模拟数据是随机生成的每次运行结果会有差异。这正是测量驱动的意义选择结果会随状态变化而变化而不是固定指向某个子网。5. 运行验证与结果分析5.1 运行方式与预期输出把上述模块放在同一目录后执行python simulate.py预期输出类似round 1: scores{subnet-1: 0.73, subnet-2: 0.45, subnet-3: 0.61}, selectedsubnet-1 round 2: scores{subnet-1: 0.52, subnet-2: 0.68, subnet-3: 0.39}, selectedsubnet-2 ... selection distribution: {subnet-1: 7, subnet-2: 8, subnet-3: 5}由于随机种子固定为 42实际输出是确定的不同机器上应当一致。如果看到较均匀的分布说明模拟数据没有让某一个子网始终占优选择逻辑确实在根据指标变化切换。5.2 验证选择逻辑是否符合预期为了确认逻辑正确可以构造一组确定性数据手动验证分数from metrics import SubnetMetric from router import Router from selector import compute_scores metrics [ SubnetMetric(subnet-1, latency_ms50, availableTrue, load0.3, bandwidth_mbps300), SubnetMetric(subnet-2, latency_ms100, availableTrue, load0.8, bandwidth_mbps150), SubnetMetric(subnet-3, latency_ms200, availableFalse, load0.6, bandwidth_mbps80), ] scores compute_scores(metrics, {latency: 0.5, bandwidth: 0.2, load: 0.3}, MetricSmoother()) print(scores)这里 subnet-3 不可用应当被淘汰。subnet-1 延迟更低、负载更低、带宽更高分数应当高于 subnet-2。这可以验证评分逻辑没有方向性错误。5.3 真实环境验证建议模拟通过后需要到真实环境验证准备两个或多个子网每个子网内部部署一个 RAG 服务暴露健康检查接口。网关节点运行采集器每隔 5 秒调用一次健康检查接口获取延迟和负载数据。客户端发起真实查询观察路由器是否选择了当前延迟较低的子网。人为模拟子网故障拔掉一条子网的网络线或停掉服务观察选择结果是否在下一轮自动切换到其他子网。注意不要只验证“程序能启动”还要验证服务不可用时是否有回退、恢复后是否能重新参与选择。这两点是测量驱动路由是否可靠的关键。6. 常见问题排查6.1 测量数据抖动导致频繁切换现象选择结果在多个子网之间来回切换同一会话内不同请求可能落到不同子网导致 RAG 问答上下文不连贯。常见原因单次探测延迟出现瞬时尖峰评分器没有做平滑处理或者平滑窗口太小。也可能是权重设置过于敏感延迟权重过高。检查方式打印平滑前后的 latency 值观察尖峰是否被明显削弱。统计连续 10 轮选择结果计算切换次数。验证权重对结果的影响。解决方案增大滑动窗口大小例如从 5 调整到 10。对延迟使用 EWMA 或中位数而不是平均值。对单次异常值设置上限超过阈值时按阈值处理。预防建议在生产系统中测量周期不要过于频繁建议 3 到 5 秒一次并对指标做两层平滑。6.2 某个子网长期不被选中现象一个子网可用但从来不参与最终选择所有请求都被其他子网抢占。常见原因该子网的某些指标始终不优例如延迟高、负载高但可能它的检索质量或数据完整性更好。单纯用网络指标评分无法覆盖这类业务差异。检查方式查看该子网每个周期的指标和得分。将归一化后的各个指标单独打印判断是哪个指标拖累了总分。检查权重是否为零或过低。解决方案引入业务指标例如命中率、检索结果相关度评分。设置“最小选择比例”约束保证每个可用子网都能获得少量流量用于持续测量。使用多臂老虎机或 epsilon-greedy 策略在利用与探索之间取得平衡。预防建议把子网选择当作一个带探索的策略问题而不是单纯的贪心问题。纯贪心会导致部分子网长期饥饿后续缺少新鲜测量数据。6.3 测量请求本身占用带宽现象采集器频繁发送探测请求导致子网内部 RAG 服务压力增加测量数据反而反映出人为造成的延迟上升。常见原因测量周期过短或者探测数据包过大。部分实现会循环调用完整健康检查接口导致服务接口负载升高。检查方式查看子网服务日志中健康检查接口的调用次数。对比测量周期调大前后的延迟数据。检查采集器的探测响应体大小。解决方案将测量信息合并到业务请求的响应头中例如在响应头返回 load 和 queue_depth减少额外探测。探测请求只访问轻量接口不触发完整 RAG 检索。测量周期根据子网数量动态调整子网越多时适当拉长周期。预防建议生产环境不要用一次完整 RAG 查询做健康检查应准备专用的 /healthz 和 /metrics 接口。6.4 回退逻辑不生效现象选择的最优子网调用失败但请求没有切换到次优子网而是直接返回超时错误。常见原因路由代码只取了 candidates 的第一个没有实现逐级尝试或者回退调用后没有重新选择次优子网而是再次请求同一个不可用子网。检查方式检查路由器代码确认是否遍历 candidates。查看调用失败的异常类型判断是连接超时还是业务错误。在回退分支打印日志确认是否进入。解决方案def send_request_with_fallback(subnets, request): for subnet_id in subnets: try: return send_request(subnet_id, request) except RequestFailedError: continue raise AllSubnetsFailedError()预防建议回退次数必须有限制避免多个子网都失败时仍然做多次重试浪费请求时间。同时要把失败信息记录到日志用于告警。7. 生产环境落地建议7.1 测量数据抽稀与滑动窗口生产环境的指标采集建议同时做“时间维抽稀”和“异常值裁切”。时间维抽稀指不要在每个请求都发起一次测量而是由后台定时任务周期性更新指标快照。路由器只读取当前快照不主动探测。这样可以避免请求路径上的额外延迟。异常值裁切指对超过合理范围的数据做截断。例如延迟超过 1000ms 时可以直接按 1000ms 参与计算避免一个极端值导致归一化失效。可以使用下面的表格作为参数速查参数建议值作用设置过大设置过小采样周期3000ms 到 5000ms更新指标快照响应变化慢测量开销高滑动窗口5 到 10 次平滑抖动变化迟钝容易切换抖动延迟上限1000ms裁切异常值掩盖高延迟误判正常慢服务不可用阈值连续 3 次失败标记服务不可用反应慢瞬时抖动导致屏蔽7.2 多指标融合与权重设定权重设定没有绝对标准但可以参考以下原则延迟权重建议不低于 0.4因为 RAG 交互体验对延迟最敏感。负载权重和带宽权重根据业务调整。如果检索结果多为大文本带宽权重可以调高。可用性不应作为权重参与加权而应作为硬性门槛。需要定期用真实流量复盘权重是否合理。例如发现某个子网得分很高但实际请求失败率高说明评分指标缺少失败率维度应该加入“过去 1 分钟调用成功率”。7.3 日志、监控和回滚生产环境至少要记录三条日志测量日志每个周期各子网的指标和得分。选择日志每个请求选择的子网、候选列表和耗时。失败日志调用失败时的异常、回退结果和最终是否成功。监控指标至少要覆盖子网选择分布是否均匀。平均响应时间变化。失败率变化。回退触发次数。如果新版选择算法上线后失败率上升需要能通过配置开关快速回滚到上一版本。建议把权重和窗口大小都做成配置项不要硬编码在代码里。7.4 安全与权限On-Premise 环境下子网之间通常是内网通信但也不能忽略安全健康检查接口至少需要内部 Token 或 mTLS 认证防止外部直接探测。路由器所在节点应限制最小权限只允许访问子网服务端口不开放多余端口。测量数据如果包含业务标识则需要脱敏和访问控制。安全原则宁可配置更多认证也不要让测量接口成为信息泄露入口。8. 面向工程实践的扩展方向测量驱动的子网选择不是孤立的工具而是 RAG 工厂智能体基础设施中的一环。接下来可以根据业务需要扩展引入预测机制根据历史流量预测未来一段时间的子网负载提前调整选择策略而不是只对当前状态做反应。引入业务反馈把 RAG 检索结果的相关度、用户反馈加入评分形成业务质量感知的路由。引入多目标优化在延迟、成本、质量之间寻找帕累托最优而不是简单加权。与 Kubernetes 或容器编排平台集成把子网选择扩展为 Pod 级别的路由让测量数据直接驱动服务发现和流量分配。对于从零开始的团队建议先跑通本文的最小示例再加上真实健康检查接口最后逐步增加预测和反馈。这样每一步都能验证避免一次性引入过重设计。测量数据的价值在于让路由决策从“拍脑袋”变成“看数据”。在工厂智能体本地部署场景中这一思路不仅适用于子网选择也可以迁移到其他网络资源选择、服务路由和容量调度问题中。