用户级长期记忆:从架构到实现的完整讲解

本文面向希望理解 mystu 项目中「跨会话用户记忆」如何落地的开发者。会尽量把两种记忆的分工大模型与工具之间如何传递用户信息记忆如何写入与读出 、以及安全边界讲清楚。


一、为什么要做用户级记忆?

在引入用户级记忆之前,mystu 的对话记忆完全依赖 LangGraph 的 checkpoint 机制:客户端每次聊天携带一个 thread_id,同一 thread_id 内的多轮消息、工具调用会被持久化到 SQLite(data/checkpoints.sqlite)。

这套机制有一个天然限制:记忆是按 thread 隔离的,与登录用户无关

典型场景:

  1. 用户在对话 A 中说:「我主要关注 DCE 主力 i2605。」
  2. 用户点击「新对话」,前端生成新的 thread_id
  3. 对话 B 里 Agent 完全不知道之前的偏好,用户必须重复说明。

用户级长期记忆要解决的问题是:让已登录用户的偏好、习惯、分析口径跨 thread、跨会话保留,而单 thread 内的完整对话历史仍由 checkpoint 负责。


二、两种记忆:不要混为一谈

mystu 里同时存在两套「记忆」,职责完全不同:

维度 Thread 记忆(checkpoint) 用户级长期记忆(user_memories)
存储 SQLite checkpoints.sqlite MySQL user_memories
隔离键 thread_id user_id
内容 完整对话消息、工具调用、图状态 结构化条目(content + category)
生命周期 随 thread 存在 随用户存在,跨 thread
谁写入 LangGraph 自动 checkpoint Agent 工具 / 用户 REST API
谁读取 同 thread 下次 ainvoke 自动恢复 对话前注入 + Agent 按需 read
典型用途 「刚才你说什么来着?」 「这个用户长期关注什么合约?」

设计决策(见需求文档)明确:MySQL 是用户记忆的权威数据源 ;不采用 deepagents 内置的 /memories/ 虚拟文件系统作为权威存储,而是用结构化表 + Agent 工具桥接。


三、整体架构

