LangChain系列之Agent 的记忆(下):让 Agent 记住你这个人

2026-09-15 19:050 条评论88 次阅读

Agent 的记忆(下):让 Agent 记住你这个人

上一篇我们把会话内的上下文管住了。裁剪、删除、摘要三招下去,一条 thread 聊到 100 轮也不心疼账单。

可换个会话,Agent 还是那个陌生人。

一、昨天刚说过,今天又问一遍

有个朋友跟我吐槽过一件事。

他给团队做了个运维助手,周五下午专门跟它交代过:我不用 Windows,环境全是 Ubuntu,命令别再给我 PowerShell 了。助手当时回得挺利索,明白了,记住了。

周一早上他再打开,第一句话是:请问您平时用什么操作系统?

他气得截图发我。我看了一眼就笑了,因为这个坑我踩过。

那句话说出口的那一刻,只活在周五那个会话里。会话一关,它跟着一起没了。

上一篇我们解决的是「这一轮聊得下去」。裁剪、删除、摘要,全都是在一条 thread 内部做文章,让上下文不失控、账单不爆炸。这套办法管得住的边界,就是 thread 本身。

thread 一换,一切归零。

于是三个问题浮上来:跨会话到底该记什么?这些记忆存在哪里?下次怎么准确地取回来?

这三个问题,这一篇逐个回答。中间还会绕一个更现实的问题:mem0、Zep、Letta 这些开源方案都做得挺好了,我为什么建议你先想清楚再决定用不用。

二、先想清楚:跨会话要记什么

动手之前先分类。

认知科学里有个挺有名的分类框架,叫 CoALA,把记忆分成三类。这套分法搬到 Agent 上一样好使,因为它逼你先回答一个问题:这条信息属于哪一类?

语义记忆(semantic):关于用户的事实与偏好。名字、公司、系统偏好、技术栈、说话喜欢听结论还是听推导。

情景记忆(episodic):发生过的事。上周排查过一次检索延迟,用的是火焰图,最后定位到慢查询。

程序性记忆(procedural):规则与方法。提交代码前必须先跑一遍测试,回复客户时不要用感叹号。

我习惯用三样东西类比它们:档案卡、工作日志、操作规程。

档案卡上写的是这个人是谁。工作日志上写的是我们之间发生过什么。操作规程上写的是以后遇到同类事情该怎么处理。

三类的差别不只是内容,取用方式也不一样。

类型 类比 典型内容 什么时候用得上
语义记忆 档案卡 用户偏好 macOS、结论先行、Python 后端 每次对话开场就该加载
情景记忆 工作日志 上次排查延迟用了火焰图 话题相似时按需召回
程序性记忆 操作规程 部署前必跑回归、回复不用感叹号 进入对应流程时注入

判断归属有个简单的口径:把这句话改写成「用户是 X」,语义记忆;改写成「用户做过 X」,情景记忆;改写成「以后遇到 X 都做 Y」,程序性记忆。

三句话的句式差别,就是三类的分界线。

还有一点要说清:三类的生命周期完全不同。系统偏好可能一年不变,排查经验三个月就过期,操作规程则希望你永远生效。混在一起放,后面做遗忘时根本下不了手。

分类是后面治理的前提。

三、存在哪:store 的四层结构

分类想清楚了,接着找个地方放。

LangGraph 给的答案是 store。它和 checkpointer 是一对,但管的事完全不同。checkpointer 按 thread 存档,store 跨 thread 存任意键值数据。

store 的结构分四层,从上到下是这样的:

store:记忆仓库

namespace:层级路径
('users', 'alice', 'memories')

key:唯一标识
记忆 id 或 UUID

value:JSON 字典
{content, type, importance}

第一层 store 是整个仓库。第二层 namespace 是个元组,本质上是层级路径,你可以把它当文件夹理解。

第三层 key 是这条记忆的唯一标识。第四层 value 是个普通字典,记忆本身装在这里面。

拿起来用的时候是这样的:

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()

# namespace 用元组,层级自己定
namespace = ("users", "alice", "memories")
store.put(namespace, "pref-os", {"content": "用户操作系统偏好是 Ubuntu"})

item = store.get(namespace, "pref-os")
print(item.value)        # {'content': '用户操作系统偏好是 Ubuntu'}
print(item.namespace)    # ('users', 'alice', 'memories')

get 拿回来的是一条 Item,除了 value 还带 namespace、key、created_at、updated_at 四个字段。时间戳是白送的,做时间衰减的时候正好用上。

