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 主导;跨事件(块间)通过收尾清空避免内存膨胀。

连贯例子(一次天气查询):

  1. 收到 InputEvent("查北京天气")ChatModelAction 写入 messages=[用户提问] 到感知记忆;
  2. LLM 返回 tool_call("search_weather"),messages 追加"模型决定",产生 ToolRequestEvent
  3. 工具返回结果,messages 追加"工具返回",再次产生 ChatRequestEvent
  4. LLM 产出最终答案,生成 ChatResponseEvent -> OutputEvent;进入收尾:清空感知记忆(见上文清理入口)。
  5. 崩溃恢复:若在步骤 2/3 之间崩溃,恢复后感知记忆随状态恢复,继续在本次输入的现场续跑;完成后仍会清空。

3. 短期记忆:树状扁平化与延迟刷盘 (Short-Term Memory)

在流转架构中,Agent 经常需要读写类似 Python 字典的复杂嵌套结构。如果直接把一个巨大的 JSON 对象存入 Flink ValueState,哪怕只修改一个深层字段,也会导致整个对象的反序列化与重新全量写入,造成极大的 I/O 放大

用户是否需要感知并主动操作

  • 对"存储在哪里、何时刷盘、如何序列化"这三件事,用户不需要感知,框架会在 RunnerContext 后面自动完成。
  • 对"写什么业务字段、跨几轮输入要保留什么",用户需要感知,因为这决定了你把数据放在短期还是长期。

连贯例子(同一个用户的两轮输入):

  1. 第 1 轮输入:InputEvent("我叫张三")。你的 Action 从输入中提取结构化信息,并写入短期记忆:
    • memory.set("profile.name", "张三")
  2. 第 2 轮输入:InputEvent("我今年几岁")。Action 读取短期记忆,发现 profile.name 已存在,于是可以把它拼进 prompt 或直接用于业务判断。
  3. 清理规则:短期记忆不会像感知记忆那样自动清空,它会伴随这个 Key 的会话一直存在,直到用户逻辑显式覆盖或删除。

为了解决这个问题,框架在 MemoryObjectImpl 中实现了一套精妙的扁平化序列化机制 。参考 MemoryObjectImpl.java#L227-L258

3.1 MemoryItem 与 subKeys 树状打平

框架将嵌套对象打平存储在 Flink 的 MapState<String, MemoryItem> 中:

  1. 叶子节点存值 (Value Node) :当写入 memory.set("user.age", 25) 时,底层存为 MapState.put("user.age", MemoryItem(value=25, type=VALUE))
  2. 父节点存树结构 (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。

连贯例子(知识沉淀与检索问答分离):

  1. 知识沉淀流:每天进入一批研报摘要。Action 对每条摘要打标签或做评分,只把高价值内容写入长期记忆:
    • memorySet.add(["研报结论A", "研报结论B"], ...)
  2. 问答流:用户问"最近这家公司怎么样"。Action 先检索:
    • docs = memorySet.search("公司名", limit=5)
    • docs 作为额外上下文加入到当前这次输入的感知记忆(或直接拼进 prompt),再请求 LLM 生成答案。
  3. 压缩:当条数达到 capacity,框架会异步触发摘要压缩,把多条旧记录合并为少量摘要记录,避免长期记忆无限膨胀。

4.1 写入与隔离 (VectorStoreLongTermMemory)

  • 命名隔离 (Name Mangling) 的深层意义
    在 Flink 分布式环境中,成千上万个用户的请求(不同的 Key)在同一个算子中并发执行。如果用户 A 和用户 B 在 Action 代码中都调用了 ltm.getOrCreateMemorySet("chat_history"),它们绝对不能把数据写到同一个外部向量数据库表里,否则就会发生极其严重的数据泄露(例如用户 A 问问题,却检索出了用户 B 的私密对话)。
    为了解决这个问题,框架在底层对用户指定的 MemorySet 名称进行了自动混淆 (Mangling)
    参考 VectorStoreLongTermMemory.java#L246-L248

    java 复制代码
    private 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_timecompacted 标记,并由底层驱动进行向量化。

4.2 异步压缩机制 (CompactionFunctions)

MemorySet 的条数超过 capacity 时,会触发自动压缩。

  • 异步执行 (Async) :压缩涉及耗时的大模型调用,框架通过 CompletableFuture.runAsync 将其提交到专用的后台线程池,防止阻塞 Flink Mailbox 主线程。

  • 压缩算法 (The Summarization Logic)

    核心逻辑位于 CompactionFunctions.java#L86-L160

    1. 提取与 Prompt :查出历史条目,套用内置的 DEFAULT_ANALYSIS_PROMPT,要求大模型输出包含 summarization 和来源 messages 索引的严格 JSON。
    2. 原子替换 :解析 JSON 后,根据索引删除旧记录 (冗长的历史),然后将简短的摘要作为新记录插入 ,并合并被删除消息的时间轴(created_time_startend)。
  • 系统复杂度拆项

    • 未压缩:O(向量库全量扫描) + O(大模型处理超长上下文) -> 主导项是大模型的极高延迟与成本。
    • 压缩后:O(后台定期大模型总结) + O(大模型处理精简上下文) -> 主导项变为可控的闲时计算开销。

具象化类比

这就好比一个人记日记(向量库)。当写满 50 页时,他花周末时间(异步线程)把 50 页提炼成 3 句核心感悟写在新纸上(summarization),然后撕掉那 50 页(delete)。这样大脑(大模型)永远不会被细节撑爆,又保留了核心知识。

相关推荐
zhaoshuzhaoshu1 天前
人工智能(AI)发展史:详细里程碑
人工智能·职场和发展
Luke~1 天前
阿里云计算巢已上架!3分钟部署 Loki AI 事故分析引擎,SRE 复盘时间直接砍掉 80%
人工智能·阿里云·云计算·loki·devops·aiops·sre
weixin_156241575761 天前
基于YOLOv8深度学习花卉识别系统摄像头实时图片文件夹多图片等另有其他的识别系统可二开
大数据·人工智能·python·深度学习·yolo
夕除1 天前
javaweb--02
java·tomcat
QQ676580081 天前
AI赋能轨道交通智能巡检 轨道交通故障检测 轨道缺陷断裂检测 轨道裂纹识别 鱼尾板故障识别 轨道巡检缺陷数据集深度学习yolo第10303期
人工智能·深度学习·yolo·智能巡检·轨道交通故障检测·鱼尾板故障识别·轨道缺陷断裂检测
小陈工1 天前
2026年4月7日技术资讯洞察:下一代数据库融合、AI基础设施竞赛与异步编程实战
开发语言·前端·数据库·人工智能·python
tq10861 天前
组织的本质:从科层制到伴星系统的决断理论
人工智能
ailvyuanj1 天前
2026年Java AI开发实战:Spring AI完全指南
java
科技与数码1 天前
互联网保险迎来新篇章,元保方锐分享行业发展前沿洞察
大数据·人工智能
张np1 天前
java进阶-Dubbo
java·dubbo