本文面向希望理解 mystu 项目中「跨会话用户记忆」如何落地的开发者。会尽量把两种记忆的分工 、大模型与工具之间如何传递用户信息 、记忆如何写入与读出 、以及安全边界讲清楚。
一、为什么要做用户级记忆?
在引入用户级记忆之前,mystu 的对话记忆完全依赖 LangGraph 的 checkpoint 机制:客户端每次聊天携带一个 thread_id,同一 thread_id 内的多轮消息、工具调用会被持久化到 SQLite(data/checkpoints.sqlite)。
这套机制有一个天然限制:记忆是按 thread 隔离的,与登录用户无关。
典型场景:
- 用户在对话 A 中说:「我主要关注 DCE 主力 i2605。」
- 用户点击「新对话」,前端生成新的
thread_id。 - 对话 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
推理 + 工具调用决策
一次完整聊天请求的数据流可以概括为:
- 鉴权 :Cookie → Redis Session → 得到
user_id。 - Thread 归属 :校验/绑定
thread_id ↔ user_id。 - 记忆注入 :从 MySQL 拉取该用户记忆,格式化为
SystemMessageprepend 到本轮消息前。 - Agent 运行 :
context=AgentContext(user_id=...)传入图;大模型看到注入块 + 用户消息,决定是否调用记忆工具。 - 工具执行 :框架把
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 时 | 仅 content、category、memory_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):
- 按
updated_at降序(最近更新的优先)。 - 逐条累加 token,直到超过预算则停止。
- 若单条就超预算,仍注入该条(避免完全空白)。
6.2 注入块长什么样
格式化示例:
markdown
## 用户长期记忆(跨会话)
以下信息来自该用户历史对话;标注「只读」的条目不可通过 save_user_memory 修改。
- [id=3][可更新] [合约偏好] 用户主要关注 DCE 主力 i2605
- [id=7][只读] [分析口径] 引用普氏 62% 指数时需注明发布日期
每条带:
id:Agent 更新时可传memory_id可更新/只读:对应is_lockedcategory:可选分类
这段文本作为 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 逻辑
两种模式:
- 按 ID :
memory_id=7→ 返回单条 JSON。 - 按关键词 :
keyword="普氏"→ 在content与category上LIKE模糊搜索,最多 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 的实现方式。
十一、安全设计要点
-
user_id 不可由客户端或 LLM 指定
来自 Session;工具通过
ToolRuntime注入,不出现在tool_call_schema。 -
记忆按 user_id 隔离
所有 SQL 带
WHERE user_id = %s;Repository 层强制校验。 -
thread 归属
防止跨用户读取 checkpoint 对话。
-
锁定
Agent 工具层
allow_locked=False;用户 APIallow_locked=True。 -
软删除用户
list_by_user/get_by_idJOINusers且is_deleted = 0,停用用户记忆不再注入。 -
鉴权
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)
- alice 登录,Cookie 有效。
- POST
/agent/api/chat/stream{ "message": "我主要看 i2605", "thread_id": "t1" }。 - 绑定
t1 → user_id=2;注入块为空(尚无记忆)。 - 模型调用
save_user_memory(content="用户主要关注 DCE 主力 i2605", category="合约偏好")。 - MySQL 插入 id=1;checkpoint 记录本轮对话。
- 模型回复确认已记住。
第二轮(新对话 thread_id = t2)
- POST
{ "message": "今天盘面怎么看?", "thread_id": "t2" }。 - 绑定
t2 → user_id=2。 - 注入块含:
[id=1][可更新] [合约偏好] 用户主要关注 DCE 主力 i2605。 - checkpoint 中
t2无历史,但模型已从注入块知道偏好,回答会个性化。 - 若需更多历史条目且未注入,模型可
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。
十六、扩展与改进方向
当前实现的已知边界(规划阶段已记录):
- Token 估算为启发式,非精确 tokenizer;中英文混排时余量需人工评估。
- 相似合并靠 LLM 判断 ,无向量检索;条目很多时依赖
read_user_memory补全。 - 注入为请求级快照,同轮对话中途写入需下轮或 read 才进入注入块。
- 无跨用户共享记忆(如团队共享口径),若有需求需另建模型。
可能的演进:
- 引入 embedding 做相似条目合并与检索排序。
- 记忆条目版本历史(审计谁改了什么)。
- 按 category 分配注入配额,避免单类占满 2000 token。
十七、小结
mystu 的用户级长期记忆可以概括为三句话:
- MySQL 存什么 :结构化条目,按
user_id隔离,用户可 CRUD + 锁定。 - 大模型怎么知道 :对话前把条目格式化成
SystemMessage注入;不够时用read_user_memory。 - 大模型怎么写、写谁的 :调用
save_user_memory时只传 content/category;user_id由服务端AgentContext→ToolRuntime注入,对大模型不可见。
Thread checkpoint 管「这一段对话说了什么」;用户记忆管「这个人长期是谁」。两者配合,才实现「新对话仍记得你」的体验。