Agent产品的水平扩展设计:无状态化改造与会话亲和性方案
2026/7/12 15:24:58 网站建设 项目流程

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做上下文恢复。选型时需要根据业务场景的延迟容忍度和数据一致性要求,在亲和性与无状态方案之间做出取舍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询