先接着 Mem0 的问题往下走。外置 memory layer 的好处是应用可以把对话交给一层服务, 让它抽取、去重、排序,再把少量结果放回模型上下文。Letta 换了一个切入点: 长期工作的 agent 需要恢复的是一个运行对象,里面有偏好、人格、工具能力、当前消息窗口和文件视图。 这些内容如果只在回答前临时检索,agent 每次都像刚刚被拼装出来。

阅读契约。 读完这一篇,应该能回答三件事:Letta 的 memory_blocks 和 Mem0 的 memory records 有什么不同; 为什么 Block 更新会影响 system prompt;Letta Code 的 MemFS 为什么更像“agent 的可版本化上下文”,而不只是文件同步。

为了避免把 Block 读成“检索命中的文本片段”,这一篇只追踪一条代表性内容: human block 里写着“用户希望回答短一点”。它从创建 agent 时进入状态,回答时被编译进 system prompt, 更新时可能触发 prompt rebuild,恢复 agent 时又跟 message window、tools 和 identity 一起回来。 关键词是 state projection:Letta 先维护 agent 拥有的状态,再在构造请求时把这部分状态投影进 model view。

阶段 触发者 持久 owner model view 的变化
创建 agent 创建请求里的 memory_blocks AgentState.blocks 偏好随 agent 初始化,后续请求可以直接投影。
保存快照 运行时持久化 agent state AgentState.blocks 记忆和 model、tools、message ids 一起恢复。
进入模型 构造 LLM 请求 Memory.compile() 读取 blocks block 被编译成这一轮 system prompt 的一部分。
后续更新 用户、工具或接口修改 block AgentState.blocks;Letta Code 还会映射到 MemFS 文件上下文 下一轮重新 compile 时,第一条 system message 可能变化。

证据边界。 本文只使用 letta-ai/lettaletta-ai/letta-code 的公开 README 与源码。 代码链接使用当前公开 main 路径;托管服务、模型策略和私有后台任务不写成源码事实。

一、Letta 从创建 agent 就开始谈记忆

1.1 先决定 agent 拥有什么,再决定回答时查什么

Letta README 的 quickstart 很直接:创建 agent 时可以传入 memory_blocks, 示例里两个 block 分别标成 humanpersona。这不是回答时临时检索出来的片段, 而是 agent 初始化状态的一部分;同一个创建请求里还会指定模型和工具。 这一点可以从 README 示例CreateAgent.memory_blocks 两边互相印证。

class CreateAgent(BaseModel, validate_assignment=True):
    name: str = Field(default_factory=lambda: create_random_username())

    # memory creation
    memory_blocks: Optional[List[CreateBlock]] = Field(
        None,
        description="The blocks to create in the agent's in-context memory.",
    )
    tools: Optional[List[str]] = Field(None, description="The tools used by the agent.")

这段入口契约已经把 memory 和 tools 放在同一个创建面上。也就是说,Letta 不是等用户发起 query 后才去找一条旧事实; 它先决定这个 agent 出生时带着哪些长期上下文,后面再由运行时把这些上下文投影进当前 step。

这个入口很重要。外置 memory layer 的典型问题是“这次 query 应该取回哪些历史片段”;Letta 的第一问更早: 这个 agent 被创建时就应该带着哪些长期身份、用户画像和工作约束。前者把记忆当作回答前的候选材料, 后者把记忆当作 agent 可恢复状态的一部分。于是同样一句“用户喜欢简洁回答”,在 Mem0 里更像一条可检索 memory record, 在 Letta 里更像 human block 或 persona 约束,后续每次重建 agent 时都要重新进入模型视野。

创建时:
  1. caller 传入 memory_blocks、model、tools
  2. server 生成 AgentState
  3. memory blocks 成为可持久化 state

回答时:
  1. Memory.compile() 渲染 blocks
  2. system prompt / model view 获得当前记忆
  3. step 成功后消息窗口再 checkpoint

1.2 AgentState 把记忆、工具和消息窗口放在同一张快照里

