diff --git a/docs/powermem-integration.md b/docs/powermem-integration.md index 76547b60..818e65ed 100644 --- a/docs/powermem-integration.md +++ b/docs/powermem-integration.md @@ -17,6 +17,8 @@ ## 功能特性 - **本地总结**:通过 LLM 在本地进行记忆总结和提取 +- **用户画像**:通过 `UserMemory` 自动提取用户信息(姓名、职业、兴趣等),持续更新用户画像 +- **智能遗忘**:基于艾宾浩斯遗忘曲线,自动"遗忘"过时噪声信息 - **多种存储后端**:支持 OceanBase(推荐,最佳性能)、SeekDB(推荐,AI应用存储一体)、PostgreSQL、SQLite(轻量备选) - **多种 LLM 支持**:通义千问、智谱(glm-4-flash 免费)、OpenAI 等 - **智能检索**:基于向量搜索的语义检索能力 @@ -44,6 +46,8 @@ selected_module: Memory: powermem: type: powermem + # 是否启用用户画像功能(需要OceanBase) + enable_user_profile: false # 数据库提供者: oceanbase(推荐,最佳性能), seekdb, postgres, sqlite(轻量备选) database_provider: sqlite # 资源充足时建议使用 oceanbase 或 seekdb # LLM提供者: qwen(默认), openai, 等 @@ -62,6 +66,7 @@ Memory: | 参数 | 说明 | 默认值 | 可选值 | |------|------|--------|--------| +| `enable_user_profile` | 启用用户画像模式 | `false` | `true`(需OceanBase), `false` | | `database_provider` | 存储后端类型 | `sqlite` | `oceanbase`(推荐), `seekdb`, `postgres`, `sqlite`(轻量) | | `llm_provider` | LLM 提供商 | `qwen` | `qwen`, `zhipu`(免费), `openai`, 等 | | `embedding_provider` | 嵌入模型提供商 | `qwen` | `qwen`, `zhipu`, `openai`, 等 | @@ -72,6 +77,15 @@ Memory: | `embedding_model` | 嵌入模型名称 | - | 根据提供商选择 | | `embedding_base_url` | 嵌入模型 API 地址(可选) | - | - | +### 记忆模式说明 + +PowerMem 支持两种记忆模式: + +| 模式 | 配置 | 功能 | 存储要求 | +|------|------|------|----------| +| **普通记忆** | `enable_user_profile: false` | 对话记忆存储与检索 | 支持所有数据库 | +| **用户画像** | `enable_user_profile: true` | 记忆 + 自动提取用户画像 | 仅支持 OceanBase | + ### 使用通义千问(推荐) 1. 访问 [阿里云百炼平台](https://bailian.console.aliyun.com/) 注册账号 @@ -198,6 +212,54 @@ PowerMem 会自动使用设备 ID(`device_id`)作为 `user_id` 进行记忆 - 不同设备之间的记忆完全隔离 - 同一设备的多次对话可以共享记忆上下文 +## 用户画像(UserMemory) + +PowerMem 提供 `UserMemory` 类,可自动从对话中提取用户画像信息。 + +### 启用用户画像 + +在配置中设置 `enable_user_profile: true` 即可启用: + +```yaml +Memory: + powermem: + type: powermem + enable_user_profile: true # 启用用户画像 + database_provider: oceanbase # 必须使用 OceanBase + llm_provider: qwen + embedding_provider: qwen + llm_api_key: sk-xxxxxxxxxxxxxxxx + llm_model: qwen-plus + embedding_api_key: sk-xxxxxxxxxxxxxxxx + embedding_model: text-embedding-v3 + # OceanBase 数据库连接配置 + vector_store: + provider: oceanbase + config: + host: 127.0.0.1 + port: 2881 + user: root@test + password: your_password + database: powermem +``` + +### 用户画像能力 + +| 能力 | 说明 | +|------|------| +| **信息提取** | 自动从对话中提取姓名、年龄、职业、兴趣等 | +| **持续更新** | 随着对话进行,不断完善用户画像 | +| **画像检索** | 将用户画像与记忆搜索结合,提升检索相关性 | +| **智能遗忘** | 基于艾宾浩斯遗忘曲线,淡化过时信息 | + +### 工作原理 + +启用用户画像后,小智在查询记忆时会自动返回: +1. **用户画像**:用户的基本信息、兴趣爱好等 +2. **相关记忆**:与当前对话相关的历史记忆 + +> ⚠️ **注意**:`UserMemory` 功能需要 OceanBase 作为存储后端,其他数据库暂不支持。 + ## 与其他记忆组件的对比 | 特性 | PowerMem | mem0ai | mem_local_short | @@ -206,6 +268,8 @@ PowerMem 会自动使用设备 ID(`device_id`)作为 `user_id` 进行记忆 | 存储位置 | 本地/云端DB | 云端 | 本地YAML | | 费用 | 取决于LLM和DB | 1000次/月免费 | 完全免费 | | 智能检索 | ✅ 向量搜索 | ✅ 向量搜索 | ❌ 全量返回 | +| 用户画像 | ✅ UserMemory | ❌ | ❌ | +| 智能遗忘 | ✅ 遗忘曲线 | ❌ | ❌ | | 私有部署 | ✅ 支持 | ❌ 仅云端 | ✅ 支持 | | 数据库支持 | OceanBase(推荐)/SeekDB/PostgreSQL/SQLite | - | YAML 文件 | @@ -239,6 +303,9 @@ source .venv/bin/activate # 测试 PowerMem 导入 python -c "from powermem import AsyncMemory; print('PowerMem 导入成功')" + +# 测试 UserMemory 导入(用户画像功能) +python -c "from powermem import UserMemory; print('UserMemory 导入成功')" ``` ## 更多资源 diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index cb735e63..e5420b2c 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -284,8 +284,13 @@ Memory: # 官网: https://www.powermem.ai/ # 使用示例: https://github.com/oceanbase/powermem/tree/main/examples type: powermem + # 是否启用用户画像功能(需要OceanBase作为存储后端) + # - false: 使用普通记忆模式(AsyncMemory),支持所有数据库 + # - true: 使用用户画像模式(UserMemory),自动提取用户信息,仅支持OceanBase + enable_user_profile: false # 数据库提供者: oceanbase(推荐,最佳性能), seekdb(推荐,AI应用存储一体), postgres, sqlite(轻量备选) # 资源充足时建议使用 oceanbase 或 seekdb + # 注意:用户画像功能(enable_user_profile: true)仅支持oceanbase database_provider: sqlite # LLM提供者: qwen(默认), zhipu(免费glm-4-flash), openai, 等 llm_provider: qwen diff --git a/main/xiaozhi-server/core/providers/memory/powermem/powermem.py b/main/xiaozhi-server/core/providers/memory/powermem/powermem.py index ec9fea7a..c1891932 100644 --- a/main/xiaozhi-server/core/providers/memory/powermem/powermem.py +++ b/main/xiaozhi-server/core/providers/memory/powermem/powermem.py @@ -7,13 +7,13 @@ PowerMem is an open-source agent memory component from OceanBase GitHub: https://github.com/oceanbase/powermem Website: https://www.powermem.ai/ +@Author: wayyoungboy """ import traceback from typing import Optional, Dict, Any from ..base import MemoryProviderBase, logger -from powermem import AsyncMemory TAG = __name__ @@ -27,19 +27,37 @@ class MemoryProvider(MemoryProviderBase): Supports multiple storage backends (sqlite, oceanbase, postgres), LLM providers (qwen, openai, etc.) and embedding providers. + + Config options: + - enable_user_profile: bool - Enable UserMemory for user profiling (requires OceanBase) + - database_provider: str - Storage backend (sqlite, oceanbase, postgres) + - llm_provider: str - LLM provider (qwen, openai, etc.) + - embedding_provider: str - Embedding provider (qwen, openai, etc.) """ def __init__(self, config: Dict[str, Any], summary_memory: Optional[str] = None): super().__init__(config) self.use_powermem = False self.memory_client = None + self.enable_user_profile = False - try: + try: + # Check if user profile mode is enabled + self.enable_user_profile = config.get("enable_user_profile", False) + # Get configuration parameters database_provider = config.get("database_provider", "sqlite") llm_provider = config.get("llm_provider", "qwen") embedding_provider = config.get("embedding_provider", "qwen") + # UserMemory requires OceanBase + if self.enable_user_profile and database_provider not in ["oceanbase"]: + logger.bind(tag=TAG).warning( + f"UserMemory requires OceanBase as storage backend, but got {database_provider}. " + "Falling back to AsyncMemory mode." + ) + self.enable_user_profile = False + # Build powermem configuration dict # PowerMem supports two config styles: # 1. powermem style: database, llm, embedding @@ -89,13 +107,21 @@ class MemoryProvider(MemoryProviderBase): "config": embedder_config } - # Initialize AsyncMemory client - self.memory_client = AsyncMemory(config=powermem_config) + # Initialize memory client based on mode + if self.enable_user_profile: + from powermem import UserMemory + self.memory_client = UserMemory(config=powermem_config) + memory_mode = "UserMemory (用户画像模式)" + else: + from powermem import AsyncMemory + self.memory_client = AsyncMemory(config=powermem_config) + memory_mode = "AsyncMemory (普通记忆模式)" + self.use_powermem = True logger.bind(tag=TAG).info( - f"PowerMem initialized successfully with database={database_provider}, " - f"llm={llm_provider}, embedding={embedding_provider}" + f"PowerMem initialized successfully: mode={memory_mode}, " + f"database={database_provider}, llm={llm_provider}, embedding={embedding_provider}" ) except ImportError as e: @@ -167,6 +193,14 @@ class MemoryProvider(MemoryProviderBase): logger.bind(tag=TAG).debug("No role_id set, returning empty memory") return "" + result_parts = [] + + # If user profile mode is enabled, include user profile in results + if self.enable_user_profile: + profile = await self.get_user_profile() + if profile: + result_parts.append(f"【用户画像】\n{profile}") + # Search memories using PowerMem SDK results = await self.memory_client.search( query=query, @@ -174,54 +208,92 @@ class MemoryProvider(MemoryProviderBase): limit=30 ) - if not results or "results" not in results: - logger.bind(tag=TAG).debug("No memory results found") - return "" - - # Format each memory entry with its update time - memories = [] - for entry in results.get("results", []): - # Get timestamp from updated_at or created_at - timestamp = "" - if "updated_at" in entry and entry["updated_at"]: - timestamp = str(entry["updated_at"]) - elif "created_at" in entry and entry["created_at"]: - timestamp = str(entry["created_at"]) - - if timestamp: - try: - # Parse and reformat the timestamp (remove milliseconds if present) - if "." in timestamp: - dt = timestamp.split(".")[0] - else: - dt = timestamp - formatted_time = dt.replace("T", " ") - except Exception: - formatted_time = timestamp - else: - formatted_time = "" - - memory = entry.get("memory", "") or entry.get("content", "") - if memory: - if formatted_time: - # Store tuple of (timestamp, formatted_string) for sorting - memories.append((timestamp, f"[{formatted_time}] {memory}")) + if results and "results" in results: + # Format each memory entry with its update time + memories = [] + for entry in results.get("results", []): + # Get timestamp from updated_at or created_at + timestamp = "" + if "updated_at" in entry and entry["updated_at"]: + timestamp = str(entry["updated_at"]) + elif "created_at" in entry and entry["created_at"]: + timestamp = str(entry["created_at"]) + + if timestamp: + try: + # Parse and reformat the timestamp (remove milliseconds if present) + if "." in timestamp: + dt = timestamp.split(".")[0] + else: + dt = timestamp + formatted_time = dt.replace("T", " ") + except Exception: + formatted_time = timestamp else: - memories.append(("", memory)) + formatted_time = "" + + memory = entry.get("memory", "") or entry.get("content", "") + if memory: + if formatted_time: + # Store tuple of (timestamp, formatted_string) for sorting + memories.append((timestamp, f"[{formatted_time}] {memory}")) + else: + memories.append(("", memory)) - # Sort by timestamp in descending order (newest first) - memories.sort(key=lambda x: x[0], reverse=True) + # Sort by timestamp in descending order (newest first) + memories.sort(key=lambda x: x[0], reverse=True) - # Extract only the formatted strings - memories_str = "\n".join(f"- {memory[1]}" for memory in memories) - logger.bind(tag=TAG).debug(f"Query results: {memories_str}") - return memories_str + # Extract only the formatted strings + if memories: + memories_str = "\n".join(f"- {memory[1]}" for memory in memories) + result_parts.append(f"【相关记忆】\n{memories_str}") + + final_result = "\n\n".join(result_parts) + logger.bind(tag=TAG).debug(f"Query results: {final_result}") + return final_result except Exception as e: logger.bind(tag=TAG).error(f"Error querying memory: {str(e)}") logger.bind(tag=TAG).debug(f"Detailed error: {traceback.format_exc()}") return "" + async def get_user_profile(self) -> str: + """ + Get user profile from PowerMem (only available in UserMemory mode). + + Returns: + Formatted user profile string or empty string if not available + """ + if not self.use_powermem or self.memory_client is None: + return "" + + if not self.enable_user_profile: + logger.bind(tag=TAG).debug("User profile mode is not enabled") + return "" + + try: + if not getattr(self, "role_id", None): + return "" + + # Get user profile using UserMemory SDK + profile = await self.memory_client.get_profile(user_id=self.role_id) + + if not profile: + return "" + + # Format profile as readable string + profile_parts = [] + for key, value in profile.items(): + if value: + profile_parts.append(f"- {key}: {value}") + + return "\n".join(profile_parts) + + except Exception as e: + logger.bind(tag=TAG).error(f"Error getting user profile: {str(e)}") + logger.bind(tag=TAG).debug(f"Detailed error: {traceback.format_exc()}") + return "" + # Register the memory provider instance powermem = MemoryProvider({})