namespace 的设计是这里唯一需要动脑的地方。

很多人的第一反应是给每个用户建一个 store。用户多了就得管一堆 store 实例,连接池、生命周期、扩容全得自己扛。用 namespace 就没这个问题:一个 store 装所有用户,隔离靠路径。

我实测确认过一件事。同一条 key 放在不同 namespace 下,就是两条完全独立的记忆:

store.put(("u1", "memories"), "k1", {"content": "用户操作系统偏好是 macOS"})
store.put(("u2", "memories"), "k1", {"content": "另一个用户"})

print(store.get(("u1", "memories"), "k1").value)   # macOS
print(store.get(("u2", "memories"), "k1").value)   # 另一个用户

keys 一样,取出来的东西两样。隔离的边界是 namespace,不是 key 的唯一性。这条认知很重要,它决定了你怎么设计路径。

后端选择上,官方给了几种:

后端 导入路径 适用
InMemoryStore langgraph.store.memory 本地开发、单元测试
PostgresStore langgraph.store.postgres 生产环境,需装 langgraph-checkpoint-postgres
MongoDBStore / RedisStore / UpstashStore 对应集成包 已有对应基础设施时

换成 Postgres 的写法和 checkpointer 几乎一样,from_conn_string 拿连接,setup() 建表,然后编译时传进去。

写到这里提醒一个官方文档里写得很清楚、但很容易被忽略的行为:namespace 是前缀匹配,不是精确匹配。

你传 ("alice",) 进去搜,("alice", "memories") 和 ("alice", "preferences") 下的东西全都会带出来。想限定在一层,要么传完整路径,要么拿到结果后在客户端按 namespace 过滤。

这个设计在「我想看这个用户所有记忆」时很顺手,但在「我只要 memories 这一域」时就是个坑。

四、怎么存怎么取:三个接口与一次语义检索

store 的日常操作就三个方法:put 写、get 按 key 取、search 搜。

# 写:namespace + key + value
store.put(("users", "alice", "memories"), "pref-os",
          {"content": "用户操作系统偏好是 Ubuntu", "type": "semantic"})

# 按 key 精确取
item = store.get(("users", "alice", "memories"), "pref-os")

# 搜:两种模式
by_filter = store.search(("users", "alice", "memories"), filter={"type": "semantic"})
by_query = store.search(("users", "alice", "memories"), query="用户用什么系统", limit=3)

search 的第一个参数是仅位置参数,写法上不能带关键字名。query 是自然语言语义检索,filter 是结构化过滤,limit 默认 10。

两种模式的差别不只是参数,返回值也不一样。我实测过:

  • filter 模式返回的 score 恒为 None,它是筛选,不产生相似度
  • query 模式返回真实的相似度分,按分降序
  • 两者可以同时给,先按 filter 缩小范围,再按 query 算相似度

第三种组合用法在产线上很实用。比如只在「未失效的语义记忆」里做语义检索,就是 filter={"active": True, "type": "semantic"} 配一个 query 一起传。

语义检索要配 index,不配等于白配

这一段是这篇最想让你记住的部分。

query 语义检索不是默认就有的,得先给 store 配一个嵌入模型:

store = InMemoryStore(
    index={
        "embed": my_embedder,   # 一个把文本列表转成向量列表的可调用对象
        "dims": 1024,           # 维度,必须和嵌入模型对上
        "fields": ["content"],  # 哪些字段要嵌
    }
)

fields 是这里最需要搞明白的参数。我拿一个假嵌入函数做了实测,把每种写法实际被嵌入的文本抓出来看:

fields 写法 实际嵌入的内容
["$"] 整个 value 序列化后的 JSON 字符串
["content"] content 字段的值本身
["meta.source"] 点路径钻进嵌套结构,取到 meta.source 的值
["tags[*]"] 数组元素逐个嵌,2 个元素生成 2 个向量
["content", "meta.source"] 两个字段各一个向量,共 2 个

规律很清楚:一个字段生成一个向量,[*] 会让数组每个元素各生成一个。dims 必须和嵌入模型的实际输出维度一致,写错了不会报错,检索会静默失效。

现在是那个坑。

如果你忘了配 index,query 参数会被彻底忽略。我实测的场景是这样:命名空间下放 3 条互不相关的记忆,用「操作系统 macOS」去搜,返回 3 条,score 全是 None。

换成「完全无关的天气话题」,还是 3 条。连空字符串进去,依然是 3 条。

不配 index 时,search 退化成了「把这个命名空间下的东西列出来」。你以为在做语义检索,拿到的其实是全量列表。

