You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
17 KiB
17 KiB
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 核心设计原则(修改点)
- 纯 Redis 记忆存储:对话记忆用 Redis String + List + Hash 组合存储,Redis 开启
--appendonly yesAOF 持久化保证不失忆。速度优先,毫秒级读写 - 透明通用中间件:记忆不是流中的节点,不需要在流编辑器里配置。对所有已发布流自动生效,是 flow_engine 层面的通用钩子
- 用户画像存 PostgreSQL:画像需要复杂聚合查询和结构化字段,用 PG 合适;不要求实时性,每次对话结束异步更新即可
- 用户-工作流隔离:记忆以
(user_id, flow_id, session_id)为最小隔离单元 - 独立模块: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
# 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 透明集成
# 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 集成
# 统一在 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)
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.pyAPI 端点 - FlowChat.vue 增加「清空记忆」按钮
- 注册路由到 main.py
Phase 2 — 用户画像系统(后续)
- 新增
user_profiles表 + 自动提取逻辑 - 画像注入 LLM context
- 管理端画像查看 + 下属评估报告
Phase 3 — 记忆检索优化(后续)
- 长会话自动摘要压缩
- 语义相似度检索历史
- Redis 数据过期清理 cron
九、核心优势
| 特性 | 说明 |
|---|---|
| 对用户透明 | 不需要在流编辑器里做任何配置,所有流自动获得记忆 |
| 对开发者透明 | LLMNodeAgent 自动从 context 读记忆,不用改每个节点 |
| 纯 Redis 存储 | 毫秒级读写,AOF 持久化保证不失忆 |
| 用户-工作流隔离 | 不同用户不同流程的记忆互不可见 |
| 7 天自动过期 | 不需要手动清理,旧记忆自动淘汰 |
| 异步记录 | record_exchange 用 asyncio.create_task,不影响响应速度 |