#mermaid-svg-q475Z3ja94Rlmdcb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q475Z3ja94Rlmdcb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q475Z3ja94Rlmdcb .error-icon{fill:#552222;}#mermaid-svg-q475Z3ja94Rlmdcb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q475Z3ja94Rlmdcb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q475Z3ja94Rlmdcb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q475Z3ja94Rlmdcb .marker.cross{stroke:#333333;}#mermaid-svg-q475Z3ja94Rlmdcb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q475Z3ja94Rlmdcb p{margin:0;}#mermaid-svg-q475Z3ja94Rlmdcb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-q475Z3ja94Rlmdcb .cluster-label text{fill:#333;}#mermaid-svg-q475Z3ja94Rlmdcb .cluster-label span{color:#333;}#mermaid-svg-q475Z3ja94Rlmdcb .cluster-label span p{background-color:transparent;}#mermaid-svg-q475Z3ja94Rlmdcb .label text,#mermaid-svg-q475Z3ja94Rlmdcb span{fill:#333;color:#333;}#mermaid-svg-q475Z3ja94Rlmdcb .node rect,#mermaid-svg-q475Z3ja94Rlmdcb .node circle,#mermaid-svg-q475Z3ja94Rlmdcb .node ellipse,#mermaid-svg-q475Z3ja94Rlmdcb .node polygon,#mermaid-svg-q475Z3ja94Rlmdcb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-q475Z3ja94Rlmdcb .rough-node .label text,#mermaid-svg-q475Z3ja94Rlmdcb .node .label text,#mermaid-svg-q475Z3ja94Rlmdcb .image-shape .label,#mermaid-svg-q475Z3ja94Rlmdcb .icon-shape .label{text-anchor:middle;}#mermaid-svg-q475Z3ja94Rlmdcb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-q475Z3ja94Rlmdcb .rough-node .label,#mermaid-svg-q475Z3ja94Rlmdcb .node .label,#mermaid-svg-q475Z3ja94Rlmdcb .image-shape .label,#mermaid-svg-q475Z3ja94Rlmdcb .icon-shape .label{text-align:center;}#mermaid-svg-q475Z3ja94Rlmdcb .node.clickable{cursor:pointer;}#mermaid-svg-q475Z3ja94Rlmdcb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-q475Z3ja94Rlmdcb .arrowheadPath{fill:#333333;}#mermaid-svg-q475Z3ja94Rlmdcb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-q475Z3ja94Rlmdcb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-q475Z3ja94Rlmdcb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q475Z3ja94Rlmdcb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-q475Z3ja94Rlmdcb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q475Z3ja94Rlmdcb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-q475Z3ja94Rlmdcb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-q475Z3ja94Rlmdcb .cluster text{fill:#333;}#mermaid-svg-q475Z3ja94Rlmdcb .cluster span{color:#333;}#mermaid-svg-q475Z3ja94Rlmdcb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-q475Z3ja94Rlmdcb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-q475Z3ja94Rlmdcb rect.text{fill:none;stroke-width:0;}#mermaid-svg-q475Z3ja94Rlmdcb .icon-shape,#mermaid-svg-q475Z3ja94Rlmdcb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q475Z3ja94Rlmdcb .icon-shape p,#mermaid-svg-q475Z3ja94Rlmdcb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-q475Z3ja94Rlmdcb .icon-shape .label rect,#mermaid-svg-q475Z3ja94Rlmdcb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q475Z3ja94Rlmdcb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-q475Z3ja94Rlmdcb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-q475Z3ja94Rlmdcb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DeepSeek 大模型
存储
Agent 层 deepagent.py
FastAPI 层
浏览器
tool_call 仅含 content/category
聊天页 / 记忆管理页
Session 鉴权 → user_id
/agent/api/chat/stream
/memories/api
记忆注入 SystemMessage
AgentContext user_id
save_user_memory / read_user_memory
MySQL
SQLite checkpoint
推理 + 工具调用决策

一次完整聊天请求的数据流可以概括为:

  1. 鉴权 :Cookie → Redis Session → 得到 user_id
  2. Thread 归属 :校验/绑定 thread_id ↔ user_id
  3. 记忆注入 :从 MySQL 拉取该用户记忆,格式化为 SystemMessage prepend 到本轮消息前。
  4. Agent 运行context=AgentContext(user_id=...) 传入图;大模型看到注入块 + 用户消息,决定是否调用记忆工具。
  5. 工具执行 :框架把 user_id 注入到 ToolRuntime,工具写 MySQL;checkpoint 记录对话轨迹到 SQLite。

四、数据模型

4.1 用户记忆表 user_memories