这个坑的隐蔽之处在于它不报错。分页、排序、返回条数看着都正常,只有检索质量悄悄没了。

配好 index 之后,还有几个行为值得提前知道。

InMemoryStore 不支持 ttl。想给记忆设过期时间,写 ttl=60 进去会直接抛 NotImplementedError。

这个限制挺合理,内存存储本来就活不过进程,过期没意义。要按时间自动清理,得换持久化后端,或者在自己的记忆层里管到期时间。

结果超过 limit 会静默截断,没有任何溢出信号。设 limit 的时候宁大勿小,或者用 offset 分页。

默认排序依赖后端:PostgresStore 按 updated_at 降序,InMemoryStore 按插入顺序(最新在最后)。别依赖默认顺序,需要排序就在客户端按 item.updated_at 自己排。

还有个小而好用的能力:put 支持逐条控制嵌入范围。index=["food_preference"] 表示这条只嵌指定字段,index=False 表示这条只存不嵌。后者适合那种需要留痕但不需要检索的记录,比如审计日志。

五、在 Agent 里怎么用上:两种接入姿势

store 会用了,接下来把它接进 Agent。

LangGraph 1.x 里,节点函数、工具函数、中间件都能拿到 store,但拿到的方式有区别,这里有个必须记住的细节。

节点函数里注入的是 Runtime,工具函数里注入的是 ToolRuntime。

这两个不是同一个东西。ToolRuntime 比 Runtime 多带了 state 和 tool_call_id。

注解写错的时候,报错信息只说 Error invoking tool,不会告诉你注解用错了类型。我第一次遇到时翻了半天代码才反应过来。

先看姿势一,把记忆做成工具,让模型自己决定什么时候记、什么时候查:

from langgraph.prebuilt import ToolRuntime      # 工具里用这个
from langchain_core.tools import tool

@tool
def recall_memory(query: str, runtime: ToolRuntime[AgentContext]) -> str:
    """检索当前用户的长期记忆。用户提到"我之前说过""我的偏好"时调用。"""
    user_id = runtime.context.user_id
    memories = memory.recall(user_id, query, k=5)
    return ";".join(m.content for m in memories) if memories else "没有检索到相关记忆。"

@tool
def save_memory(content: str, runtime: ToolRuntime[AgentContext]) -> str:
    """把一条关于用户的稳定信息写入长期记忆。"""
    report = memory.remember(runtime.context.user_id, content)
    return f"已写入长期记忆:{report.summary()}"

注意 user_id 是从 runtime.context 里拿的,不是闭包绑定的。这一点关系到图能不能复用:用 context 传身份,一张编译好的图能服务所有用户;用闭包绑死 user_id,一个用户就得编译一张图。

再进一层说,runtime.store 也在。如果你想直接用官方 store 而不是自己的记忆层,把 store 传给 create_agent 就能用:

@before_model
def load_profile(state, runtime):
    """前置钩子:把用户档案加载进上下文。"""
    if runtime.store is None:
        return None
    hits = runtime.store.search(("users", "alice", "memories"), query="用户偏好")
    if hits:
        profile = ";".join(h["content"] for h in [i.value for i in hits])
        return {"messages": [SystemMessage(f"关于这位用户:{profile}")]}
    return None

@after_model
def write_back(state, runtime):
    """后置钩子:把这一轮用户说的话固定写一次。"""
    if runtime.store is None:
        return None
    runtime.store.put(("users", "alice", "memories"), new_id(),
                      {"content": f"用户说过:{last_user_text(state)}", "type": "episodic"})
    return None

agent = create_agent(model, store=store, middleware=[load_profile, write_back])

这就是姿势二,在流程里固定写入。它不看模型脸色,该落的一定落得下。

我实测确认过 before_model、after_model 两个钩子里 runtime.store 都能拿到,而且就是传进 create_agent 的那个 store 实例。wrap_model_call 也能,它的 ModelRequest 里带着 runtime 字段,写 request.runtime.store 即可。

两种姿势怎么选,一张表说清:

工具内访问 中间件里访问
谁决定时机 模型 你
可控性 取决于模型判断 完全可控
优点 灵活,能理解复杂意图 确定性,规则一定执行
缺点 可能该记的没记 不够灵活,容易记多
适合 用户偏好这类需要判断的内容 审计、合规这类硬要求

产线上通常是混用的。硬规则走中间件兜底,需要判断的交给工具补充。

