Memory 系统如何从 0 到 1 构建(企业级入门版

0 阅读5分钟

Bg

AI已经进入程序员很多日常中。过去一年,驻波从前端转到后端再转向 AI Infra,后面有空写文章时候将分享一些真实的企业级AI开发相关的知识(主播不买课),Runtime,评测,vLLM,这些都会聊到的。进入正题我们看向本集重点:“Memory系统初步构建”

ShowCase

User:“帮我出一个杭州旅游攻略。”
Output: "这是一份详细的杭州旅游攻略......(Conetxt1)"

---------- 一段时间之后 ---------
User:“上次我们聊天杭州游玩攻略再给我一下。”
Output: "我还记得呢,上次我们说的杭州旅游攻略......(Context1)"

为什么长对话的情况下AI貌似真有记忆,而且比人清楚的多,真实的一字不差的复述以前很久的内容。

行为拆解

第一次输入

这里我们先不分析Runtime对用户Query的操作,当作全部的Query不做加工直接给Memory服务,那么第一次对话。Memory系统接收到的是:

{
  "query": "杭州旅游攻略"
  "session_id": "session_uid"
}
由于当前没有历史Memory,Memory服务返回给Runtime:
{
  "items": [],
  "support_status": "EMPTY"
}

后面就是Runitme的自由发挥了。

Runtime服务给用户输出了Content内容这时候异步触发一下Memory系统的的Input服务,由Memory服务自己维护一个关键实体表。比如这里:将解析出来杭州 是实体;旅游攻略 是主题;本次对话是来源; 具体攻略正文作为 Evidence 或 Conversation Content 与实体分开保存。

{
  "conversation_id": "conv_1001",
  "entities": [
    {
      "entity_id": 10086,
      "type": "PLACE",
      "name": "杭州市"
    }
  ],
  "topics": [
    {
      "topic_id": "TRAVEL_GUIDE",
      "name": "旅游攻略"
    }
  ],
  "created_at": "2026-11-01T10:00:00+08:00"
}

到这里,我们完成了第一条可检索的历史对话记录。它是否进一步沉淀为长期 Memory,要根据内容类型和记忆策略决定。

Conversation Content 和 Memory 的区别

第一次对话中,系统至少保存三类数据:

  1. 原始消息,作为 Source;
  2. 杭州、旅游攻略等结构化索引字段;
  3. 助手生成的完整攻略,作为 Conversation Content。

这条记录可以被后续查询找回,但不一定代表用户形成了长期偏好。只有“我喜欢安静的酒店”这类用户事实、偏好或要求,才更适合沉淀为长期 Memory。

第二次输入

{
  "query": "上次我们聊天杭州旅游攻略再给我一下",
  "session_id": "session_uid"
}

Memory 意图识别模型解析出:

{
  "mentions": [
    {
      "text": "杭州",
      "type": "PLACE"
    }
  ],
  "topics": [
    {
      "text": "游玩攻略",
      "type": "TRAVEL_GUIDE"
    }
  ],
  "temporal_intent": "PREVIOUS_RELATED",
  "source_type": "CONVERSATION"
}

这里面我们需要注意一个点: 服务先对杭州做实体链接。如果实体库中已经存在杭州,则复用已有的 entity_id;如果不存在,先保存为未解析实体,不立即让模型创建正式主键。

游玩攻略表示一个主题,但是上次不是一个需要生成主键的实体,它应该被解析为:查找当前对话之前,最近一次满足其他条件的相关 ConversationGroup

实体获取模型

我们发现的是: 杭州这个主体上次我们直接是在,库里面有能找到对应的entity_id的实体所以我们能直接索引到内容的(检查一下是否有就行了,不必要全部拉出来)。但是游玩攻略这个词我们没有啊,如果别名表没有命中,就把游玩攻略和候选主题的名称、描述、示例分别转换成向量,使用向量检索召回最相关的候选主题。(库表由可产品、算法同学提供

topic
------------------------------------------------
topic_id       canonical_name
TRAVEL_GUIDE   旅游攻略
TRAVEL_DIARY   旅行游记
HOTEL_BOOKING  酒店预订

topic_alias
------------------------------------------------
alias          topic_id
旅游攻略       TRAVEL_GUIDE
游玩攻略       TRAVEL_GUIDE
旅行攻略       TRAVEL_GUIDE
出行指南       TRAVEL_GUIDE
游记           TRAVEL_DIARY
旅行记录       TRAVEL_DIARY

假设找到了就直接退出,如果没有找找到,可以先根据主题描述检索,通过 BM25 或 embedding 得到:

输入:游玩攻略
输出
[  {    "topic_id": "TRAVEL_GUIDE",    "desc": "描述:介绍目的地的游玩、景点、路线和出行建议"    "final_score": 0.91  },  {    "topic_id": "ITINERARY",    "desc": "描述:按日期和时间安排具体旅行行程"    "final_score": 0.76  },  {    "topic_id": "TRAVEL_DIARY",    "desc": "记录已经发生的旅行经历"    "final_score": 0.62  }]

注意:这些分数只是“候选相关度”,还不是最终确认。 中间的final_score确认可以使用多条件评分:alias_score,context_score等等 补充:这里的候选主题都是我们需要提前写入关系数据库中的内容,eg:

topic_id          name          description
TRAVEL_GUIDE      旅游攻略      介绍目的地怎么玩,包括景点、路线和出行建议
ITINERARY         行程规划      按日期和时间安排具体旅行行程
TRAVEL_DIARY      旅行游记      记录已经发生的旅行经历

模型输出之后我们的可以在设置规则final_score >= 0.90且Top1 - Top2 >= 0.10才自动归一化。

比如TRAVEL_GUIDE 0.91,ITINERARY 0.89 就不能直接确定,因为两个主题太接近。应该: 保留两个候选;继续结合上下文去找内容。

这一层走过我们肯定能找到我们的id了,所以我们大致的流程就是:

文本标准化
  ↓
精确别名匹配
  ↓
规则 / 同义词匹配
  ↓
关键词和 BM25 匹配
  ↓
Embedding 候选召回
  ↓
分类模型在候选主题中选择
  ↓
低置信度则不归一化

最终查询会变成上次的杭州游玩攻略再给我一下=解析=>

{
  "entity_id": 10086,
  "topic_id": "TRAVEL_GUIDE",
  "time_intent": "PREVIOUS_RELATED"
}

所以最终不是查“旅游攻略”这四个字,而是查:

杭州实体 + TRAVEL_GUIDE 规范主题 + 之前的对话 + 当前用户

这个时候我们便完成了一次Memory系统的基础查询Output动作。

Final

其实核心原则就是原始表达保留用于证据和展示,规范主题用于检索和聚合,模型只负责在已有主题集合中做分类,不能临时创造主题主键。

流程ER图

flowchart TD
    A[&#34;用户首次输入<br/>帮我出一个杭州旅游攻略&#34;] --> B[&#34;Runtime 主模型&#34;]
    B --> C[&#34;memory_recall<br/>POST /v1/memory/recall<br/>query + user_id + business_id + session_id&#34;]

    C --> D{&#34;查询结果&#34;}
    D -->|EMPTY| E[&#34;Memory 返回<br/>support_status = EMPTY&#34;]
    E --> F[&#34;Runtime 正常生成回答&#34;]
    F --> G[&#34;异步提交对话<br/>POST /v1/memory/ingestions&#34;]

    G --> G1[&#34;请求字段<br/>request_id<br/>business_id<br/>user_id<br/>session_id<br/>messages[]&#34;]
    G1 --> H[&#34;PM API 保存原始消息 Source&#34;]
    H --> I[&#34;创建异步构建任务<br/>operation_id&#34;]
    I --> J[&#34;PM Worker 主动领取任务<br/>ClaimReadyWork&#34;]

    J --> K[&#34;解析并结构化&#34;]
    K --> K1[&#34;Entity<br/>杭州&#34;]
    K --> K2[&#34;Topic<br/>旅游攻略&#34;]
    K --> K3[&#34;ConversationGroup<br/>本次历史对话&#34;]
    K --> K4[&#34;Evidence<br/>原始消息与回答正文&#34;]

    K1 --> L[&#34;实体解析<br/>entity_alias / entity&#34;]
    K2 --> M[&#34;主题解析<br/>topic_alias / topic_vector&#34;]
    K3 --> N[&#34;保存会话索引&#34;]
    K4 --> O[&#34;保存来源与版本&#34;]

    L --> P[&#34;MySQL 正式数据&#34;]
    M --> P
    N --> P
    O --> P

    P --> Q[&#34;建立检索投影&#34;]
    Q --> Q1[&#34;BM25 / 倒排索引&#34;]
    Q --> Q2[&#34;Milvus 向量索引&#34;]
    Q --> Q3[&#34;RedKV Profile 缓存&#34;]

    R[&#34;一段时间后<br/>上次我们聊天杭州游玩攻略再给我一下&#34;] --> S[&#34;Runtime 主模型&#34;]
    S --> T[&#34;memory_recall<br/>POST /v1/memory/recall&#34;]
    T --> T1[&#34;query<br/>user_id<br/>business_id<br/>session_id<br/>recent_messages&#34;]

    T1 --> U[&#34;Memory Query Resolver&#34;]
    U --> U1[&#34;文本标准化&#34;]
    U1 --> U2[&#34;解析 Entity<br/>杭州&#34;]
    U1 --> U3[&#34;解析 Topic<br/>游玩攻略&#34;]
    U1 --> U4[&#34;解析时间意图<br/>PREVIOUS_RELATED&#34;]

    U2 --> V[&#34;实体链接&#34;]
    V --> V1{&#34;entity_alias 精确匹配&#34;}
    V1 -->|命中| V2[&#34;entity_id = 10086&#34;]
    V1 -->|未命中| V3[&#34;BM25 / Embedding<br/>召回候选实体&#34;]
    V3 --> V4[&#34;阈值 + Top1/Top2 Margin<br/>或 UNRESOLVED&#34;]

    U3 --> W[&#34;主题归一化&#34;]
    W --> W1{&#34;topic_alias 精确匹配&#34;}
    W1 -->|命中| W2[&#34;topic_id = TRAVEL_GUIDE&#34;]
    W1 -->|未命中| W3[&#34;BM25 + BGE 向量召回&#34;]
    W3 --> W4[&#34;候选主题重排<br/>只允许选择已有 topic_id&#34;]

    U4 --> X[&#34;时间过滤条件<br/>created_at < 当前时间<br/>ORDER BY created_at DESC&#34;]

    V2 --> Y[&#34;结构化检索&#34;]
    V4 --> Y
    W2 --> Y
    W4 --> Y
    X --> Y

    Y --> Y1[&#34;SQL / 倒排索引查询<br/>entity_id + topic_id + user_id&#34;]
    Y1 --> Y2[&#34;权限过滤&#34;]
    Y2 --> Y3[&#34;删除状态过滤&#34;]
    Y3 --> Y4[&#34;版本与 Evidence 校验&#34;]
    Y4 --> Z[&#34;返回 RecallBundle&#34;]

    Z --> Z1[&#34;query_id<br/>bundle_id<br/>execution_status<br/>support_status<br/>conversation_refs<br/>memory items<br/>evidence&#34;]
    Z1 --> AA[&#34;Runtime 主模型组织最终回答&#34;]