湾流离岸——SonettoHere长期记忆系统"湾流"技术报告

SonettoHere长期记忆系统技术报告

Posted by Miso on July 28, 2026

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 这条系统提示词。它的设计经历了多次迭代,最终形成了四个方向的匹配标准:

  1. 字面匹配 — 记忆文本直接包含查询的关键词。最朴素的匹配方式。
  2. 语义关联 — 用词不同但概念相通。比如用户说“重构”,记忆里记住的是“修改”也需要命中。
  3. 主题一致 — 利用记忆的分区(theme)信息辅助判断。查询关于编程时,优先关注“项目”分区;查询关于口味偏好时,留意“品味”分区。分区相当于给 LLM 指了一条捷径。
  4. 时间线索 — 当查询中出现“最近”“上周”“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。她的记忆里装满了与这个世界交互的一切,当然,更多的是你。

欢迎使用SonettoHere工程