18 changed files with 1382 additions and 341 deletions
@ -0,0 +1,464 @@ |
|||||
|
# PLAN8 — 记忆管理模块 & 用户画像系统 |
||||
|
|
||||
|
## 一、需求背景 |
||||
|
|
||||
|
### 1.1 核心需求对照(README L11-L16) |
||||
|
|
||||
|
| # | 需求 | 记忆/画像关联 | |
||||
|
|---|------|--------------| |
||||
|
| 1 | 用户和AI沟通工作,有现成流程给用户选择使用 | **用户-工作流对话记忆**:用户选了一个流程对话,下次回来继续聊,AI记得之前聊了什么 | |
||||
|
| 2 | 上司查看下属工作情况,AI综合评估 | **用户画像+行为轨迹**:积累下属的工作数据,AI才能做综合评估 | |
||||
|
| 3 | 上司通过AI指派任务给下属 | **跨用户上下文**:上司和AI说"给小明派个任务",AI需要知道小明是谁 | |
||||
|
| 4 | 流程由后台无代码搭建 | **无需在流中加记忆节点**:记忆是通用中间件,对所有流透明生效 | |
||||
|
| 5 | MCP服务获取/更新系统数据 | **外部数据注入记忆**:MCP返回的系统数据可选择性注入记忆上下文 | |
||||
|
| 6 | 知识库提升模型认知 | **知识+记忆融合检索**:RAG检索结果与历史对话记忆合并排序 | |
||||
|
|
||||
|
### 1.2 当前记忆现状与缺陷 |
||||
|
|
||||
|
``` |
||||
|
FlowSessionMemory (engine.py:15) |
||||
|
self._messages: list[dict] = [] ← 纯 Python list |
||||
|
生命周期 = 单次 HTTP 请求 |
||||
|
请求结束 → 内存释放 → 记忆全丢 |
||||
|
|
||||
|
ChatSession / ChatMessage (models/__init__.py:92-113) |
||||
|
已有数据库表!但只被 chat/router.py 基本对话使用 |
||||
|
FlowEngine.execute() 完全不碰这两个表 |
||||
|
|
||||
|
UserIsolatedMemory (agentscope_integration/memory/) |
||||
|
基于 agentscope MemoryBase |
||||
|
但只在 AgentFactory.create_agent() 中为 agent 聊天使用 |
||||
|
流执行完全不经过它 |
||||
|
|
||||
|
断链点: |
||||
|
router.py context 里没放 session_id |
||||
|
→ FlowEngine 每次都生成新 uuid |
||||
|
→ FlowSessionMemory 永远从空列表开始 |
||||
|
→ 无任何持久化写回逻辑 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 二、架构设计 |
||||
|
|
||||
|
### 2.1 核心设计原则(修改点) |
||||
|
|
||||
|
1. **纯 Redis 记忆存储**:对话记忆用 Redis String + List + Hash 组合存储,Redis 开启 `--appendonly yes` AOF 持久化保证不失忆。速度优先,毫秒级读写 |
||||
|
2. **透明通用中间件**:记忆不是流中的节点,不需要在流编辑器里配置。对所有已发布流自动生效,是 flow_engine 层面的通用钩子 |
||||
|
3. **用户画像存 PostgreSQL**:画像需要复杂聚合查询和结构化字段,用 PG 合适;不要求实时性,每次对话结束异步更新即可 |
||||
|
4. **用户-工作流隔离**:记忆以 `(user_id, flow_id, session_id)` 为最小隔离单元 |
||||
|
5. **独立模块**:MemoryManager 是独立服务,FlowEngine、AgentChat 统一调用 |
||||
|
|
||||
|
### 2.2 中间件触发位置(关键设计) |
||||
|
|
||||
|
``` |
||||
|
所有流的执行路径: |
||||
|
|
||||
|
router.py: execute_flow() / execute_flow_stream() |
||||
|
│ |
||||
|
├── ❶ 执行前:MemoryManager.inject_memory(user_id, flow_id, session_id) |
||||
|
│ → 从 Redis 检索该用户-流组合的历史对话 |
||||
|
│ → 注入 context["_memory_context"] |
||||
|
│ |
||||
|
├── FlowEngine.execute() 执行各节点 |
||||
|
│ └── LLMNodeAgent.reply() |
||||
|
│ ├── 从 context["_memory_context"] 获取历史消息 |
||||
|
│ ├── 拼入 messages 数组发给 LLM |
||||
|
│ └── 返回结果 |
||||
|
│ |
||||
|
├── ❷ 执行后:MemoryManager.record_exchange(user_id, flow_id, session_id, user_msg, assistant_msg) |
||||
|
│ → 写入 Redis(异步,不阻塞响应) |
||||
|
│ |
||||
|
└── 返回响应给前端 |
||||
|
``` |
||||
|
|
||||
|
**用户无感知**:不需要在流编辑器中加任何东西,所有流自动获得记忆能力。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 三、Redis 键设计 |
||||
|
|
||||
|
### 3.1 键命名空间 |
||||
|
|
||||
|
``` |
||||
|
# 会话消息列表(LPUSH + LTRIM 控制长度) |
||||
|
mem:{user_id}:{flow_id}:{session_id}:messages |
||||
|
→ Redis List,每条是 JSON: {"role":"user","content":"...","ts":"..."} |
||||
|
→ LTRIM 控制在 max_history * 2 条(每轮对话 user+assistant 两条) |
||||
|
|
||||
|
# 会话元数据 |
||||
|
mem:{user_id}:{flow_id}:{session_id}:meta |
||||
|
→ Redis Hash |
||||
|
→ {flow_name, created_at, last_active_at, message_count} |
||||
|
|
||||
|
# 用户的会话索引(SMEMBERS 获取该用户所有 session) |
||||
|
mem:{user_id}:sessions |
||||
|
→ Redis Set,存 session_id 列表 |
||||
|
|
||||
|
# 会话摘要(超过阈值时生成,独立 Key) |
||||
|
mem:{user_id}:{flow_id}:{session_id}:summary |
||||
|
→ Redis String,LLM 生成的摘要文本 |
||||
|
|
||||
|
# TTL 策略 |
||||
|
mem:{user_id}:{flow_id}:{session_id}:messages → 7 天(EXPIRE 604800) |
||||
|
mem:{user_id}:{flow_id}:{session_id}:meta → 7 天 |
||||
|
mem:{user_id}:sessions → 30 天 |
||||
|
mem:{user_id}:{flow_id}:{session_id}:summary → 30 天(摘要保留更久) |
||||
|
``` |
||||
|
|
||||
|
### 3.2 内存估算 |
||||
|
|
||||
|
``` |
||||
|
单条消息 ≈ 500 bytes(含 JSON 开销) |
||||
|
每轮对话 ≈ 2 条 = 1KB |
||||
|
30 轮/会话 × 1KB = 30KB/会话 |
||||
|
100 个活跃用户 × 10 个会话/用户 × 30KB = 30MB |
||||
|
|
||||
|
Redis AOF 文件预估:初始 30MB,日常增长可控 |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 四、核心代码设计 |
||||
|
|
||||
|
### 4.1 模块位置 |
||||
|
|
||||
|
``` |
||||
|
backend/ |
||||
|
└── modules/ |
||||
|
└── memory/ ← 新建独立模块 |
||||
|
├── __init__.py → 导出 |
||||
|
├── manager.py → MemoryManager 核心类 |
||||
|
├── profile.py → UserProfileEngine(Phase 2) |
||||
|
├── router.py → 管理 API |
||||
|
└── schemas.py → Pydantic 模型 |
||||
|
``` |
||||
|
|
||||
|
### 4.2 MemoryManager |
||||
|
|
||||
|
```python |
||||
|
# modules/memory/manager.py |
||||
|
|
||||
|
class MemoryManager: |
||||
|
"""记忆管理中心 — 纯 Redis 存储""" |
||||
|
|
||||
|
KEY_PREFIX = "mem" |
||||
|
DEFAULT_TTL = 604800 # 7 天 |
||||
|
SESSION_INDEX_TTL = 2592000 # 30 天 |
||||
|
MAX_HISTORY = 40 # 每个会话最多保留 40 条消息(20 轮对话) |
||||
|
|
||||
|
def __init__(self, redis: Redis): |
||||
|
self.redis = redis |
||||
|
|
||||
|
# ===== 核心 API ===== |
||||
|
|
||||
|
async def inject_memory( |
||||
|
self, |
||||
|
user_id: str, |
||||
|
flow_id: str, |
||||
|
session_id: str, |
||||
|
context: dict, |
||||
|
): |
||||
|
"""执行前:将历史记忆注入 context""" |
||||
|
messages = await self._get_recent_messages(user_id, flow_id, session_id) |
||||
|
summary = await self._get_summary(user_id, flow_id, session_id) |
||||
|
|
||||
|
context["_memory_context"] = { |
||||
|
"recent_messages": list(reversed(messages)), |
||||
|
"summary": summary, |
||||
|
"session_id": session_id, |
||||
|
} |
||||
|
|
||||
|
async def record_exchange( |
||||
|
self, |
||||
|
user_id: str, |
||||
|
flow_id: str, |
||||
|
session_id: str, |
||||
|
user_msg: str, |
||||
|
assistant_msg: str, |
||||
|
flow_name: str = "", |
||||
|
): |
||||
|
"""执行后:异步记录本轮对话""" |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
ts = datetime.utcnow().isoformat() |
||||
|
|
||||
|
async with self.redis.pipeline() as pipe: |
||||
|
pipe.lpush(key, |
||||
|
json.dumps({"role": "assistant", "content": assistant_msg, "ts": ts}), |
||||
|
json.dumps({"role": "user", "content": user_msg, "ts": ts}), |
||||
|
) |
||||
|
pipe.ltrim(key, 0, self.MAX_HISTORY - 1) |
||||
|
pipe.expire(key, self.DEFAULT_TTL) |
||||
|
|
||||
|
pipe.hset(self._meta_key(user_id, flow_id, session_id), mapping={ |
||||
|
"flow_name": flow_name, |
||||
|
"last_active_at": ts, |
||||
|
}) |
||||
|
pipe.expire(self._meta_key(user_id, flow_id, session_id), self.DEFAULT_TTL) |
||||
|
|
||||
|
pipe.sadd(f"{self.KEY_PREFIX}:{user_id}:sessions", session_id) |
||||
|
pipe.expire(f"{self.KEY_PREFIX}:{user_id}:sessions", self.SESSION_INDEX_TTL) |
||||
|
|
||||
|
await pipe.execute() |
||||
|
|
||||
|
# 异步触发摘要检查 |
||||
|
asyncio.create_task(self._maybe_summarize(user_id, flow_id, session_id)) |
||||
|
|
||||
|
async def get_conversation_history( |
||||
|
self, user_id: str, flow_id: str, session_id: str, limit: int = 20 |
||||
|
) -> list[dict]: |
||||
|
"""获取完整历史(管理 API 用)""" |
||||
|
messages = await self._get_recent_messages(user_id, flow_id, session_id, limit) |
||||
|
return list(reversed(messages)) |
||||
|
|
||||
|
async def delete_session(self, user_id: str, session_id: str): |
||||
|
"""清除某个会话的所有记忆""" |
||||
|
patterns = await self.redis.keys(f"{self.KEY_PREFIX}:{user_id}:*:{session_id}:*") |
||||
|
async with self.redis.pipeline() as pipe: |
||||
|
if patterns: |
||||
|
pipe.delete(*patterns) |
||||
|
pipe.srem(f"{self.KEY_PREFIX}:{user_id}:sessions", session_id) |
||||
|
await pipe.execute() |
||||
|
|
||||
|
async def list_user_sessions(self, user_id: str) -> list[dict]: |
||||
|
"""列出用户所有会话""" |
||||
|
session_ids = await self.redis.smembers(f"{self.KEY_PREFIX}:{user_id}:sessions") |
||||
|
sessions = [] |
||||
|
for sid in session_ids: |
||||
|
keys = await self.redis.keys(f"{self.KEY_PREFIX}:{user_id}:*:{sid}:meta") |
||||
|
for k in keys: |
||||
|
meta = await self.redis.hgetall(k) |
||||
|
parts = k.split(":") |
||||
|
flow_id = parts[2] if len(parts) > 2 else "" |
||||
|
sessions.append({ |
||||
|
"session_id": sid, |
||||
|
"flow_id": flow_id, |
||||
|
"flow_name": meta.get("flow_name", ""), |
||||
|
"last_active_at": meta.get("last_active_at", ""), |
||||
|
}) |
||||
|
return sorted(sessions, key=lambda s: s["last_active_at"], reverse=True) |
||||
|
|
||||
|
# ===== 内部方法 ===== |
||||
|
|
||||
|
async def _get_recent_messages( |
||||
|
self, user_id: str, flow_id: str, session_id: str, limit: int = None |
||||
|
) -> list[dict]: |
||||
|
limit = limit or self.MAX_HISTORY |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
raw = await self.redis.lrange(key, 0, limit - 1) |
||||
|
return [json.loads(m) for m in raw] |
||||
|
|
||||
|
async def _get_summary(self, user_id: str, flow_id: str, session_id: str) -> str: |
||||
|
key = f"{self.KEY_PREFIX}:{user_id}:{flow_id}:{session_id}:summary" |
||||
|
val = await self.redis.get(key) |
||||
|
return val or "" |
||||
|
|
||||
|
async def _maybe_summarize(self, user_id: str, flow_id: str, session_id: str): |
||||
|
"""消息超过阈值时异步生成摘要""" |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
count = await self.redis.llen(key) |
||||
|
if count < 30: # 15 轮以上才触发摘要 |
||||
|
return |
||||
|
|
||||
|
recent = await self._get_recent_messages(user_id, flow_id, session_id, 20) |
||||
|
dialogue = "\n".join(f"{m['role']}: {m['content'][:500]}" for m in recent[:10]) |
||||
|
|
||||
|
try: |
||||
|
import httpx |
||||
|
api_base = settings.LLM_API_BASE.rstrip("/") |
||||
|
resp = await httpx.AsyncClient(timeout=30).post( |
||||
|
f"{api_base}/chat/completions", |
||||
|
json={ |
||||
|
"model": settings.LLM_MODEL, |
||||
|
"messages": [{ |
||||
|
"role": "user", |
||||
|
"content": f"请用一段话简要总结以下对话的关键内容。保留人名、任务、决策、时间等关键信息。\n\n{dialogue}" |
||||
|
}], |
||||
|
"max_tokens": 200, |
||||
|
}, |
||||
|
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"}, |
||||
|
) |
||||
|
data = resp.json() |
||||
|
summary = data.get("choices", [{}])[0].get("message", {}).get("content", "") |
||||
|
|
||||
|
if summary: |
||||
|
key = f"{self.KEY_PREFIX}:{user_id}:{flow_id}:{session_id}:summary" |
||||
|
await self.redis.setex(key, 2592000, summary) # 30 天 |
||||
|
except Exception: |
||||
|
pass # 摘要失败不影响主流程 |
||||
|
|
||||
|
@staticmethod |
||||
|
def _msg_key(user_id, flow_id, session_id): |
||||
|
return f"mem:{user_id}:{flow_id}:{session_id}:messages" |
||||
|
|
||||
|
@staticmethod |
||||
|
def _meta_key(user_id, flow_id, session_id): |
||||
|
return f"mem:{user_id}:{flow_id}:{session_id}:meta" |
||||
|
``` |
||||
|
|
||||
|
### 4.3 LLMNodeAgent 透明集成 |
||||
|
|
||||
|
```python |
||||
|
# engine.py LLMNodeAgent.reply() 的变更 |
||||
|
|
||||
|
async def reply(self, msg: Msg, **kwargs) -> Msg: |
||||
|
user_text = msg.get_text_content() |
||||
|
context = kwargs.get("context", {}) |
||||
|
memory_ctx = context.get("_memory_context", {}) |
||||
|
|
||||
|
messages = [{"role": "system", "content": self.system_prompt}] |
||||
|
|
||||
|
# ★ 透明注入记忆(所有流自动生效,无需配置) |
||||
|
if memory_ctx: |
||||
|
summary = memory_ctx.get("summary", "") |
||||
|
recent = memory_ctx.get("recent_messages", []) |
||||
|
if summary: |
||||
|
messages.append({"role": "system", "content": f"[历史对话摘要]\n{summary}"}) |
||||
|
for m in recent[-10:]: # 最近 10 条 |
||||
|
messages.append({"role": m["role"], "content": m["content"]}) |
||||
|
|
||||
|
messages.append({"role": "user", "content": user_text}) |
||||
|
|
||||
|
# 流式/阻塞调用 LLM(已有逻辑) |
||||
|
... |
||||
|
return Msg(self.name, res_text, "assistant") |
||||
|
``` |
||||
|
|
||||
|
### 4.4 flow_engine/router.py 集成 |
||||
|
|
||||
|
```python |
||||
|
# 统一在 execute_flow() 和 execute_flow_stream() 中调用 |
||||
|
|
||||
|
memory_manager = get_memory_manager() |
||||
|
context = { |
||||
|
"user_id": user_ctx["id"], |
||||
|
"username": user_ctx.get("username", ""), |
||||
|
"session_id": session_id, # ← 从请求 body 传入 |
||||
|
... |
||||
|
} |
||||
|
|
||||
|
# ★ 执行前:注入记忆 |
||||
|
await memory_manager.inject_memory( |
||||
|
user_id=user_ctx["id"], |
||||
|
flow_id=str(flow_id), |
||||
|
session_id=session_id, |
||||
|
context=context, |
||||
|
) |
||||
|
|
||||
|
engine = FlowEngine(definition) |
||||
|
result = await engine.execute(input_msg, context) |
||||
|
|
||||
|
output = result.get_text_content() |
||||
|
|
||||
|
# ★ 执行后:记录本轮对话 |
||||
|
asyncio.create_task(memory_manager.record_exchange( |
||||
|
user_id=user_ctx["id"], |
||||
|
flow_id=str(flow_id), |
||||
|
session_id=session_id, |
||||
|
user_msg=input_text, |
||||
|
assistant_msg=output, |
||||
|
flow_name=f.name, |
||||
|
)) |
||||
|
|
||||
|
return {"code": 200, "data": {"output": output, "session_id": session_id}} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 五、数据库设计(仅用户画像) |
||||
|
|
||||
|
### 5.1 `user_profiles` 表(PostgreSQL) |
||||
|
|
||||
|
```python |
||||
|
class UserProfile(Base): |
||||
|
__tablename__ = "user_profiles" |
||||
|
user_id = UUID, FK→users.id, PK |
||||
|
display_name = String(100) |
||||
|
department_name = String(100) |
||||
|
position = String(100) |
||||
|
role_tags = JSON, default=[] |
||||
|
total_conversations = Integer, default=0 |
||||
|
total_workflows_used = Integer, default=0 |
||||
|
favorite_workflows = JSON, default=[] # [{flow_id, flow_name, count}] |
||||
|
preferred_work_hours = JSON, default=[] |
||||
|
work_style_tags = JSON, default=[] |
||||
|
common_topics = JSON, default=[] |
||||
|
expertise_areas = JSON, default=[] |
||||
|
total_tasks_assigned = Integer, default=0 |
||||
|
total_tasks_created = Integer, default=0 |
||||
|
avg_task_completion_h = Float, default=0 |
||||
|
task_completion_rate = Float, default=0 |
||||
|
preferred_llm_model = String(50), default="" |
||||
|
preferred_language = String(20), default="zh" |
||||
|
communication_tone = String(50), default="" |
||||
|
profile_version = Integer, default=1 |
||||
|
last_updated_at = DateTime |
||||
|
last_conversation_at = DateTime |
||||
|
created_at = DateTime, default=utcnow |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 六、API 端点 |
||||
|
|
||||
|
| 方法 | 路径 | 说明 | |
||||
|
|------|------|------| |
||||
|
| GET | `/api/memory/sessions` | 列出当前用户的记忆会话列表 | |
||||
|
| GET | `/api/memory/sessions/{session_id}` | 查看某个会话的完整对话历史 | |
||||
|
| DELETE | `/api/memory/sessions/{session_id}` | 清除某个会话记忆 | |
||||
|
| GET | `/api/memory/profile` | 查看自己的用户画像 | |
||||
|
| GET | `/api/memory/profile/{user_id}` | 管理者查看下属画像(Phase 2) | |
||||
|
| POST | `/api/memory/profile/report` | 生成下属综合评估报告(Phase 2) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 七、用户画像 Prompt 注入格式(Phase 2) |
||||
|
|
||||
|
``` |
||||
|
[用户画像] |
||||
|
姓名: 张三 | 职位: 技术主管 | 部门: 研发中心 |
||||
|
工作风格: 注重细节, 快速响应 |
||||
|
擅长领域: 项目管理, 数据分析 |
||||
|
最近话题: 任务管理(15次), 文档处理(8次) |
||||
|
常用流程: 任务分配(23次), 周报生成(12次) |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 八、实现阶段 |
||||
|
|
||||
|
### Phase 1 — 基础记忆持久化(本次实施)✅ |
||||
|
|
||||
|
- [ ] 创建 `modules/memory/` 模块目录 |
||||
|
- [ ] 实现 `MemoryManager`(纯 Redis 存储) |
||||
|
- [ ] 在 `flow_engine/router.py` 的 execute_flow 和 execute_flow_stream 中集成(执行前注入 + 执行后记录) |
||||
|
- [ ] 在 `LLMNodeAgent.reply()` 中读取 `_memory_context` |
||||
|
- [ ] 修复 `session_id` 传递链路 |
||||
|
- [ ] 实现 `router.py` API 端点 |
||||
|
- [ ] FlowChat.vue 增加「清空记忆」按钮 |
||||
|
- [ ] 注册路由到 main.py |
||||
|
|
||||
|
### Phase 2 — 用户画像系统(后续) |
||||
|
|
||||
|
- [ ] 新增 `user_profiles` 表 + 自动提取逻辑 |
||||
|
- [ ] 画像注入 LLM context |
||||
|
- [ ] 管理端画像查看 + 下属评估报告 |
||||
|
|
||||
|
### Phase 3 — 记忆检索优化(后续) |
||||
|
|
||||
|
- [ ] 长会话自动摘要压缩 |
||||
|
- [ ] 语义相似度检索历史 |
||||
|
- [ ] Redis 数据过期清理 cron |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 九、核心优势 |
||||
|
|
||||
|
| 特性 | 说明 | |
||||
|
|------|------| |
||||
|
| **对用户透明** | 不需要在流编辑器里做任何配置,所有流自动获得记忆 | |
||||
|
| **对开发者透明** | LLMNodeAgent 自动从 context 读记忆,不用改每个节点 | |
||||
|
| **纯 Redis 存储** | 毫秒级读写,AOF 持久化保证不失忆 | |
||||
|
| **用户-工作流隔离** | 不同用户不同流程的记忆互不可见 | |
||||
|
| **7 天自动过期** | 不需要手动清理,旧记忆自动淘汰 | |
||||
|
| **异步记录** | `record_exchange` 用 `asyncio.create_task`,不影响响应速度 | |
||||
@ -0,0 +1,4 @@ |
|||||
|
from .manager import MemoryManager, get_memory_manager |
||||
|
from .router import router |
||||
|
|
||||
|
__all__ = ["MemoryManager", "get_memory_manager", "router"] |
||||
@ -0,0 +1,201 @@ |
|||||
|
import json |
||||
|
import asyncio |
||||
|
import logging |
||||
|
from datetime import datetime |
||||
|
from redis.asyncio import Redis |
||||
|
from config import settings |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_memory_manager: "MemoryManager | None" = None |
||||
|
|
||||
|
|
||||
|
def get_memory_manager() -> "MemoryManager": |
||||
|
global _memory_manager |
||||
|
if _memory_manager is None: |
||||
|
raise RuntimeError("MemoryManager 未初始化,请先调用 init_memory_manager()") |
||||
|
return _memory_manager |
||||
|
|
||||
|
|
||||
|
async def init_memory_manager(): |
||||
|
global _memory_manager |
||||
|
redis = Redis.from_url(settings.REDIS_URL, decode_responses=True) |
||||
|
await redis.ping() |
||||
|
_memory_manager = MemoryManager(redis) |
||||
|
|
||||
|
|
||||
|
class MemoryManager: |
||||
|
KEY_PREFIX = "mem" |
||||
|
DEFAULT_TTL = 604800 |
||||
|
SESSION_INDEX_TTL = 2592000 |
||||
|
MAX_HISTORY = 40 |
||||
|
|
||||
|
def __init__(self, redis: Redis): |
||||
|
self.redis = redis |
||||
|
|
||||
|
async def inject_memory( |
||||
|
self, |
||||
|
user_id: str, |
||||
|
flow_id: str, |
||||
|
session_id: str, |
||||
|
context: dict, |
||||
|
): |
||||
|
messages = await self._get_recent_messages(user_id, flow_id, session_id) |
||||
|
summary = await self._get_summary(user_id, flow_id, session_id) |
||||
|
|
||||
|
context["_memory_context"] = { |
||||
|
"recent_messages": list(reversed(messages)), |
||||
|
"summary": summary, |
||||
|
"session_id": session_id, |
||||
|
} |
||||
|
|
||||
|
async def record_exchange( |
||||
|
self, |
||||
|
user_id: str, |
||||
|
flow_id: str, |
||||
|
session_id: str, |
||||
|
user_msg: str, |
||||
|
assistant_msg: str, |
||||
|
flow_name: str = "", |
||||
|
): |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
ts = datetime.utcnow().isoformat() |
||||
|
|
||||
|
try: |
||||
|
async with self.redis.pipeline() as pipe: |
||||
|
pipe.lpush(key, |
||||
|
json.dumps({"role": "assistant", "content": assistant_msg, "ts": ts}, ensure_ascii=False), |
||||
|
json.dumps({"role": "user", "content": user_msg, "ts": ts}, ensure_ascii=False), |
||||
|
) |
||||
|
pipe.ltrim(key, 0, self.MAX_HISTORY - 1) |
||||
|
pipe.expire(key, self.DEFAULT_TTL) |
||||
|
|
||||
|
pipe.hset(self._meta_key(user_id, flow_id, session_id), mapping={ |
||||
|
"flow_name": flow_name, |
||||
|
"last_active_at": ts, |
||||
|
}) |
||||
|
pipe.expire(self._meta_key(user_id, flow_id, session_id), self.DEFAULT_TTL) |
||||
|
|
||||
|
pipe.sadd(f"{self.KEY_PREFIX}:{user_id}:sessions", session_id) |
||||
|
pipe.expire(f"{self.KEY_PREFIX}:{user_id}:sessions", self.SESSION_INDEX_TTL) |
||||
|
|
||||
|
await pipe.execute() |
||||
|
except Exception as e: |
||||
|
logger.warning(f"记录记忆失败: {e}") |
||||
|
|
||||
|
asyncio.create_task(self._maybe_summarize(user_id, flow_id, session_id)) |
||||
|
|
||||
|
async def get_conversation_history( |
||||
|
self, user_id: str, flow_id: str, session_id: str, limit: int = 20 |
||||
|
) -> list[dict]: |
||||
|
messages = await self._get_recent_messages(user_id, flow_id, session_id, limit) |
||||
|
return list(reversed(messages)) |
||||
|
|
||||
|
async def delete_session(self, user_id: str, session_id: str): |
||||
|
try: |
||||
|
patterns = await self.redis.keys(f"{self.KEY_PREFIX}:{user_id}:*:{session_id}:*") |
||||
|
async with self.redis.pipeline() as pipe: |
||||
|
if patterns: |
||||
|
pipe.delete(*patterns) |
||||
|
pipe.srem(f"{self.KEY_PREFIX}:{user_id}:sessions", session_id) |
||||
|
await pipe.execute() |
||||
|
except Exception as e: |
||||
|
logger.warning(f"清除记忆失败: {e}") |
||||
|
|
||||
|
async def list_user_sessions(self, user_id: str) -> list[dict]: |
||||
|
try: |
||||
|
session_ids = await self.redis.smembers(f"{self.KEY_PREFIX}:{user_id}:sessions") |
||||
|
except Exception: |
||||
|
return [] |
||||
|
|
||||
|
sessions = [] |
||||
|
for sid in session_ids: |
||||
|
try: |
||||
|
keys = await self.redis.keys(f"{self.KEY_PREFIX}:{user_id}:*:{sid}:meta") |
||||
|
for k in keys: |
||||
|
meta = await self.redis.hgetall(k) |
||||
|
parts = k.split(":") |
||||
|
flow_id = parts[2] if len(parts) > 2 else "" |
||||
|
sessions.append({ |
||||
|
"session_id": sid, |
||||
|
"flow_id": flow_id, |
||||
|
"flow_name": meta.get("flow_name", ""), |
||||
|
"last_active_at": meta.get("last_active_at", ""), |
||||
|
}) |
||||
|
except Exception: |
||||
|
continue |
||||
|
|
||||
|
return sorted(sessions, key=lambda s: s.get("last_active_at", ""), reverse=True) |
||||
|
|
||||
|
async def _get_recent_messages( |
||||
|
self, user_id: str, flow_id: str, session_id: str, limit: int = None |
||||
|
) -> list[dict]: |
||||
|
limit = limit or self.MAX_HISTORY |
||||
|
try: |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
raw = await self.redis.lrange(key, 0, limit - 1) |
||||
|
result = [] |
||||
|
for m in raw: |
||||
|
try: |
||||
|
result.append(json.loads(m)) |
||||
|
except json.JSONDecodeError: |
||||
|
continue |
||||
|
return result |
||||
|
except Exception: |
||||
|
return [] |
||||
|
|
||||
|
async def _get_summary(self, user_id: str, flow_id: str, session_id: str) -> str: |
||||
|
try: |
||||
|
key = f"{self.KEY_PREFIX}:{user_id}:{flow_id}:{session_id}:summary" |
||||
|
val = await self.redis.get(key) |
||||
|
return val or "" |
||||
|
except Exception: |
||||
|
return "" |
||||
|
|
||||
|
async def _maybe_summarize(self, user_id: str, flow_id: str, session_id: str): |
||||
|
try: |
||||
|
key = self._msg_key(user_id, flow_id, session_id) |
||||
|
count = await self.redis.llen(key) |
||||
|
if count < 30: |
||||
|
return |
||||
|
|
||||
|
summary_key = f"{self.KEY_PREFIX}:{user_id}:{flow_id}:{session_id}:summary" |
||||
|
existing = await self.redis.get(summary_key) |
||||
|
if existing: |
||||
|
return |
||||
|
|
||||
|
recent = await self._get_recent_messages(user_id, flow_id, session_id, 20) |
||||
|
dialogue = "\n".join( |
||||
|
f"{m['role']}: {m['content'][:500]}" for m in reversed(recent[:10]) |
||||
|
) |
||||
|
|
||||
|
import httpx |
||||
|
api_base = settings.LLM_API_BASE.rstrip("/") |
||||
|
async with httpx.AsyncClient(timeout=30) as client: |
||||
|
resp = await client.post( |
||||
|
f"{api_base}/chat/completions", |
||||
|
json={ |
||||
|
"model": settings.LLM_MODEL, |
||||
|
"messages": [{ |
||||
|
"role": "user", |
||||
|
"content": f"请用一段话简要总结以下对话的关键内容。保留人名、任务、决策、时间等关键信息。\n\n{dialogue}" |
||||
|
}], |
||||
|
"max_tokens": 200, |
||||
|
}, |
||||
|
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"}, |
||||
|
) |
||||
|
data = resp.json() |
||||
|
summary = data.get("choices", [{}])[0].get("message", {}).get("content", "") |
||||
|
|
||||
|
if summary: |
||||
|
await self.redis.setex(summary_key, 2592000, summary) |
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
@staticmethod |
||||
|
def _msg_key(user_id: str, flow_id: str, session_id: str) -> str: |
||||
|
return f"mem:{user_id}:{flow_id}:{session_id}:messages" |
||||
|
|
||||
|
@staticmethod |
||||
|
def _meta_key(user_id: str, flow_id: str, session_id: str) -> str: |
||||
|
return f"mem:{user_id}:{flow_id}:{session_id}:meta" |
||||
@ -0,0 +1,30 @@ |
|||||
|
from fastapi import APIRouter, Request, Depends, HTTPException |
||||
|
from dependencies import get_current_user |
||||
|
from modules.memory.manager import get_memory_manager |
||||
|
|
||||
|
router = APIRouter(prefix="/api/memory", tags=["记忆管理"]) |
||||
|
|
||||
|
|
||||
|
@router.get("/sessions") |
||||
|
async def list_sessions(request: Request, user=Depends(get_current_user)): |
||||
|
mm = get_memory_manager() |
||||
|
sessions = await mm.list_user_sessions(str(user.id)) |
||||
|
return {"code": 200, "data": sessions} |
||||
|
|
||||
|
|
||||
|
@router.get("/sessions/{session_id}") |
||||
|
async def get_session(session_id: str, flow_id: str = "", request: Request, user=Depends(get_current_user)): |
||||
|
mm = get_memory_manager() |
||||
|
history = await mm.get_conversation_history( |
||||
|
user_id=str(user.id), |
||||
|
flow_id=flow_id, |
||||
|
session_id=session_id, |
||||
|
) |
||||
|
return {"code": 200, "data": history} |
||||
|
|
||||
|
|
||||
|
@router.delete("/sessions/{session_id}") |
||||
|
async def clear_session(session_id: str, request: Request, user=Depends(get_current_user)): |
||||
|
mm = get_memory_manager() |
||||
|
await mm.delete_session(str(user.id), session_id) |
||||
|
return {"code": 200, "message": "记忆已清除"} |
||||
@ -0,0 +1,19 @@ |
|||||
|
from pydantic import BaseModel, ConfigDict |
||||
|
from datetime import datetime |
||||
|
|
||||
|
|
||||
|
class MemorySessionOut(BaseModel): |
||||
|
session_id: str |
||||
|
flow_id: str |
||||
|
flow_name: str |
||||
|
last_active_at: str |
||||
|
|
||||
|
|
||||
|
class ConversationMessage(BaseModel): |
||||
|
role: str |
||||
|
content: str |
||||
|
ts: str = "" |
||||
|
|
||||
|
|
||||
|
class ClearSessionRequest(BaseModel): |
||||
|
session_id: str |
||||
Loading…
Reference in new issue