Agent产品的水平扩展设计:无状态化改造与会话亲和性方案

📅 2026/7/12 15:24:59
Agent产品的水平扩展设计:无状态化改造与会话亲和性方案
Agent产品的水平扩展设计无状态化改造与会话亲和性方案一、深度引言Agent产品从原型走向规模化部署时最先暴露的瓶颈通常是单实例架构。一个Agent实例承载所有用户会话当并发从几十上升到数千内存占用、CPU竞争和请求排队问题会同时出现。水平扩展是必然选择但Agent系统有两个天然障碍会话上下文的持续性要求和LLM调用的长连接特性。过去两年我在多个Agent产品中验证了一套组合方案——通过无状态化改造消除节点依赖再用会话亲和性路由降低延迟开销。本文记录这套方案的设计要点与关键实现。Agent系统的水平扩展不能简单套用Web应用的负载均衡模型。Web请求通常是无状态的任意节点可处理任意请求。但Agent的每次对话轮次都依赖之前的上下文包括历史消息、Tool Use执行状态和中间推理结果。如果前一轮在节点A处理、下一轮被路由到节点B而节点B没有恢复上下文的能力用户体验就是Agent失忆。解决这个问题的两条路径是要么让所有节点都能访问完整上下文无状态化要么保证同一会话始终落在同一节点会话亲和性。实践中两者需要配合使用。二、原理剖析Agent会话的水平扩展架构可以用以下流程图描述flowchart TB LB[负载均衡层] SR[会话路由层] N1[Agent节点A] N2[Agent节点B] N3[Agent节点C] RC[(Redis集群br/会话存储)] MQ[消息队列br/异步任务] LLM[LLM API服务] LB --|会话ID哈希| SR SR --|亲和性路由| N1 SR --|亲和性路由| N2 SR --|亲和性路由| N3 N1 --|写入/恢复| RC N2 --|写入/恢复| RC N3 --|写入/恢复| RC N1 --|异步执行| MQ N2 --|异步执行| MQ N3 --|异步执行| MQ N1 -- LLM N2 -- LLM N3 -- LLM核心设计包含三个层次会话存储层将Agent的对话历史、内存状态和Tool Use中间结果序列化到Redis。每次对话轮次开始时从Redis恢复结束时写回。这要求Agent的状态必须是可序列化的不能依赖进程内存中的运行时对象。路由层在负载均衡器之后增加一层会话路由。根据会话ID的一致性哈希选择目标节点。这里的哈希策略需要在节点增减时最小化重映射范围通常使用带虚拟节点的一致性哈希环。异步任务层Agent调用Tool Use时可能涉及耗时操作需要将任务提交到消息队列异步处理。Agent节点轮询任务结果而非阻塞等待。这避免了长连接占用节点线程。三、生产级代码以下是以Go实现的会话路由与状态管理核心代码package router import ( context crypto/sha256 encoding/json fmt sort sync time github.com/redis/go-redis/v9 ) // SessionState 定义Agent会话的完整状态所有字段必须可序列化 type SessionState struct { SessionID string json:session_id Messages []Message json:messages Memory map[string]any json:memory ToolsState map[string]any json:tools_state UpdatedAt time.Time json:updated_at } // ConsistentHashRing 带虚拟节点的一致性哈希环 type ConsistentHashRing struct { // 为什么使用sync.RWMutex路由表的读远多于写节点变更才需要写 mu sync.RWMutex // ring存储哈希值到节点名的映射 ring map[uint32]string // 排序后的哈希值列表用于二分查找 sorted []uint32 // 每物理节点的虚拟节点数越大分布越均衡但内存占用越高 replicas int } // NewConsistentHashRing 创建哈希环 // replicas一般设为150~200经验值节点数×replicas≈5000时分布标准差5% func NewConsistentHashRing(replicas int) *ConsistentHashRing { return ConsistentHashRing{ ring: make(map[uint32]string), replicas: replicas, } } // AddNode 将节点加入哈希环 func (c *ConsistentHashRing) AddNode(nodeID string) { c.mu.Lock() defer c.mu.Unlock() for i : 0; i c.replicas; i { // 使用节点ID虚拟索引生成不同哈希避免所有虚拟节点坍缩到同一位置 key : fmt.Sprintf(%s#%d, nodeID, i) hash : c.hash(key) c.ring[hash] nodeID c.sorted append(c.sorted, hash) } sort.Slice(c.sorted, func(i, j int) bool { return c.sorted[i] c.sorted[j] }) } // RemoveNode 从哈希环中移除节点 func (c *ConsistentHashRing) RemoveNode(nodeID string) { c.mu.Lock() defer c.mu.Unlock() var newSorted []uint32 for hash, node : range c.ring { if node nodeID { delete(c.ring, hash) continue } newSorted append(newSorted, hash) } c.sorted newSorted sort.Slice(c.sorted, func(i, j int) bool { return c.sorted[i] c.sorted[j] }) } // GetNode 根据会话ID查找目标节点 func (c *ConsistentHashRing) GetNode(sessionID string) (string, error) { c.mu.RLock() defer c.mu.RUnlock() if len(c.ring) 0 { return , fmt.Errorf(哈希环为空无可用节点) } hash : c.hash(sessionID) // 二分查找第一个大于等于hash的位置 idx : sort.Search(len(c.sorted), func(i int) bool { return c.sorted[i] hash }) // 如果超出范围则回绕到第一个节点环状结构 if idx len(c.sorted) { idx 0 } return c.ring[c.sorted[idx]], nil } // hash 计算一致性哈希值 func (c *ConsistentHashRing) hash(key string) uint32 { h : sha256.Sum256([]byte(key)) // 取前4字节作为uint32以便二分查找比较 return uint32(h[0])24 | uint32(h[1])16 | uint32(h[2])8 | uint32(h[3]) } // SessionManager 管理Agent会话的存储与恢复 type SessionManager struct { rdb *redis.Client ring *ConsistentHashRing } // NewSessionManager 创建会话管理器 func NewSessionManager(rdb *redis.Client, ring *ConsistentHashRing) *SessionManager { return SessionManager{rdb: rdb, ring: ring} } // SaveSession 将会话状态写入Redis func (s *SessionManager) SaveSession(ctx context.Context, state *SessionState) error { state.UpdatedAt time.Now() data, err : json.Marshal(state) if err ! nil { return fmt.Errorf(序列化会话状态失败: %w, err) } key : fmt.Sprintf(agent:session:%s, state.SessionID) // TTL设为24小时过期的会话自动清理防止Redis内存泄漏 if err : s.rdb.Set(ctx, key, data, 24*time.Hour).Err(); err ! nil { return fmt.Errorf(写入Redis失败: %w, err) } return nil } // LoadSession 从Redis恢复会话状态 func (s *SessionManager) LoadSession(ctx context.Context, sessionID string) (*SessionState, error) { key : fmt.Sprintf(agent:session:%s, sessionID) data, err : s.rdb.Get(ctx, key).Bytes() if err redis.Nil { // 新会话返回初始状态 return SessionState{ SessionID: sessionID, Messages: make([]Message, 0), Memory: make(map[string]any), }, nil } if err ! nil { return nil, fmt.Errorf(读取Redis失败: %w, err) } var state SessionState if err : json.Unmarshal(data, state); err ! nil { return nil, fmt.Errorf(反序列化会话状态失败: %w, err) } return state, nil } // RouteRequest 路由请求到目标节点 func (s *SessionManager) RouteRequest(sessionID string) (string, error) { return s.ring.GetNode(sessionID) } // Message 定义消息结构 type Message struct { Role string json:role Content string json:content }上述代码实现了两个核心组件ConsistentHashRing负责会话路由SessionManager负责状态持久化。一致性哈希环的设计要点是使用虚拟节点来保证负载均衡replicas参数越大各节点负载越均匀但哈希环的内存占用也相应增加。四、边界权衡有状态 vs 无状态的取舍纯无状态方案每次请求都从Redis恢复全量上下文的优点是节点完全对等、任意节点可处理任意请求。代价是每次对话轮次都要序列化/反序列化整个会话栈当消息历史积累到数百条时网络IO和反序列化开销不可忽略。我们的实测数据显示1000条消息的会话在Redis往返耗时约30ms加上JSON反序列化约15ms总计45ms的额外延迟。与此对比会话亲和性方案下节点内存中保持热会话只需在会话切换时写回一次。命中率在95%以上时额外延迟几乎为零。但代价是节点故障时正在处理的会话会丢失内存中的增量更新。混合方案实践中我们采用混合方案亲和性路由作为默认策略同时每个对话轮次结束异步写回Redis。这样做的好处是正常情况享受亲和性的低延迟节点故障时最多丢失当前轮次的增量而非整个会话算是可接受的权衡。WebSocket连接的特殊处理Agent的流式响应通常基于WebSocket这种长连接天然带有会话亲和性连接建立后不会切换节点。如果WebSocket断线重连到其他节点新节点需要从Redis恢复上下文后才能继续。这里需要客户端配合重连时携带会话ID新节点在onOpen阶段从Redis恢复状态然后推送未完成的响应。五、总结Agent产品的水平扩展本质上是在有状态与无状态之间做权衡。一致性哈希路由配合Redis会话存储在大多数场景下可以满足需求。核心实现要点是虚拟节点保证负载均衡、异步写回降低延迟、WebSocket重连时依赖Redis做上下文恢复。选型时需要根据业务场景的延迟容忍度和数据一致性要求在亲和性与无状态方案之间做出取舍。