数据截至 (上游 commit be263ee23085)
集成模式 — 把同一条记忆环塞进 12 个集成包(覆盖 10 个框架)
30 秒导读: Zep 的核心价值是一条与框架无关的"记忆环"(见 01-memory-model.md)。可真实世界里 agent 跑在 ADK、CrewAI、LangGraph、AutoGen…… 十几个互不兼容的框架上。本章讲同一条记忆环怎么反复落进每个框架的"原生"记忆钩子,并把这些落地方式归纳成两种通用形态——(A)每轮自动往 system prompt 前拼一段 Context Block、(B)把
graph.search暴露成模型自己决定何时调用的工具。最后讲一条贯穿所有集成的横切原则:永不因 Zep 失败而拖垮 agent。
1. 这是什么(零基础也能懂)
一句话定义: 集成层是一组薄适配器——每个把 Zep 的记忆环翻译成某一个 agent 框架"本来就懂"的记忆/上下文接口,让你不用改 agent 代码就获得长期记忆。
它解决什么问题: 假设你已经用 Google ADK 写好了一个客服 agent,现在想让它"记住"用户上次说过的偏好。你不想重写 agent、也不想手动在每轮对话里塞检索代码。集成层的承诺是:装一个包(pip install zep-adk)、把一个工具挂进 agent、剩下的自动发生。
为什么需要一层"集成",而不是直接调 SDK: Zep 的 SDK 到处都一样(thread.add_messages、thread.get_user_context、graph.search),但每个框架"注入记忆"的位置和形状都不同:
- ADK 有一个
process_llm_request钩子(在发给模型前改请求); - LangGraph 是一张显式的图,你在节点里自己拼 system message;
- CrewAI 期望一个 带
save()/search()方法的存储对象。
集成层就是把同一条记忆环,分别塞进这三种(乃至十种)不同形状的"插座"里。
一句话直觉/类比: 记忆环是同一枚灯泡;每个框架是不同国家的插座。集成包就是那一堆转接头——灯泡不变,只换插脚。
12 个包一览(事实源:integrations/CLAUDE.md 的 "Layout" 表)。注意:包数 = 12,框架数 = 10——adk 一个框架就出了 python/typescript/go 三份包,其余 9 个框架各一份:
| 框架 | 语言 | 分发名 | import |
|---|---|---|---|
| adk | python / typescript / go | zep-adk | zep_adk |
| ag2 | python | zep-ag2 | zep_ag2 |
| autogen | python | zep-autogen | zep_autogen |
| crewai | python | zep-crewai | zep_crewai |
| langgraph | python | zep-langgraph | zep_langgraph |
| livekit | python | zep-livekit | zep_livekit |
| mastra | typescript | @getzep/zep-mastra | @getzep/zep-mastra |
| ms-agent-framework | python | zep-ms-agent-framework | zep_ms_agent_framework |
| pydantic-ai | python | zep-pydantic-ai | zep_pydantic_ai |
| vercel-ai | typescript | @getzep/zep-vercel-ai | @getzep/zep-vercel-ai |
本章不逐个罗列全部 12 个包。我们用 ADK / CrewAI / LangGraph 三个 Python 包做代表,把"模式"和"两种形态"讲透。最难的适配——
ZepStore(在时序图谱上假扮一个 KV 存储)——单独留给 03-zepstore-hybrid-delegate.md。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 中间竖线左边是不变的记忆环(所有集成共享),右边是每个框架各自的插座;两种形态 A / B 是灯泡通电的两种方式。
不变的记忆环(见 01 章) 每个框架的"原生"钩子
┌──────────────────────────────┐ ┌────────────────────────────┐
│ create user → create thread │ │ ADK: process_llm_request │
│ → add_messages → 取上下文 │◀────▶│ LangGraph: 图节点里手写 │
│ │ │ CrewAI: storage.save/search│
│ Zep SDK: thread.* / graph.* │ └────────────────────────────┘
└──────────────┬───────────────┘
│ 集成层把环"接"进钩子,分两种形态:
┌────────────┴─────────────┐
▼ ▼
┌───────────────────┐ ┌────────────────────────┐
│ 形态 A 自动注入 │ │ 形态 B 按需搜索工具 │
│ 每轮: 取 Context │ │ 把 graph.search 变成一个 │
│ Block, 拼到 system │ │ tool, 由"模型"决定何时调 │
│ prompt 最前面 │ │ (参数可在构造时 pin 死) │
│ ——你不写调用 │ │ ——模型主动写调用 │
└───────────────────┘ └────────────────────────┘
两种形态一句话职责:
| 形态 | 谁触发检索 | 落点 | 代表实现 |
|---|---|---|---|
| A 自动上下文注入 | 框架(每轮固定发生) | system prompt 前缀 | ZepContextTool、build_system_message、ZepStorage.search |
| B 模型可调用工具 | 模型(自己决定) | 一次 function call 的返回值 | ZepGraphSearchTool、create_graph_search_tool、ZepSearchTool |
主线走一遍(高层,不进代码):
- 一次用户消息进来。
- 形态 A 无条件触发:把消息写进 Zep,同时取回一段"关于这个用户的 Context Block",塞到即将发给模型的 system 指令最前面。
- 模型带着这段记忆生成回复。若模型觉得"我还需要查点更具体的东西",它可以主动调用形态 B 的搜索工具,拿到几条事实再继续。
- 助手回复被写回 Zep,喂给下一轮。
两种形态不是二选一:典型 agent 会 A 打底(保证每轮都有基本记忆)、B 兜底(让模型能主动深挖)。
3. 目录布局与命名推导规则
在读代码前,先建立"东西放在哪、叫什么"的地图。这层的组织本身就是一种设计。
framework-first,再 language。 目录先按框架分,框架下再按语言分(事实源:integrations/CLAUDE.md 的 "Layout: platform-first" 节):
integrations/
<framework>/ # adk, crewai, langgraph, ...
<language>/ # python | typescript | go
src/zep_<framework>/
__init__.py # 包入口 + __version__ + 依赖自检
<core>.py # 核心适配(context / tools / memory)
exceptions.py
命名从"框架目录名"这一个 key 机械推导,好让 CI 不必维护映射表:
| 已知 | 推导规则 | 例子 |
|---|---|---|
| 框架目录 key | —— | pydantic-ai |
| Python import 名 | zep_ + key(连字符→下划线) | zep_pydantic_ai |
| PyPI 分发名 | zep- + key | zep-pydantic-ai |
| Python 包路径 | integrations/<key>/python | integrations/pydantic-ai/python |
这条规则的价值在 CI:加一个新 Python 包,只需在 paths-filter 里加一行,路径与 import 名都能从 key 自动算出(事实源:integrations/CLAUDE.md 的 "Naming convention" 段)。
每个包内部结构统一: __init__.py 里都有一段依赖自检——先尝试 import 框架本体,失败就抛一个友好的 ZepDependencyError 告诉你该装什么。例如 ADK 的入口在导入前先探 google.adk.tools.base_tool,见 integrations/adk/python/src/zep_adk/__init__.py:65-96;CrewAI 只硬依赖 crewai.tools(因为 1.x 删了旧的 Storage 基类),见 integrations/crewai/python/src/zep_crewai/__init__.py:51-90。
4. 形态 A:自动上下文注入(每轮拼 Context Block)
它要解决的小问题: 让 agent"无脑地"每轮都带上关于用户的长期记忆,开发者一行检索代码都不用写。
思路/直觉: 找到每个框架里"消息即将发给模型、但还没发"的那个瞬间,在那一刻:①把这轮用户消息写进 Zep;② 取回一段 prompt-ready 的 Context Block;③拼到 system 指令最前面。三步的位置因框架而异,动作完全一样。
4.1 ADK:劫持 process_llm_request 钩子
ADK 提供了一个官方钩子 process_llm_request——ADK 自带的 PreloadMemoryTool 也用它。Zep 的做法是同一个钩子:定义一个 BaseTool 子类,但它从不被模型调用,只在每次 LLM 请求前修改请求。
真实实现:ZepContextTool.process_llm_request 在 integrations/adk/python/src/zep_adk/context_tool.py:308-324 覆写该钩子。核心步骤都在这个方法里:抽取用户文本 → 从会话 state 解析身份 → 同轮去重 → 写消息+取上下文 → 注入(建资源已改为带外 ensure_user/ensure_thread,见下)。
注入那一步是关键——把上下文包进一对 <ZEP_CONTEXT> 标签,再用 ADK 的 llm_request.append_instructions() 追加进系统指令,见 context_tool.py:406-408:
# 示意,贴近源码。重点看:上下文被包进标签,再 append 进 system 指令
if context_text:
instruction = (
"The following context is retrieved from Zep's long-term memory ...\n\n"
"<ZEP_CONTEXT>\n"
f"{context_text}\n"
"</ZEP_CONTEXT>"
)
llm_request.append_instructions([instruction])
身份不写死、从会话 state 运行时解析,所以一个 ZepContextTool 实例能被所有用户/会话共享。解析顺序在 ZepContextTool._resolve_identity(context_tool.py:228-293):zep_user_id 优先,回退到 ADK 会话的 user_id;线程同理。
两个易被忽略的坑,Zep 都处理了:
- 同轮重复触发。 在一轮里如果发生工具调用循环,
process_llm_request会用同一个user_contentPython 对象触发多次。Zep 用id(user_content)做"同轮守卫",跳过重复而不误伤后续轮里同样的文本(那会是新对象),见context_tool.py:355-359。 - 助手回复由另一个钩子写回。 用户侧消息由
ZepContextTool写,助手侧由create_after_model_callback返回的after_model_callback写(integrations/adk/python/src/zep_adk/callbacks.py:30)。这个回调特意跳过含 function_call 的中间响应,只落一条干净的最终助手消息,见callbacks.py:74-76。
默认单次往返 vs 自定义并行: 默认走 thread.add_messages(return_context=True)——一次 API 调用同时"写消息"和"取上下文"(context_tool.py:382-387)。若传了自定义 context_builder,则改用 asyncio.gather 并行跑"写消息"和"建上下文",降延迟,见 ZepContextTool._persist_and_build_context(context_tool.py:430-485)。
4.2 LangGraph:图节点里显式拼 system message
LangGraph 没有隐藏钩子——它是一张显式的图,你在节点里自己写。所以 Zep 提供的是辅助函数,让你在 agent 节点顶部一行拿到 system message。
推荐用法(integrations/langgraph/python/src/zep_langgraph/context.py:290 的 build_system_message):
# 示意,贴近源码文档示例。重点看:节点顶部取上下文,prepend 到消息列表
async def agent_node(state):
system = await build_system_message(
zep_client,
thread_id=state["thread_id"],
base_instructions="You are a helpful assistant.",
)
messages = [system, *state["messages"]]
response = await llm.ainvoke(messages)
...
底层就是 thread.get_user_context——注意它从整张用户图组装 Context Block,thread 只用来"界定此刻什么相关",见 get_zep_context(context.py:118)对 zep_client.thread.get_user_context 的封装(context.py:173)。拼装逻辑在 format_context_block(context.py:258),默认用 DEFAULT_CONTEXT_TEMPLATE(context.py:49)把上下文包进 <MEMORY> 标签。
同步/异步双份 API。 LangGraph 节点可能是 sync 也可能是 async,所以每个 helper 都成对出现:get_zep_context / get_zep_context_sync、build_system_message / build_system_message_sync(context.py:191、192)。这是本层一个反复出现的模式。
4.3 CrewAI:实现存储的 search() 契约
CrewAI 期望一个带 save() / search() 的存储对象。Zep 的 ZepStorage(integrations/crewai/python/src/zep_crewai/memory.py:20)就实现这个契约,把"取上下文"藏在 search() 后面。
它的 search() 值得一看:并行取两个来源再合并——线程上下文(thread.get_user_context)+ 针对用户图的 graph.search(scope="edges"),用 ThreadPoolExecutor(max_workers=2) 同时发,见 memory.py:104-170。
CrewAI 1.x 删掉了旧的
Storage基类和ExternalMemory包装器,所以这些类现在是独立、鸭子类型的适配器(只靠save/search/reset三个方法),不再继承任何 CrewAI 基类,见memory.py:20-26的类注释。