更关键的是 AgentState 的定义。源码把它描述为“某个时刻的 agent state”,并且说这些信息足以重建一个持久化 agent。 这个 state 里有 message_ids、system prompt、agent type、model、compaction settings、 blocks、tools、sources、tags、secrets、identities 等字段。 换句话说,Letta 的长期记忆并不是单独挂在旁边的一张 facts 表,而是和工具、消息窗口、文件、身份一起进入 AgentState

class AgentState(OrmMetadataBase, validate_assignment=True):
    """Representation of an agent's state... persisted in the DB backend."""

    message_ids: Optional[List[str]] = Field(...)
    system: str = Field(..., description="The system prompt used by the agent.")
    model: Optional[str] = Field(None, description="The model handle used by the agent.")
    blocks: List[Block] = Field(..., description="The memory blocks used by the agent.")
    tools: List[Tool] = Field(..., description="The tools used by the agent.")
    secrets: List[AgentEnvironmentVariable] = Field(default_factory=list)
    identities: List[Identity] = Field([])

这段源码把 Letta 的 ownership 讲得很清楚:恢复 agent 时,memory blocks、工具、消息窗口和身份一起回来。 如果只讲“Letta 有 memory blocks”,读者会漏掉这个更重要的事实:block 的生命周期跟 agent state 对齐, 不跟某一次检索请求对齐。

这解释了 Letta 为什么读起来不像“记忆插件”。如果一个长期 agent 要连续几周工作,系统要恢复的不是一个 facts list, 而是“这个 agent 当时是谁、有哪些工具、当前消息窗口到哪里、哪些上下文块会编译进 prompt、哪些 sources 和 identities 仍然有效”。 AgentState 把这些面向恢复的对象放到一起,等于把记忆问题推进到 runtime ownership: 记忆不只是被搜到的内容,而是恢复一个 agent 时必须重新装配的材料。

在 Mem0 里常见的问题 Letta 里的对应边界 这意味着什么
哪些事实值得长期保存? memory_blocks / blocks 稳定信息先成为 agent state 的一部分。
本轮模型能看到什么? Memory.compile() 与 system prompt block 会被渲染成模型可见上下文,而不是只在检索层等待查询。
对话历史如何延续? message_ids / conversation in-context messages 成功 step 之后,当前消息窗口会被 checkpoint。
记忆如何被长期维护? block update、block history、MemFS 记忆更新会影响 prompt,也能被历史记录或 git-backed 文件体系承接。

二、Block 是上下文里的保留区,不是检索结果

2.1 Block 有预算、标签和权限,所以它不是普通文本片段

Block 在源码注释里被定义为 LLM context window 的 reserved section。它至少有 valuelimitlabelread_onlydescriptionhidden 等字段。 默认的 HumanPersona block 也在同一个文件里给出。 这就解释了为什么 Letta 的记忆读起来像“状态槽”:每个 block 有标签、描述、字数限制和权限,而不只是一个相似度命中的文本片段。

class BaseBlock(LettaBase, validate_assignment=True):
    """Base block of the LLM context"""

    value: str = Field(..., description="Value of the block.")
    limit: int = Field(CORE_MEMORY_BLOCK_CHAR_LIMIT)
    label: Optional[str] = Field(None, description="Label of the block...")
    read_only: bool = Field(False, description="Whether the agent has read-only access.")
    description: Optional[str] = Field(None, description="Description of the block.")
    hidden: Optional[bool] = Field(None, description="If set to True, the block will be hidden.")

这几个字段共同保护一个边界:哪些内容可以稳定占用上下文,哪些内容可以被工具或系统改写。 limit 让 block 不至于无限膨胀;read_only 让某些 block 更像系统约束; description 给模型或工具解释这个 block 应该放什么;hidden 则给运行时保留“不一定直接显示”的空间。 如果把它误解成“向量检索结果”,就会错过 Letta 的核心:block 是上下文里的结构化槽位,槽位本身也有治理规则。

2.2 compile() 把 state 变成这一轮的 model view

