大模型持久化记忆架构:用 Go 构建支持滑动窗口与层次化检索的记忆引擎

cover

一、话还没说几句,模型就开始“老年痴呆”:长对话记忆的 Token 与遗忘痛点

在构建智能助理、长期伴随式 Agent 或客服系统的实战中,长会话的上下文记忆管理几乎是所有研发团队必须翻越的一座大山。

很多刚接触大模型开发的技术人员,在处理多轮对话时,采用的姿势通常简单粗暴:把历史上的所有聊天记录(Message List)全部存在数据库里,每次用户发送新问题,就把历史对话全部读出来,打包拼接进 Prompt 里发送给大模型。

这种把所有历史聊天记录一股脑塞给模型的做法,在上线几天后就会暴露出两大工程灾难:

  1. Token 费用呈指数级暴涨:大模型的计费方式是按每次交互的 Prompt 长度计算。如果用户的会话持续了上百轮,每次交互都需要把历史几万字全部上传,单次提问的成本会从几分钱暴涨到几块钱。
  2. 严重的遗忘与“中间迷失”:很多研究表明,大模型具有“中间迷失(Lost in the Middle)”的特性。当上下文窗口极长时,模型很容易忽略掉堆积在中间的历史内容。你可能在第 3 轮告诉过它“我的女儿叫朵朵,今年 3 岁”,到了第 50 轮问它“我女儿多大”时,它早已在一大堆无用废话中迷失,开始胡言乱语。

见证奇迹的时刻往往不是用户对大模型的博古通今感到惊叹,而是当我们看到月末的 API 账单和低劣的回答准确率时,欲哭无泪。

大厂的常用解法是用大型的分布式图数据库和外置记忆库来搞,但对我们小研发团队来说,架构复杂度高,维护成本吃不消。最务实的解法是用 Go 在本地构建一个近期(滑动窗口)、中期(Key-Value 实体属性)与远期(向量检索)三层结构相互协作的层次化记忆(Hierarchical Memory)引擎


二、从滑动窗口到向量空间的阶梯:三层记忆治理的底层机制

要在 Go 中管理长对话记忆,我们必须改变“全量堆砌”的陈旧思维,将人类的记忆机制——近期缓存、事实事实提取以及联想检索——引入到大模型工程中。

一个层次化记忆引擎,其底层数据流转和架构由三层记忆库及一个上下文装配器(Assembler)协同运转。

下面是该层次化记忆引擎的 Mermaid 原理架构图:

flowchart TD
    A[用户输入新问题 Query] --> B[记忆引擎调度器]
    
    subgraph 三层记忆架构
        B --> C[短期记忆: 最近 N 轮滑动窗口]
        B --> D[中期记忆: KV 属性提取库]
        B --> E[长期记忆: 本地向量检索库]
    end
    
    C -->|获取最近上下文| F[上下文装配器]
    D -->|获取匹配 Key-Value 属性| F
    E -->|通过相似度召回相关片段| F
    
    F -->|组装 System + History Prompt| G[大模型 API 调用]
    G --> H[大模型返回响应 Response]
    
    H --> I[记忆更新管道]
    I -->|滑动移出旧对话| E
    I -->|异步提取新实体属性| D
    I -->|追加最新一轮对话| C

这三层记忆的运行逻辑是:

  1. 近期记忆(Short-term Memory):采用严格的滑动窗口(Sliding Window)。为了保障大模型对当前话题有最精确的感知,我们只在 Prompt 中保留最近 N 轮(如最近 3 轮)的原始对话记录。超出 N 轮的历史对话自动从滑动窗口中被移出。
  2. 中期记忆/实体记忆(Entity Memory):这属于结构化的事实库。当对话流转时,引擎会通过异步的轻量大模型或规则引擎,提取对话中关于用户的核心属性信息,并以简单的 Key-Value(如 user_child_name: 朵朵, user_child_age: 3)形式保存在本地 KV 存储或 Redis 中。每次请求时,我们把这部分提取出的静态实体卡片直接注入到 System Prompt 头部。
  3. 长期记忆/语义联想记忆(Long-term Memory):那些因为滑动窗口被移出的老对话,我们并不是直接丢弃。我们会在后台将它们切片(Chunk)并生成 Embedding 向量,写入本地的内存向量库中。当用户发起新问题时,我们拿着当前的问题去长期记忆库里进行向量检索。如果发现用户提到了“前几天我改过的那个网关超时配置”,向量检索就会把第 30 轮关于网关超时修改的历史片段召回出来,拼进上下文。

通过这三层阶梯状的记忆分布,我们成功地将上传给模型的 Token 长度保持在了一个极低的常数范围,且模型永远不会遗忘核心的实体事实。


三、用 Go 实现支持近期、实体与语义匹配的层次化记忆引擎

