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)。这样大脑(大模型)永远不会被细节撑爆,又保留了核心知识。

相关推荐
HIT_Weston1 分钟前
65、【Agent】【OpenCode】用户对话提示词(费米估算)
人工智能·agent·opencode
njsgcs8 分钟前
我的知识是以图片保存的,我的任务状态可能也与图片有关,我把100张知识图片丢给vlm实时分析吗
人工智能
星爷AG I23 分钟前
20-4 长时工作记忆(AGI基础理论)
人工智能·agi
#卢松松#38 分钟前
用秒悟(meoo)制作了一个GEO查询小工具。
人工智能·创业创新
zandy101142 分钟前
Agentic BI 架构实战:当AI Agent接管数据建模、指标计算与可视化全链路
人工智能·架构
数字供应链安全产品选型43 分钟前
关键领域清单+SBOM:834号令下软件供应链的“精准治理“逻辑与技术落地路径
人工智能·安全
Flying pigs~~1 小时前
RAG智慧问答项目
数据库·人工智能·缓存·微调·知识库·rag
zuozewei1 小时前
从线下到等保二级生产平台:一次公有云新型电力系统 AI 部署复盘
人工智能
sanshanjianke1 小时前
AI辅助网文创作理论研究初步总结(一):AI辅助网文创作系统
人工智能·ai写作
碳基硅坊1 小时前
OpenClaw 落地应用实践:把 AI 从“能聊“变成“能干活“
人工智能·openclaw