一个容易踩的顺序问题:after_model 钩子触发时,状态里已经包含模型刚产出的那条回复。想拿「用户这轮说了什么」,不能直接取 messages[-1],得反向找最近一条 HumanMessage。

六、什么时候写:热路径还是后台

写入时机是个容易被忽略、但直接影响体验的问题。

放在热路径上,也就是主流程里同步写,好处是立刻生效。用户说完一句话,下一句就能用上。代价是给每次对话增加了延迟,尤其是记忆抽取要调模型的时候,几百毫秒到一两秒都可能。

放后台异步写,主流程不受影响,代价是有一段时间窗口,刚说的话还不能立刻用上。

热路径写入 后台写入
生效时间 立即 有延迟
对延迟的影响 直接叠加到响应时间 几乎为零
实现复杂度 低 需要队列与重试
丢数据的风险 低 队列故障时会丢
适合 用户偏好、账号资料 对话摘要、经验沉淀

我的建议是按内容分。用户明确交代的偏好和账号资料走热路径,这类信息量小、写入快,而且用户说完马上就想看到效果。对话摘要、经验沉淀这类批量且耗时的动作丢后台,晚几分钟没人在意。

有个折中办法:热路径只做抽取判断,把结果塞进队列就算完,真正的写入交给后台消费者。这样延迟只增加抽取那一点点,还不丢数据。

七、别只增不减:记忆的治理

讲完了存和取,说一个迟早会撞上的问题。

记忆库会膨胀。三个月后,你会发现召回质量在下降。

搜出来的 5 条里,有 3 条是早就没用的旧信息,噪声压过了信号。

这就是为什么第七个动作叫「何时忘」。

治理有三个维度:重要性评分给每条记忆打分,决定权重;时间衰减让老记忆慢慢降权;容量上限给单用户记忆数设个天花板,超了就淘汰。

最终排序可以按这三项加权合成:

def score(relevance: float, age_days: float, importance: float) -> float:
    """相关性 x 时间衰减 x 重要性,三路合成最终得分。"""
    decay = 0.5 ** (age_days / 30)      # 30 天衰减一半
    return relevance * decay * (0.5 + importance)

相关性来自检索相似度,时间衰减按半衰期算,重要性是写入时估的值(用户明确说「记住」的给高分,随口提到的给低分)。

遗忘有三种策略。

按重要性淘汰适合清理低价值数据:低于某个阈值、且很久没被访问过的,直接删。我 demo 里有条 200 天前、重要性 0.1 的「用户随口问过今天天气怎么样」,跑清理时被判了「超过时效」,真删掉了。

按时间淘汰适合有时效性的记忆,三个月前的临时排期、上个月的活动安排,过期就没价值。

按容量淘汰是兜底。前面两种策略下还超上限,就按综合得分从低到高腾位置。

比遗忘更值钱的,是整合。

用户跟你聊了 3 次检索延迟的排查,每次都留了一条情景记忆。下次他再提这个话题,你要把 3 条碎片的原文全塞进去吗?那不如把它们压成一条结论。

我的 demo 跑出来是这样:

--- 整合前的经历条目
    [episodic  ] 上次排查检索延迟用了火焰图
    [episodic  ] 上次排查检索延迟最后发现是缓存穿透
    [episodic  ] 上次排查检索延迟调了批量大小就好了

--- 执行整合
    生成结论:用户在「排查检索延迟」这类问题上已积累 3 次处理经验
    来源条数:3 条原始经历已标记失效

3 条变成 1 条,而且这条的抽象层级更高,更容易泛化到新问题。这就是情景记忆升级成语义记忆的过程,认知科学里叫巩固,工程上叫整合。

关键是「标记失效」而不是删除。原始经历留在库里,需要追溯细节的时候还能翻出来,只是不再参与日常检索。这个设计待会儿还会再提一次,它是记忆系统里一个反复出现的好模式。

八、造还是买:先把开源方案摸一遍

到这里,记忆系统该干哪几件事已经清楚了:存、取、冲突合并、遗忘。现在可以聊那个更现实的问题了。

想给 Agent 装记忆,第一反应是找个开源库。这条路对,但得先看清每家给你什么、又拿走了什么。

我花时间把几家主流的都摸了一遍,2026 年 9 月核对的现状:

