0. 前言
Agent区别于裸LLM的地方,除了Func Call之外,想必就是跨越会话的长期记忆系统了。
长期记忆是一种本地信息保存和检索机制,可以基于形形色色的存储介质进行搭建,从SQL、.yaml到.md,任君挑选。
把上千字的个人偏好一次性写成文档告诉她恐怕不容易,但是每轮对话后都让她写一点点就简单多了。从老前辈Open Claw到新晋顶流Hermes,个人Agent们无一例外都具备从每一轮对话中提取有效信息并加以保存的能力。Sonetto也不例外。长期记忆能让她逐步了解用户的喜好,一点点拉近她和现实世界的距离。
帮我查一下后天的天气。 好呀。我记得你后天还有考试对吧,考完了想去哪里玩呀?我帮你看看天气…… *一阵激烈的Func Call 嗯,后天你的离散数学考试是好天气呢……
这就是湾流为Sonetto带来的涌现般的亲切感,记忆对于她来说不是一个可选项而是一个必选项,为了让她更像一个人,我们必须加上长期记忆。
本文描述的湾流最新版本是湾流V5-preview,SonettoHere V3.4.0+,其主要特征如下:
- 一个经过长期记忆特化设计的Agent Graph,其中有长期记忆的唤起和写入节点
- 一个
LongTermMemory类,含直接向图暴露的读接口和写接口 - 一个长期记忆写时管理附属Agent,负责在写入新记忆时删除过时的记忆条目并标记对话中提及的记忆
- 至少一个与本地存储介质取得交互并暴露以
MemoryItem字典形式的统一内存行为的BaseMemoryManager派生类 - 一个成熟的面向用户的长期记忆编辑前端界面
- 暂不考虑RAG
1. 存储设施的建设与记忆条目数据结构设计
湾流V0(2026-05-09)— SonettoHere v0.1.0BETA+。我们的故事从这里开始。
那时Sonetto最初的长期记忆设计是一篇MEMORY.md,形式上跟OpenClaw非常相似。这时的长期记忆写入方式是这样的:用户输入和一轮Sonetto输出拼接在MEMORY.md后方送入LLM,由LLM生成新的md文件,融合了旧md文件的信息和新信息。
很快我们就意识到了这套方案的不足。全量覆写对于LLM来说会产生难以忍受的时间成本和经济成本,同时时间偏早的记忆信息会在多轮的覆写中逐渐消失。因此,我们提出了以下解决要求:
- 记忆需要是条目式的,新记忆的追加不会影响旧记忆。需要记忆条目数据结构。
- 记忆的CRUD本身可以视作Agent Tool,适合交由一位Agent而不是LLM来处理。
- 记忆的写入是一个长时间过程,可能长达数十秒。这数十秒不应该让用户进行等待。
- 记忆的上升是一个中等时间过程,允许用户被迫等待,因为记忆上升后Sonetto才会开始说话。
数次迭代之后,湾流V5实现了上述要求。
MemoryItem 记忆条目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MemoryItem:
"""单条记忆的数据模型。不依赖具体存储后端。
每个 MemoryItem 实例代表一条独立的记忆条目,包含描述内容、
分类主题、变更历史以及引用计数。通过其 update / merge 方法
记录每次变更的完整轨迹,供 show_description_history() 追溯。
"""
def __init__(
self,
description: str,
theme: str,
history: list[dict[str, str]] | None = None,
latest_update_time: str | None = None,
hit: int = 0,
) -> None:
def update(
self,
reason: str,
new_description: str | None = None,
new_theme: str | None = None,
) -> None:
"""更新记忆内容并记录历史。"""
def show_description_history(self) -> list[dict[str, str]]:
"""返回描述变更历史(从当前到最早)。"""
def merge(
self,
another: "MemoryItem",
reason: str,
merged_description: str,
merged_theme: str,
) -> None:
"""合并另一条记忆的历史到本条。"""
上述代码为MemoryItem类的核心属性和方法。其中最重要的字段是memory_item.description # 记忆文本本身和元数据性质的memory_item.theme # 所属主题。通过调用内置方法,记忆被创建出来之后可以被更新和合并。
记忆是一个纯内存对象,与硬盘交互无关。
MemoryManager 记忆管理器
记忆管理器类负责items的管理,保证其更改可以同步到硬盘。介质由具体子类决定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
class BaseMemoryManager(ABC):
"""记忆管理器抽象接口 — 基于三个原语的泛化 CRUD 框架。
所有记忆存储后端(YAML、数据库等)均应继承此类。
## 子类需实现的最小原语集
必须实现以下三个抽象方法,即可免费获得完整的 CRUD、字段校验和查询能力:
- ``_load_all() -> dict[str, MemoryItem]`` — **全量读取**。返回当前所有条目的 ID→对象映射。
默认 CRUD 的每次写操作都会调用此方法获取最新快照。
- ``_save_all(items) -> None`` — **全量覆写**。将整个条目字典持久化到后端。
YAML 后端使用 ``yaml.dump`` 写整个文件;数据库后端可保存单个快照表。
- ``_write_lock() -> AbstractContextManager[None]`` — **写操作锁上下文**。
所有 CRUD 方法在锁内执行 ``_load_all → 修改 → _save_all`` 序列。
YAML 后端以 ``portalocker.Lock`` 实现;数据库后端可使用事务。
## 覆写 CRUD 以获得超越全量更新的性能
默认 CRUD 的模式是「加锁 → **_load_all 全量加载** → 修改 → **_save_all 全量覆写」。
这对 YAML 等文件后端合理,但对支持定点读写的后端(SQL、Redis 等)效率低下。
子类可直接覆写 CRUD 方法,利用后端的原生操作避免全量读写:
注意:即使覆写了部分 CRUD,仍建议通过 ``_write_lock`` 保持并发语义一致性。
## 基类提供的默认工具方法
=== ================================
类别 方法 说明
=== ================================
查询 ``show()`` 返回 ``[{id, description, theme}]`` 列表
查询 ``show_description_history(id)`` 追溯指定条目的描述变更史
查询 ``get_memories_grouped()`` 按 theme 分组,供前端瀑布流展示
ID ``_generate_id()`` 默认 `secrets.token_hex(4)`,子类可覆写
校验 ``_validate_all_items(items)`` 遍历检查字段完整性,返回 (issues, repaired)
CRUD ``add(description, theme)`` 添加条目
CRUD ``delete(id)`` 删除条目
CRUD ``update(id, reason, ...)`` 更新条目(可改描述和/或主题)
CRUD ``merge(id1, id2, ...)`` 合并两条记忆,保留双方历史
CRUD ``hit(id)`` 增加引用计数
=== ================================
"""
# ── 抽象存储原语 ────────────────────────────────────────
@abstractmethod
def _load_all(self) -> dict[str, MemoryItem]:
"""读取全部条目。子类须自行保证并发安全。"""
...
@abstractmethod
def _save_all(self, items: dict[str, MemoryItem]) -> None:
"""覆写全部条目。子类须自行保证并发安全。"""
...
@abstractmethod
def _write_lock(self) -> AbstractContextManager[None]:
"""写操作的锁上下文。子类必须覆盖以实现并发控制。"""
...
# ── 启动自检 ────────────────────────────────────────────
@abstractmethod
def self_check(self) -> SelfCheckReport:
"""启动自检,验证存储后端状态正常。
子类应检查:
- 后端存储介质是否可达、可读写
- 所有数据条目字段是否完整(description / theme / history / latest_update_time / hit)
- 必要时自动修复可修复的问题(如空字符串、类型异常)
Returns:
SelfCheckReport 包含 status("OK" / "WARN" / "FAIL")、
issues(不可修复问题)、repaired(已修复问题)、item_count(有效条目数)。
"""
...
# ── 公共查询方法(基于 _load_all) ───────────────────────
def show(self) -> list[dict[str, Any]]:
"""返回所有记忆条目(大模型友好格式)。"""
# ── CRUD 方法(基于 _load_all / _save_all / _write_lock) ──
def add(self, description: str, theme: str) -> str:
"""添加一条新的记忆条目。"""
def delete(self, id: str) -> str:
"""删除指定 ID 的记忆条目,返回被删除条目的描述。"""
def merge(
self,
id1: str,
id2: str,
merged_description: str,
merged_theme: str,
reason: str,
) -> None:
"""将两条记忆合并为一条,id1 保留,id2 被删除。"""
def update(
self,
id: str,
reason: str,
new_description: str | None = None,
new_theme: str | None = None,
) -> None:
"""更新指定记忆条目的内容和/或主题。"""
def hit(self, id: str) -> int:
"""将指定记忆的 hit 计数加一,返回新的计数。"""
有删减
BaseMemoryManager通过写入内存和离开内存的两个原语_load_all和_save_all抽离了不同存储介质与内存中记忆条目字典的转换逻辑。湾流V5使用的是.yaml存储介质,使用了YamlMemoryManager子类。
1
2
3
4
5
6
7
8
9
10
11
12
13
def _load_all(self) -> dict[str, MemoryItem]:
"""读取完整文件。调用方必须已持有文件锁。"""
yaml_path = Path(self._yaml_file)
with yaml_path.open(encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return {id: MemoryItem(**data[id]) for id in data}
def _save_all(self, items: dict[str, MemoryItem]) -> None:
"""覆写完整文件。调用方必须已持有文件锁。"""
data_dict = {id: item.__dict__ for id, item in items.items()}
yaml_path = Path(self._yaml_file)
with yaml_path.open("w", encoding="utf-8") as f:
yaml.dump(data_dict, f, default_flow_style=False, allow_unicode=True)
日后可能使用数据库?
2. 长期记忆的写入
为了实现用户无感且不阻塞Graph运行结束的记忆写入,我们决定使用异步队列的消费者模式方式进行这一操作。V5开始这个组件被抽离至单独文件,下面将讲述湾流V5的异步消费者组件的搭建思路。
我们需要依赖注入什么
- 一个
llm: BaseChatModel,我们要用它构建agent: CompiledStateGraph - 一个
mm: MemoryManager,进行无关心介质的记忆操作
我们需要原创什么
- 一套提示词阵列
_CORE_PRINCIPLES: str,_COLD_PREFIX: str,_UPDATE_PREFIX: str及其组织:COLD_START_SYSTEM = _COLD_PREFIX + _CORE_PRINCIPLES; UPDATE_SYSTEM = _UPDATE_PREFIX + _CORE_PRINCIPLES给记忆管理Agent用。应对无原有记忆情形和有记忆情形。
我们基于依赖和原创建立什么
- 一组记忆CRUD工具集。使用
@tool def func()形式 - 一个搭载此工具集和提示词阵列的
ReAct Agent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# ── CRUD 工具 ───────────────────────────────────────
@tool
@_require_mm
def create_memory(content: str, section: str) -> str:
"""添加一条新的记忆条目到指定分区。调用后返回该条目的唯一 ID。
Args:
content: 记忆内容,用第三人称中文描述用户的一个事实。
section: 记忆分区。优先使用已有分区;若不适合任何已有分区或用户明确要求新建,可创建新分区(1-4字中文):
- "身份"(用户的基本身份信息:教育、职业、家乡等)
- "音乐"(虚拟歌手、声库、歌曲、专辑、创作者)
- "品味"(电影、美食、UP主、品牌偏好等)
- "地点与路径"(具体地点和文件系统路径)
- "瞬间"(即时观察和感受:天气、正在做的事、念头)
- "时效待办"(有截止日期的事项:作业、预约、考试)
"""
@tool
@_require_mm
def read_memories() -> str:
"""查看当前所有记忆条目及其 ID 和分区。在增删改之前必须先调用此工具了解现有条目。"""
@tool
@_require_mm
def update_memory(id: str, content: str, reason: str) -> str:
"""根据 ID 更新一条已有记忆。更新成功后会自动增加该记忆的引用计数(hit),表示该记忆被重新关注。
Args:
id: 要更新的记忆 ID(来自 read_memories 的输出)。
content: 更新后的完整内容。
reason: 修改原因,说明为什么要更新这条记忆。
"""
@tool
@_require_mm
def delete_memory(id: str, reason: str) -> str:
"""根据 ID 删除一条记忆。
Args:
id: 要删除的记忆 ID(来自 read_memories 的输出)。
reason: 删除原因,说明为什么要删除这条记忆。
"""
@tool
@_require_mm
def merge_memories(id1: str, id2: str, content: str, section: str, reason: str) -> str:
"""将两条相似记忆合并为一条,id1 保留、id2 被删除,同时保留两者的修改历史。
当两条记忆描述同一事物(如分散的身份信息、同一首歌在不同分区的重复条目)
时使用,避免碎片化。
Args:
id1: 合并后保留的记忆 ID(主条目)。
id2: 合并后将被删除的记忆 ID(从条目)。
content: 合并后的完整记忆内容,涵盖两条原条目的信息。
section: 合并后的记忆分区。
reason: 合并原因,说明为什么这两条记忆需要合并。
"""
@tool
@_require_mm
def hit_memory(id: str) -> str:
"""标记一条记忆被引用/点击一次,增加其 hit 计数。
当记忆被引用(如被用于回答用户问题或被关联到对话)且没有对被引用记忆进行Update时调用此工具标记。
Args:
id: 要标记的记忆 ID(来自 read_memories 的输出)。
"""
@_require_mm是个卫语句,确保LongTermMemory在生命周期激活时MemoryManager已经作为模块依赖被注入该模块的顶部全局变量。此激活注入过程见后文。
有了封装mm的tool和全局mm引用,接下来的消费者搭建是一气呵成的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class MemoryConsumer:
"""后台消费一轮对话:WebSocket 通知 → CRUD Agent → memory.yaml 写入。"""
def __init__(self, llm: BaseChatModel | None) -> None:
self._llm = llm
async def consume(
self,
session_id: str | None,
turn_id: str,
turn_messages: list[dict[str, str]],
) -> None:
"""消费一轮对话。非阻塞——Agent 执行在内部 await。"""
try:
# 展开记忆到内存
items = _current_mm.show()
messages_text = _format_messages(turn_messages)
if items:
system_prompt = UPDATE_SYSTEM
user_prompt = f"## 新一轮对话\n{messages_text}"
else:
system_prompt = COLD_START_SYSTEM
user_prompt = messages_text
now = datetime.now()
user_prompt += 含有星期的当前时间字符串
crud_tools = [
create_memory, read_memories, update_memory,
delete_memory, merge_memories, hit_memory,
]
agent = create_agent(
model=self._llm,
tools=crud_tools,
system_prompt=system_prompt,
checkpointer=MemorySaver(),
)
callbacks: list[MemoryToolCallback]
await agent.ainvoke(
{"messages": [HumanMessage(content=user_prompt)]},
config={
"configurable": {"thread_id": "ltm-consumer"},
"callbacks": callbacks,
},
)
except Exception as e:
finally:
逐行删除了调试信息和前端推送两种副作用
我们最后暴露什么
1
2
3
set_current_mm(self._mm)
consumer = MemoryConsumer(get_default_llm())
await consumer.consume(session_id, turn_id, turn_messages)
一个依赖注入函数、一个构造函数和一个异步业务函数。非常简洁。依赖注入函数注入MemoryManager,构造函数需传入LLM,业务函数负责将信息写入指定mm的硬盘介质。
3. 长期记忆异步消费者管线
如上文所说,我们需要一条异步管线来让消费者业务函数在上面运行。LongTermMemory类负责搭建这条管线,并提供一切外部调用长期记忆功能所需的接口。
基于异步队列的消费者管线是异步编程界老少咸宜的知名话题。我们是这样实现的:
- 使用
None哨兵值的毒丸模式 - 队列传递需传入的消息和会话id等元数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 进行依赖注入和队列搭建,启动消费者监听协程
def start(self) -> None:
"""创建 asyncio.Queue、注入 _current_mm 并启动后台消费者协程。
必须在运行中的事件循环内调用。
内部通过 get_default_llm() 获取 LLM,无需外部传入。
"""
set_current_mm(self._mm)
self._queue = asyncio.Queue()
self._consumer_task = asyncio.create_task(self._consumer_loop())
# 哨兵值模式,使用None
async def _consumer_loop(self) -> None:
"""后台消费者协程:从队列取消息,交给 MemoryConsumer 处理。"""
consumer = MemoryConsumer(get_default_llm())
while True:
item = await self._queue.get()
if item is None:
break
session_id, turn_id, turn_messages = item
await consumer.consume(session_id, turn_id, turn_messages)
async def send_history(
self,
turn_messages: list[dict[str, str]],
session_id: str | None = None,
turn_id: str | None = None,
) -> None:
"""生产者:将本轮对话消息放入队列(非阻塞)。
可附带 session_id 和 turn_id,供后台消费者关联到前端对话轮次。
"""
# 好多卫语句
await self._queue.put((session_id, turn_id, list(turn_messages)))
# 好多错误处理逻辑
async def stop(self) -> None:
"""发送 None 哨兵并等待消费者排空队列。"""
if self._queue is not None:
await self._queue.put(None)
await self._consumer_task
self._queue = None
self._consumer_task = None
这就是LTM内部关于异步管线的所有底层设计。在此之上,我们还设计了async def send_history_from_session等顶层封装接口。在LTM的生命周期之内,调用发送对话历史相关方法,就能无阻塞地让后台Agent处理会话并写入长期记忆。
4. 长期记忆上行链路
上行链路是湾流V5区别于湾流V4的重要更新点。Sonetto不再接受关于用户的全量长期记忆,而是由上行链路筛选与用户输入有关的记忆后再进行注入。这使得长期记忆的上下文占用量降低到了V4的百分之五左右(目测)。
我们尝试了三种长期记忆条目筛选策略:
- LLM。有时间和经济成本。但是泛化能力和灵性极佳。
- BM25。非常快,不用钱。但是非常笨。
- RAG高维向量检索增强生成技术。本人在RAG方面的技术奇差无比,不知怎的成功结合了BM25的愚蠢和LLM的缓慢。遂放弃。
湾流V5的代码库中默认使用LLM,BM25作为可用策略保留。可以通过LTM文件里的一个枚举进行替换。表层接口如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_related_memory_from(
self, prompt: str, mode: RetrievalMode = RetrievalMode.LLM
) -> list[dict[str, str]]:
"""根据查询提示检索相关记忆条目。
通过 ``mode`` 参数选择检索策略:
- ``RetrievalMode.LLM`` → :meth:`_retrieve_llm`(LLM 语义检索)
- ``RetrievalMode.MECHANICAL`` → :meth:`_retrieve_mechanical`(BM25 机械检索)
两种路径的返回格式均为 ``[{id, description, theme}, ...]``。
Args:
prompt: 用户查询文本。
mode: 检索模式,默认 LLM 语义检索。
"""
湾流V5默认启用LLM 语义检索,具体的实现委托给了一个独立的 LLMRetriever 类。这个类的职责非常单一,接收查询文本和长期记忆库的全部记忆,交给 LLM 筛选,返回相关条目。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class LLMRetriever:
"""LLM 语义检索器:将全量记忆注入 LLM,由 LLM 判断相关条目。
与 :class:`~api.memory.mechanical_retriever.MechanicalRetriever` 对称,
一个靠语义理解,一个靠纯机械匹配。
用法::
retriever = LLMRetriever(mm)
results = retriever.retrieve("塔罗牌重构")
Args:
memory_manager: 已初始化的 MemoryManager 实例,用于读取全量记忆。
"""
def __init__(self, memory_manager: BaseMemoryManager) -> None:
self._mm = memory_manager
def retrieve(self, prompt: str) -> list[dict[str, str]]:
"""根据查询提示检索相关记忆条目,返回 ``{id, description, theme}`` 列表。
Args:
prompt: 用户查询文本。
Returns:
相关记忆条目列表,按相关度降序排列。
若无相关记忆或 LLM 不可用,返回空列表。
"""
if get_default_llm() is None:
return []
items = self._mm.show()
if not items:
return []
# 格式化为带列表序号的编号列表,供 LLM 引用
lines = []
for i, item in enumerate(items):
lines.append(f"[{i}] ({item['theme']}) {item['description']}")
memory_text = "\n".join(lines)
user_prompt = f"## 记忆库\n{memory_text}\n\n## 查询要求\n{prompt}"
response = get_default_llm().invoke(
[SystemMessage(content=FIND_RELATED_MEMORY.strip()), HumanMessage(content=user_prompt)],
config=RunnableConfig(callbacks=[]),
)
try:
indices = json.loads(response.content)
if isinstance(indices, list):
return [
{"id": items[i]["id"], "description": items[i]["description"], "theme": items[i]["theme"]}
for i in indices if 0 <= i < len(items)
]
except (json.JSONDecodeError, TypeError, IndexError):
pass
return []
如上,我们把 mm.show() 返回的条目列表加上编号和分区信息,拼接成一段文本,直接推向LLM,让LLM 自己判断哪些相关。LLM 返回一个 JSON 数组,里面是索引编号,调用方再根据编号从原列表中取出对应的条目。
这种方案有一个 BM25 和 RAG 都无法替代的优势:LLM能够真正读懂记忆的含义。我们用经济成本和时间成本换来了让Sonetto灵性减弱最小的方案,以此达成减少Sonetto上下文中记忆量的目的。
提示词的设计思路
对于裸LLM来说,我们曾经热爱过又抛弃的提示词工程要被重新考虑,整个 LLM 检索器的灵魂在于 FIND_RELATED_MEMORY 这条系统提示词。它的设计经历了多次迭代,最终形成了四个方向的匹配标准:
- 字面匹配 — 记忆文本直接包含查询的关键词。最朴素的匹配方式。
- 语义关联 — 用词不同但概念相通。比如用户说“重构”,记忆里记住的是“修改”也需要命中。
- 主题一致 — 利用记忆的分区(theme)信息辅助判断。查询关于编程时,优先关注“项目”分区;查询关于口味偏好时,留意“品味”分区。分区相当于给 LLM 指了一条捷径。
- 时间线索 — 当查询中出现“最近”“上周”“7月”这类时间限定词时,优先匹配该时间窗口内的记忆。这在处理时效性强的查询(如“最近在忙什么”)时非常关键。
四个维度将被LLM综合估计,而不是依赖单一匹配信号。这是 LLM 方案区别于 BM25 纯词频匹配的核心差异。
提示词还加入了三条处理规则来约束 LLM 的行为边界:
- 宽查询降级:当查询比较宽泛(如”项目”“最近”),优先匹配近期记录和高 hit 数的条目。hit 数是记忆被引用的次数,某种程度上代表了记忆的”重要程度”。
- 空结果:没有任何相关内容就返回空数组
[],不硬凑。这点很重要——LLM 有很强的”讨好倾向”,容易编造看起来合理的关联。显式要求允许空结果能有效降低幻觉。 - 降序排列:结果按相关度从高到低排序,调用方可以直接取前 N 条。
Few-Shot 示例的选型逻辑
提示词末尾附带了三个示例,每个示例都有完整的推理过程:
1
2
3
4
5
6
示例1:查询"塔罗牌重构"
[0] (项目) 塔罗牌工具是Sonetto项目的人文性质彩蛋,Miso正在重构。
[1] (瞬间) 2026年7月11日,Miso让Sonetto分析塔罗牌工具代码...
[2] (品味) Miso 喜欢夏天和猫。
[3] (瞬间) 2026年7月12日凌晨,Miso抽塔罗牌问期末考运势...
相关条目:[0, 1, 3]
三个示例覆盖了三种典型场景。命中(示例1,多个条目不同程度相关)、时间敏感条目(示例2)和跨分区排除(示例3,项目类记忆不应混入选品类的查询)。每个示例末尾都附带了推理过程,这比单纯给输入输出对有效得多。我们希望LLM 能从中学会“为什么这样判断”,而不仅仅是“这样判断”。
一切提示词就位之后,我们只需要向Transformer和自注意力机制祈祷就好了,祈祷到有返回值为止。
输出解析与容错
LLM 输出 JSON 未必是所有厂商的共通逻辑。LLMRetriever.retrieve() 的末尾有一段 try-except:
1
2
3
4
5
6
7
try:
indices = json.loads(response.content)
if isinstance(indices, list):
return [...]
except (json.JSONDecodeError, TypeError, IndexError):
pass
return []
捕获了三种异常:JSON 解析失败(LLM 偶尔会在 JSON 外面生成多余文字)、类型错误(返回了 null 而不是数组)、索引越界(返回了超出范围的编号)。任何一种情况都静默降级为空结果。
这是有意为之的设计决策:记忆检索是辅助性的,对话流畅度优先级更高。检索不到记忆最多让 Sonetto 少一些亲切感,但不会影响她正常回答用户的问题。如果 LLM 检索器抛出了异常,整个对话就会中断,这得不偿失。
5. 为长期记忆上下行而特别优化的Graph
Sonetto的核心Graph一直以来都是一个build_react_agent使用默认的ReAct图生成的,仅包含两个有效节点。湾流V5针对长期记忆读写优化了核心Graph,使记忆的读写成为单独的节点。
1
2
↓← [tools] ←↑
START ──→ [retrieve_memory] ──→ [agent] ──→ [ltm_write] ──→ END
我们希望这种设计增强核心图对象的节点职责分离和可审计性。
6. 结语
湾流冲刷着岸边的礁石,在余晖的天光下漫无边际的离开海岸。
这就是Sonetto。她的记忆里装满了与这个世界交互的一切,当然,更多的是你。