这些 block 最终要进入模型视野。Memory 把它描述为 agent 的 in-context memory,包含带标签的 Block 对象以及编辑这些 block 的工具。 标准渲染路径会输出 <memory_blocks>,并把每个 block 的 label、description、metadata 和 value 写进去; compile() 再根据 agent type、模型供应商和 git-backed memory 模式选择渲染方式。

def compile(self, tool_usage_rules=None, sources=None, max_files_open=None, llm_config=None, client_skills=None) -> str:
    """Efficiently render memory, tool rules, and sources into a prompt string."""

    if not is_react:
        if self.git_enabled:
            self._render_memory_blocks_git(s)
        elif is_line_numbered:
            self._render_memory_blocks_line_numbered(s)
        else:
            self._render_memory_blocks_standard(s)

这一层可以看成 Letta 的“模型视图投影”。持久化 state 里可以保存很多对象,但模型这一轮只能看到被编译出来的视图。 所以 Letta 的难点不是“有没有长期存储”,而是每次 step 前如何把 block、sources、tool rules 和消息窗口投影成稳定的 prompt。 这也是它和 Mem0 的一个关键差异:Mem0 的 model view 常由检索结果临时注入;Letta 的 model view 先由 agent state 编译出来, 再在当前 step 里和新消息、工具调用一起运行。

Letta 记忆机制图,展示 memory_blocks 成为 AgentState 后经 Memory.compile 进入 system prompt,并在 step success 后更新 message_ids 与 DB state
Letta 的主线是 state ownership:block 属于 agent state,经 Memory.compile() 进入 system prompt;成功 step 后再 checkpoint 当前消息窗口。

三、为什么更新 block 会牵动 system prompt

3.1 先判断是不是 prompt-affecting change

如果 block 是模型上下文里的保留区,那它一旦变化,系统 prompt 也可能需要重建。Letta 的 BlockManager 明确列出影响 prompt 的 block 字段:descriptionlabellimitread_onlyvalueupdate_block_async 会判断字段是否真的变化;如果是 prompt-affecting change,就重建所有关联 agent 的 system prompt。

PROMPT_AFFECTING_BLOCK_FIELDS = {"description", "label", "limit", "read_only", "value"}

has_prompt_changes = any(
    key in PROMPT_AFFECTING_BLOCK_FIELDS and getattr(block, key) != value
    for key, value in update_data.items()
)

if has_prompt_changes:
    await self._rebuild_system_prompts_for_connected_agents(block_id, actor)
形状示例:block 改动如何影响 model view

before:
  block(label="human", value="用户希望回答短一点")
  compiled system prompt contains:
    <memory_blocks>
      <human>用户希望回答短一点</human>
    </memory_blocks>

update:
  value = "用户希望先给结论,再展开细节"

after rebuild:
  AgentState.blocks 持久化新值
  第一条 system message 里的 memory projection 也随之更新

下一轮构造模型请求时,运行时会从 AgentState.blocks 重新编译 memory projection, 第一条 system message 因此发生变化。这就是 Letta 的注入点:运行时把 agent state 投影进 prompt,作为这类上下文进入模型的路径。

这里的判断比“写数据库”多一层含义。修改 block metadata 可能只是后台记录变化;修改 valuelabellimit,则可能改变模型下一轮看到的上下文。Letta 把这类字段单独列出来,等于承认 memory update 是 prompt update 的上游。没有这道判断,系统可能出现两种坏结果:数据库里已经是新记忆,但模型仍带着旧 system prompt; 或者每个无关字段变化都重建 prompt,造成不必要的写放大。

3.2 重建 prompt 和 checkpoint 消息窗口是两件事

这一步是 Letta 和“外置检索层”的明显分水岭。Mem0 更像在回答前检索一组可注入事实; Letta 的 block 更新则会改变 agent 的基底视图。旧系统消息里有没有当前 memory string, _rebuild_memory 会重新编译 memory、sources 和 tool rules,必要时更新第一条 system message。 也就是说,block 不是“查到才生效”,它更像 agent 的人格、用户画像和局部工作台的一部分。

