用结构化 Schema 管理用户记忆——深度讲解 TypedDict vs Pydantic 定义、with_structured_output 的局限、Trustcall create_extractor 的 JSON Patch 增量更新机制与完整 Chatbot 实现。
上一节(Lesson 1)的记忆是纯文本字符串,例如 "User likes biking in SF"。这种方式简单,但有明显缺陷:
解决方案是使用结构化记忆 Schema:将记忆定义为 TypedDict 或 Pydantic 模型,让 LLM 按 Schema 提取信息。
Profile 是一条持续更新的单一用户画像记录。每次有新信息时,不重新生成,而是对已有 Profile 做增量 patch——只改变有变化的字段,保留其他字段的现有值。
Python 的 TypedDict 是定义结构化数据的最简方式,字段带类型注解。
from typing import TypedDict, List
class UserProfile(TypedDict):
"""User profile schema with typed fields"""
user_name: str # The user's preferred name
interests: List[str] # A list of the user's interests
# TypedDict 实例就是普通字典
user_profile: UserProfile = {
"user_name": "Lance",
"interests": ["biking", "technology", "coffee"]
}
# TypedDict 实例可以直接作为 store.put 的 value
user_id = "1"
namespace_for_memory = (user_id, "memory")
in_memory_store.put(namespace_for_memory, "user_profile", user_profile)
# 检索验证
for m in in_memory_store.search(namespace_for_memory):
print(m.dict())
# {'value': {'user_name': 'Lance', 'interests': ['biking', 'technology', 'coffee']}, ...}
LangGraph Store 的 put() 方法接受任意 Python 字典作为 value。TypedDict 实例本质上是字典,Pydantic 模型需要调用 .model_dump() 转为字典再存入。
LangChain 的 with_structured_output() 方法可以强制模型输出符合指定 Schema 的结构化数据,并自动完成解析。
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage
# 绑定 Schema 到模型
model_with_structure = model.with_structured_output(UserProfile)
# 调用后直接得到符合 Schema 的字典
structured_output = model_with_structure.invoke(
[HumanMessage("My name is Lance, I like to bike.")]
)
# 输出:{'user_name': 'Lance', 'interests': ['biking']}
def write_memory(state, config, store):
user_id = config["configurable"]["user_id"]
namespace = ("memory", user_id)
existing_memory = store.get(namespace, "user_memory")
# 格式化已有记忆作为上下文
if existing_memory and existing_memory.value:
memory_dict = existing_memory.value
formatted_memory = (
f"Name: {memory_dict.get('user_name', 'Unknown')}\n"
f"Interests: {', '.join(memory_dict.get('interests', []))}"
)
else:
formatted_memory = None
system_msg = CREATE_MEMORY_INSTRUCTION.format(memory=formatted_memory)
# 用 with_structured_output 从对话中提取 UserProfile
new_memory = model_with_structure.invoke(
[SystemMessage(content=system_msg)] + state['messages']
)
# 覆写存入 Store(每次重新生成整个 Profile)
store.put(namespace, "user_memory", new_memory)
这种方案每次 write_memory 时都让 LLM 重新生成整个 Profile,即使只有一点新信息。问题:① 效率低,token 浪费;② 如果上下文太长,旧信息可能被遗漏;③ 无法处理复杂 Schema(下节展示)。
对于嵌套较深的复杂 Pydantic Schema,即使是高能力模型(如 deepseek-v4-pro)也容易产生验证错误:
# 复杂嵌套 Schema 示例(TelegramAndTrustFallPreferences)
class OutputFormat(BaseModel):
preference: str
sentence_preference_revealed: str
class TelegramPreferences(BaseModel):
preferred_encoding: Optional[List[OutputFormat]] = None
favorite_telegram_operators: Optional[List[OutputFormat]] = None
preferred_telegram_paper: Optional[List[OutputFormat]] = None
# ... 更多嵌套层级
# 用 with_structured_output 调用会报 ValidationError:
# 1 validation error for TelegramAndTrustFallPreferences
# semaphore: Input should be a valid dictionary or instance of Semaphore
with_structured_output 是一次性提取——如果模型输出不符合 Schema,整个调用失败,没有重试机制。面对可选嵌套字段,模型经常漏填或填错。这正是引入 Trustcall 的动机。
Trustcall 是 LangChain 团队开源的结构化提取库,专门解决 with_structured_output 的上述问题。它底层使用工具调用来提取结构化数据,并内置自我纠错和增量 patch 更新机制。
from trustcall import create_extractor
# 使用 Pydantic 模型定义 Schema(支持 Field 描述)
class UserProfile(BaseModel):
"""User profile schema with typed fields"""
user_name: str = Field(description="The user's preferred name")
interests: List[str] = Field(description="A list of the user's interests")
# 创建提取器:model + schema + tool_choice(强制使用该 schema)
trustcall_extractor = create_extractor(
model,
tools=[UserProfile],
tool_choice="UserProfile"
)
# 调用:传入消息列表
result = trustcall_extractor.invoke({
"messages": [SystemMessage(content="Extract the user profile")] + conversation
})
# result 包含三个键:
# - "messages":包含工具调用的 AIMessage 列表
# - "responses":解析后的 Pydantic 模型实例列表
# - "response_metadata":各响应对应的元数据(含 json_doc_id)
result["responses"][0]
# UserProfile(user_name='Lance', interests=['biking around San Francisco'])
result["responses"][0].model_dump()
# {'user_name': 'Lance', 'interests': ['biking around San Francisco']}
同样的 TelegramAndTrustFallPreferences 复杂 Schema,Trustcall 可以成功提取——因为它会尝试重试并自我纠错:
bound = create_extractor(model, tools=[TelegramAndTrustFallPreferences], tool_choice="TelegramAndTrustFallPreferences")
result = bound.invoke(conversation_text)
result["responses"][0] # 成功提取完整嵌套 Schema,不再报 ValidationError
Trustcall 最核心的优势是增量更新:通过 "existing" 参数传入已有记忆,Trustcall 会生成一个 JSON Patch 来只修改有变化的部分,而不是重新生成整个 Schema。
# 1. 先提取初始 Profile
result = trustcall_extractor.invoke({"messages": initial_conversation})
schema = result["responses"] # [UserProfile(user_name='Lance', interests=['biking'])]
# 2. 对话继续:用户说他还喜欢去面包店
updated_conversation = [...]
# 3. 将已有 Schema 作为 existing 传入(key=schema 名, value=dict)
result = trustcall_extractor.invoke(
{"messages": [SystemMessage(content="Update the memory to incorporate new information")] + updated_conversation},
{"existing": {"UserProfile": schema[0].model_dump()}}
)
result["responses"][0].model_dump()
# {'user_name': 'Lance', 'interests': ['biking', 'visiting bakeries']}
# → 只增加了 'visiting bakeries',名字保留,无需重新生成
Trustcall 内部让 LLM 生成一组 patch 操作(如 {"op": "add", "path": "/interests/-", "value": "visiting bakeries"}),然后将这些操作应用到现有文档上,得到更新后的完整记录。这比"从头重写"更精准、更省 token。
result["response_metadata"]
# [{'id': 'call_WeZl0ACfQStxblim0ps8LNKT'}]
# 新建时:只有 id,没有 json_doc_id
# 更新时:包含 json_doc_id 指向被更新的文档
# 在 Store 中保存时,用 json_doc_id(如果有)作为 key:
for r, rmeta in zip(result["responses"], result["response_metadata"]):
store.put(
namespace,
rmeta.get("json_doc_id", str(uuid.uuid4())),
r.model_dump(mode="json")
)
# 对 Profile:key 通常是固定的(Trustcall 返回同一 json_doc_id 表示更新同一文档)
class UserProfile(BaseModel):
"""Profile of a user"""
user_name: str = Field(description="The user's preferred name")
user_location: str = Field(description="The user's location")
interests: list = Field(description="A list of the user's interests")
trustcall_extractor = create_extractor(
model, tools=[UserProfile], tool_choice="UserProfile"
)
def call_model(state, config, store):
user_id = config["configurable"]["user_id"]
namespace = ("memory", user_id)
existing_memory = store.get(namespace, "user_memory")
# 结构化记忆可以精确提取各字段
if existing_memory and existing_memory.value:
memory_dict = existing_memory.value
formatted_memory = (
f"Name: {memory_dict.get('user_name', 'Unknown')}\n"
f"Location: {memory_dict.get('user_location', 'Unknown')}\n"
f"Interests: {', '.join(memory_dict.get('interests', []))}"
)
else:
formatted_memory = None
system_msg = MODEL_SYSTEM_MESSAGE.format(memory=formatted_memory)
response = model.invoke([SystemMessage(content=system_msg)] + state["messages"])
return {"messages": response}
def write_memory(state, config, store):
user_id = config["configurable"]["user_id"]
namespace = ("memory", user_id)
existing_memory = store.get(namespace, "user_memory")
# 将已有 Profile 格式化为 Trustcall 的 existing 参数
existing_profile = {"UserProfile": existing_memory.value} if existing_memory else None
# Trustcall 提取:有 existing 时做增量 patch,无 existing 时新建
result = trustcall_extractor.invoke({
"messages": [SystemMessage(content=TRUSTCALL_INSTRUCTION)] + state["messages"],
"existing": existing_profile
})
# 保存更新后的 Profile(覆写同一 key)
updated_profile = result["responses"][0].model_dump()
store.put(namespace, "user_memory", updated_profile)
Profile 使用固定 key("user_memory"),每个用户始终只有一条 Profile 记录。每次 put 都是覆写同一个 key,保证同一命名空间下只存在一条最新画像。这与 Collection 模式(每条记忆一个 UUID key)形成对比。
config = {"configurable": {"thread_id": "1", "user_id": "1"}}
Thread 2 是全新对话,代理从未被告知用户是 Lance 或他在 SF。但因为结构化 Profile 跨线程保存在 Store 中,call_model 读取后解析 user_location 和 interests 字段,给出了精准的个性化推荐。
| 维度 | Profile 模式(本节) | Collection 模式(Lesson 3) |
|---|---|---|
| 记录数 | 每用户一条(固定 key 覆写) | 每用户多条(UUID key,持续累积) |
| Schema | 固定字段(user_name, location, interests...) | 灵活,每条记忆可以是不同格式 |
| 更新方式 | Trustcall JSON Patch 更新部分字段 | 新增独立记忆 or Patch 已有记忆 |
| 检索方式 | store.get(namespace, "user_memory") | store.search(namespace, query) |
| 适用场景 | 用户画像、偏好设置(需要全局一致) | ToDo 列表、日记条目(需要多条并存) |
| enable_inserts | 不需要(只有一条记录) | 需要(允许新建多条记忆) |
如果你的记忆代表"用户当前的状态"(单一、全局、会更新),选 Profile。如果记忆代表"发生过的事件"或"待办的任务"(多条、独立、会累积),选 Collection。