This commit is contained in:
Soulter
2025-11-21 17:25:55 +08:00
parent 98c5466b5d
commit 9c8025acce
11 changed files with 1596 additions and 172 deletions
+4
View File
@@ -24,6 +24,7 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.memory.memory_manager import MemoryManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
from astrbot.core.platform.manager import PlatformManager
@@ -136,6 +137,8 @@ class AstrBotCoreLifecycle:
# 初始化知识库管理器
self.kb_manager = KnowledgeBaseManager(self.provider_manager)
# 初始化记忆管理器
self.memory_manager = MemoryManager()
# 初始化提供给插件的上下文
self.star_context = Context(
@@ -149,6 +152,7 @@ class AstrBotCoreLifecycle:
self.persona_mgr,
self.astrbot_config_mgr,
self.kb_manager,
self.memory_manager,
)
# 初始化插件管理器
+10 -1
View File
@@ -1,11 +1,20 @@
import abc
from dataclasses import dataclass
from typing import TypedDict
@dataclass
class Result:
class ResultData(TypedDict):
id: str
doc_id: str
text: str
metadata: dict
created_at: int
updated_at: int
similarity: float
data: dict
data: ResultData | dict
class BaseVecDB:
+822
View File
@@ -0,0 +1,822 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "l6cYurMvF69IM4Kc33Qou",
"type": "rectangle",
"x": 173.140625,
"y": -29.0234375,
"width": 92.95703125,
"height": 77.109375,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": {
"type": 3
},
"seed": 1409469537,
"version": 91,
"versionNonce": 307958671,
"isDeleted": false,
"boundElements": [],
"updated": 1763703733605,
"link": null,
"locked": false
},
{
"id": "1ZvS6t8U6ihUjNU0dakgl",
"type": "arrow",
"x": 409.30859375,
"y": 9.6875,
"width": 118.2734375,
"height": 1.9609375,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a1",
"roundness": {
"type": 2
},
"seed": 326508865,
"version": 120,
"versionNonce": 199367023,
"isDeleted": false,
"boundElements": null,
"updated": 1763703733605,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
-118.2734375,
-1.9609375
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "tfdUGiJdcMoOHGfqFHXK6",
"type": "text",
"x": 153.46875,
"y": -70.9765625,
"width": 136.4598846435547,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a2",
"roundness": null,
"seed": 688712865,
"version": 67,
"versionNonce": 300660705,
"isDeleted": false,
"boundElements": null,
"updated": 1763703743816,
"link": null,
"locked": false,
"text": "FAISS+SQLite",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "FAISS+SQLite",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "AeL3kEB9a8_TAvAXpAbpl",
"type": "text",
"x": 438.36328125,
"y": -3.78125,
"width": 116.109375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a3",
"roundness": null,
"seed": 788579535,
"version": 33,
"versionNonce": 946602095,
"isDeleted": false,
"boundElements": null,
"updated": 1763703932431,
"link": null,
"locked": false,
"text": "FACT",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "FACT",
"autoResize": false,
"lineHeight": 1.25
},
{
"id": "Pe3TeMZvxQ8tRTcbD5v6P",
"type": "arrow",
"x": 297.125,
"y": 40.2578125,
"width": 120.2421875,
"height": 1.421875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a4",
"roundness": {
"type": 2
},
"seed": 1146229999,
"version": 44,
"versionNonce": 636917679,
"isDeleted": false,
"boundElements": null,
"updated": 1763703759050,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
120.2421875,
1.421875
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "GhmQoadtQRK8c8aEEbYKQ",
"type": "text",
"x": 283.53515625,
"y": 64.76171875,
"width": 130.85989379882812,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a5",
"roundness": null,
"seed": 1445650959,
"version": 79,
"versionNonce": 566193167,
"isDeleted": false,
"boundElements": null,
"updated": 1763703768982,
"link": null,
"locked": false,
"text": "top-n Similary\n",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "top-n Similary\n",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "uTEFJs8cNS09WFq2pi9P7",
"type": "rectangle",
"x": 528.1586158430439,
"y": -173.43472375183552,
"width": 135.7578125,
"height": 128.73828125,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a6",
"roundness": {
"type": 3
},
"seed": 223409231,
"version": 44,
"versionNonce": 1066827105,
"isDeleted": false,
"boundElements": [
{
"id": "FfWdx1_yCq6UYfXamJX9N",
"type": "arrow"
}
],
"updated": 1763704050188,
"link": null,
"locked": false
},
{
"id": "2SzqzpJ4C2ymVj8-8vN7H",
"type": "text",
"x": 548.1480270948795,
"y": -211,
"width": 86.43992614746094,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a7",
"roundness": null,
"seed": 1015608623,
"version": 23,
"versionNonce": 950374849,
"isDeleted": false,
"boundElements": null,
"updated": 1763704047884,
"link": null,
"locked": false,
"text": "Memories",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Memories",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "CgW6Yf9v0a9q1tsjhDl7b",
"type": "text",
"x": 568.3099317299038,
"y": -154.69469411681115,
"width": 62.099945068359375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aA",
"roundness": null,
"seed": 452254927,
"version": 10,
"versionNonce": 972895023,
"isDeleted": false,
"boundElements": null,
"updated": 1763704057762,
"link": null,
"locked": false,
"text": "chunk1",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "knvlKpaFZ8lY-73Y-e9W6",
"type": "text",
"x": 569.11328125,
"y": -116.91056665512056,
"width": 67.55995178222656,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aB",
"roundness": null,
"seed": 914644015,
"version": 90,
"versionNonce": 158135631,
"isDeleted": false,
"boundElements": null,
"updated": 1763704057762,
"link": null,
"locked": false,
"text": "chunk2",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk2",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "Q7URqvTSMpvj08ye-afTT",
"type": "rectangle",
"x": 444.515625,
"y": 36.7890625,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aC",
"roundness": {
"type": 3
},
"seed": 1642537601,
"version": 19,
"versionNonce": 948406575,
"isDeleted": false,
"boundElements": null,
"updated": 1763703870173,
"link": null,
"locked": false
},
{
"id": "JjxBt9cZIZXNTd6CmwyKL",
"type": "rectangle",
"x": 452.203125,
"y": 46.064453125,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aD",
"roundness": {
"type": 3
},
"seed": 1746916641,
"version": 40,
"versionNonce": 1650978255,
"isDeleted": false,
"boundElements": [],
"updated": 1763703871882,
"link": null,
"locked": false
},
{
"id": "XGBCPPFnjriqsL8LvLwyQ",
"type": "rectangle",
"x": 461.56640625,
"y": 56.162109375,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aE",
"roundness": {
"type": 3
},
"seed": 529794575,
"version": 85,
"versionNonce": 2131900641,
"isDeleted": false,
"boundElements": [],
"updated": 1763703874182,
"link": null,
"locked": false
},
{
"id": "FfWdx1_yCq6UYfXamJX9N",
"type": "arrow",
"x": 537.6875,
"y": 48.203125,
"width": 6.615850226297994,
"height": 75.81335873223107,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF",
"roundness": {
"type": 2
},
"seed": 1982870689,
"version": 90,
"versionNonce": 25307457,
"isDeleted": false,
"boundElements": null,
"updated": 1763704050188,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
6.615850226297994,
-75.81335873223107
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": {
"elementId": "uTEFJs8cNS09WFq2pi9P7",
"focus": 0.6071885090336794,
"gap": 24.64453125
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "jgJgqGMRWcaNX_28wY4CU",
"type": "text",
"x": 570,
"y": 10,
"width": 67.11994934082031,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aG",
"roundness": null,
"seed": 1065220559,
"version": 26,
"versionNonce": 2115991521,
"isDeleted": false,
"boundElements": null,
"updated": 1763703959397,
"link": null,
"locked": false,
"text": "update",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "update",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "_5pSPPOpp9h1TpFCIc055",
"type": "text",
"x": 292.36328125,
"y": -138.5703125,
"width": 122.87992858886719,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aH",
"roundness": null,
"seed": 51461025,
"version": 26,
"versionNonce": 1647492655,
"isDeleted": false,
"boundElements": null,
"updated": 1763703925147,
"link": null,
"locked": false,
"text": "ADD Memory",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "ADD Memory",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "YG6MdL14l7lk4ypQNMZ_k",
"type": "text",
"x": 296.71885397566257,
"y": 161.399157096715,
"width": 295.27984619140625,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aJ",
"roundness": null,
"seed": 1183210273,
"version": 122,
"versionNonce": 1702733281,
"isDeleted": false,
"boundElements": [],
"updated": 1763704085083,
"link": null,
"locked": false,
"text": "RETRIEVE Memory (STATIC)",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "RETRIEVE Memory (STATIC)",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "Foa3VPJYqhj1uAX5mn3n0",
"type": "rectangle",
"x": 324.7616636099071,
"y": 248.63213980937013,
"width": 135.7578125,
"height": 128.73828125,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aL",
"roundness": {
"type": 3
},
"seed": 995116257,
"version": 225,
"versionNonce": 1886900225,
"isDeleted": false,
"boundElements": [],
"updated": 1763704055846,
"link": null,
"locked": false
},
{
"id": "pe3veI_yBFKYtbaJwDKQT",
"type": "text",
"x": 344.7510748617428,
"y": 211.06686356120565,
"width": 86.43992614746094,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aM",
"roundness": null,
"seed": 26673345,
"version": 204,
"versionNonce": 1004546017,
"isDeleted": false,
"boundElements": [],
"updated": 1763704055846,
"link": null,
"locked": false,
"text": "Memories",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Memories",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "bOlhO8AaKE86_43viu5UG",
"type": "text",
"x": 365.50408375566445,
"y": 269.24725381983865,
"width": 62.099945068359375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aN",
"roundness": null,
"seed": 1849784033,
"version": 106,
"versionNonce": 762320737,
"isDeleted": false,
"boundElements": [],
"updated": 1763704060295,
"link": null,
"locked": false,
"text": "chunk1",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "V_iDW10PKwMe7vWb5S5HF",
"type": "text",
"x": 366.3074332757606,
"y": 307.03138128152926,
"width": 67.55995178222656,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aO",
"roundness": null,
"seed": 1670509249,
"version": 186,
"versionNonce": 1964540737,
"isDeleted": false,
"boundElements": [],
"updated": 1763704060295,
"link": null,
"locked": false,
"text": "chunk2",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk2",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "LHKMRdSowgcl2LsKacxTz",
"type": "text",
"x": 484.9493410573871,
"y": 292.45619471187945,
"width": 273.579833984375,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aP",
"roundness": null,
"seed": 945666991,
"version": 104,
"versionNonce": 1512137505,
"isDeleted": false,
"boundElements": null,
"updated": 1763704096016,
"link": null,
"locked": false,
"text": "RANKED By DECAY SCORE,\nTOP K",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "RANKED By DECAY SCORE,\nTOP K",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}
+57 -75
View File
@@ -1,94 +1,76 @@
# AstrBot Long-Term Memory
## Decay Score
这个模块实现了 AstrBot 的长期记忆功能,基于 Function Calling 机制,允许模型调用预定义的 Tools 来管理记忆,并实现了简单的记忆遗忘机制。使用 AstrBot 向量数据库 API 存储和检索记忆片段。
记忆衰减分数定义为:
## 一些概念
\[
\text{decay\_score}
= \alpha \cdot e^{-\lambda \cdot \Delta t \cdot \beta}
### 相关理论
1. 赫布理论:记忆通过神经元之间的连接(突触)进行存储和强化,频繁使用的连接会变得更强。
2. 遗忘曲线:记忆会随着时间的推移而减弱,除非通过复习或使用来强化。
#### 建模方案一
为了建模上面两个理论,我们需要引入以下的变量:
1. 记忆强度(Memory Strength):表示记忆的持久性和易检索性。记忆强度越高,记忆越不容易遗忘。
2. 使用频率(Usage Frequency):表示记忆被访问或使用的频率。使用频率越高,记忆强度越高。
3. 时间衰减(Time Decay):表示记忆强度随时间的自然衰减。时间越长,记忆强度越低。
4. 强化因子(Reinforcement Factor):表示每次使用记忆时对记忆强度的提升效果。强化因子越大,记忆强度提升越显著。
写入记忆时,我们可以初始化记忆强度和使用频率:
- 记忆强度(S)初始化为一个基准值 S0。
- 使用频率(F)初始化为 1。
读取记忆时,我们可以根据以下公式计算记忆强度:
S = S0 * e^(-λt) + kF
+ (1-\alpha)\cdot (1 - e^{-\gamma \cdot c})
\]
其中:
- S0 是初始记忆强度。
- λ 是时间衰减常数,表示记忆强度随时间的衰减速度。
- t 是自记忆创建以来经过的时间。
- k 是强化因子,表示每次使用记忆时对记忆强度的提升效果。
- F 是使用频率,表示记忆被访问或使用的次数
+ \(\Delta t\):自上次检索以来经过的时间(天),由 `last_retrieval_at` 计算;
+ \(c\):检索次数,对应字段 `retrieval_count`
+ \(\alpha\):控制时间衰减和检索次数影响的权重;
+ \(\gamma\):控制检索次数影响的速率;
+ \(\lambda\):控制时间衰减的速率;
+ \(\beta\):时间衰减调节因子;
当记忆被访问时,我们更新使用频率和记忆强度:
\[
\beta = \frac{1}{1 + a \cdot c}
\]
- 使用频率(F)增加 1
- 记忆强度(S)根据上述公式重新计算。
+ \(a\):控制检索次数对时间衰减影响的权重
相似记忆的合并:
## ADD MEMORY
对相似记忆我们有两种处理模式:
+ LLM 通过 `astr_add_memory` 工具调用,传入记忆内容和记忆类型。
+ 生成 `mem_id = uuid4()`
+ 从上下文中获取 `owner_id = unified_message_origin`
- 过于相似的记忆,我们会执行合并成新的记忆。
- 较为相似的记忆,比如某些实体相同,根据赫布理论,我们会提升相似记忆的记忆强度和使用频率。
步骤:
具体算法如下:
1. 使用 VecDB 以新记忆内容为 query,检索前 20 条相似记忆。
2. 从中取相似度最高的前 5 条:
+ 若相似度超过“合并阈值”(如 `sim >= merge_threshold`):
+ 将该条记忆视为同一记忆,使用 LLM 将旧内容与新内容合并;
+ 在同一个 `mem_id` 上更新 MemoryDB 和 VecDBUPDATE,而非新建)。
+ 否则:
+ 作为全新的记忆插入:
+ 写入 VecDBmetadata 中包含 `mem_id`, `owner_id`);
+ 写入 MemoryDB 的 `memory_chunks` 表,初始化:
+ `created_at = now`
+ `last_retrieval_at = now`
+ `retrieval_count = 1` 等。
3. 对 VecDB 返回的前 20 条记忆,如果相似度高于某个“赫布阈值”(`hebb_threshold`),则:
+ `retrieval_count += 1`
+ `last_retrieval_at = now`
1. 计算新记忆与现有记忆的相似度
2. 根据相似度,执行以下操作:
- 如果相似度超过高阈值,合并记忆内容,更新记忆强度和使用频率。
- 如果相似度在中等范围内,提升现有记忆的记忆强度和使用频率。
- 如果不是高似记忆,都按正常流程存储新记忆。
这一步体现了赫布学习:与新记忆共同被激活的旧记忆会获得一次强化
#### 建模方案二
## QUERY MEMORY (STATIC)
我们参考艾宾浩斯遗忘曲线,基于这两个变量设计了一个公式,其表示了每个对话总结的遗忘得分
+ LLM 通过 `astr_query_memory` 工具调用,无参数
每个记忆节点带有
步骤:
1. last_retrieval_at
2. retrieval_count
1. 从 MemoryDB 的 `memory_chunks` 表中查询当前用户所有活跃记忆:
+ `SELECT * FROM memory_chunks WHERE owner_id = ? AND is_active = 1`
2. 对每条记忆,根据 `last_retrieval_at``retrieval_count` 计算对应的 `decay_score`
3.`decay_score` 从高到低排序,返回前 `top_k` 条记忆内容给 LLM。
4. 对返回的这 `top_k` 条记忆:
+ `retrieval_count += 1`
+ `last_retrieval_at = now`
$decayscore = \alpha * exp(-\lambda * \delta_t * \beta) + (1-\alpha) * (1-exp(-\gamma * c))$
## QUERY MEMORY (DYNAMIC)(暂不实现)
其中:
- $\delta_t$: 自上次检索以来经过的时间(以天为单位)
- $c$ 检索次数。
- $\alpha$: 控制时间衰减和检索次数影响的权重
- $\gamma$: 控制检索次数影响的速率
- $\lambda$: 控制时间衰减影响的速率
- $\beta$: 时间衰减的调节因子
$\beta = \frac{1}{1 + a * c}$
- $a$: 控制检索次数对时间衰减影响的权重
相似记忆的合并:
对相似记忆我们有两种处理模式:
- 过于相似的记忆,我们会执行合并成新的记忆。
- 较为相似的记忆,比如某些实体相同,根据赫布理论,我们会提升相似记忆的记忆强度和使用频率。
具体算法如下:
1. 计算新记忆与现有记忆的相似度。
2. 根据相似度,执行以下操作:
- 如果相似度超过高阈值,合并记忆内容
- 如果相似度在中等范围内
- 如果不是高似记忆,都按正常流程存储新记忆。
+ LLM 提供查询内容作为语义 query。
+ 使用 VecDB 检索与该 query 最相似的前 `N` 条记忆(`N > top_k`)。
+ 根据 `mem_id``memory_chunks` 中加载对应记录
+ 对这批候选记忆计算:
+ 语义相似度(来自 VecDB)
+ `decay_score`
+ 最终排序分数(例如 `w1 * sim + w2 * decay_score`
+ 按最终排序分数从高到低返回前 `top_k` 条记忆内容,并更新它们的 `retrieval_count``last_retrieval_at`
+26
View File
@@ -0,0 +1,26 @@
"""
AstrBot Memory Module
This module implements a long-term memory system with semantic retrieval,
decay-based ranking, and Hebbian learning reinforcement.
"""
from .entities import MEMORY_TYPE_IMPORTANCE, MemoryChunk
from .mem_db_sqlite import MemoryDatabase
from .memory_manager import HEBB_THRESHOLD, MERGE_THRESHOLD, MemoryManager
from .tools import AddMemory, QueryMemory
__all__ = [
# Entities
"MemoryChunk",
"MEMORY_TYPE_IMPORTANCE",
# Database
"MemoryDatabase",
# Manager
"MemoryManager",
"MERGE_THRESHOLD",
"HEBB_THRESHOLD",
# Tools
"AddMemory",
"QueryMemory",
]
+51 -96
View File
@@ -1,108 +1,63 @@
from datetime import datetime
import uuid
from datetime import datetime, timezone
from pydantic import BaseModel
import numpy as np
from sqlmodel import Field, MetaData, SQLModel
"""
我们参考艾宾浩斯遗忘曲线,基于这两个变量设计了一个公式,其表示了每个对话总结的遗忘得分。
$decayscore = alpha * exp(-lambda * delta_t * \beta) + (1-alpha) * (1-exp(-gamma * c))$
其中:
- $delta_t$: 自上次检索以来经过的时间(以天为单位)。
- $c$ 检索次数。
- $alpha$: 控制时间衰减和检索次数影响的权重
- $gamma$: 控制检索次数影响的速率
- $lambda$: 控制时间衰减影响的速率
- $beta$ 时间衰减的调节因子
$beta = frac{1}{1 + a * c}$
- $a$: 控制检索次数对时间衰减影响的权重
相似记忆的合并:
对相似记忆我们有两种处理模式:
- 过于相似的记忆,我们会执行合并成新的记忆。
- 较为相似的记忆,比如某些实体相同,根据赫布理论,我们会提升相似记忆的记忆强度和使用频率。
具体算法如下:
1. 计算新记忆与现有记忆的相似度。
2. 根据相似度,执行以下操作:
- 如果相似度超过高阈值,合并记忆内容
- 如果相似度在中等范围内
- 如果不是高似记忆,都按正常流程存储新记忆。
"""
MEMORY_TYPE_IMPORTANCE = {"persona": 1.3, "fact": 1.0, "ephemeral": 0.8}
class MemoryChunk(BaseModel):
class BaseMemoryModel(SQLModel, table=False):
metadata = MetaData()
class MemoryChunk(BaseMemoryModel, table=True):
"""A chunk of memory stored in the system."""
id: str
fact: str
__tablename__ = "memory_chunks" # type: ignore
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
mem_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
index=True,
)
fact: str = Field(nullable=False)
"""The factual content of the memory chunk."""
created_at: datetime
owner_id: str = Field(max_length=255, nullable=False, index=True)
"""The identifier of the owner (user) of the memory chunk."""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
"""The timestamp when the memory chunk was created."""
last_retrieval_at: datetime
last_retrieval_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
"""The timestamp when the memory chunk was last retrieved."""
retrieval_count: int
retrieval_count: int = Field(default=1, nullable=False)
"""The number of times the memory chunk has been retrieved."""
importance_bias: float
"""A bias score indicating the importance of the memory chunk."""
memory_type: str = Field(max_length=20, nullable=False, default="fact")
"""The type of memory (e.g., 'persona', 'fact', 'ephemeral')."""
is_active: bool = Field(default=True, nullable=False)
"""Whether the memory chunk is active."""
def compute_decay_score(self, current_time: datetime) -> float:
"""Compute the decay score of the memory chunk based on time and retrievals."""
# Constants for the decay formula
alpha = 0.5
gamma = 0.1
lambda_ = 0.05
a = 0.1
# from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
# from astrbot.core.provider.provider import EmbeddingProvider
# memdb = None
# async def test_mem(embed_provider: EmbeddingProvider):
# global memdb
# mem_doc_path = "data/astr_memory/doc.db"
# mem_index_path = "data/astr_memory/index.faiss"
# memdb = FaissVecDB(
# doc_store_path=mem_doc_path,
# index_store_path=mem_index_path,
# embedding_provider=embed_provider,
# )
# await memdb.initialize()
# @dataclass
# class AddMemory(FunctionTool[AstrAgentContext]):
# name: str = "astr_add_memory"
# description: str = (
# "Add a new memory to the user's long-term memory storage. "
# "Use this tool only when the user explicitly asks you to remember something, "
# "or when they share stable preferences, identity, or long-term goals that will be useful in future interactions."
# )
# parameters: dict = Field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "query": {
# "type": "string",
# "description": "A concise keyword query for the knowledge base.",
# },
# },
# "required": ["query"],
# }
# )
# async def call(
# self, context: ContextWrapper[AstrAgentContext], **kwargs
# ) -> ToolExecResult:
# query = kwargs.get("query", "")
# if not query:
# return "error: Query parameter is empty."
# result = await retrieve_knowledge_base(
# query=kwargs.get("query", ""),
# umo=context.context.event.unified_msg_origin,
# context=context.context.context,
# )
# if not result:
# return "No relevant knowledge found."
# return result
# Calculate delta_t in days
delta_t = (current_time - self.last_retrieval_at).total_seconds() / 86400
c = self.retrieval_count
beta = 1 / (1 + a * c)
decay_score = alpha * np.exp(-lambda_ * delta_t * beta) + (1 - alpha) * (
1 - np.exp(-gamma * c)
)
return decay_score * MEMORY_TYPE_IMPORTANCE.get(self.memory_type, 1.0)
+174
View File
@@ -0,0 +1,174 @@
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlmodel import col
from astrbot.core import logger
from .entities import BaseMemoryModel, MemoryChunk
class MemoryDatabase:
def __init__(self, db_path: str = "data/astr_memory/memory.db") -> None:
"""Initialize memory database
Args:
db_path: Database file path, default is data/astr_memory/memory.db
"""
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
# Ensure directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Create async engine
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=3600,
)
# Create session factory
self.async_session = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def get_db(self):
"""Get database session
Usage:
async with mem_db.get_db() as session:
# Perform database operations
result = await session.execute(stmt)
"""
async with self.async_session() as session:
yield session
async def initialize(self) -> None:
"""Initialize database, create tables and configure SQLite parameters"""
async with self.engine.begin() as conn:
# Create all memory related tables
await conn.run_sync(BaseMemoryModel.metadata.create_all)
# Configure SQLite performance optimization parameters
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=20000"))
await conn.execute(text("PRAGMA temp_store=MEMORY"))
await conn.execute(text("PRAGMA mmap_size=134217728"))
await conn.execute(text("PRAGMA optimize"))
await conn.commit()
await self._create_indexes()
self.inited = True
logger.info(f"Memory database initialized: {self.db_path}")
async def _create_indexes(self) -> None:
"""Create indexes for memory_chunks table"""
async with self.get_db() as session:
async with session.begin():
# Create memory chunks table indexes
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_mem_id "
"ON memory_chunks(mem_id)",
),
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_owner_id "
"ON memory_chunks(owner_id)",
),
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_owner_active "
"ON memory_chunks(owner_id, is_active)",
),
)
await session.commit()
async def close(self) -> None:
"""Close database connection"""
await self.engine.dispose()
logger.info(f"Memory database closed: {self.db_path}")
async def insert_memory(self, memory: MemoryChunk) -> MemoryChunk:
"""Insert a new memory chunk"""
async with self.get_db() as session:
session.add(memory)
await session.commit()
await session.refresh(memory)
return memory
async def get_memory_by_id(self, mem_id: str) -> MemoryChunk | None:
"""Get memory chunk by mem_id"""
async with self.get_db() as session:
stmt = select(MemoryChunk).where(col(MemoryChunk.mem_id) == mem_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_memory(self, memory: MemoryChunk) -> MemoryChunk:
"""Update an existing memory chunk"""
async with self.get_db() as session:
session.add(memory)
await session.commit()
await session.refresh(memory)
return memory
async def get_active_memories(self, owner_id: str) -> list[MemoryChunk]:
"""Get all active memories for a user"""
async with self.get_db() as session:
stmt = select(MemoryChunk).where(
col(MemoryChunk.owner_id) == owner_id,
col(MemoryChunk.is_active) == True, # noqa: E712
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def update_retrieval_stats(
self,
mem_ids: list[str],
current_time: datetime | None = None,
) -> None:
"""Update retrieval statistics for multiple memories"""
if not mem_ids:
return
if current_time is None:
current_time = datetime.now(timezone.utc)
async with self.get_db() as session:
async with session.begin():
stmt = (
update(MemoryChunk)
.where(col(MemoryChunk.mem_id).in_(mem_ids))
.values(
retrieval_count=MemoryChunk.retrieval_count + 1,
last_retrieval_at=current_time,
)
)
await session.execute(stmt)
await session.commit()
async def deactivate_memory(self, mem_id: str) -> bool:
"""Deactivate a memory chunk"""
async with self.get_db() as session:
async with session.begin():
stmt = (
update(MemoryChunk)
.where(col(MemoryChunk.mem_id) == mem_id)
.values(is_active=False)
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0 if result.rowcount else False # type: ignore
+280
View File
@@ -0,0 +1,280 @@
import uuid
from datetime import datetime, timezone
from pathlib import Path
from astrbot.core import logger
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.provider import Provider as LLMProvider
from .entities import MemoryChunk
from .mem_db_sqlite import MemoryDatabase
MERGE_THRESHOLD = 0.85
"""Similarity threshold for merging memories"""
HEBB_THRESHOLD = 0.70
"""Similarity threshold for Hebbian learning reinforcement"""
MERGE_SYSTEM_PROMPT = """You are a memory consolidation assistant. Your task is to merge two related memory entries into a single, comprehensive memory.
Input format:
- Old memory: [existing memory content]
- New memory: [new memory content to be integrated]
Your output should be a single, concise memory that combines the essential information from both entries. Preserve specific details, update outdated information, and eliminate redundancy. Output only the merged memory content without any explanations or meta-commentary."""
class MemoryManager:
"""Manager for user long-term memory storage and retrieval"""
def __init__(self, memory_root_dir: str = "data/astr_memory"):
self.memory_root_dir = Path(memory_root_dir)
self.memory_root_dir.mkdir(parents=True, exist_ok=True)
self.mem_db: MemoryDatabase | None = None
self.vec_db: FaissVecDB | None = None
self._initialized = False
async def initialize(
self,
embedding_provider: EmbeddingProvider,
merge_llm_provider: LLMProvider,
):
"""Initialize memory database and vector database"""
# Initialize MemoryDB
db_path = self.memory_root_dir / "memory.db"
self.mem_db = MemoryDatabase(db_path.as_posix())
await self.mem_db.initialize()
self.embedding_provider = embedding_provider
self.merge_llm_provider = merge_llm_provider
# Initialize VecDB
doc_store_path = self.memory_root_dir / "doc.db"
index_store_path = self.memory_root_dir / "index.faiss"
self.vec_db = FaissVecDB(
doc_store_path=doc_store_path.as_posix(),
index_store_path=index_store_path.as_posix(),
embedding_provider=self.embedding_provider,
)
await self.vec_db.initialize()
logger.info("Memory manager initialized")
self._initialized = True
async def terminate(self):
"""Close all database connections"""
if self.vec_db:
await self.vec_db.close()
if self.mem_db:
await self.mem_db.close()
async def add_memory(
self,
fact: str,
owner_id: str,
memory_type: str = "fact",
) -> MemoryChunk:
"""Add a new memory with similarity check and merge logic
Implements the ADD MEMORY workflow from _README.md:
1. Search for similar memories using VecDB
2. If similarity >= merge_threshold, merge with existing memory
3. Otherwise, create new memory
4. Apply Hebbian learning to similar memories (similarity >= hebb_threshold)
Args:
fact: Memory content
owner_id: User identifier
memory_type: Memory type ('persona', 'fact', 'ephemeral')
Returns:
The created or updated MemoryChunk
"""
if not self.vec_db or not self.mem_db:
raise RuntimeError("Memory manager not initialized")
current_time = datetime.now(timezone.utc)
# Step 1: Search for similar memories
similar_results = await self.vec_db.retrieve(
query=fact,
k=20,
fetch_k=50,
metadata_filters={"owner_id": owner_id},
)
# Step 2: Check if we should merge with existing memories (top 3 similar ones)
merge_candidates = [
r for r in similar_results[:3] if r.similarity >= MERGE_THRESHOLD
]
if merge_candidates:
# Get all candidate memories from database
candidate_memories: list[tuple[str, MemoryChunk]] = []
for candidate in merge_candidates:
mem_id = candidate.data["metadata"]["mem_id"]
memory = await self.mem_db.get_memory_by_id(mem_id)
if memory:
candidate_memories.append((mem_id, memory))
if candidate_memories:
# Use the most similar memory as the base
base_mem_id, base_memory = candidate_memories[0]
# Collect all facts to merge (existing candidates + new fact)
all_facts = [mem.fact for _, mem in candidate_memories] + [fact]
merged_fact = await self._merge_multiple_memories(all_facts)
# Update the base memory
base_memory.fact = merged_fact
base_memory.last_retrieval_at = current_time
base_memory.retrieval_count += 1
updated_memory = await self.mem_db.update_memory(base_memory)
# Update VecDB for base memory
await self.vec_db.delete(base_mem_id)
await self.vec_db.insert(
content=merged_fact,
metadata={
"mem_id": base_mem_id,
"owner_id": owner_id,
"memory_type": memory_type,
},
id=base_mem_id,
)
# Deactivate and remove other merged memories
for mem_id, _ in candidate_memories[1:]:
await self.mem_db.deactivate_memory(mem_id)
await self.vec_db.delete(mem_id)
logger.info(
f"Merged {len(candidate_memories)} memories into {base_mem_id} for user {owner_id}"
)
return updated_memory
# Step 3: Create new memory
mem_id = str(uuid.uuid4())
new_memory = MemoryChunk(
mem_id=mem_id,
fact=fact,
owner_id=owner_id,
memory_type=memory_type,
created_at=current_time,
last_retrieval_at=current_time,
retrieval_count=1,
is_active=True,
)
# Insert into MemoryDB
created_memory = await self.mem_db.insert_memory(new_memory)
# Insert into VecDB
await self.vec_db.insert(
content=fact,
metadata={
"mem_id": mem_id,
"owner_id": owner_id,
"memory_type": memory_type,
},
id=mem_id,
)
# Step 4: Apply Hebbian learning to similar memories
hebb_mem_ids = [
r.data["metadata"]["mem_id"]
for r in similar_results
if r.similarity >= HEBB_THRESHOLD
]
if hebb_mem_ids:
await self.mem_db.update_retrieval_stats(hebb_mem_ids, current_time)
logger.debug(
f"Applied Hebbian learning to {len(hebb_mem_ids)} memories for user {owner_id}",
)
logger.info(f"Created new memory {mem_id} for user {owner_id}")
return created_memory
async def query_memory(
self,
owner_id: str,
top_k: int = 5,
) -> list[MemoryChunk]:
"""Query user's memories using static retrieval with decay score ranking
Implements the QUERY MEMORY (STATIC) workflow from _README.md:
1. Get all active memories for user from MemoryDB
2. Compute decay_score for each memory
3. Sort by decay_score and return top_k
4. Update retrieval statistics for returned memories
Args:
owner_id: User identifier
top_k: Number of memories to return
Returns:
List of top_k MemoryChunk sorted by decay score
"""
if not self.mem_db:
raise RuntimeError("Memory manager not initialized")
current_time = datetime.now(timezone.utc)
# Step 1: Get all active memories for user
all_memories = await self.mem_db.get_active_memories(owner_id)
if not all_memories:
return []
# Step 2-3: Compute decay scores and sort
memories_with_scores = [
(mem, mem.compute_decay_score(current_time)) for mem in all_memories
]
memories_with_scores.sort(key=lambda x: x[1], reverse=True)
# Get top_k memories
top_memories = [mem for mem, _ in memories_with_scores[:top_k]]
# Step 4: Update retrieval statistics
mem_ids = [mem.mem_id for mem in top_memories]
await self.mem_db.update_retrieval_stats(mem_ids, current_time)
logger.debug(f"Retrieved {len(top_memories)} memories for user {owner_id}")
return top_memories
async def _merge_multiple_memories(self, facts: list[str]) -> str:
"""Merge multiple memory facts using LLM in one call
Args:
facts: List of memory facts to merge
Returns:
Merged memory content
"""
if not self.merge_llm_provider:
return " ".join(facts)
if len(facts) == 1:
return facts[0]
try:
# Format all facts as a numbered list
facts_list = "\n".join(f"{i + 1}. {fact}" for i, fact in enumerate(facts))
user_prompt = (
f"Please merge the following {len(facts)} related memory entries "
"into a single, comprehensive memory:"
f"\n{facts_list}\n\nOutput only the merged memory content."
)
response = await self.merge_llm_provider.text_chat(
prompt=user_prompt,
system_prompt=MERGE_SYSTEM_PROMPT,
)
merged_content = response.completion_text.strip()
return merged_content if merged_content else " ".join(facts)
except Exception as e:
logger.warning(f"Failed to merge memories with LLM: {e}, using fallback")
return " ".join(facts)
+156
View File
@@ -0,0 +1,156 @@
from pydantic import Field
from pydantic.dataclasses import dataclass
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, ContextWrapper
@dataclass
class AddMemory(FunctionTool[AstrAgentContext]):
"""Tool for adding memories to user's long-term memory storage"""
name: str = "astr_add_memory"
description: str = (
"Add a new memory to the user's long-term memory storage. "
"Use this tool only when the user explicitly asks you to remember something, "
"or when they share stable preferences, identity, or long-term goals that will be useful in future interactions."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"fact": {
"type": "string",
"description": (
"The concrete memory content to store, such as a user preference, "
"identity detail, long-term goal, or stable profile fact."
),
},
"memory_type": {
"type": "string",
"enum": ["persona", "fact", "ephemeral"],
"description": (
"The relative importance of this memory. "
"Use 'persona' for core identity or highly impactful information, "
"'fact' for normal long-term preferences, "
"and 'ephemeral' for minor or tentative facts."
),
},
},
"required": ["fact", "memory_type"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
"""Add a memory to long-term storage
Args:
context: Agent context
**kwargs: Must contain 'fact' and 'memory_type'
Returns:
ToolExecResult with success message
"""
mm = context.context.context.memory_manager
fact = kwargs.get("fact")
memory_type = kwargs.get("memory_type", "fact")
if not fact:
return "Missing required parameter: fact"
try:
# Get owner_id from context
owner_id = context.context.event.unified_msg_origin
# Add memory using memory manager
memory = await mm.add_memory(
fact=fact,
owner_id=owner_id,
memory_type=memory_type,
)
return f"Memory added successfully (ID: {memory.mem_id})"
except Exception as e:
return f"Failed to add memory: {str(e)}"
@dataclass
class QueryMemory(FunctionTool[AstrAgentContext]):
"""Tool for querying user's long-term memories"""
name: str = "astr_query_memory"
description: str = (
"Query the user's long-term memory storage and return the most relevant memories. "
"Use this tool when you need user-specific context, preferences, or past facts "
"that are not explicitly present in the current conversation."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"top_k": {
"type": "integer",
"description": (
"Maximum number of memories to retrieve after retention-based ranking. "
"Typically between 3 and 10."
),
"default": 5,
"minimum": 1,
"maximum": 20,
},
},
"required": [],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
"""Query memories from long-term storage
Args:
context: Agent context
**kwargs: Optional 'top_k' parameter
Returns:
ToolExecResult with formatted memory list
"""
mm = context.context.context.memory_manager
top_k = kwargs.get("top_k", 5)
try:
# Get owner_id from context
owner_id = context.context.event.unified_msg_origin
# Query memories using memory manager
memories = await mm.query_memory(
owner_id=owner_id,
top_k=top_k,
)
if not memories:
return "No memories found for this user."
# Format memories for output
formatted_memories = []
for i, mem in enumerate(memories, 1):
formatted_memories.append(
f"{i}. [{mem.memory_type.upper()}] {mem.fact} "
f"(retrieved {mem.retrieval_count} times, "
f"last: {mem.last_retrieval_at.strftime('%Y-%m-%d')})"
)
result_text = "Retrieved memories:\n" + "\n".join(formatted_memories)
return result_text
except Exception as e:
return f"Failed to query memories: {str(e)}"
ADD_MEMORY_TOOL = AddMemory()
QUERY_MEMORY_TOOL = QueryMemory()
@@ -30,6 +30,7 @@ from ....astr_agent_context import AgentContextWrapper
from ....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....astr_agent_run_util import AgentRunner, run_agent
from ....astr_agent_tool_exec import FunctionToolExecutor
from ....memory.tools import ADD_MEMORY_TOOL, QUERY_MEMORY_TOOL
from ...context import PipelineContext, call_event_hook
from ..stage import Stage
from ..utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
@@ -124,6 +125,15 @@ class LLMRequestSubStage(Stage):
req.func_tool = ToolSet()
req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
async def _apply_memory(self, req: ProviderRequest):
mm = self.ctx.plugin_manager.context.memory_manager
if not mm or not mm._initialized:
return
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(ADD_MEMORY_TOOL)
req.func_tool.add_tool(QUERY_MEMORY_TOOL)
def _truncate_contexts(
self,
contexts: list[dict],
@@ -377,6 +387,9 @@ class LLMRequestSubStage(Stage):
# apply knowledge base feature
await self._apply_kb(event, req)
# apply memory feature
await self._apply_memory(req)
# fix contexts json str
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
+3
View File
@@ -14,6 +14,7 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.memory.memory_manager import MemoryManager
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.platform import Platform
@@ -65,6 +66,7 @@ class Context:
persona_manager: PersonaManager,
astrbot_config_mgr: AstrBotConfigManager,
knowledge_base_manager: KnowledgeBaseManager,
memory_manager: MemoryManager,
):
self._event_queue = event_queue
"""事件队列。消息平台通过事件队列传递消息事件。"""
@@ -79,6 +81,7 @@ class Context:
self.persona_manager = persona_manager
self.astrbot_config_mgr = astrbot_config_mgr
self.kb_manager = knowledge_base_manager
self.memory_manager = memory_manager
async def llm_generate(
self,