下面的代码实现了一个结构紧凑但功能完备的层次化记忆引擎。它支持了双向链表实现的滑动窗口、内存级 KV 实体库以及线性余弦相似度匹配的长期向量记忆检索。

package memory

import (
	"context"
	"fmt"
	"math"
	"sync"
	"time"
)

// Message 代表单条对话记录
type Message struct {
	Role    string // "user" 或 "assistant"
	Content string
}

// MemoryItem 存储在长期记忆向量库中的实体
type MemoryItem struct {
	Content   string
	Embedding []float32
	Timestamp time.Time
}

// HierarchicalMemory 层次化记忆引擎核心结构
type HierarchicalMemory struct {
	mu           sync.RWMutex
	sessionID    string
	shortTermCap int                         // 短期记忆窗口最大轮数(1轮=1user+1assistant)
	shortTerm    []Message                   // 短期记忆列表
	entityKV     map[string]string           // 中期实体事实库
	longTerm     []MemoryItem                // 长期语义记忆向量列表
	embedFn      func(ctx context.Context, text string) ([]float32, error)
}

func NewHierarchicalMemory(sessionID string, shortTermCap int, embedFn func(ctx context.Context, text string) ([]float32, error)) *HierarchicalMemory {
	return &HierarchicalMemory{
		sessionID:    sessionID,
		shortTermCap: shortTermCap,
		shortTerm:    make([]Message, 0, shortTermCap*2),
		entityKV:     make(map[string]string),
		longTerm:     make([]MemoryItem, 0),
		embedFn:      embedFn,
	}
}

// AppendChat 往短期记忆追加对话,并自动把超出窗口的部分归档到长期记忆
func (m *HierarchicalMemory) AppendChat(ctx context.Context, userMsg, assistantMsg string) {
	m.mu.Lock()
	defer m.mu.Unlock()

	// 1. 追加到短期记忆
	m.shortTerm = append(m.shortTerm, Message{Role: "user", Content: userMsg})
	m.shortTerm = append(m.shortTerm, Message{Role: "assistant", Content: assistantMsg})

	// 2. 检查短期记忆窗口是否溢出
	// 因为一轮包含 2 条消息,所以长度上限为 shortTermCap * 2
	if len(m.shortTerm) > m.shortTermCap*2 {
		// 移出最老的一轮(前两条)
		archivedMsgs := m.shortTerm[:2]
		m.shortTerm = m.shortTerm[2:]

		// 3. 异步将移出窗口的消息归档到长期向量记忆库,不阻塞当前聊天写入
		go m.archiveToLongTerm(archivedMsgs[0].Content + "\n" + archivedMsgs[1].Content)
	}
}

// SetEntity 维护中期的 Key-Value 静态事实事实库
func (m *HierarchicalMemory) SetEntity(key, value string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.entityKV[key] = value
}

// RetrieveContext 装配完整的上下文 Prompt
func (m *HierarchicalMemory) RetrieveContext(ctx context.Context, query string) (string, []Message, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()

	// 1. 组装中期记忆:将 KV 实体拼成一段事实描述,作为 System Prompt 指引
	entitySnippet := "已知事实:\n"
	if len(m.entityKV) == 0 {
		entitySnippet += "无已知事实背景。\n"
	} else {
		for k, v := range m.entityKV {
			entitySnippet += fmt.Sprintf("- 用户 %s: %s\n", k, v)
		}
	}

	// 2. 生成 Query 的向量以检索长期语义记忆
	queryEmbed, err := m.embedFn(ctx, query)
	if err != nil {
		return "", nil, fmt.Errorf("生成长期记忆检索向量失败: %w", err)
	}

	// 3. 在长期记忆中进行向量空间余弦相似度匹配,寻找相关的历史线索
	var bestSnippet string
	var maxSimilarity float32 = -1.0
	const matchThreshold float32 = 0.82 // 语义记忆匹配相似度门槛

	for _, item := range m.longTerm {
		sim, err := cosineSim(queryEmbed, item.Embedding)
		if err != nil {
			continue
		}
		if sim > maxSimilarity && sim >= matchThreshold {
			maxSimilarity = sim
			bestSnippet = item.Content
		}
	}

	// 4. 将长期语义记忆融入 System 背景中
	systemPrompt := entitySnippet
	if bestSnippet != "" {
		systemPrompt += fmt.Sprintf("\n相关的历史对话记忆参考:\n%s\n", bestSnippet)
	}

	// 5. 返回装配好的 SystemPrompt 及短期对话窗口列表
	return systemPrompt, m.shortTerm, nil
}