方案 记忆模型 存储后端 抽取靠谁 自托管形态
mem0 事实抽取 + 向量检索,2026 年新增图记忆 多种向量库,图记忆需图数据库 LLM 必选 + 嵌入模型 三形态:库 / 自托管 server / 云平台,Apache 2.0
Zep / Graphiti 时序知识图谱,事实带有效期,被取代的事实失效而非删除 Neo4j / FalkorDB / Neptune LLM 且需结构化输出 Graphiti 为开源框架,仅自托管;Zep 平台托管
Letta 分层记忆:核心常驻、召回可搜索、归档全量 可插拔,Postgres / Mongo / Weaviate 等 LLM + 显式记忆工具 开源,主源码已迁至 letta-code
LangMem 库级,让模型自己调记忆工具 挂 LangGraph store LLM 库,随 LangChain 发布
LangGraph 原生 Store KV + 可选语义索引 InMemoryStore / PostgresStore / 自定义 你自己 框架自带

这张表信息量不小,但我想让你注意的其实是最后一行。

原生的 store 只做存取,不做抽取、不做去重、不做遗忘。 它给你砖,不给你房子。这不是缺陷,是定位:框架给原语,成品交给生态。

按这个角度看,五家其实分成三类:

层级 代表 给你什么 拿走什么
原语 LangGraph Store 存取的砖 抽取、去重、治理要自己写
库 / SDK LangMem、mem0 开源版 抽取与检索的成品 数据落在别人定义的抽象里
平台 / 框架 Zep 云、Letta 整套能力与运维 接受它的 Agent 模型与部署形态

三类没有优劣,只有匹配度。但选之前,有件事值得你先看一眼。

看一眼它的钱从哪来

2026 年有三件公开的事,放在一起看很有意思。

第一件,mem0 的图记忆能力,也就是实体关系与多跳查询那一块,进了高价位订阅档,开源版停在向量检索。第二件,Zep 下线了 Community Edition,重心转向托管平台;开源的 Graphiti 框架仍然可以自托管,但图数据库得自己扛。第三件,Letta 把主源码迁到了 letta-code 仓库,旧的 API server 进了归档分支。

三个动作方向一致:开源核心保能力,商业价值往上收。

这是这类项目正常的生存方式,谈不上谁对谁错。开源项目要活下去,总得有个地方收钱。

我提这个没别的意思,是想说清一件常被忽略的事:你依赖的到底是它的开源层,还是它的商业层。

再往深一层想。

一个记忆系统真正决定效果的是三件事:记什么、什么时候忘、冲突怎么合并。这三件事恰好都是各家「替你决定」的部分。它们有默认值,用得最省心,但如果你的业务对这三件事有特别的要求,默认值就成了天花板。

这就是第九节要聊的。

九、什么时候该自己造

先声明立场:我不反对用开源方案。该用现成的时候,自己造纯属浪费。

以下三条都成立,直接用现成的:

需求是通用个性化,不需要特殊规则;团队没有向量库与图数据库的运维能力;数据可以完整自托管,不需要额外隔离。

反过来,下面三条只要中一条,就值得认真考虑自建。

第一条,写入策略与业务强绑。

哪些字段能记、哪些要脱敏、谁能读谁能写,这些规则长在你的业务里。举个具体的:客服场景下,用户的身份证号绝对不能进记忆库;订单金额可以记但要按权限分级。这种规则用开源库的配置去表达,往往比直接写代码还费劲,改起来更麻烦。

第二条,你已经有检索基础设施。

这一点对做过 RAG 的团队特别实际。向量库已经有了,嵌入模型已经在跑,分片、索引、监控、扩容方案都验证过了。这时候再引入一套记忆专用的向量存储,等于双份运维、双份成本,还得接受两份数据可能不一致的风险。

第三条,隔离边界要对齐账号体系。

记忆的 namespace 怎么切,最好跟你现有的权限模型一致。多租户、多 Agent、多业务域,如果切法跟账号体系对不上,迟早会出现越权读取或者该隔离的没隔离。用第三方的抽象去迁就,边界就不在你手上了。

三条判断整理成一张图:

是

否

是

否

是

否

要给 Agent 装长期记忆

写入策略与业务强绑?

自建:抽取与治理自己实现

已有向量库与嵌入模型?

隔离边界要对齐账号体系?

先用开源方案

但这张图不是二选一。

中间路线才是大多数团队的正解:拿原语,借鉴开源设计,自己实现抽取与治理。

具体说,存储与检索用官方 store 的接口(或者自己实现 BaseStore),治理规则自己写,而抽取策略、双时序失效这些设计思路,大胆从 mem0 和 Graphiti 那里学。它们踩过的坑是公开的,照着抄设计不丢人。

下一篇我们要做的 demo,走的就是这条路。