sql 复制代码
CREATE TABLE user_memories (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id     BIGINT UNSIGNED NOT NULL,
    category    VARCHAR(64)     NULL,      -- 如「合约偏好」「分析口径」
    content     TEXT            NOT NULL,
    is_locked   TINYINT(1)      NOT NULL DEFAULT 0,
    created_at  DATETIME(3)     NOT NULL,
    updated_at  DATETIME(3)     NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

每条记忆是一个独立条目,不是把整段对话原文塞进去。Agent 写入时会做摘要式归纳,例如:

  • category: 合约偏好
  • content: 用户主要关注 DCE 主力合约 i2605,次要关注 i2609

4.2 Thread 归属表 thread_bindings

sql 复制代码
CREATE TABLE thread_bindings (
    thread_id   VARCHAR(64)     PRIMARY KEY,
    user_id     BIGINT UNSIGNED NOT NULL,
    created_at  DATETIME(3)     NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

checkpoint 本身不知道「这个 thread 属于哪个用户」。因此服务端在首次使用某 thread_id 时将其绑定到当前登录用户;若他人持有同一 thread_id 尝试访问,返回 403

这防止了:用户 A 的 thread_id 被用户 B 猜到后继续对话、读取 checkpoint 历史。


五、用户身份从哪里来?

大模型永远不应该 在工具参数里看到 user_id。否则模型可能被 prompt 注入诱导,伪造他人 ID 写入记忆。

mystu 的身份链路如下:
save_user_memory LangGraph ToolNode deepagent Redis Session FastAPI 浏览器 save_user_memory LangGraph ToolNode deepagent Redis Session FastAPI 浏览器 #mermaid-svg-GGMfAFZreLxytxGn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GGMfAFZreLxytxGn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GGMfAFZreLxytxGn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GGMfAFZreLxytxGn .error-icon{fill:#552222;}#mermaid-svg-GGMfAFZreLxytxGn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GGMfAFZreLxytxGn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GGMfAFZreLxytxGn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GGMfAFZreLxytxGn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GGMfAFZreLxytxGn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GGMfAFZreLxytxGn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GGMfAFZreLxytxGn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GGMfAFZreLxytxGn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GGMfAFZreLxytxGn .marker.cross{stroke:#333333;}#mermaid-svg-GGMfAFZreLxytxGn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GGMfAFZreLxytxGn p{margin:0;}#mermaid-svg-GGMfAFZreLxytxGn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GGMfAFZreLxytxGn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GGMfAFZreLxytxGn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GGMfAFZreLxytxGn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GGMfAFZreLxytxGn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GGMfAFZreLxytxGn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GGMfAFZreLxytxGn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GGMfAFZreLxytxGn .sequenceNumber{fill:white;}#mermaid-svg-GGMfAFZreLxytxGn #sequencenumber{fill:#333;}#mermaid-svg-GGMfAFZreLxytxGn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GGMfAFZreLxytxGn .messageText{fill:#333;stroke:none;}#mermaid-svg-GGMfAFZreLxytxGn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GGMfAFZreLxytxGn .labelText,#mermaid-svg-GGMfAFZreLxytxGn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GGMfAFZreLxytxGn .loopText,#mermaid-svg-GGMfAFZreLxytxGn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GGMfAFZreLxytxGn .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GGMfAFZreLxytxGn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GGMfAFZreLxytxGn .noteText,#mermaid-svg-GGMfAFZreLxytxGn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GGMfAFZreLxytxGn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GGMfAFZreLxytxGn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GGMfAFZreLxytxGn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GGMfAFZreLxytxGn .actorPopupMenu{position:absolute;}#mermaid-svg-GGMfAFZreLxytxGn .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GGMfAFZreLxytxGn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GGMfAFZreLxytxGn .actor-man circle,#mermaid-svg-GGMfAFZreLxytxGn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GGMfAFZreLxytxGn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 大模型只看到 content/category 等业务参数 POST /chat/stream + Cookie 解析 mystu_session user_id=42 astream_chat(msg, thread_id, user_id=42) AgentContext(user_id=42) astream(..., context=AgentContext) 注入 runtime.context.user_id=42 memory_repo.create(42, content)

5.1 HTTP 层:Session → User

聊天与记忆 API 都挂载了鉴权依赖:

python 复制代码
router = APIRouter(
    prefix="/agent/api",
    dependencies=[Depends(get_current_user), Depends(require_password_changed)],
)

get_current_user 从 Cookie mystu_session 查 Redis,得到当前用户对象 User,其 id 就是 user_id

聊天接口显式传入:

python 复制代码
async for token in astream_chat(request.message, request.thread_id, current.id):
    ...

关键点user_id 只在服务端可信代码路径中出现,来自 Session,不是客户端 JSON 字段。

5.2 Agent 层:AgentContext + ToolRuntime

Agent 构建时注册上下文 schema:

python 复制代码
return create_deep_agent(
    model=build_model(),
    tools=[place_order, *MEMORY_TOOLS, ...],
    system_prompt=system_prompt,
    context_schema=AgentContext,  # dataclass: user_id: int
    checkpointer=checkpointer,
)

每次调用传入 context:

python 复制代码
context = AgentContext(user_id=user_id)
await get_agent().ainvoke(
    {"messages": messages},
    config={"configurable": {"thread_id": tid}},
    context=context,
)

记忆工具通过 LangChain ToolRuntime 读取 context:

python 复制代码
@tool
async def save_user_memory(
    content: str,
    category: str | None = None,
    memory_id: int | None = None,
    *,
    runtime: ToolRuntime[AgentContext, Any],  # keyword-only 注入参数
) -> str:
    user_id = runtime.context.user_id
    ...

5.3 为什么 runtime 必须是 keyword-only?

ToolRuntime 是框架在工具执行时自动注入的运行时对象,不能暴露给大模型

工具参数有两层 schema:

Schema 谁用 包含什么
tool_call_schema 大模型发起 tool_call 时 contentcategorymemory_id 等业务字段
args_schema 框架内部校验 runtime 等注入字段

正确写法:

python 复制代码
*, runtime: ToolRuntime[AgentContext, Any]  # ✅ 必填、keyword-only

错误写法:

python 复制代码
runtime: ToolRuntime | None = None  # ❌ 会进入 schema,且 Pydantic 无法序列化 CallableSchema

若写错,调用 /agent/api/chat/stream 时会报错:

复制代码
Failed to generate JSON schema for 'save_user_memory':
Cannot generate a JsonSchema for core_schema.CallableSchema

回归测试应断言:

python 复制代码
schema = save_user_memory.tool_call_schema.model_json_schema()
assert "runtime" not in schema["properties"]
assert "user_id" not in schema["properties"]

六、记忆注入:对话开始前发生了什么

每次 achat / astream_chat 在调用大模型之前,都会执行 _build_invoke_messages

python 复制代码
async def _build_invoke_messages(user_id: int, message: str) -> list:
    memories = await memory_repo.list_by_user(user_id)
    selected = select_memories_for_injection(memories)
    inject_text = format_memory_system_message(selected)
    messages = []
    if inject_text:
        messages.append(SystemMessage(content=inject_text))
    messages.append(HumanMessage(content=message))
    return messages

6.1 选取策略与 token 预算

默认预算 2000 token(启发式估算,非精确 tokenizer):

python 复制代码
def estimate_tokens(text: str) -> int:
    cjk = len(汉字)
    other = len(text) - cjk
    return int(cjk * 1.5 + other * 0.25)

选取规则(select_memories_for_injection):

  1. updated_at 降序(最近更新的优先)。
  2. 逐条累加 token,直到超过预算则停止。
  3. 若单条就超预算,仍注入该条(避免完全空白)。

6.2 注入块长什么样

格式化示例:

markdown 复制代码
## 用户长期记忆(跨会话)
以下信息来自该用户历史对话;标注「只读」的条目不可通过 save_user_memory 修改。
- [id=3][可更新] [合约偏好] 用户主要关注 DCE 主力 i2605
- [id=7][只读] [分析口径] 引用普氏 62% 指数时需注明发布日期

每条带:

  • id:Agent 更新时可传 memory_id
  • 可更新 / 只读:对应 is_locked
  • category:可选分类

这段文本作为 SystemMessage 放在用户本轮 HumanMessage 之前。大模型在 system prompt(铁矿石预测人设)之外,额外看到这份「用户画像摘要」。

6.3 注入 vs 全量

注入是快照,不是实时订阅:

  • 本轮对话中途 Agent 新写入的记忆,下一轮 请求才会出现在注入块里(当轮仍可通过 read_user_memory 查到)。
  • 超出 2000 token 的条目不会注入,但 Agent 可用 read_user_memory(keyword=...) 按需拉取。

七、大模型如何保存记忆:工具调用全链路

系统提示词(iron_ore_forecast.md)告诉模型何时写记忆:

markdown 复制代码
# 用户长期记忆
- 系统可能在对话开始时注入「用户长期记忆」块;请据此个性化回答。
- 当用户透露值得跨会话保留的偏好、习惯或关键分析结论时,调用 save_user_memory 写入;
  主题相似且条目未锁定时可传 memory_id 更新,否则新建。
- 标注「只读」的记忆条目不可通过 save_user_memory 修改。
- 注入块未涵盖的信息,可调用 read_user_memory 按键词或 ID 查询。

7.1 典型时序

MySQL save_user_memory ToolNode DeepSeek 用户 MySQL save_user_memory ToolNode DeepSeek 用户 #mermaid-svg-Ai2yEOmn8CTop0ln{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ai2yEOmn8CTop0ln .error-icon{fill:#552222;}#mermaid-svg-Ai2yEOmn8CTop0ln .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ai2yEOmn8CTop0ln .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ai2yEOmn8CTop0ln .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ai2yEOmn8CTop0ln .marker.cross{stroke:#333333;}#mermaid-svg-Ai2yEOmn8CTop0ln svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ai2yEOmn8CTop0ln p{margin:0;}#mermaid-svg-Ai2yEOmn8CTop0ln .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ai2yEOmn8CTop0ln text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ai2yEOmn8CTop0ln .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Ai2yEOmn8CTop0ln .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Ai2yEOmn8CTop0ln #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Ai2yEOmn8CTop0ln .sequenceNumber{fill:white;}#mermaid-svg-Ai2yEOmn8CTop0ln #sequencenumber{fill:#333;}#mermaid-svg-Ai2yEOmn8CTop0ln #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Ai2yEOmn8CTop0ln .messageText{fill:#333;stroke:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ai2yEOmn8CTop0ln .labelText,#mermaid-svg-Ai2yEOmn8CTop0ln .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .loopText,#mermaid-svg-Ai2yEOmn8CTop0ln .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Ai2yEOmn8CTop0ln .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Ai2yEOmn8CTop0ln .noteText,#mermaid-svg-Ai2yEOmn8CTop0ln .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Ai2yEOmn8CTop0ln .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ai2yEOmn8CTop0ln .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ai2yEOmn8CTop0ln .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Ai2yEOmn8CTop0ln .actorPopupMenu{position:absolute;}#mermaid-svg-Ai2yEOmn8CTop0ln .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Ai2yEOmn8CTop0ln .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Ai2yEOmn8CTop0ln .actor-man circle,#mermaid-svg-Ai2yEOmn8CTop0ln line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Ai2yEOmn8CTop0ln :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tool_call JSON 不含 user_id 「我主要看 i2605,以后分析都按这个来」 判断:值得长期保留 tool_call save_user_memory(content="...", category="合约偏好") 构造 ToolRuntime(context=AgentContext(user_id=42)) coroutine(content=..., runtime=...) INSERT user_memories (user_id=42, ...) "已新建记忆条目 id=15。" ToolMessage(结果) 「好的,我已记住您主要关注 i2605...」

7.2 save_user_memory 逻辑分支

python 复制代码
user_id = runtime.context.user_id

if memory_id is not None:
    # 更新路径:检查存在 + 未锁定
    updated = await memory_repo.update(memory_id, user_id, content=..., category=...)
    return f"已更新记忆条目 id={updated.id}。"
else:
    # 新建路径
    created = await memory_repo.create(user_id, content, category=category)
    return f"已新建记忆条目 id={created.id}。"

异常处理(返回给模型的文本,不抛 HTTP 异常):

情况 返回
条目已锁定 记忆条目 id=X 已锁定,无法修改。可新建条目或请用户解锁。
条目不存在 记忆条目 id=X 不存在。

模型收到这些字符串后会调整策略,例如新建一条或提示用户去前端解锁。

合并策略 由模型自行判断:注入块里每条有 id 和主题,模型决定是 memory_id=3 更新还是新建。服务端不做 embedding 相似度计算。

7.3 read_user_memory 逻辑

两种模式:

  1. 按 IDmemory_id=7 → 返回单条 JSON。
  2. 按关键词keyword="普氏" → 在 contentcategoryLIKE 模糊搜索,最多 20 条。

同样通过 runtime.context.user_id 限定只能读自己的记忆。

7.4 Agent 不能删记忆

需求 R9:save_user_memory / read_user_memory 之外没有 Agent 删除工具。删除只能用户在前端或 REST API 发起。这避免模型误删用户精心锁定的条目。


八、用户手动管理记忆:REST API

除 Agent 自主写入外,用户提供完整 CRUD + 锁定能力(/memories/api):

方法 路径 说明
GET /memories/api/ 列出当前用户全部记忆
POST /memories/api/ 用户手动创建
GET /memories/api/{id} 查看单条
PUT /memories/api/{id} 编辑(含锁定条目,allow_locked=True
DELETE /memories/api/{id} 删除
PATCH /memories/api/{id}/lock 锁定/解锁

与 Agent 工具的差异:

操作 Agent 工具 用户 API
创建
更新未锁定
更新已锁定 ❌ 拒绝 ✅ 允许
删除 ❌ 无工具
锁定切换 ❌ 无工具

锁定是用户主权机制:用户锁定某条后,Agent 只能读、不能改;用户自己仍可在管理页编辑。

前端双入口:

  • 独立页 /memories:完整 CRUD。
  • 聊天页「🧠 记忆」抽屉:快捷查看 + 跳转管理页。

九、Thread 归属校验

ensure_thread_access(user_id, thread_id) 在每次聊天前执行:

python 复制代码
owner = await thread_repo.get_owner(thread_id)
await thread_repo.bind_if_absent(thread_id, user_id)
# 若 thread 已绑定他人 → ThreadAccessDeniedError → HTTP 403

行为:

场景 结果
新 thread_id,未绑定 绑定到当前 user,继续
thread 已属当前 user 继续
thread 属其他 user 403 无权访问该会话

这保证了 checkpoint SQLite 里的对话历史与 MySQL 里的用户身份一致。


十、与 LangGraph checkpoint 的协作

Web 应用 lifespan 启动时:

python 复制代码
async with AsyncSqliteSaver.from_conn_string(db_path) as saver:
    set_agent(build_agent(checkpointer=saver, extra_tools=mcp_tools))

单次流式聊天:

python 复制代码
config = {"configurable": {"thread_id": tid}}
async for chunk, _ in agent.astream(
    {"messages": messages},  # 含注入 SystemMessage + HumanMessage
    config=config,
    context=AgentContext(user_id=user_id),
    stream_mode="messages",
):
    ...
  • thread_id:checkpoint 键,恢复该 thread 内历史消息。
  • user_id :走 context,供记忆注入与工具使用。
  • 注入 SystemMessage:每轮都重新从 MySQL 拉最新记忆快照,不写入 checkpoint 作为永久 system(避免锁定状态变更后 checkpoint 里仍是旧标注)。

用户点击「新对话」→ 新 thread_id → checkpoint 空白,但 _build_invoke_messages 仍从 MySQL 注入同一用户的长期记忆。这就是跨 thread recall 的实现方式。


十一、安全设计要点

  1. user_id 不可由客户端或 LLM 指定

    来自 Session;工具通过 ToolRuntime 注入,不出现在 tool_call_schema

  2. 记忆按 user_id 隔离

    所有 SQL 带 WHERE user_id = %s;Repository 层强制校验。

  3. thread 归属

    防止跨用户读取 checkpoint 对话。

  4. 锁定

    Agent 工具层 allow_locked=False;用户 API allow_locked=True

  5. 软删除用户

    list_by_user / get_by_id JOIN usersis_deleted = 0,停用用户记忆不再注入。

  6. 鉴权

    Agent 与记忆 API 均需登录且完成强制改密。


十二、日志与排查

关键路径已打日志,便于线上对照:

位置 示例
deepagent._build_invoke_messages 记忆注入 user_id=42 总条目=5 注入=3 ...
deepagent.astream_chat astream_chat 开始 user_id=42 thread_id=abc...
memory_tools.save_user_memory save_user_memory 调用 user_id=42 ...
memory_repo.create 记忆已写入数据库 id=15 user_id=42 ...
thread_service thread 新绑定 / thread 访问被拒绝

若流式接口报 schema 错误,优先检查记忆工具签名是否 keyword-only runtime


十三、端到端示例

场景:用户 alice(id=2)首次说明偏好,然后开新对话再问行情。

第一轮(thread_id = t1

  1. alice 登录,Cookie 有效。
  2. POST /agent/api/chat/stream { "message": "我主要看 i2605", "thread_id": "t1" }
  3. 绑定 t1 → user_id=2;注入块为空(尚无记忆)。
  4. 模型调用 save_user_memory(content="用户主要关注 DCE 主力 i2605", category="合约偏好")
  5. MySQL 插入 id=1;checkpoint 记录本轮对话。
  6. 模型回复确认已记住。

第二轮(新对话 thread_id = t2

  1. POST { "message": "今天盘面怎么看?", "thread_id": "t2" }
  2. 绑定 t2 → user_id=2
  3. 注入块含:[id=1][可更新] [合约偏好] 用户主要关注 DCE 主力 i2605
  4. checkpoint 中 t2 无历史,但模型已从注入块知道偏好,回答会个性化。
  5. 若需更多历史条目且未注入,模型可 read_user_memory(keyword="...")

十四、模块与文件索引

模块 文件 职责
上下文 buildagent/agent/agent_context.py AgentContext(user_id)
注入 buildagent/agent/memory_inject.py token 估算、格式化、选取
工具 buildagent/agent/memory_tools.py save_user_memory / read_user_memory
Agent 集成 buildagent/agent/deepagent.py 构建 agent、注入、chat/stream
持久化 auth/memory_repo.py MySQL CRUD
Thread auth/thread_repo.py / thread_service.py thread 归属
HTTP controller/api.py 聊天 API 传 current.id
HTTP controller/memories_api.py 用户记忆 REST
提示词 buildagent/prompt/md/iron_ore_forecast.md 何时读/写记忆
表结构 scripts/sql/init.sql user_memories / thread_bindings
测试 tests/test_memory_tools.py 工具、API、注入、thread

十五、与 deepagents 内置记忆方案的对比

deepagents 提供基于虚拟文件系统 /memories/ 的记忆 middleware,适合「Agent 自己写 markdown 笔记」风格。

mystu 选用 MySQL 结构化记忆 + 工具桥接,原因包括:

  • 与现有用户体系(MySQL users)自然 JOIN,隔离清晰。
  • 用户可在前端 CRUD、锁定,不依赖 Agent 文件操作。
  • 注入 token 可控(2000 上限 + 按需 read)。
  • checkpoint 仍用 SQLite,职责分离,运维简单。

若未来需要 Agent 写长文笔记,可考虑在现有表之外增加附件或摘要字段,而不必整体迁移到 /memories/ FS。


十六、扩展与改进方向

当前实现的已知边界(规划阶段已记录):

  1. Token 估算为启发式,非精确 tokenizer;中英文混排时余量需人工评估。
  2. 相似合并靠 LLM 判断 ,无向量检索;条目很多时依赖 read_user_memory 补全。
  3. 注入为请求级快照,同轮对话中途写入需下轮或 read 才进入注入块。
  4. 无跨用户共享记忆(如团队共享口径),若有需求需另建模型。

可能的演进:

  • 引入 embedding 做相似条目合并与检索排序。
  • 记忆条目版本历史(审计谁改了什么)。
  • 按 category 分配注入配额,避免单类占满 2000 token。

十七、小结

mystu 的用户级长期记忆可以概括为三句话:

  1. MySQL 存什么 :结构化条目,按 user_id 隔离,用户可 CRUD + 锁定。
  2. 大模型怎么知道 :对话前把条目格式化成 SystemMessage 注入;不够时用 read_user_memory
  3. 大模型怎么写、写谁的 :调用 save_user_memory 时只传 content/category;user_id 由服务端 AgentContextToolRuntime 注入,对大模型不可见。

Thread checkpoint 管「这一段对话说了什么」;用户记忆管「这个人长期是谁」。两者配合,才实现「新对话仍记得你」的体验。