记忆系统,上下文处理+mem0
This commit is contained in:
@@ -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
|
### [CL-20260413-1130] 2026-04-13 11:30 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash
|
||||||
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
|
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
||||||
|
|
||||||
|
- [CL-20260413-2320] 集成 Mem0 记忆系统:Mem0 OSS + DeepSeek 事实提取 + Ollama 本地 embedding + 滑动窗口,一次性解决上下文管理和跨会话长期记忆
|
||||||
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash
|
- [CL-20260413-1130] 修复 SDXL 模型 404 错误:与 Kolors 同因,Replicate 非 Flux 官方模型需补全版本 hash
|
||||||
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
|
- [CL-20260413-1100] 修复切换模型后 LLM 仍声称使用旧模型名:System Prompt 动态注入当前模型名,阻止 LLM 从对话历史幻觉旧模型
|
||||||
- [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试:DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具
|
- [CL-20260413-1030] Agent Loop 假生成检测 + 自动重试:DeepSeek 用文字模拟生图时自动注入纠正消息强制调用工具
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
当 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
|
### [CL-20260413-1130] 2026-04-13 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash
|
||||||
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
|
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
|
||||||
- **affected_files**: art-agent/backend/app/config.py
|
- **affected_files**: art-agent/backend/app/config.py
|
||||||
|
|||||||
@@ -91,53 +91,18 @@
|
|||||||
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
||||||
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
|
- **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
|
## 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 本地向量库),与上下文管理一并实施
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ out/
|
|||||||
# ─── 运行时生成物 ────────────────────────────────────────
|
# ─── 运行时生成物 ────────────────────────────────────────
|
||||||
art-agent/backend/uploads/
|
art-agent/backend/uploads/
|
||||||
art-agent/backend/generated/
|
art-agent/backend/generated/
|
||||||
|
art-agent/backend/data/
|
||||||
|
|
||||||
# ─── 工具二进制 ──────────────────────────────────────────
|
# ─── 工具二进制 ──────────────────────────────────────────
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
|
"""Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
@@ -8,9 +10,17 @@ from typing import AsyncGenerator, Optional
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
|
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
|
from app.services.image_gen import to_data_uri
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> AsyncOpenAI:
|
def _get_client() -> AsyncOpenAI:
|
||||||
base_url = os.getenv("OPENAI_BASE_URL")
|
base_url = os.getenv("OPENAI_BASE_URL")
|
||||||
@@ -59,6 +69,7 @@ async def run_agent_loop(
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
ref_image_url: Optional[str] = None,
|
ref_image_url: Optional[str] = None,
|
||||||
image_model: Optional[str] = None,
|
image_model: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[dict, None]:
|
||||||
"""
|
"""
|
||||||
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
||||||
@@ -67,10 +78,42 @@ async def run_agent_loop(
|
|||||||
- text_delta: LLM 文字增量
|
- text_delta: LLM 文字增量
|
||||||
- tool_start: 开始执行工具
|
- tool_start: 开始执行工具
|
||||||
- image_result: 图片生成结果
|
- image_result: 图片生成结果
|
||||||
|
- memory_warning: 记忆存储异常(不中断对话)
|
||||||
- done: 完成
|
- done: 完成
|
||||||
- error: 错误
|
- 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)
|
model_config = get_image_model_config(image_model)
|
||||||
current_model_name = model_config.get('name', '未知')
|
current_model_name = model_config.get('name', '未知')
|
||||||
current_model_id = model_config.get('id', '未知')
|
current_model_id = model_config.get('id', '未知')
|
||||||
@@ -81,7 +124,7 @@ async def run_agent_loop(
|
|||||||
f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。"
|
f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。"
|
||||||
f"本次生成使用的是 {current_model_name},在回复中只能使用这个名称。"
|
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 是否支持 vision(多模态图片输入)
|
||||||
llm_model = get_llm_model().lower()
|
llm_model = get_llm_model().lower()
|
||||||
@@ -90,7 +133,6 @@ async def run_agent_loop(
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
|
if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
|
||||||
if vision_capable:
|
if vision_capable:
|
||||||
# 支持 vision 的模型:直接发送图片
|
|
||||||
api_messages.append({
|
api_messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
@@ -102,7 +144,6 @@ async def run_agent_loop(
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 不支持 vision 的模型:以文字提示告知有参考图,风格由 IP-Adapter 处理
|
|
||||||
hint = (
|
hint = (
|
||||||
f"{msg['content']}\n\n"
|
f"{msg['content']}\n\n"
|
||||||
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
|
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
|
||||||
@@ -181,7 +222,9 @@ async def run_agent_loop(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
yield {"type": "done", "data": {}}
|
# 对话正常结束,异步存储记忆
|
||||||
|
async for evt in _store_and_done(messages, session_id):
|
||||||
|
yield evt
|
||||||
return
|
return
|
||||||
|
|
||||||
# 将 assistant 消息(含 tool_calls)加入历史
|
# 将 assistant 消息(含 tool_calls)加入历史
|
||||||
@@ -253,6 +296,34 @@ async def run_agent_loop(
|
|||||||
# ref_image_url 不清除:消息构建只在循环外执行一次,
|
# ref_image_url 不清除:消息构建只在循环外执行一次,
|
||||||
# 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image)
|
# 后续工具调用仍需参考图(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": {}}
|
yield {"type": "done", "data": {}}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async def chat(
|
|||||||
ref_image: Optional[UploadFile] = File(None),
|
ref_image: Optional[UploadFile] = File(None),
|
||||||
ref_image_url: Optional[str] = Form(None),
|
ref_image_url: Optional[str] = Form(None),
|
||||||
image_model: 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: 可选的参考图文件(兼容旧方式)
|
||||||
- ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径
|
- ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径
|
||||||
- image_model: 可选,指定本次使用的生图模型短 ID
|
- image_model: 可选,指定本次使用的生图模型短 ID
|
||||||
|
- session_id: 可选,前端会话 ID,用于 Mem0 记忆作用域
|
||||||
"""
|
"""
|
||||||
parsed_messages = json.loads(messages)
|
parsed_messages = json.loads(messages)
|
||||||
|
|
||||||
@@ -72,7 +74,10 @@ async def chat(
|
|||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
async for event in run_agent_loop(
|
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 {
|
yield {
|
||||||
"event": event["type"],
|
"event": event["type"],
|
||||||
|
|||||||
@@ -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:
|
def get_image_aspect_ratio() -> str:
|
||||||
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
|
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
|
||||||
|
|
||||||
|
|||||||
99
art-agent/backend/app/memory.py
Normal file
99
art-agent/backend/app/memory.py
Normal file
@@ -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
|
||||||
@@ -7,3 +7,5 @@ python-multipart>=0.0.12
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
Pillow>=10.4.0
|
Pillow>=10.4.0
|
||||||
|
mem0ai
|
||||||
|
ollama
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function Home() {
|
|||||||
let usedModelName = "";
|
let usedModelName = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel)) {
|
for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel, activeSessionId)) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text_delta":
|
case "text_delta":
|
||||||
assistantText += event.data.text as string;
|
assistantText += event.data.text as string;
|
||||||
@@ -166,6 +166,11 @@ export default function Home() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "memory_warning":
|
||||||
|
console.warn("[记忆系统]", event.data.message);
|
||||||
|
setStatusText(`⚠ ${event.data.message}`);
|
||||||
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
assistantText += `\n\n[错误: ${event.data.message}]`;
|
assistantText += `\n\n[错误: ${event.data.message}]`;
|
||||||
setStreamingText(assistantText);
|
setStreamingText(assistantText);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function uploadRefImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSEEvent {
|
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<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,8 @@ export async function fetchModels(): Promise<{
|
|||||||
export async function* sendChat(
|
export async function* sendChat(
|
||||||
messages: ApiMessage[],
|
messages: ApiMessage[],
|
||||||
refImageUrl?: string | null,
|
refImageUrl?: string | null,
|
||||||
imageModel?: string | null
|
imageModel?: string | null,
|
||||||
|
sessionId?: string | null
|
||||||
): AsyncGenerator<SSEEvent> {
|
): AsyncGenerator<SSEEvent> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@@ -113,6 +114,10 @@ export async function* sendChat(
|
|||||||
formData.append("image_model", imageModel);
|
formData.append("image_model", imageModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
formData.append("session_id", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/api/chat`, {
|
const response = await fetch(`${API_URL}/api/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
Reference in New Issue
Block a user