Flink Agents:Memory 层级分析 (Sensory, Short-Term, Long-Term)
本篇主要分析 Flink Agents 框架中记忆 (Memory) 的整体架构与底层物理存储设计。重点解析为何需要分层、短期记忆如何将树状 JSON 映射到 Flink 扁平状态、以及长期记忆如何通过大模型实现自动压缩。
1. 为什么需要记忆分层?(架构演进)
在原生的 Flink 中,状态(State)通常是单一维度的(例如 ValueState 存聚合结果)。但在 Agent 场景中,状态的生命周期和访问模式发生了极大的分化:
- 感知记忆 (Sensory Memory) :
- 特点:极高频读写,生命周期极短(仅限当前事件处理)。
- 存储 :堆内内存 + Flink 状态。每次
Event处理完毕后会自动清空。
- 短期记忆 (Short-Term Memory) :
- 特点:高频读写,需要支持复杂的嵌套 JSON 操作,生命周期伴随整个会话。
- 存储 :Flink
MapState(背后是 RocksDB)。
- 长期记忆 (Long-Term Memory) :
- 特点:体积巨大(如历史文档、几个月的对话),需要语义检索 (Embedding)。
- 存储:外部向量数据库 (Vector Store)。
这种分层设计(类似计算机的 L1 Cache、RAM 和 硬盘),使得系统能够在吞吐量、持久化和上下文容量之间取得完美平衡。
2. 感知记忆:单次处理期的上下文 (Sensory Memory)
定义 :单条输入事件在内部 ReAct 循环期间使用的临时上下文(如 messages、工具返回片段)。
生命周期 :仅在"本次输入处理"有效,产出 OutputEvent 后立刻清空。
存储路径:
- 运行中:优先放在算子内存的
MemoryContext,减少 JNI 往返; - 挂起/恢复:与短期记忆共用
MapState/CachedMemoryStore管道,确保 Checkpoint 崩溃后能接着跑; - 收尾:调用清理逻辑释放感知记忆(见 ActionExecutionOperator.java#L582-L595)。
为何需要单独一层:
- 避免跨事件污染:ReAct 多轮拼接的中间上下文不应进入会话级别的短期记忆;
- 保障恢复:处理中途崩溃时,需要把"这次输入的现场"随状态一起恢复;但处理完成后必须干净地清空;
- 复杂度拆项:单次处理(块内)主要受外部 I/O 主导;跨事件(块间)通过收尾清空避免内存膨胀。
连贯例子(一次天气查询):
- 收到
InputEvent("查北京天气"),ChatModelAction写入 messages=[用户提问] 到感知记忆; - LLM 返回 tool_call("search_weather"),messages 追加"模型决定",产生
ToolRequestEvent; - 工具返回结果,messages 追加"工具返回",再次产生
ChatRequestEvent; - LLM 产出最终答案,生成
ChatResponseEvent->OutputEvent;进入收尾:清空感知记忆(见上文清理入口)。 - 崩溃恢复:若在步骤 2/3 之间崩溃,恢复后感知记忆随状态恢复,继续在本次输入的现场续跑;完成后仍会清空。
3. 短期记忆:树状扁平化与延迟刷盘 (Short-Term Memory)
在流转架构中,Agent 经常需要读写类似 Python 字典的复杂嵌套结构。如果直接把一个巨大的 JSON 对象存入 Flink ValueState,哪怕只修改一个深层字段,也会导致整个对象的反序列化与重新全量写入,造成极大的 I/O 放大。
用户是否需要感知并主动操作:
- 对"存储在哪里、何时刷盘、如何序列化"这三件事,用户不需要感知,框架会在
RunnerContext后面自动完成。 - 对"写什么业务字段、跨几轮输入要保留什么",用户需要感知,因为这决定了你把数据放在短期还是长期。
连贯例子(同一个用户的两轮输入):
- 第 1 轮输入:
InputEvent("我叫张三")。你的 Action 从输入中提取结构化信息,并写入短期记忆:memory.set("profile.name", "张三")
- 第 2 轮输入:
InputEvent("我今年几岁")。Action 读取短期记忆,发现profile.name已存在,于是可以把它拼进 prompt 或直接用于业务判断。 - 清理规则:短期记忆不会像感知记忆那样自动清空,它会伴随这个 Key 的会话一直存在,直到用户逻辑显式覆盖或删除。
为了解决这个问题,框架在 MemoryObjectImpl 中实现了一套精妙的扁平化序列化机制 。参考 MemoryObjectImpl.java#L227-L258。
3.1 MemoryItem 与 subKeys 树状打平
框架将嵌套对象打平存储在 Flink 的 MapState<String, MemoryItem> 中:
- 叶子节点存值 (Value Node) :当写入
memory.set("user.age", 25)时,底层存为MapState.put("user.age", MemoryItem(value=25, type=VALUE))。 - 父节点存树结构 (Object Node & subKeys) :在写入叶子前,框架会向上回溯,生成中间节点:
MapState.put("user", MemoryItem(type=OBJECT, subKeys=["age", "name"]))。参考 MemoryObjectImpl.java#L205-L225。
- 过程与原理 (How & Why) :
如果没有subKeys这种目录结构,要在 Flink 的扁平MapState中执行memory.get("user").getFieldNames(),必须遍历包含上万个 Key 的整个 Map 并做前缀匹配。有了subKeys,框架只需 O(1) 复杂度取出user节点即可返回其子字段列表。
3.2 延迟刷盘缓存 (CachedMemoryStore)
- 过程 (How) :框架在
MapState之上封装了CachedMemoryStore。在当前ActionTask执行期间,所有的读写都优先在堆内的HashMap缓存中进行。当算子挂起或结束时,统一调用persistCache()刷盘。 - 原理 (Why) :兼顾了单线程的绝对安全与极高吞吐量,将 RocksDB JNI 调用开销推迟并合并。每次更新还会生成
MemoryUpdate,这是跨语言执行(如 Python 进程同步)时状态对齐的关键依据。
4. 长期记忆与自动压缩机制 (Long-Term Memory)
随着 Agent 运行,外部向量库的数据量会持续增长,导致检索时召回过多历史记录,撑爆大模型的 Context Window。
用户是否需要感知并主动操作:
- 长期记忆通常是显式的:用户需要决定哪些文本值得沉淀,并调用
MemorySet.add()写入。 - 检索也是显式的:用户需要在合适的 Action 中调用
MemorySet.search(),把召回结果拼接进当前这次输入的感知记忆或 prompt。
连贯例子(知识沉淀与检索问答分离):
- 知识沉淀流:每天进入一批研报摘要。Action 对每条摘要打标签或做评分,只把高价值内容写入长期记忆:
memorySet.add(["研报结论A", "研报结论B"], ...)
- 问答流:用户问"最近这家公司怎么样"。Action 先检索:
docs = memorySet.search("公司名", limit=5)- 把
docs作为额外上下文加入到当前这次输入的感知记忆(或直接拼进 prompt),再请求 LLM 生成答案。
- 压缩:当条数达到
capacity,框架会异步触发摘要压缩,把多条旧记录合并为少量摘要记录,避免长期记忆无限膨胀。
4.1 写入与隔离 (VectorStoreLongTermMemory)
-
命名隔离 (Name Mangling) 的深层意义 :
在 Flink 分布式环境中,成千上万个用户的请求(不同的 Key)在同一个算子中并发执行。如果用户 A 和用户 B 在 Action 代码中都调用了ltm.getOrCreateMemorySet("chat_history"),它们绝对不能把数据写到同一个外部向量数据库表里,否则就会发生极其严重的数据泄露(例如用户 A 问问题,却检索出了用户 B 的私密对话)。
为了解决这个问题,框架在底层对用户指定的MemorySet名称进行了自动混淆 (Mangling) :
参考 VectorStoreLongTermMemory.java#L246-L248:javaprivate String nameMangling(String name) { return String.join("-", this.jobId, this.key, name); }通过拼接
JobId(防止同集群不同任务污染)和Key(防止同任务不同用户污染),用户代码里看似全局相同的"chat_history",在实际发送给外部 Elasticsearch 或 Chroma 建立 Collection 时,会变成类似my_agent_job-user_10086-chat_history的物理隔离表。这是一种经典的 逻辑与物理隔离映射 思想。 -
元数据注入 :调用
memorySet.add()时,框架会自动将文本包装为Document,注入created_time和compacted标记,并由底层驱动进行向量化。
4.2 异步压缩机制 (CompactionFunctions)
当 MemorySet 的条数超过 capacity 时,会触发自动压缩。
-
异步执行 (Async) :压缩涉及耗时的大模型调用,框架通过
CompletableFuture.runAsync将其提交到专用的后台线程池,防止阻塞 Flink Mailbox 主线程。 -
压缩算法 (The Summarization Logic) :
核心逻辑位于 CompactionFunctions.java#L86-L160。
- 提取与 Prompt :查出历史条目,套用内置的
DEFAULT_ANALYSIS_PROMPT,要求大模型输出包含summarization和来源messages索引的严格 JSON。 - 原子替换 :解析 JSON 后,根据索引删除旧记录 (冗长的历史),然后将简短的摘要作为新记录插入 ,并合并被删除消息的时间轴(
created_time_start和end)。
- 提取与 Prompt :查出历史条目,套用内置的
-
系统复杂度拆项:
- 未压缩:
O(向量库全量扫描)+O(大模型处理超长上下文)-> 主导项是大模型的极高延迟与成本。 - 压缩后:
O(后台定期大模型总结)+O(大模型处理精简上下文)-> 主导项变为可控的闲时计算开销。
- 未压缩:
具象化类比 :
这就好比一个人记日记(向量库)。当写满 50 页时,他花周末时间(异步线程)把 50 页提炼成 3 句核心感悟写在新纸上(summarization),然后撕掉那 50 页(delete)。这样大脑(大模型)永远不会被细节撑爆,又保留了核心知识。