LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 5 · Long-term Memory
1 5-1 Memory Store
2 5-2 Schema Profile
3 5-3 Schema Coll.
4 5-4 Memory Agent
SUM Module Summary
HomeLangGraphModule 5 · Long-term Memory5-2 Schema Profile
📓 Notebook 📖 Explained
LANGGRAPH MODULE 5 · LESSON 2

Memory Schema: Profile

用结构化 Schema 管理用户记忆——深度讲解 TypedDict vs Pydantic 定义、with_structured_output 的局限、Trustcall create_extractor 的 JSON Patch 增量更新机制与完整 Chatbot 实现。

1 为什么需要结构化记忆 Schema

上一节(Lesson 1)的记忆是纯文本字符串,例如 "User likes biking in SF"。这种方式简单,但有明显缺陷:

解决方案是使用结构化记忆 Schema:将记忆定义为 TypedDict 或 Pydantic 模型,让 LLM 按 Schema 提取信息。

Profile Schema 的核心设计思路

Profile 是一条持续更新的单一用户画像记录。每次有新信息时,不重新生成,而是对已有 Profile 做增量 patch——只改变有变化的字段,保留其他字段的现有值。

2 TypedDict 定义 Profile Schema

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']}, ...}
Store 对 value 类型的要求

LangGraph Store 的 put() 方法接受任意 Python 字典作为 value。TypedDict 实例本质上是字典,Pydantic 模型需要调用 .model_dump() 转为字典再存入。

3 with_structured_output:提取结构化输出

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']}

在 write_memory 节点中使用

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)
with_structured_output 的问题:每次从头重新生成

这种方案每次 write_memory 时都让 LLM 重新生成整个 Profile,即使只有一点新信息。问题:① 效率低,token 浪费;② 如果上下文太长,旧信息可能被遗漏;③ 无法处理复杂 Schema(下节展示)。

4 with_structured_output 的局限:复杂 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
核心问题:LLM 无法自我纠错

with_structured_output 是一次性提取——如果模型输出不符合 Schema,整个调用失败,没有重试机制。面对可选嵌套字段,模型经常漏填或填错。这正是引入 Trustcall 的动机。

5 Trustcall:create_extractor 深度讲解

Trustcall 是 LangChain 团队开源的结构化提取库,专门解决 with_structured_output 的上述问题。它底层使用工具调用来提取结构化数据,并内置自我纠错增量 patch 更新机制。

基础用法:create_extractor

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']}

Trustcall 对复杂 Schema 的处理

同样的 TelegramAndTrustFallPreferences 复杂 Schema,Trustcall 可以成功提取——因为它会尝试重试并自我纠错:

bound = create_extractor(model, tools=[TelegramAndTrustFallPreferences], tool_choice="TelegramAndTrustFallPreferences")
result = bound.invoke(conversation_text)
result["responses"][0]  # 成功提取完整嵌套 Schema,不再报 ValidationError

6 Trustcall 的增量更新:JSON Patch 机制

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',名字保留,无需重新生成
JSON Patch 的工作原理

Trustcall 内部让 LLM 生成一组 patch 操作(如 {"op": "add", "path": "/interests/-", "value": "visiting bakeries"}),然后将这些操作应用到现有文档上,得到更新后的完整记录。这比"从头重写"更精准、更省 token。

response_metadata 中的 json_doc_id

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 表示更新同一文档)

7 完整 Chatbot:Trustcall + Store 的组合实现

Schema 定义

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"
)

call_model:读取结构化 Profile 并注入提示词

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}

write_memory:Trustcall 增量更新 Profile

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 模式的存储设计

Profile 使用固定 key"user_memory"),每个用户始终只有一条 Profile 记录。每次 put 都是覆写同一个 key,保证同一命名空间下只存在一条最新画像。这与 Collection 模式(每条记忆一个 UUID key)形成对比。

8 运行演示与跨线程记忆验证

config = {"configurable": {"thread_id": "1", "user_id": "1"}}
Thread 1:建立用户画像

Human: Hi, my name is Lance
AI: Hello, Lance! It's nice to meet you. How can I assist you today?

Human: I like to bike around San Francisco
AI: That sounds like a great way to explore the city! Do you have any favorite trails?

Store 状态:{'user_name': 'Lance', 'user_location': 'San Francisco', 'interests': ['biking']}
继续:增量更新兴趣字段

Human: I also enjoy going to bakeries
AI: Biking and visiting bakeries sounds like a delightful combination!

Store 更新:interests: ['biking', 'visiting bakeries'](Trustcall 只 patch 了 interests 字段)
Thread 2:新会话,长期记忆生效

Human: What bakeries do you recommend for me?
AI: Since you're in San Francisco and enjoy going to bakeries, here are some recommendations:
1. Tartine Bakery — known for bread and pastries
2. B. Patisserie — famous for kouign-amann
3. Arsicault Bakery — renowned croissants
4. Craftsman and Wolves — inventive pastries
关键验证

Thread 2 是全新对话,代理从未被告知用户是 Lance 或他在 SF。但因为结构化 Profile 跨线程保存在 Store 中,call_model 读取后解析 user_locationinterests 字段,给出了精准的个性化推荐。

9 Profile vs Collection:何时用哪种模式

维度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。