另一方面,Letta 也不是把所有对话都永久塞进 prompt。v3 agent 的 _checkpoint_messages 只在 step 成功后持久化新消息,并更新 message_ids 或 conversation 的 in-context message 集合。 如果系统 prompt 本身超出 context window,源码会单独报 context_window_overflow_in_system_prompt, 说明 block、工具和 system instructions 这一层也有自己的容量边界。

动作 改变的对象 如果混在一起会怎样
更新 block 长期 state 和后续 system prompt 投影。 把它当普通消息,会丢掉“长期槽位”语义。
重建 memory / system prompt 模型下一轮看到的基底上下文。 如果不重建,state 和 model view 会分叉。
checkpoint messages 当前 step 成功后的消息窗口和恢复位置。 如果把所有历史都当 block,prompt 会快速膨胀。

四、Letta Code 把记忆推进到本地长期工作

4.1 MemFS 把上下文资产变成可版本化文件

Letta Code README 把自己称为 stateful agent harness:agent 有 memory、identity 和随时间积累的 experience。 它的功能表里有两项和这一篇最相关: Self-improvement & Learning 说 agent 会重写自己的 context,包括 memory blocks、skills、prompts; MemFS 则说所有 context,包括 memory blocks,都通过 git 跟踪。

这一步把 Letta 的 state ownership 又往前推了一格。普通 memory block 解决的是“长期信息怎样进入 agent state”; MemFS 解决的是“这些上下文资产怎样随着工作积累、被审计、被回滚、被其他机制读取”。如果一个 agent 会长期写代码、总结经验、 调整技能,那么 memory 不再只是用户画像,而会变成一组工作资产:prompt、skills、notes、文件化的上下文和它们的版本历史。

4.2 git-backed rendering 让文件和 prompt 之间有明确投影

Letta 本体里也能看到 git-backed memory 的形状。 _render_memory_blocks_git 会把 system/persona 渲染成 <self>, 其他 system/* block 渲染到 <memory> 的嵌套标签里, 还会把外部 block 作为 file tree 投影出来。compile_available_skills 也会从 skills/ block 中渲染 agent-scoped skills。 所以 MemFS 不是“顺手把文本放进 git”,而是在把 agent 的上下文资产变成可投影、可版本化、可被 prompt 编译读取的文件系统。

这里也有边界:git-backed memory 并不意味着模型可以任意改写所有上下文,也不意味着文件内容天然适合进 prompt。 关键仍然是投影规则:哪些 block 变成 <self>,哪些进入 <memory>, 哪些 skills 被列入可用能力。Letta Code 的价值,是把长期上下文从“一团 prompt 文本”拆成可管理资产; 真正进入模型的一轮视图,仍然由编译路径决定。

五、和 Mem0 放在一起看

5.1 选型时先问:记忆是服务拥有,还是 agent 自己拥有

Mem0 和 Letta 都在解决长期记忆,但它们的主问题不同。Mem0 适合回答“应用怎样把跨会话事实交给一层 memory service 管”; Letta 适合回答“一个 agent 怎样带着自己的状态、工具、消息窗口和可编辑记忆持续工作”。 前者的中心是 retrieval-time context assembly,后者的中心是 stateful agent reconstruction。

问题 更像 Mem0 更像 Letta
记忆主要由谁拥有? 外置 memory layer agent state
记忆什么时候进入模型? 回答前检索并注入 编译进 system prompt 或当前上下文
更新会影响什么? memory store 的记录和后续检索排序 block、本轮可见上下文、关联 agent 的 system prompt
更适合哪类场景? 多个应用共享用户事实与偏好 长期运行、可自我维护、带工具和身份的 agent

下一篇进入 Graphiti / Zep,问题会再换一次:如果记忆不是 state slot,而是带时间有效期和来源的 context graph, 系统怎样回答“以前成立、现在不成立、是谁告诉我的”。

参考资料