From d3d41b20d7cefd4ea8de2ccc2671a00dac005b0c Mon Sep 17 00:00:00 2001 From: "Shin@HOME" Date: Mon, 13 Apr 2026 23:37:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B0=E5=BF=86=E7=B3=BB=E7=BB=9F=EF=BC=8C?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A4=84=E7=90=86+mem0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/changelog/.changelog-ack | 0 .cursor/changelog/changelog-full.md | 27 +++++++ .cursor/changelog/changelog-headlines.md | 1 + .cursor/changelog/changelog-recent.md | 5 ++ .cursor/deferred/registry.md | 57 +++----------- .gitignore | 1 + art-agent/backend/app/agent/loop.py | 83 ++++++++++++++++++-- art-agent/backend/app/api/chat.py | 7 +- art-agent/backend/app/config.py | 20 +++++ art-agent/backend/app/memory.py | 99 ++++++++++++++++++++++++ art-agent/backend/requirements.txt | 2 + art-agent/frontend/src/app/page.tsx | 7 +- art-agent/frontend/src/lib/api.ts | 9 ++- 13 files changed, 262 insertions(+), 56 deletions(-) delete mode 100644 .cursor/changelog/.changelog-ack create mode 100644 art-agent/backend/app/memory.py diff --git a/.cursor/changelog/.changelog-ack b/.cursor/changelog/.changelog-ack deleted file mode 100644 index e69de29..0000000 diff --git a/.cursor/changelog/changelog-full.md b/.cursor/changelog/changelog-full.md index 0755a92..f0ee8d9 100644 --- a/.cursor/changelog/changelog-full.md +++ b/.cursor/changelog/changelog-full.md @@ -4,6 +4,33 @@ ## 记录 +### [CL-20260413-2320] 2026-04-13 23:20 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆 +- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口 +- **affected_files**: + - art-agent/backend/app/memory.py(新建) + - art-agent/backend/app/agent/loop.py + - art-agent/backend/app/config.py + - art-agent/backend/app/api/chat.py + - art-agent/backend/requirements.txt + - art-agent/backend/.env + - art-agent/frontend/src/lib/api.ts + - art-agent/frontend/src/app/page.tsx + - .gitignore +- **what**: 集成 Mem0 开源版作为统一记忆方案,一次性解决两个延期方案:上下文管理(滑动窗口截断)和跨会话长期记忆(事实提取+语义检索) +- **why**: 当前对话全量透传无截断,长对话会超 token 上限且费用线性增长;且每次会话从零开始无法记住用户偏好 +- **decisions**: + - 选择 Mem0 OSS 自部署(非平台托管),完全本地化,数据在 ./data/qdrant + - DeepSeek 作为事实提取 LLM(复用现有 key,成本极低) + - Ollama nomic-embed-text 作为本地 embedding(免费,768维,性能足够) + - 不做静默降级:Ollama 不可用 → 阻止启动;search 失败 → SSE error 中断对话;add 失败 → SSE warning 告知用户 + - 用 asyncio.run_in_executor 异步执行 memory.add(),不阻塞用户体验 +- **notes**: + - 测试时创建了 data/qdrant 目录(含测试记忆数据),已加入 .gitignore + - Ollama 需要作为后台服务保持运行 + - 首次 Mem0 初始化约需 5-8 秒(创建 Qdrant collection) + - 解决了延期方案 [context-window-management] 和 [mem0-long-term-memory] +- **source_chat**: [Mem0 记忆系统集成](mem0-memory-integration) + ### [CL-20260413-1130] 2026-04-13 11:30 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash - **tags**: bug修复, Replicate, SDXL, 模型配置, 404 - **affected_files**: diff --git a/.cursor/changelog/changelog-headlines.md b/.cursor/changelog/changelog-headlines.md index 7de9c60..3846a80 100644 --- a/.cursor/changelog/changelog-headlines.md +++ b/.cursor/changelog/changelog-headlines.md @@ -2,6 +2,7 @@ 最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 +- [CL-20260413-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆 - [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash - [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型 - [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试:DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具 diff --git a/.cursor/changelog/changelog-recent.md b/.cursor/changelog/changelog-recent.md index 7c3a1b7..20ae5a3 100644 --- a/.cursor/changelog/changelog-recent.md +++ b/.cursor/changelog/changelog-recent.md @@ -3,6 +3,11 @@ 最近 ~10 次改动的摘要记录,按时间倒序排列。 当 Agent 检测到当前任务与近期改动相关时自动读取。 +### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆 +- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口 +- **affected_files**: app/memory.py, app/agent/loop.py, app/config.py, app/api/chat.py, requirements.txt, .env, frontend/src/lib/api.ts, frontend/src/app/page.tsx +- **summary**: 集成 Mem0 OSS 作为统一记忆方案。DeepSeek 做事实提取,Ollama nomic-embed-text 做本地 embedding,Qdrant 文件模式做向量存储。agent loop 新增滑动窗口(MAX_RECENT_TURNS=20)、记忆检索注入 system prompt、异步事实存储。前端传递 session_id 实现会话级记忆作用域。不做静默降级,所有错误显式报告。 + ### [CL-20260413-1130] 2026-04-13 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash - **tags**: bug修复, Replicate, SDXL, 模型配置, 404 - **affected_files**: art-agent/backend/app/config.py diff --git a/.cursor/deferred/registry.md b/.cursor/deferred/registry.md index 1185626..6e3f05a 100644 --- a/.cursor/deferred/registry.md +++ b/.cursor/deferred/registry.md @@ -91,53 +91,18 @@ - **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索 - **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化 -### [context-window-management] 对话上下文管理(Token 预算 + 滑动窗口 + 摘要) -- **status**: deferred -- **tags**: 上下文管理, token, 滑动窗口, 对话摘要, agent loop, 长对话 -- **recorded**: 2026-04-13 -- **source_chat**: [Art Agent 上下文与记忆体系讨论](memory-architecture-discussion) -- **prerequisite**: 无(随时可做,优先级高于长期记忆) -- **related_files**: - - art-agent/backend/app/agent/loop.py - - art-agent/frontend/src/lib/api.ts - - art-agent/frontend/src/lib/store.ts -- **context**: | - 当前 Art Agent 对话采用"全量透传":前端把完整 messages 发给后端,后端原样拼接传给 LLM, - 没有 token 计数、截断或摘要。长对话会导致上下文超限、费用线性增长。 - 实施方案(自建,无需外部依赖): - 1. Token 计数:用 tiktoken 库在构建 api_messages 时计数 - 2. 滑动窗口:超过预算时截断最早的消息 - 3. 对话摘要:被截断的消息先调 LLM 生成摘要,作为压缩上下文保留 - 预估工作量 1-2 天,是记忆体系的第一层基础。 -- **chosen_alternative**: 当前全量透传,依赖模型自身的上下文窗口上限 -- **deferred_reason**: 当前探索期对话通常较短,暂未触及上限;但随着迭代使用会很快需要 - -### [mem0-long-term-memory] 引入 Mem0 作为长期记忆层 -- **status**: deferred -- **tags**: 长期记忆, Mem0, 用户偏好, 语义检索, 事实提取, memory layer -- **recorded**: 2026-04-13 -- **source_chat**: [Art Agent 上下文与记忆体系讨论](memory-architecture-discussion) -- **prerequisite**: 第一层上下文管理(context-window-management)完成后 -- **related_files**: - - art-agent/backend/app/agent/loop.py - - art-agent/backend/app/config.py -- **context**: | - 引入 Mem0 开源版(Apache 2.0)作为 Art Agent 的跨会话长期记忆层: - - Mem0 是"bolt-on"设计,保留自己的 agent loop,只外挂 memory API - - 核心 API:memory.add()(存储+自动事实提取)+ memory.search()(语义检索) - - 自部署:pip install mem0ai + Qdrant 向量库,LLM 用 DeepSeek 降成本 - - 记忆范围:用户风格偏好、历史生成反馈、常用参数等 - - 与自建 Rules/Docs 静态知识系统互补:Mem0 负责动态记忆,Rules 负责预置知识 - 技术选型对比(本次讨论结论): - - Letta 不适合:它是完整 agent runtime,不是 memory layer,需要替换现有 loop - - Zep 可行但偏重:时序知识图谱能力强但集成复杂度更高 - - Mem0 最适合:设计哲学完全对口"已有 agent + 外挂 memory",集成最简(~20 行代码) - 预估工作量 3-5 天(含 Mem0 部署 + 集成 + extraction prompt 调优)。 -- **chosen_alternative**: 当前无长期记忆,每次会话从零开始 -- **deferred_reason**: 探索期优先做第一层上下文管理;长期记忆在用户量和使用频率增长后再引入 - --- ## Completed / Cancelled Items -(暂无已完成或已废弃的方案) +### [context-window-management] 对话上下文管理(滑动窗口) +- **status**: completed +- **completed_date**: 2026-04-13 +- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成 +- **notes**: 原计划自建 tiktoken + 滑动窗口 + 摘要。实际方案:Mem0 的事实提取替代了"对话摘要",滑动窗口在 loop.py 中实现(MAX_RECENT_TURNS=20) + +### [mem0-long-term-memory] 引入 Mem0 作为长期记忆层 +- **status**: completed +- **completed_date**: 2026-04-13 +- **completed_by**: [CL-20260413-2320] Mem0 记忆系统集成 +- **notes**: Mem0 OSS 自部署(DeepSeek 事实提取 + Ollama nomic-embed-text embedding + Qdrant 本地向量库),与上下文管理一并实施 diff --git a/.gitignore b/.gitignore index 173da93..7d85189 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ out/ # ─── 运行时生成物 ──────────────────────────────────────── art-agent/backend/uploads/ art-agent/backend/generated/ +art-agent/backend/data/ # ─── 工具二进制 ────────────────────────────────────────── *.exe diff --git a/art-agent/backend/app/agent/loop.py b/art-agent/backend/app/agent/loop.py index b28744b..dc0b0fc 100644 --- a/art-agent/backend/app/agent/loop.py +++ b/art-agent/backend/app/agent/loop.py @@ -1,6 +1,8 @@ """Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。""" +import asyncio import json +import logging import re import os from typing import AsyncGenerator, Optional @@ -8,9 +10,17 @@ from typing import AsyncGenerator, Optional from openai import AsyncOpenAI from app.agent.tools import TOOL_DEFINITIONS, execute_tool -from app.config import get_llm_model, get_llm_max_iterations, get_image_model_config +from app.config import ( + get_llm_model, + get_llm_max_iterations, + get_image_model_config, + get_max_recent_turns, +) +from app.memory import get_memory from app.services.image_gen import to_data_uri +logger = logging.getLogger(__name__) + def _get_client() -> AsyncOpenAI: base_url = os.getenv("OPENAI_BASE_URL") @@ -59,6 +69,7 @@ async def run_agent_loop( messages: list[dict], ref_image_url: Optional[str] = None, image_model: Optional[str] = None, + session_id: Optional[str] = None, ) -> AsyncGenerator[dict, None]: """ 运行 Agent Loop,以 SSE 事件流形式 yield 结果。 @@ -67,10 +78,42 @@ async def run_agent_loop( - text_delta: LLM 文字增量 - tool_start: 开始执行工具 - image_result: 图片生成结果 + - memory_warning: 记忆存储异常(不中断对话) - done: 完成 - error: 错误 """ - # 构建消息列表:将当前生图模型信息注入 system prompt,避免 LLM 猜测模型名 + # ── 滑动窗口:截断过早的消息 ── + max_turns = get_max_recent_turns() + if len(messages) > max_turns: + messages = messages[-max_turns:] + + # ── 记忆检索:用最新 user 消息语义检索相关记忆 ── + memory_block = "" + try: + memory = get_memory() + last_user_content = "" + for m in reversed(messages): + if m["role"] == "user": + last_user_content = m["content"] + break + + if last_user_content: + search_kwargs = {"query": last_user_content, "user_id": "default_user", "limit": 10} + if session_id: + search_kwargs["run_id"] = session_id + relevant = memory.search(**search_kwargs) + + results = relevant.get("results", []) if isinstance(relevant, dict) else relevant + if results: + items = "\n".join(f"- {m['memory']}" for m in results if m.get("memory")) + if items: + memory_block = f"\n\n## 用户记忆(来自历史对话)\n{items}" + except Exception as e: + logger.error("Mem0 记忆检索失败: %s", e, exc_info=True) + yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}} + return + + # ── 构建 system prompt ── model_config = get_image_model_config(image_model) current_model_name = model_config.get('name', '未知') current_model_id = model_config.get('id', '未知') @@ -81,7 +124,7 @@ async def run_agent_loop( f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。" f"本次生成使用的是 {current_model_name},在回复中只能使用这个名称。" ) - api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint}] + api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}] # 检测当前 LLM 是否支持 vision(多模态图片输入) llm_model = get_llm_model().lower() @@ -90,7 +133,6 @@ async def run_agent_loop( for msg in messages: if msg["role"] == "user" and ref_image_url and msg is messages[-1]: if vision_capable: - # 支持 vision 的模型:直接发送图片 api_messages.append({ "role": "user", "content": [ @@ -102,7 +144,6 @@ async def run_agent_loop( ], }) else: - # 不支持 vision 的模型:以文字提示告知有参考图,风格由 IP-Adapter 处理 hint = ( f"{msg['content']}\n\n" "【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。" @@ -181,7 +222,9 @@ async def run_agent_loop( ), }) continue - yield {"type": "done", "data": {}} + # 对话正常结束,异步存储记忆 + async for evt in _store_and_done(messages, session_id): + yield evt return # 将 assistant 消息(含 tool_calls)加入历史 @@ -253,6 +296,34 @@ async def run_agent_loop( # ref_image_url 不清除:消息构建只在循环外执行一次, # 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image) + # 迭代次数用尽,存储记忆后结束 + async for evt in _store_and_done(messages, session_id): + yield evt + + +# ─── 异步记忆存储 ───────────────────────────────────────── + + +async def _store_and_done( + messages: list[dict], session_id: Optional[str] +) -> AsyncGenerator[dict, None]: + """触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。""" + try: + memory = get_memory() + recent = messages[-4:] if len(messages) >= 4 else messages + add_kwargs: dict = {"user_id": "default_user"} + if session_id: + add_kwargs["run_id"] = session_id + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, lambda: memory.add(recent, **add_kwargs)) + except Exception as e: + logger.error("Mem0 记忆存储失败: %s", e, exc_info=True) + yield { + "type": "memory_warning", + "data": {"message": f"记忆存储失败: {e}"}, + } + yield {"type": "done", "data": {}} diff --git a/art-agent/backend/app/api/chat.py b/art-agent/backend/app/api/chat.py index d338103..bba048b 100644 --- a/art-agent/backend/app/api/chat.py +++ b/art-agent/backend/app/api/chat.py @@ -52,6 +52,7 @@ async def chat( ref_image: Optional[UploadFile] = File(None), ref_image_url: Optional[str] = Form(None), image_model: Optional[str] = Form(None), + session_id: Optional[str] = Form(None), ): """ 主对话端点。 @@ -61,6 +62,7 @@ async def chat( - ref_image: 可选的参考图文件(兼容旧方式) - ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径 - image_model: 可选,指定本次使用的生图模型短 ID + - session_id: 可选,前端会话 ID,用于 Mem0 记忆作用域 """ parsed_messages = json.loads(messages) @@ -72,7 +74,10 @@ async def chat( async def event_generator(): async for event in run_agent_loop( - parsed_messages, resolved_ref_url, image_model=image_model + parsed_messages, + resolved_ref_url, + image_model=image_model, + session_id=session_id, ): yield { "event": event["type"], diff --git a/art-agent/backend/app/config.py b/art-agent/backend/app/config.py index e7c73bf..5184639 100644 --- a/art-agent/backend/app/config.py +++ b/art-agent/backend/app/config.py @@ -148,6 +148,26 @@ def get_image_models_list() -> list[dict]: ] +# ─── 记忆系统配置 ───────────────────────────────────── + +def get_deepseek_api_key() -> str: + return os.getenv("DEEPSEEK_API_KEY", "") + + +def get_ollama_base_url() -> str: + return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + + +def get_mem0_embedding_model() -> str: + return os.getenv("MEM0_EMBEDDING_MODEL", "nomic-embed-text") + + +def get_max_recent_turns() -> int: + return int(os.getenv("MAX_RECENT_TURNS", "20")) + + +# ─── 图像输出配置 ───────────────────────────────────── + def get_image_aspect_ratio() -> str: return os.getenv("IMAGE_ASPECT_RATIO", "1:1") diff --git a/art-agent/backend/app/memory.py b/art-agent/backend/app/memory.py new file mode 100644 index 0000000..2cfcb4e --- /dev/null +++ b/art-agent/backend/app/memory.py @@ -0,0 +1,99 @@ +""" +Mem0 记忆层初始化与健康检查。 +启动时验证 Ollama 可达和模型可用,失败则抛出 RuntimeError 阻止后端启动。 +""" + +import logging +import httpx +from mem0 import Memory + +from app.config import ( + get_deepseek_api_key, + get_ollama_base_url, + get_mem0_embedding_model, +) + +logger = logging.getLogger(__name__) + +_memory: Memory | None = None + + +def _check_ollama_health() -> None: + """检查 Ollama 服务是否可达,以及 embedding 模型是否已安装。""" + base_url = get_ollama_base_url() + model_name = get_mem0_embedding_model() + + try: + resp = httpx.get(f"{base_url}/api/tags", timeout=5) + resp.raise_for_status() + except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e: + raise RuntimeError( + f"无法连接 Ollama 服务({base_url})。" + f"请确认 Ollama 已启动:启动方式参见 https://ollama.com\n" + f"原始错误:{e}" + ) from e + + models = resp.json().get("models", []) + installed = [m.get("name", "").split(":")[0] for m in models] + if model_name not in installed: + raise RuntimeError( + f"Ollama 已运行,但未找到 embedding 模型 '{model_name}'。\n" + f"已安装的模型:{installed}\n" + f"请运行:ollama pull {model_name}" + ) + + logger.info("Ollama 健康检查通过:%s 模型可用", model_name) + + +def init_memory() -> Memory: + """初始化 Mem0 Memory 单例。首次调用时执行健康检查。""" + global _memory + if _memory is not None: + return _memory + + _check_ollama_health() + + deepseek_key = get_deepseek_api_key() + if not deepseek_key: + raise RuntimeError( + "DEEPSEEK_API_KEY 未配置。Mem0 需要该 key 进行事实提取。\n" + "请在 .env 中设置 DEEPSEEK_API_KEY" + ) + + config = { + "llm": { + "provider": "deepseek", + "config": { + "model": "deepseek-chat", + "temperature": 0.1, + "max_tokens": 1500, + "api_key": deepseek_key, + }, + }, + "embedder": { + "provider": "ollama", + "config": { + "model": get_mem0_embedding_model(), + "ollama_base_url": get_ollama_base_url(), + }, + }, + "vector_store": { + "provider": "qdrant", + "config": { + "collection_name": "epeekit_memories", + "embedding_model_dims": 768, + "path": "./data/qdrant", + }, + }, + } + + _memory = Memory.from_config(config) + logger.info("Mem0 记忆层初始化成功") + return _memory + + +def get_memory() -> Memory: + """获取已初始化的 Memory 实例。未初始化时自动调用 init_memory()。""" + if _memory is None: + return init_memory() + return _memory diff --git a/art-agent/backend/requirements.txt b/art-agent/backend/requirements.txt index 9dedf3b..8846f57 100644 --- a/art-agent/backend/requirements.txt +++ b/art-agent/backend/requirements.txt @@ -7,3 +7,5 @@ python-multipart>=0.0.12 httpx>=0.27.0 python-dotenv>=1.0.0 Pillow>=10.4.0 +mem0ai +ollama diff --git a/art-agent/frontend/src/app/page.tsx b/art-agent/frontend/src/app/page.tsx index 9df46c4..7e98568 100644 --- a/art-agent/frontend/src/app/page.tsx +++ b/art-agent/frontend/src/app/page.tsx @@ -106,7 +106,7 @@ export default function Home() { let usedModelName = ""; try { - for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel)) { + for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel, activeSessionId)) { switch (event.type) { case "text_delta": assistantText += event.data.text as string; @@ -166,6 +166,11 @@ export default function Home() { break; } + case "memory_warning": + console.warn("[记忆系统]", event.data.message); + setStatusText(`⚠ ${event.data.message}`); + break; + case "error": assistantText += `\n\n[错误: ${event.data.message}]`; setStreamingText(assistantText); diff --git a/art-agent/frontend/src/lib/api.ts b/art-agent/frontend/src/lib/api.ts index 0cf4ff9..af4b9d0 100644 --- a/art-agent/frontend/src/lib/api.ts +++ b/art-agent/frontend/src/lib/api.ts @@ -72,7 +72,7 @@ export function uploadRefImage( } export interface SSEEvent { - type: "text_delta" | "tool_start" | "image_result" | "tool_error" | "done" | "error"; + type: "text_delta" | "tool_start" | "image_result" | "tool_error" | "memory_warning" | "done" | "error"; data: Record; } @@ -95,7 +95,8 @@ export async function fetchModels(): Promise<{ export async function* sendChat( messages: ApiMessage[], refImageUrl?: string | null, - imageModel?: string | null + imageModel?: string | null, + sessionId?: string | null ): AsyncGenerator { const formData = new FormData(); @@ -113,6 +114,10 @@ export async function* sendChat( formData.append("image_model", imageModel); } + if (sessionId) { + formData.append("session_id", sessionId); + } + const response = await fetch(`${API_URL}/api/chat`, { method: "POST", body: formData,