ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

多Agent编排中的状态管理:共享记忆、分布式状态与一致性实践

多Agent编排中的状态管理:共享记忆、分布式状态与一致性实践 做了两年多多 Agent 编排我最大的体会是真正拖垮系统的往往不是模型能力而是状态管理。单 Agent 对话只需要维护一个上下文窗口多 Agent 协作却要同时面对共享记忆、分布式状态、一致性这三座大山。举个最常见的例子主控 Agent 把任务拆给两个执行 Agent第一个 Agent 刚更新完任务目标第二个 Agent 还在用旧快照继续跑最后汇总时两份结果互相矛盾——这不是模型笨是状态同步出了问题。这篇文章是我把这类系统上线、踩坑、重构之后总结出来的完整思路覆盖共享记忆怎么做、分布式状态如何收敛、一致性怎么保证末尾还附了一套可以直接照抄的状态管理骨架以及三个真实事故的完整排查过程。适合正在用 LangGraph、AutoGen、CrewAI 之类框架搭多 Agent 应用、但状态一复杂就头疼的读者。1. 为什么多 Agent 的状态管理天然比单 Agent 难一个量级1.1 单 Agent 状态是线多 Agent 状态是网单 Agent 的状态演进基本是一条直线用户说一句你回一句上下文窗口按顺序膨胀。问题最多是 token 超限处理方式就是截断或者摘要思路非常线性。多 Agent 不一样。状态会分叉又会合并。比如 Planner 派了三个子任务三个 Worker 并行跑中间还要互相交换中间结果最后 Aggregator 汇总。这期间每个节点都有自己的对话历史、任务上下文同时又要共享任务目标、用户意图这类全局信息。状态不再是线性的而是图有分支、有汇聚、有回溯。这就是为什么过去做 Chatbot 时那套“对话状态管理”思路拿到多 Agent 下必须推翻重做。你没法用一个全局 context 塞给所有人也没法让每个 Agent 只守着自己那一亩三分地。另外还有一层状态很容易被忽略协作协议本身的状态。谁在等谁的结果、哪些任务当前处于阻塞、哪个资源被占用——这些不写在任何单个 Agent 的上下文里必须由系统层面统一管理。如果你把“上下文拼接”当成唯一的状态方案规模上来之后一定会出问题。不是内存爆是语义爆所有 Agent 看到的上下文里混入了大量与自己无关的信息推理质量肉眼可见地下降。1.2 共享与隔离的边界全局状态、会话状态与私有状态共享记忆最大的误区是把大家记得的东西全倒进一个桶里。那样确实实现了共享代价是噪声污染和 token 爆炸。关键是按共享范围和生命周期把状态分层。我在项目里通常分三档全局状态任务目标、用户画像、协作计划、关键约束。所有人必须看到且所有人必须对其有一致的认知。生命周期长写权限要非常收敛一般只有协调者能写。会话状态当前轮次的子任务、中间产物、已完成的工具调用记录。属于部分 Agent 共享可能是局部共享。私有状态单个 Agent 的对话历史、偏好设定、推理草稿。其他 Agent 不需要看到。往下还要再看读写权限。读可以放开写必须收敛。我给客户设计状态模型时一定会问一个问题这个字段有几个主人如果答案大于一下一步就必须设计合并规则或引入版本控制。一个字段如果谁都能写它必然不稳定。状态层级典型内容共享范围读写特性存储建议全局状态任务目标、用户意图、协作计划所有 Agent多读单写Redis/DB 版本号会话状态子任务进度、中间结果部分 Agent多读多写需协商Redis owner 标记私有状态个体上下文、偏好、草稿单个 Agent单读单写内存/本地库分层之后还要处理生命周期全局状态通常随任务结束归档会话状态随轮次结束清理私有状态随 Agent 实例销毁。生命周期定义错了就会在错误的时间读到已经不应该存在的状态典型例子是重复使用了上一个任务的全局目标。2. 共享记忆的三种实现路线与选型对比2.1 路线一上下文拼接与全量快照最省事的做法把全局状态序列化成文本块注入每个 Agent 的 system prompt。很多团队的 demo 阶段都这么干因为它零依赖、完全可解释模型也能直接看到所有信息。代价也很直接。token 成本随状态规模线性上涨很快触及上下文窗口状态每次更新所有相关 Agent 的快照都要重新构建构建期间其他 Agent 还在写快照天生滞后。更隐蔽的问题是全量拼接会把所有字段都变成“重点”模型会把噪声字段也当成决策依据。我实测下来当快照文本超过 3K token模型的指令遵循度会出现可见的漂移——它开始自作主张地忽略某些字段或者过于关注某些不重要的字段。所以这条路线只适合 Agent 数量少三五个以内、状态量小、变化频率低的场景。内部工具、原型验证可以用生产环境当主方案基本撑不住。2.2 路线二向量记忆库共享记忆的另一种理解是“让 Agent 在需要的时候想起该想起的事”。向量记忆库本质上是一个语义寻址的外部记忆。做法是把历史事件、决策理由、用户偏好切成块embedding 后写入向量库Agent 在工作开始或遇到关键节点时基于当前任务做语义检索把 TopK 相关记忆注入上下文。优点很突出不受上下文窗口限制可以积累长期记忆每个 Agent 拿到的是“按需裁剪过的共享记忆”而不是全文广播。但有几个坑必须提前处理。第一个是召回质量不稳定语义相近但事实上无关的历史块可能被错误召回。第二个是时效性旧决议在语义相似度上往往依然排前面Agent 可能基于一个已经被推翻的决策继续执行。第三个是召回块的排序排序直接影响推理我实践下来按时间倒序比按相似度排序稳定得多。还要强调一点不要把关键状态完全押在向量检索上。任务目标、截止时间、用户最新意图这类确定性信息必须走结构化存储向量库只用来补充“背景知识”和“历史经验”。检索有随机性而关键状态必须确定性可达。2.3 路线三结构化状态服务所谓结构化状态服务就是提供一个带读写接口的共享状态层Agent 通过工具函数去读、去写指定字段。常见实现是 Redis 哈希或者关系数据库。它解决的问题是精确精确地存储、精确地取用、还可以加并发控制。比如任务 A 的状态字段Agent 要更新时调用update_task_status(task_idA, new_statusrunning, expected_version3)。每个参数都是明确的没有模糊空间比在 prompt 里塞一句话可靠得多。缺点同样真实你得让 Agent 具备“调用工具”的能力而 LLM 自动填参数偶尔会填错。缓解办法是把接口粒度做小不要设计update_task(task_json)这种大而全的接口而是拆成update_task_status(task_id, new_status)这样的小操作。字段越少越不容易填错。从工程角度看结构化状态服务是公共依赖性能要求高。调用超时要短失败要支持安全重试存储端要有持久化和备份。它在整个共享记忆体系里扮演“锚点”的角色。2.4 混合架构与选型建议路线实现成本运行时成本容量上限实时性抗噪声性适用场景上下文拼接低token 线性增长受上下文窗口限制差按需注入差Demo、少量 Agent、低频状态向量记忆库中embedding 检索近无限中中长期记忆、经验积累、背景知识结构化存储中高低大高高任务状态、关键字段、并发控制最终我的默认组合是全局关键字段放结构化存储保证确定性和一致性会话级短期记忆用上下文 buffer 维护这是 LangGraph checkpointer 这类组件擅长的活长期记忆放向量库。记住一句话共享记忆共享的是“上下文的可寻址能力”不是“内容的全文广播”。这句话想明白架构不会跑偏。3. 分布式状态写冲突从单写模型到可收敛的并发协议3.1 冲突从哪来并发写入与过期覆盖多个 Agent 共享一个状态字段时最经典的故障链是Agent A 在 t1 读到statusrunningAgent B 在 t1 也读到statusrunningA 在 t2 把 status 改成 done 并写成功B 在 t3 也把 status 改成 done但它附带的是自己的执行报告写成功后把 A 的补充信息覆盖了。数据看起来没有错但信息丢了。这就是过期覆盖stale write。多 Agent 场景为什么特别容易发生因为 LLM 的写入是高延迟、低频率、大对象的操作。Agent 在生成回复的几秒甚至几十秒里状态早就变了它写出来的内容天然基于旧视图。这与普通 Web 服务的短事务完全不同需要专门的并发策略。3.2 第一道防线所有权与单写者模型第一道防线不是并发控制而是减少并发。给每个字段声明 owner任务目标字段的 owner 是 Planner执行 Agent 只能读执行结果字段的 owner 是该任务的执行者。想改别人的字段怎么办发请求让 owner 来改或者走审批式更新。这样能消灭七八成的写冲突。缺点也存在协作链路变长如果某个 owner 恰好卡住或故障状态更新会被阻塞。缓解方法有两个把 owner 定义在字段级别而不是 Agent 级别一个 Agent 可以拥有多个字段避免单点瓶颈再配合超时转移规则owner 不可用时自动转入协调者处理。多 Agent 场景下“所有权”还天然符合业务直觉因为每个 Agent 角色本来就有职责边界。3.3 乐观锁与版本号最实用的并发控制乐观锁是性价比最高的并发控制方案。核心思路每个状态记录带一个 version写操作必须声明 expected_version只有与当前版本一致时才执行写入。用 Redis Lua 脚本做原子操作是最常见也最稳的实现if redis.call(hget, KEYS[1], version) ARGV[1] then redis.call(hset, KEYS[1], value, ARGV[2], version, ARGV[3]) return 1 end return 0这里有一个必须强调的工程细节版本号必须由存储端统一生成比如用 Redis INCR绝不能在各个服务实例本地自增。本地版本号的悲剧在于每个节点都认为自己持有的是最新版本冲突时两个节点各加一版本号永远相等乐观锁等于失效。这个坑我后面会在事故复盘里详细展开。写冲突发生之后怎么办按成本从低到高排序重新拉取最新状态合并后再写一次这是最常用的设置重试上限比如最多三次如果多次重试仍失败升级到协调者仲裁协调者可以是一个专门的 Supervisor Agent也可以是一段确定性的合并代码。3.4 操作日志、LWW 与状态机编排有些状态下允许丢失部分更新可以用 LWWlast-writer-wins配合全局单调时钟版本用时间戳或 Redis 序号。如果状态是纯数值聚合比如多个 Agent 各自贡献计数CRDT 这类单调结构很好用。但现实是多 Agent 的状态大量是文本描述和语义决策自动合并两个“看起来都对”的决策文本没有意义。对于复杂状态我建议不要追求自动合并而是把状态建模成状态机每个字段定义合法状态集合和合法转移路径谁来写、写什么、从哪转移到哪全部显式约束。这就是一致性正则化机制的本质。下面给一个多 Agent 编排示例一个协作任务的状态机pending --(Planner)-- running --(Worker)-- review --(Reviewer)-- done running --(Planner)-- pending 任务被打回重做 review --(Reviewer)-- failed 审核不通过走补偿流程每个转移必须记录 operator 字段留审计依据。状态机 owner 版本号三者组合基本能覆盖业务状态层面的绝大多数一致性问题。4. 一致性不是玄学副本同步、逻辑约束与事务补偿4.1 一致性问题的两个层面要谈一致性先分清两个层面。副本一致性讲的是同一个状态在多个存储副本或多个 Agent 缓存里是否相同。业务一致性讲的是状态之间是否满足业务不变量比如“任务 done 必须先经过 running”、“总进度不能超过 100%”。分布式事务一致性这个词被讨论最多但落到多 Agent 场景我观察到的线上故障大部分其实是业务状态不一致状态机跳到非法路径、跨 Agent 的约束被破坏、资源被重复分配。分清楚目的再选手段。要防“副本不同”用同步与共识要防“业务被破坏”用状态机与约束校验。两者不能互相替代。4.2 事件溯源事件日志作为唯一真相如果需要追溯“状态为什么会变成这样”事件溯源是答案。Agent 不直接修改状态而是追加事件TaskAssigned、TaskProgressed、TaskCompleted状态由事件流的投影计算而来。好处是完整审计、支持回放、数据修复只需要修正事件。坏处是读路径变长每次读取都需要构建投影事件量大了会膨胀实现工作量明显上升。我的实践建议只对关键业务实体的状态做事件溯源比如用户订单、生产任务这类需要审计和回放的实体。Agent 协作过程中的临时状态比如谁在等待谁、哪个锁被持有Redis 直接覆盖就够。全量事件溯源在 Agent 场景下会让简单的事情变得很重。4.3 幂等与 Saga 补偿Agent 世界的分布式事务LLM 应用里重试是常态网络抖动导致请求重发Agent 发现上下文不对自己重跑一次调度框架自动重试。任何一次重试如果副作用不幂等就会产生重复执行。比如一个 Agent 明明只应该创建一次资源因为重试创建了两次。解决办法是给状态写入加幂等键。每次操作携带 operation_id写入前先查去重表或者用 Redis SETNX 原子占用。伪代码如下def set_state_with_idempotency(key, value, operation_id): # 尝试原子占用 operation_id ok redis.set(fidem:{operation_id}, 1, nxTrue, ex3600) if not ok: return read_state(key) # 已经有请求执行过直接返回当前状态 write_state(key, value)跨 Agent 的操作链本质上就是分布式事务A 分配了资源B 执行失败不能假装没发生过。用 Saga 模式做反向补偿回滚状态、释放资源、通知相关方。注意补偿逻辑要写成确定性的代码不要依赖 Agent 的随机推理否则补偿过程本身又会引入新的不一致。4.4 一致性正则化机制把约定变成可执行代码我用“一致性正则化”来概括一套工程做法把系统里关于状态的所有行为约定写成显式的、可执行的规则。包括状态机定义、字段 owner 声明、版本协议、幂等键规则、超时转移规则。为什么强调“可执行”因为规则如果只在文档里LLM 不会稳定遵守规则如果是代码每次写入都强制校验。多 Agent 系统里的对话状态管理也要遵循同样的原则会话上下文里的关键事实必须能和结构化状态对应上。不然 Agent 嘴上说着最新目标内心可能还在用旧的上下文摘要。我见过不少翻车现场追根到底都是“提示词里说好了代码里没守住”。一致性还是成本工程。强一致的成本远高于最终一致不需要所有状态都强一致。我的分级是方向性状态如是否完成、是否通过、是否支付必须接近强一致描述性状态如补充说明、执行细节、中间结果可以最终一致。把一致性投入花在方向性状态上收益最高。5. 一套可直接落地的多 Agent 状态管理骨架5.1 总体架构与模块职责我目前的团队内部框架长这样文字版架构如下入口 Router负责任务分发和维护任务图知道当前有哪些 Agent、各自在做什么。StateService状态服务的唯一入口负责读写、校验、版本、幂等所有 Agent 的读写请求都走这里。存储层三件套Redis 放结构化状态与锁向量库存长期记忆数据库存事件日志与审计记录。Agent Runtime每个 Agent 侧挂一组状态工具集读工具、写工具、订阅读工具。订阅机制值得重点说。Agent 不需要轮询状态订阅自己关心的 key状态变化时收到通知再去拉取。订阅推送建议做本地广播不要直接让 Agent 连 Redis Pub/Sub隔离性更好也方便在推送链路上加过滤和节流。5.2 状态模型与核心接口状态模型我建议统一成下面的结构type StateScope GLOBAL | SESSION | PRIVATE; type StateRecord { key: string; scope: StateScope; owner: string; version: number; value: unknown; updatedBy: string; updatedAt: number; ttl?: number; };核心接口就五个读、写、局部更新、查询、订阅。写和局部更新都必须带 expectedVersion 和 operationId。查询支持按 scope 和 key 过滤当 value 是文本时还可以接向量检索。每个接口都要设计超时和重试语义避免因为状态服务一次抖动就让整个 Agent 任务失败。5.3 关键配置与防呆设计配置项可以在部署时统一声明我列一份接近生产使用的配置示例{ stateSchema: { task:status: { type: enum, allowedValues: [pending, running, review, done, failed], owner: planner, allowedTransitions: { pending: [running], running: [review, pending], review: [done, failed] }, idempotent: true }, task:result: { type: text, owner: worker, versioned: true } }, runtime: { stateServiceTimeoutMs: 500, maxRetry: 3, snapshotMaxTokens: 3000 } }防呆设计上有一个容易被忽略的点暴露给 Agent 的工具列表只放它允许操作的字段接口不要把所有接口都挂上去。这比运行时校验更能防呆。比如 Worker 只能看到update_task_result和read_task_status看不到全局目标的写接口。接口粒度永远小权限列表永远窄这两条做到位事故率能降一大截。5.4 一个读写流程的完整示例用一个具体流程收尾这一章。Agent A 完成子任务调用setStatesetState({ key: task:7:status, expectedVersion: 2, value: review, operationId: op_abc123 })StateService 收到请求依次做四件事校验 owner 是不是 planner 授权的角色校验状态转移是否合法也就是 review 是否在 running 的 allowedTransitions 里校验 expectedVersion 是否等于当前版本幂等检查 operationId 是否已存在。校验通过后写入成功version 变成 3然后向订阅了该 key 的 Agent B 广播变更通知。Agent B 收到通知后再调用getState拉取最新值。注意广播通知里只带 key 和 version不带全量数据。这样可以避免大对象在系统里到处传播同时保证收到通知的一方拿到的数据永远是最新的。如果通知里直接带数据反而又要处理“通知里的数据过期”这个新问题。6. 真实生产环境的坑三个状态事故的完整排查记录6.1 事故一版本号在本地自增导致乐观锁失效背景是两个服务实例共同编排一组 Agent各自维护了一个本地的 version 变量。现象是明明用了乐观锁更新仍然互相覆盖。排查时我先看日志。A 写成功时 expected_version2B 写成功时也是 2。按理说存储层的 version 应该从 1 升到 3两次写都携带 expected_version2第二次必然失败不可能都成功。继续追代码发现版本号是各实例在读取状态之后本地自增的B 实例读的时候看到 2没意识到 A 已经把状态改成了 3拿着 2 去写但存储层看到的也是它本地写的那个“2”。问题就出在“本地”两个字上。修复方案很简单版本号统一由 StateService 用 Redis INCR 生成Agent 侧只读取、不生成。教训也深刻乐观锁的前提是版本号有唯一的权威来源多来源的版本号等于没有版本号。6.2 事故二旧决议被向量检索反复召回背景是用向量库做共享记忆Agent 决策时会检索历史决议。现象是协作组已经通过新决议某个 Agent 还在按旧决议执行。排查时看检索记录发现旧决议块与新决议块语义相似度非常高TopK 召回时旧块常驻前排。更关键的是旧块没有失效标记它在向量库里始终是“活跃”的。Agent 检索到的历史里旧决议排前面新决议排后面模型自然更可能参考旧决议。修复分三块写入新决议时把同主题旧块的 active 字段置为 false检索时强制过滤 activetrue加入时间衰减权重越新的块权重越高。最有价值的教训是共享记忆必须设计遗忘机制记忆库不只追加还要有失效和覆盖。否则记忆越多系统越容易活在过去。6.3 事故三任务永久卡在 running背景是 Agent A 领取任务、占用资源中途异常退出没有释放资源。现象是资源被占用、任务状态一直 running后续依赖该资源的任务全部阻塞。排查时发现状态转移只覆盖了正常路径running 可以被 Worker 置为 review但没有任何机制处理“Worker 消失了”的情况。补偿逻辑没有挂在失败路径上所以异常退出后状态就冻结了。修复做了三件事给状态机加超时转移规则running 超过 N 分钟自动转 failed增加独立的巡检任务定时扫描超时状态并触发补偿把资源占用改成带 TTL 的租约过期自动回收。核心教训是状态转移不能只有正常路径异常路径必须有同样完整的转移规则。这也是为什么我把状态机和超时规则放在配置里而不是依赖 Agent 自觉。事故表象根因解法版本号失效乐观锁下仍互相覆盖版本号在服务实例本地自增无唯一权威来源版本号统一由 StateService/Redis INCR 生成旧决议召回Agent 使用已被推翻的决策向量记忆只追加不失效检索无过滤失效标记、时间衰减、关键决策走结构化存储任务永久挂起状态卡在 running资源被占异常路径缺少状态转移与补偿规则超时转移规则、巡检任务、资源租约 TTL这三个事故都不是模型能力问题全部是状态管理设计缺陷。排完这些坑之后我给自己定了一条原则状态管理方案的复杂度应该分布在这张表里——便宜状态用拼接重要状态用结构化长期记忆用向量一切写入可审计任何变更必须带版本号和操作者身份。最后再分享一个小技巧把状态写入接口设计成小粒度一次只改一个字段不要提供“大对象覆盖”的工具。比如只提供update_task_status和update_task_result不提供update_task_all。实测这个小习惯让我少解决了一半的写冲突也让 Agent 填参变得更稳定。多 Agent 的状态管理本质上是在和不确定性共舞我们能做的不是消除不确定性而是把不确定的影响圈在可控范围内。
返回列表