一、引言:用户只问了一句话,八个 Agent 各说各话
先看一个真实场景。你基于 Dify 搭了一套企业智能客服系统,里面跑了八个子 Agent:订单查询、物流跟踪、售后理赔、优惠计算、库存查询、支付咨询、发票开具、人工转接。用户发来一句:"我上周买的那台电脑,物流怎么还没到?退款的话多久能到账?"
这句话里有两个诉求:查物流、问退款。于是编排层把任务拆给物流 Agent 和售后 Agent。物流 Agent 查完说"在途,预计明天到",售后 Agent 说"退款 3-5 个工作日到账"。看起来没问题,但用户紧接着追问:"那我能同时申请退款吗?还是等收到货再退?"
问题来了:这句话该由谁回答? 物流 Agent 知道订单在途,售后 Agent 知道退款规则,但没有一个 Agent 同时知道"订单状态 + 退款政策 + 用户此前的诉求",上下文散落在不同 Agent 的会话里。最终要么是某个 Agent 瞎猜,要么是编排层把两份对话历史全塞给某个 Agent------token 翻倍、噪声翻倍、还可能答非所问。
这就是多 Agent 系统里最隐蔽也最致命的工程问题:会话状态与上下文管理。单 Agent 系统里,会话就是一份 message 数组,追加、截断、完事;多 Agent 系统里,一份会话被拆成多份,状态要跨 Agent 传递、同步、恢复,任何一环出问题,用户体感就是"它失忆了"或"它精神分裂了"。
这一期,我们就把多 Agent 的会话管理讲透:状态怎么建模、上下文怎么传、冲突怎么解决、断线怎么续接,最后落到 Dify 工作流里的工程实现。上一期我们让系统"越炸越稳",这一期我们让系统"记得住、传得对、接得上"。
二、从"消息列表"到"共享工作记忆":会话管理的三层模型
2.1 单 Agent 时代的会话:一个数组打天下
单 Agent 聊天机器人里,会话上下文就是一个消息数组:
messages = [
{"role": "system", "content": "你是订单助手"},
{"role": "user", "content": "查一下订单 12345"},
{"role": "assistant", "content": "订单 12345 已发货,预计明天送达"},
]
每次调用模型,把整个数组塞进 prompt,超出窗口就做截断或摘要。逻辑简单,状态只有一份,不存在同步问题。这是所有聊天应用的起点,但也是多 Agent 系统里最不该照搬的模型------因为一份数组承载不了多个执行者的状态。
2.2 多 Agent 时代的会话:四层状态模型
多 Agent 系统里,"会话"至少包含四个互相独立又彼此关联的状态层:
| 状态层 | 内容 | 生命周期 | 谁读写 |
|---|---|---|---|
| 用户会话层 | 用户画像、偏好、历史对话 | 长期(跨会话) | 入口 Agent |
| 任务上下文层 | 当前任务目标、约束、中间结果 | 短期(本次任务) | 执行 Agent |
| 协作上下文层 | Agent 间交接信息、调用链记录 | 任务期 | 编排 Agent |
| 共享黑板层 | 多 Agent 共同读写的状态(订单状态、用户意图) | 任务期+ | 所有相关 Agent |
这四层各有各的读写方、各有各的生命周期。把四层混成一份 message 数组,是绝大多数多 Agent 上下文事故的根源------物流 Agent 的中间推理混进了用户看到的对话,售后 Agent 的私有草稿被当成事实传给用户,共享黑板的订单状态被两个 Agent 同时覆盖。
2.3 一个关键心智模型:上下文是"工作记忆",不是"聊天记录"
很多团队把"上下文管理"等同于"聊天记录管理",这是认知偏差。多 Agent 系统的上下文,本质上是团队的工作记忆:谁在做什么、做到哪一步、发现了什么、答应了用户什么。聊天记录只是工作记忆的一部分输入。
用这个心智模型看问题,很多设计决策就清晰了:
- 该记什么?------工作记忆里该放的是"结论、承诺、进度、约束",而不是"每一句原始对话";
- 该忘什么?------临时推理、已完成的中间结果、与当前任务无关的历史,都是该清理的工作记忆垃圾;
- 该怎么传?------传的是"交接单",不是"会议录音"。
三、会话状态建模:哪些该记、哪些该忘、记多久
3.1 状态分类:先给状态打标签
在设计会话状态之前,先对系统里所有可能的状态做一次分类盘点。我的建议是按"敏感度 × 生命周期 × 作用域"三个维度打标签:
@dataclass
class SessionState:
key: str # 状态键,如 order_status
scope: str # user / task / coordination / blackboard
ttl: int # 生命周期(秒),-1 表示永不过期
sensitive: bool # 是否敏感(手机号、地址、支付信息)
writer: str # 主写者 Agent,如 order_agent
readers: list # 可读 Agent 列表
version: int = 0 # 版本号,用于冲突检测
举个例子,一次"查物流 + 问退款"的会话里:
user_preference(用户偏好):scope=user,ttl=-1,sensitive=False,writer=entry;order_status(订单状态):scope=blackboard,ttl=3600,writer=order_agent,readers=logistics, after_sale;refund_policy_hit(退款政策命中结果):scope=task,ttl=600,writer=after_sale;user_phone(用户手机号):scope=user,sensitive=True,只允许售后 Agent 在必要环节读取。
给状态打标签的最大价值:让"该记/该忘"从拍脑袋变成可执行规则------scope 决定谁能读写,ttl 决定何时过期,sensitive 决定谁有权限碰。
3.2 遗忘策略:没有遗忘机制的记忆系统是垃圾场
长期记忆系统最常见的病是"只进不出"。多 Agent 会话状态必须有三道遗忘闸门:
- TTL 过期:任务上下文层的状态(如"退款政策命中结果")任务结束即失效,设短 TTL 自动清除;
- 容量上限:共享黑板设最大条目数,超出按"最后写入时间 + 优先级"淘汰最不相关的;
- 相关性清理:会话进行中,定期评估每条状态与当前任务的相关性,低于阈值就归档到冷存储。
遗忘不是丢数据,是把短期工作记忆和长期记忆分开:工作记忆保持轻量,长期记忆负责沉淀用户画像和业务规则。
3.3 Dify 里的落地:会话变量与会话 ID
Dify 原生提供了两个关键机制:
- 会话变量(Conversation Variables):在应用级定义,跟随会话存在,所有工作流节点都可以读写。这天然就是"共享黑板层"的载体;
- 会话 ID(Conversation ID):每次用户消息都会带上会话 ID,用它把多轮消息串联成同一份上下文。
工程上的做法:把 3.1 的状态模型映射到会话变量,命名规范用 scope_key 前缀(如 blackboard_order_status、task_refund_hit),写一个统一的状态读写工具节点,所有 Agent 通过它访问状态,而不是各写各的变量------这就为后面的"一致性"章节打下了基础。
四、上下文传递:传结论不传对话,传引用不传副本
4.1 三种传递方式:全量、摘要、引用
多 Agent 交接上下文,常见有三种策略,成本与保真度依次变化:
| 策略 | 做法 | token 成本 | 信息保真 | 适用场景 |
|---|---|---|---|---|
| 全量传递 | 把完整对话历史塞给下一个 Agent | 高 | 最高 | 单 Agent 深度长对话 |
| 摘要传递 | 用 LLM 生成结构化摘要再传递 | 中 | 高(有损) | 跨 Agent 任务交接 |
| 引用传递 | 传会话 ID + 查询接口,按需取数 | 低 | 可控 | 共享黑板、知识库、数据库 |
全量传递在多 Agent 场景下几乎总是错误选择:假设八个 Agent 每人 2000 token 的对话历史,汇聚到编排层就是 16000 token 的噪声堆。上一期成本治理我们算过,多 Agent 系统的 token 消耗中,上下文膨胀通常占 40% 以上,而膨胀的根源就是全量传递。
4.2 摘要传递:分层摘要(Rolling Summary)的正确姿势
摘要传递不是简单让 LLM 总结一段话,而是要做分层:
- 细节层(保留最近 2-3 轮原始消息):保证近因信息不丢失;
- 摘要层(更早的历史压缩成要点):只保留"结论、承诺、状态变更",丢弃"过程性对话";
- 事实层(结构化字段):订单号、金额、时间这些必须精确的,绝不能靠 LLM 摘要(会编造),直接从状态库取值。
一个可落地的分层摘要实现:
import json
from typing import List, Dict
def rolling_summary(messages: List[Dict], llm, keep_recent: int = 3,
summary_so_far: str = "") -> Dict:
"""分层摘要:近 N 轮保原文,更早的并入摘要,结构化事实单独提取"""
recent = messages[-keep_recent:]
old = messages[:-keep_recent]
# 1. 事实层:从消息里正则提取结构化字段(不经过 LLM,保证精确)
facts = extract_facts(old) # {"order_id": "12345", "amount": 8999.0}
# 2. 摘要层:只把"无结构化落点"的信息交给 LLM
if old:
prompt = (
"以下是多轮对话的早期部分,请压缩为要点清单,只保留:"
"①已确认的事实 ②对用户的承诺 ③状态变更 ④未决问题。"
"丢弃寒暄、重复、过程性思考。\n\n" + json.dumps(old, ensure_ascii=False)
)
new_summary = llm.chat(prompt)
summary_so_far = merge_summary(summary_so_far, new_summary)
# 3. 组装返回:事实层 + 摘要层 + 细节层
return {
"facts": facts,
"summary": summary_so_far,
"recent": recent,
}
关键点:结构化事实绝不进 LLM 摘要。LLM 摘要适合"压缩语义",不适合"保存精确值"------它会在你不知道的时候把 8999 改成 8900。订单号、金额、地址、时间,全部走事实层。
4.3 引用传递:上下文即查询
引用传递的思路是:上下文不搬家,给 Agent 一个"门牌号" 。交接时传 session_id + 查询协议,接收方需要什么再按需取:
- 会话历史 → 调历史查询接口(只取需要的片段);
- 订单状态 → 查共享黑板或业务库;
- 用户画像 → 查用户档案服务。
这在 Dify 里的落地非常自然:Agent 的工具节点本身就是"按需取数"的通道,把"读上下文"设计成工具而不是把上下文塞进 prompt。引用传递的额外好处是天然解决一致性问题------大家读的是同一份数据源,而不是各自拷贝的过期副本。
五、一致性:两个 Agent 同时改一份状态怎么办
5.1 冲突从哪来
多 Agent 并发写同一个状态,是必然发生的事。典型场景:
- 用户同时问了"物流"和"退款",物流 Agent 更新
order_status为"在途",售后 Agent 同时读取它准备回答,读到的是旧值; - 两个 Agent 分别给用户承诺了不同的送达时间;
- 编排层把同一任务分发给了两个候选 Agent(超时重试导致的重复执行)。
5.2 三个治理手段
手段一:主写者模式(Single Writer) 。每个状态只允许一个 Agent 写,其他 Agent 只读。order_status 归订单 Agent 写,物流 Agent 和售后 Agent 都只读。冲突在源头消失------不是靠锁,是靠"职责单一"。这是最推荐的第一道防线,成本最低、效果最好。
手段二:乐观锁版本号。允许并发写,但每次写都带版本号:
def update_state(store, key: str, new_value, expected_version: int) -> bool:
"""乐观锁:版本不匹配则拒绝写入,返回 False 由调用方重读重试"""
current = store.get(key)
if current.version != expected_version:
return False # 冲突,调用方需重新读取后合并再写
store.put(key, State(new_value, version=current.version + 1))
return True
写失败的一方执行"重读 → 合并 → 重试",合并规则可以是"以最新为准"或"以主写者为准"。
手段三:事件溯源(Event Sourcing) 。不存最终状态,存状态变更事件流(order_status_changed: in_transit@14:32),任何时刻的状态由事件流重放得出。优点是天然有审计轨迹、可回滚;缺点是实现重,适合订单、资金这类高价值状态。
5.3 冲突仲裁规则
当合并不可避免时,仲裁规则要有明确的优先级,并写进文档:
- 事实类冲突(订单号、金额):以业务系统为准,LLM 输出绝不参与仲裁;
- 承诺类冲突(给用户的承诺):以先承诺者为准,后承诺者必须引用前文("按之前客服的说法...");
- 状态类冲突(任务进度):以主写者为准;
- 仲裁失败:宁可转人工/明确说"我需要再确认",也不许编造。
六、会话续接:让用户感觉"它一直记得我"
6.1 断点从哪来
多 Agent 任务链路长,中断是常态:子 Agent 超时、模型 API 限流、编排进程重启、用户离开几小时再回来。用户回来问"刚才说到哪了",系统如果一脸茫然,前面所有上下文工程都白做了。
6.2 会话快照 + 状态重建
工程上要保证两件事:会话可快照、状态可重建。
def snapshot_session(session_id: str) -> str:
"""把会话的完整状态打成快照,返回快照 ID"""
state = collect_states(session_id) # 四层状态全量收集
recent_msgs = fetch_recent_messages(session_id)
snapshot = {
"states": state,
"recent_messages": recent_msgs,
"created_at": now(),
}
return persist_snapshot(session_id, snapshot)
def restore_session(session_id: str, snapshot_id: str) -> None:
"""恢复:先重建状态,再重放关键消息,最后验证一致性"""
snap = load_snapshot(snapshot_id)
restore_states(session_id, snap["states"]) # 1. 恢复四层状态
replay_to_agents(session_id, snap["recent_messages"]) # 2. 通知相关 Agent 重放
verify_consistency(session_id) # 3. 校验黑板状态无冲突
注意恢复顺序:先状态后消息。如果先重放消息再恢复状态,Agent 在重放过程中读到的是空状态,可能产生错误中间结果。
6.3 超时任务的"诚实交接"
如果任务确实中断且无法恢复(比如订单 Agent 宕机超过 TTL),系统要做的不是假装记得,而是诚实地说"我需要重新确认"------这比编造一个上下文强一百倍。我们的多 Agent 故障演练(上一期)里验证过:用户对"系统明确说不知道"的容忍度,远高于"系统编造了一个错误的订单状态"。
七、Dify 工程实战:一套完整的会话上下文管理方案
7.1 架构总览
把上面的理论落到 Dify 工作流,我推荐的结构是:
入口节点
├─ 会话恢复检查(用户回来?→ 从快照恢复状态)
├─ 状态读取节点(读会话变量 → 组装上下文包)
├─ 路由节点(按意图分发到子 Agent)
│ ├─ 订单 Agent / 物流 Agent / 售后 Agent ...
│ └─ 每个 Agent 执行后:写回状态节点(乐观锁)
├─ 上下文压缩节点(对话超长 → 分层摘要)
├─ 响应组装节点(结论 + 引用来源)
└─ 会话快照节点(异步保存)
7.2 会话变量设计规范
在 Dify 的"会话变量"里按规范定义:
blackboard_order_status # 共享黑板:订单状态(订单 Agent 主写)
blackboard_refund_hit # 共享黑板:退款政策命中(售后 Agent 主写)
task_current_intent # 任务层:当前用户意图
user_phone # 用户层:敏感,脱敏存储
ctx_facts # 事实层:结构化字段 JSON
ctx_summary # 摘要层:滚动摘要文本
命名即契约:作用域_业务名,谁主写、谁能读,写进 README 并在工作流里用"变量赋值节点"统一收敛写入口,禁止子 Agent 直接改别人的域。
7.3 上下文压缩节点:Dify 里的 Rolling Summary
Dify 工作流里可以用"代码节点"实现 4.2 的分层摘要。注意两个工程细节:
- 触发条件:不要每轮都摘要,设阈值------最近消息数 > 15 或估算 token > 窗口 60% 才触发;
- 摘要用独立会话调用:摘要生成使用单独的 LLM 调用(低温度、专用 prompt),不走主对话,避免污染主上下文。
7.4 完整链路示例:物流+退款的多轮会话
跑一遍完整流程验证设计:
- 第 1 轮 :用户问物流。路由 → 物流 Agent,读
blackboard_order_status(空)→ 查业务库写状态"在途",把事实写入ctx_facts,回复"预计明天送达"; - 第 2 轮:用户问退款。路由 → 售后 Agent,读黑板拿到订单状态"在途",结合退款政策回复"建议先收货再申请退货";
- 第 3 轮:用户问"收货后退款多久到账"。路由 → 售后 Agent,此时上下文包里:事实层(订单号、状态)、摘要层(前两轮的结论与承诺)、细节层(本轮),直接给出精确回答,全程无需重查订单------因为订单 Agent 已经把状态写进黑板,售后 Agent 读的是同一份最新数据,而不是自己上一轮的拷贝。
这个链路里,四个设计点各司其职:黑板消除状态孤岛、主写者消除写冲突、分层摘要控制 token、会话快照保证可恢复。
八、踩坑清单:多 Agent 会话管理的六个坑
- 把聊天记录当上下文全量塞:token 爆炸 + 噪声淹没关键信息,必须分层(事实/摘要/细节);
- 让 LLM 摘要订单号:LLM 会"合理"地编造精确值,结构化字段一律正则/工具提取;
- 所有 Agent 直接写共享变量:没有主写者约束,状态互相覆盖,加版本号 + 单写者;
- 恢复时先重放消息后恢复状态:Agent 在空状态下产生错误中间结果,顺序必须是先状态后消息;
- 敏感字段明文进上下文:手机号、地址进了共享黑板就可能被任意 Agent 读走,必须脱敏 + 最小授权;
- 只记不忘:任务层状态不清理,会话越拖越臃肿,TTL 和容量上限要设成默认值而非可选项。
九、FAQ:六个高频问题
Q1:多 Agent 上下文一定要用共享黑板吗?单传引用不行吗?
引用传递解决"读一致性",黑板解决"写一致性"。如果只有读没有写,引用就够了;一旦多个 Agent 都要更新同一状态(订单、进度),就必须有黑板 + 主写者。
Q2:分层摘要会丢信息吗?
会,但丢的是"过程性信息",保的是"结论性信息"。验收标准:用户追问"你刚才说的那个承诺",系统必须答得出来------答不出就是摘要丢了承诺,摘要质量不合格。
Q3:会话快照多久打一次?
按业务价值定。高频高价值会话(涉及支付、订单)每轮异步快照;普通问答 3-5 轮一次即可。快照是异步的,别阻塞主链路。
Q4:子 Agent 之间的上下文会不会互相污染?
会,所以要"作用域隔离":任务层状态随任务销毁,黑板层状态按业务域隔离(blackboard_order_* 只允许订单域 Agent 读写)。命名规范 + 读写白名单是两道闸。
Q5:Dify 会话变量能存大对象吗?
能存但别存大。会话变量适合存结构化状态(事实层、进度),大段文本走外部存储(Redis/数据库),变量里只放引用。
Q6:用户隐私数据在共享黑板里怎么保护?
三层:脱敏存储(手机号存 138****1234)、最小授权(白名单限定读者)、任务结束即清理(TTL 设短)。敏感字段永远不进 LLM 摘要。
十、总结:上下文工程的三个铁律
这一期把多 Agent 会话管理与上下文工程讲完了。如果只能带走三条:
- 分层:事实层保精确、摘要层保语义、细节层保近因,三层各司其职,绝不混装;
- 定界:每个状态有作用域、有主写者、有 TTL------该谁读、该谁写、该活多久,先定清楚再写代码;
- 可恢复:任何时刻的会话都能快照、都能重建,恢复顺序先状态后消息,恢复不了就诚实承认。
回想开头的场景:八个 Agent 各说各话的智能客服,在分层上下文 + 共享黑板 + 主写者 + 快照恢复这套体系下,用户追问"能不能同时申请退款",售后 Agent 读到黑板里的最新订单状态和此前的承诺,给出的是"建议先收货再退货,退款 3-5 个工作日到账"------上下文连贯、状态一致、承诺不冲突。用户感知是:这个客服记得我说过什么。
多 Agent 系统的工程能力,就藏在这些"看不见的记得"里。
下一期我们聊聊多 Agent 系统的安全边界与权限治理------当智能体越来越多、能调的工具越来越强,怎么防止一个被攻破的 Agent 成为整个系统的突破口。
📚 延伸阅读
如果你对 DeepSeek 的实战用法感兴趣,推荐阅读我的另一篇文章:
👉 DeepSeek 实战指南:提示词工程、API 集成与效率提升全攻略
这篇文章系统地拆解了 DeepSeek 的提示词工程技巧、API 封装方法以及日常效率提升场景,全文代码可直接运行,适合已经上手 DeepSeek 但希望更高效使用的开发者。
Dify 多 Agent 实战系列:
本文是"华为云Flexus+DeepSeek征文"系列文章之一。该系列基于华为云 Flexus 云服务器与 MaaS 平台 DeepSeek 推理服务,结合 Dify 工作流引擎,从部署、Agent 开发、评测、成本治理到稳定性建设,系统拆解企业级 AI 应用的工程实践。