十、产线视角:把 store 换成自己的向量库

官方 store 的后端到 PostgresStore 为止。如果你的检索能力已经建在别的向量库上,接法是自己实现 BaseStore。

我原本以为这一步挺重,动手之后发现比想象中轻。

官方文档说需要实现 5 个异步方法。我实测查了一下抽象方法列表,实际强制要求实现的是 2 个:batch 和 abatch。

BaseStore.__abstractmethods__ = ['abatch', 'batch']

put、get、delete、search、list_namespaces 全都有默认实现,它们内部走 batch 分发。所以你要做的只是实现一个 batch,在里面按操作类型分发:

from langgraph.store.base import BaseStore, GetOp, PutOp, SearchOp, ListNamespacesOp
from langgraph.store.base import Item, SearchItem

class MyVectorStore(BaseStore):
    """把 LangGraph 的 store 接口接到自己的向量库上。"""

    def batch(self, ops):
        return [self._run(op) for op in ops]

    async def abatch(self, ops):
        return self.batch(ops)

    def _run(self, op):
        if isinstance(op, PutOp):
            ...   # 写入:value 为 None 表示删除
        if isinstance(op, GetOp):
            return self._to_item(backend.get(op.namespace, op.key))
        if isinstance(op, SearchOp):
            ...   # 检索:query 做向量召回,filter 做结构化过滤
        if isinstance(op, ListNamespacesOp):
            ...
        raise NotImplementedError(type(op).__name__)

映射关系很直接:namespace 对 namespace,key 对记忆 id,value 对记忆本身。底层的存储和检索完全是你自己的东西。

有两点容易漏。

一是同步异步要配对。batch 是抽象要求,abatch 也是。只实现一个,另一个会走默认实现抛异常。

我在 demo 里让 abatch 直接复用同步的 batch,简单但够用。

二是如果底层不支持向量检索,query 非空时应该抛 NotImplementedError,而不是悄悄返回一堆不相干结果。这条是官方文档明确要求的。静默降级的坑,比直接报错难查得多。

最后说隔离设计。我的做法是在 store 里加一个「从 namespace 取租户」的方法:

def tenant_of(self, namespace: tuple[str, ...]) -> str:
    """namespace 约定成 ('users', user_id, ...),接到自己系统时改这里。"""
    if namespace[0] == "users" and len(namespace) > 1:
        return namespace[1]
    return namespace[0]

所有读写都强制先过这一步,跨租户的访问在入口就被拦掉。隔离边界跟着账号体系走,不用迁就任何第三方库的抽象。

这个设计在 demo 里实测过:越权拿另一个用户的记忆 id 去读,返回 None;交叉检索命中 0 条。

还有一句关于部署的。记忆库最好独立于会话存储。原因不难理解:会话状态是高频写、可丢弃的,记忆是低频写、长期保留的。

两者的备份策略、扩容节奏、数据保留要求完全不同,混在一个库里,运维上会互相掣肘。

十一、把两篇合起来看

回顾一下,两篇合起来构成一套完整的记忆体系。

跨会话:留着的记忆

会话内:活着的上下文

消息历史

checkpointer + thread_id

store + namespace

治理:评分 / 遗忘 / 整合

Agent

会话内那一半,管的是「这一轮怎么聊下去」,靠的是消息历史加 checkpointer,配裁剪、删除、摘要三招。跨会话这一半,管的是「下次还认识你」,靠的是 store 加 namespace,配分类、评分、遗忘、整合四个动作。

两条线在 Agent 这里汇合:启动时加载记忆,结束时写回记忆。

如果你要现在动手,三条清单给你:

记什么,先分类。语义、情景、程序性三类分开存,生命周期不一样,治理策略也不一样。

存哪里,先想隔离边界。namespace 怎么切,跟着账号体系走,别迁就工具的默认值。

造还是买,先算长期账。看清依赖的是开源层还是商业层,再看那三件决定效果的事(记什么、何时忘、冲突怎么合并)能不能自己说了算。

回到开头那位朋友的问题。他现在给助手加了一层记忆,周一早上再打开,助手会说:还是 Ubuntu 那套环境吧,命令我按 Linux 的来。

他说那一刻的感觉挺奇妙,像同事真的记住了你。

记忆解决了「知道什么」。


本篇完整工程化 demo(抽取、双时序失效、混合召回、遗忘与整合、多租户隔离,含 LangGraph 集成与 14 项测试,核心零依赖、无需 API key 即可跑通)已打包。在公众号回复 memory 领取。