func (m *HierarchicalMemory) archiveToLongTerm(text string) {
	// 生成向量并存入长期内存向量列表中
	// 这里为了简单,使用 context.Background()
	embed, err := m.embedFn(context.Background(), text)
	if err != nil {
		return
	}

	m.mu.Lock()
	defer m.mu.Unlock()
	m.longTerm = append(m.longTerm, MemoryItem{
		Content:   text,
		Embedding: embed,
		Timestamp: time.Now(),
	})
}

func cosineSim(a, b []float32) (float32, error) {
	if len(a) != len(b) {
		return 0, fmt.Errorf("vector dim mismatch")
	}
	var dotProduct, normA, normB float64
	for i := 0; i < len(a); i++ {
		dotProduct += float64(a[i] * b[i])
		normA += float64(a[i] * a[i])
		normB += float64(b[i] * b[i])
	}
	if normA == 0 || normB == 0 {
		return 0, nil
	}
	return float32(dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))), nil
}

关键代码剖析与设计细节:

  1. 归档异步协程化 (go m.archiveToLongTerm):当短期记忆滑动窗口溢出时,我们会把老的对话片段拿去生成 Embedding 向量。如果这个动作写在当前请求的主协程中,那么用户的提问就会卡在 Embedding API 的网络延迟上(约几百毫秒)。通过启动独立的 goroutine 异步生成向量并归档,保证了主交互通道的极致流畅。
  2. 读写锁的细粒度划分RetrieveContext 需要遍历长期记忆列表 m.longTerm 并生成检索结果,它通常只读。我们在这个方法上加了 RLock(读锁),而在 AppendChatSetEntity 上加写锁,避免了在高并发下多实例读取上下文时产生严重的读阻断。
  3. 分层记忆装配设计:在 RetrieveContext 中,我们将中期的实体 KV 库和召回的长期语义片段,统一压进 systemPrompt 字符串返回给上游。这种设计保证了上游业务代码无需关心底层记忆是如何分布和剔除的,仅需接收一个组装好的 Prompt 直接传给 LLM 即可。

四、语义噪声、提取延迟与一致性失效的架构折衷

层次化记忆引擎极大压缩了 Token 账单并缓解了遗忘,但我们必须对其在长周期执行中的技术折衷和局限有清晰的边界认知。

1. 语义记忆召回时的“指代不清”与“噪声干扰”

  • 向量检索是基于整体语义进行的。当用户问“那个怎么处理?”,由于 Query 太短,向量检索没有明确特征,往往会召回出一段风马牛不相及的第 15 轮对话(比如关于报销的讨论),从而将错误的证据强塞入 System Prompt,形成语义噪声,反而干扰了大模型的判断。
  • 妥协与应对:必须提高长期记忆的匹配相似度门槛(如设定在 0.82 以上),并且限制长期记忆召回的 Chunk 数量(如每次最多只拼入 1 个最相似的片段),防止垃圾上下文污染当前的会话焦点。

2. KV 事实提取的时机与“事实冲突”

  • 如何判断何时更新中期实体库(比如用户把“女儿今年 3 岁”改口为了“女儿 4 岁了”)?如果在每一轮对话中都派生一个“提取 Agent”去扫描并更新实体,会带来极高的额外 Token 成本和调用延迟。
  • 架构折衷:不要在同步请求中做实体提取。最划算的 ROI 是:在后台启动异步消费任务(如将对话内容写入 Go channel,由后台消费者隔几轮或者在会话挂起时才触发一次“事实审查提取”),并且在发现冲突时以最新发生的时间戳事实为准,进行 KV 覆写,保证事实的一致性。

五、总结

记忆是智能体(Agent)向生产演进的灵魂。将长历史聊天一股脑硬塞给大模型是低效且昂贵的。

用 Go 实现一个近期滑动窗口、中期 Key-Value 事实实体、远期向量语义关联的层次化记忆调度器,是当前大模型工程中极具性价比的落地手段。

在实际生产中上线本记忆引擎时,请务必关注以下两条落地策略:

  1. 多端会话的一致性存储(State Backend):内存级记忆库只适用于单机临时测试。对于生产多实例容器部署,中期 KV 事实实体应当存储在 Redis 中,长期语义向量记忆应当存储在带向量检索插件的关系数据库中(如 PostgreSQL pgvector),通过 sessionID 进行跨实例数据拉取。
  2. 记忆片段的时效退化(Time Decay):长期向量检索在相似度打分时,往往容易匹配到极古老的对话。在计算 Cosine Similarity 评分时,可以额外引入一个基于时间的指数衰减系数(Time Decay Ratio),让时间越近的记忆拥有更高的权重得分,使机器的联想更符合人类的“近因记忆规律”。

综上所述,构建近期、中期与远期协同的层次化记忆管理机制,能够在维持极低 Token 开销的前提下,显著增强大模型在长周期交互中的记忆稳定性与指代准确度。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