拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建命令行AI助手:记忆系统设计与向量检索实战

从零构建命令行AI助手:记忆系统设计与向量检索实战

从第1天开始,我每天逼自己产出一个能跑的东西,不是看视频、不是收藏教程,而是真的把代码写出来、跑起来、摔跟头、再爬起来。今天是第14天,我说实话,心里是有点悬的。前13天学的都是零散的知识块:Python语法、API调用、数据清洗、JSON处理、正则表达式、基本的文件读写、爬虫小demo、用django写个带页面展示的小后端……每一样都像拼图碎片,但一直没有一块完整的拼图把碎片接起来。今天这个综合项目——命令行AI助手v2(加记忆系统),就是逼我把过去两周的碎片一次性焊死。如果你也在自学AI、或者从前端准备转去做AI应用开发,这篇文章应该能给你一个非常具体的参考:一个依赖纯命令行交互、有短期记忆和长期记忆的AI助手,是怎么一点点搭起来的,哪些坑是真的会卡你三个小时的。

先说结论:项目做完了,跑通了,对话体验比v1有了质的提升。最明显的差别是什么?v1里的AI每次启动都是“失忆人士”,你说过的话它一句不记得,连“我刚才让你记住的项目叫小明”这种基础操作都做不到。而v2加了这个记忆系统之后,它终于像一个勉强有点人样的助理了——你一周前提过的一件事,它会主动带到今天的对话里。这篇文章不吹概念,核心就讲三件事:整个v2项目的架构怎么拆、记忆系统到底是怎么模型化的、以及我实际写完代码跑起来后踩到的那些坑。

1. 为什么偏偏在第14天做这个综合项目

1.1 前13天的技能树盘点

先花点时间梳理一下前13天我具体学了什么,这样你就能理解为什么第14天的项目刚好能踩中“综合”这两个字。

前3天是Python打底,包括数据类型、函数、类、装饰器这些语言基础。第4天开始接触API调用,用requests库去请求公开接口,把JSON数据解析成结构化内容。第5天到第6天重点做了数据清洗和文本处理,正则表达式、字符串切片、格式化输出这些,回头看不复杂,但没这些基础后面根本走不动。第7天和第8天专攻文件读写和SQLite轻量级数据库,把之前抓下来的数据落库。第9天和第10天写了两个练手项目:一个爬虫小程序和一个基于Flask的小型API服务。第11天玩了一下Python的异步编程——asyncio和aiohttp。第12天和第13天开始接触大模型接口调用,把参数、token、system prompt这些核心概念理清楚了。

看到这里你可能发现了,这13天几乎把Python后端开发的基础链路摸了一遍:网络请求、数据解析、存储、异步处理,最后落到大模型对话上。今天这个命令行AI助手v2,本质上就是把这条链路上的每个环节组合成一条完整的产品链路。

1.2 为什么选择“命令行AI助手”而不是做网页应用

这个决策是我反复斟酌过的。我原本可以继续做前端的老本行,把AI助手做成一个网页界面,用React或者Vue套起来,视觉上肯定比命令行好看一百倍。但我再三权衡之后还是选了命令行,原因有三条。

第一,命令行是衡量一个AI工具“好不好用”最苛刻的测试场。没有按钮、没有下拉框、没有友好的错误提示,用户面对的就是一个闪烁的光标,交互的全部就是输入一句话、拿到一堆文字。如果你的逻辑设计得不够顺畅,哪怕只是“开机时没有加载配置文件”这种细节,体验都会立刻变得很糟糕。反过来,命令行跑得顺畅了,说明底层逻辑是扎实的。

第二,命令行工具的开发效率高,适合14天这个时间节点做“收网”。网页应用还要考虑路由、样式、状态管理、前后端联调,这些我都熟悉,但会分散精力。用Python写一个终端交互程序,主循环加几个分支,核心业务逻辑全部集中在助手本体和记忆模块上。今天的目标是“把记忆系统做好”,选择命令行能让所有精力都砸在这件事上。

第三,也是很有意思的一点:大量真实的AI辅助工具在早期形态就是CLI工具。你看很多开发者的工作流里,AI代码补全、AI命令行解释器、AI git提交信息生成器,全都是跑在终端里的。CLI是AI最快触达开发者日常的路径之一,做这个方向的工具,至少在未来很长一段时间里都有真实需求。

1.3 记忆系统为什么成为v2的核心目标

我手里的v1是什么状态?就是最简单的“用户输入一句话,转发给大模型,把大模型的回复打印出来”。它最大的缺陷在于:无状态。无状态意味着什么?你问它“我上周让你记的那个客户的名字是什么”,它只能一脸茫然地说“抱歉,我无法记住之前的对话”。这在一对一闲聊场景里还能忍,但如果真把它当作生产力工具,这就是不可接受的短板。

给AI助手加记忆系统,本质上是让它从“每次遇见你都是陌生人”进化成“和老朋友聊天”的过程。我第13天学到的核心概念——system prompt、上下文窗口、多轮对话——都是记忆系统的地基。而记忆系统本身要解决的问题,从我实际使用者的角度来看,一共四个子问题:

  1. 记什么?对话里什么东西值得被沉淀下来。
  2. 存哪里?用什么样的数据结构和存储介质来保存记忆。
  3. 怎么捞?对话当下,把哪些记忆重新拿回上下文里。
  4. 什么时候忘?记忆不会无限增长,得有衰退和重要性排序策略。

这四个问题就是今天这篇博文的骨架。后面的每一节,都是围绕这些问题来展开的。

2. 命令行AI助手v2的整体架构:从无状态到有记忆

2.1 v1的简单回顾:一个单轮问答的“无脑对话器”

v1的代码结构和逻辑,说穿了就三层:

读取终端输入 → 拼接大模型调用参数 → 打印AI回复。

每轮对话之间没有任何共享状态。每次启动程序,所有信息归零。当时写v1的时候我没有太在意这个问题,因为目标就是先跑通API链路,把“前端能调通大模型接口”这一关过了。但跑通之后我立刻意识到,如果只是这样,那跟直接在网页里打开大模型聊天窗口有什么区别?我为什么要多花这些时间做成命令行工具?答案就是:命令行工具和网页聊天窗口的本质差异恰恰在于“工具”二字——工具需要有状态,需要能配置,需要能积累信息。

2.2 v2的模块拆解:三大层的职责边界

v2我把它拆成了三个清晰的分层,这也是前端开发经验带给我的习惯——组件的边界越清晰,后续维护成本越低。三个层的职责分别如下:

模块核心职责关键技术点
CLI交互层接收用户输入、渲染输出、管理命令分发循环读取、命令解析(普通对话/系统指令)
AI调用层组装请求参数、调用大模型接口、解析返回值系统提示词拼接、token上限控制、结构化输出解析
记忆系统层记忆的写入、读取、衰减、删除向量化、相似度检索、时序权重

这三层之间是单向依赖的关系:CLI层调用AI调用层,AI调用层依赖记忆系统层提供历史记忆,记忆系统层自己不关心上层是命令行还是网页。这样设计是个非常关键的决定:将来如果我打算给这个助手加上Web界面或者接入聊天软件,只需要重写CLI层,AI调用层和记忆系统层完全可以复用。

2.3 数据流:一次对话在v2里经历了什么

这一段是理解整个项目的心脏,我直接画一条完整的调用链路出来。假设我现在在终端敲了一句话:“我下周要参加Python面试,帮我简单整理一下复习重点。”

第一步,CLI交互层把这一行字接住,判断是普通对话还是系统指令。这里如果以“/”开头,会被识别为系统指令,比如“/remember 记住……”“/dump 导出记忆”。普通对话就转入第二步。

第二步,AI调用层把用户的输入发送给记忆系统层,触发检索。记忆系统会基于这句话生成一个检索用的向量(这个细节后面会专门讲),然后去记忆库里捞出一批相似度最高的历史记忆条目,同时评估这些记忆的时间权重和时间衰减系数,最终返回给AI调用层一段拼接好的“记忆上下文文本”。

第三步,AI调用层把“记忆上下文文本”和“用户当前输入”共同组装进请求参数。此时请求里有两个角色:system角色负责告知大模型该采用什么身份、有哪些历史记忆可以参考;user角色就是用户当前输入的原文。

第四步,大模型返回结果后,AI调用层先拿到原始文本,再把文本打印到终端给用户看。到这里v1就结束了,但v2没有停。

第五步,AI调用层再次调用记忆系统层,把“用户输入的内容”和“AI回复的内容”打包,交给一个“记忆提取器”。这个提取器负责判断:这段对话里有没有值得长期记住的信息?如果有,格式化成记忆条目并写入记忆库。

这五步走完,一次对话才算真正结束。关键差异在于:v1是一次点到点的转发,v2是一次包含写入和读取的完整闭环。

2.4 为什么“记忆上下文”要拼进system消息而不是user消息

这是一个值得单独讲的经验点。刚开始做v2的时候,我犯过一个错误:把历史记忆和用户输入一起塞进user消息里,结果AI经常分不清哪些是“用户现在说的话”,哪些是“之前的历史记录”。它会以为“你明天面Python”是历史记忆的一部分,导致回答的方向完全偏离。

后来我改成把记忆上下文放进system消息,效果立刻不一样了。原因在消息的角色分工上有本质差异:system消息定义了AI的全局行为模式,它看到system里有“这是用户的历史记忆”时,会以“知情者”的身份来回答;而user消息是当前轮次的用户意图表达。这两者的信息用途不同,混在一起会让模型混淆信息的时效优先级。这是一个在实际写代码中总结出来的血泪经验,也是为什么我强调一定要把记忆装配机制单独做成一个模块。模块化设计不仅是为了代码整洁,更重要的是它逼你把“用户说了什么”和“AI知道什么”这两件事严格分开。

3. 记忆系统建模:核心是把“聊天记录”变成“可检索资产”

3.1 记忆条目怎么设计:一种来自前端数据模型的灵感

我现在要说记忆系统的数据结构设计。前端工程师都知道,一个组件的数据模型如果设计错了,后期改起来有多痛苦。同样的道理也适用于记忆系统。我最初的想法很天真:把每天的对话日志原封不动存下来,要用的时候全文搜索。但很快发现这样做有两个致命问题:一是原始日志会迅速膨胀,挤爆存储空间;二是检索出来的内容噪声太大,大部分是废话。

最后我设计的记忆条目是一个结构化对象,包含六个核心字段:

  • id:唯一标识符,自增整数。
  • content:记忆承载的内容文本。这是本体,一句话或三句话。
  • kind:记忆的类型,包括fact(客观事实)、preference(用户偏好)、event(事件)、task(任务/待办)。
  • timestamp:记忆写入的时间戳。
  • importance:重要程度,区间1到5。这是给后续“该不该遗忘”做依据的。
  • embedding:该条记忆对应的向量表示。这是检索阶段要用到的核心字段。

以字段kind为例,我觉得这是一个容易被忽略但实际价值极大的设计点。为什么需要分类?因为不同类型的记忆在后续检索时优先级不一样。举个实际的场景:用户说“我下周三要去北京出差”。这本质上是一个event(事件),它会在那一天到来前具有极强的时效性,但过完那一天之后,它的价值就断崖式下跌。而用户说“我一直用的是Mac电脑”,这是fact,长期稳定,重要性不会随时间快速衰减。如果没有分类字段,所有的记忆在检索和衰减计算时都会被一视同仁地对待——这显然不合理。

3.2 短期记忆与长期记忆:用衰减因子把时间拉进计算

记忆系统如果“什么都记”或者“永远不忘”,那跟没记没什么区别。我这次做了一个双通道模型:短期记忆通道和长期记忆通道。

短期记忆通道的载体就是memory_short表,存储最近N小时内的对话摘要,设计目标是保留“最近几天”的有效上下文。它会被频繁读取、频繁写入,不适合做精细化的长期沉淀。长期记忆通道对应的就是刚才说的记忆条目主体,它会通过重要性分数和衰减系数来决定权重。

这里直接给一个衰减权重公式,是我在实际实现里用过的:

score = similarity * (0.98 ^ (hours_elapsed / 24))

翻译成大白话就是:两条记忆跟当前对话的相似度都是0.8,但一条是1小时前的,一条是30天前的。1小时前那条的最终得分就是0.8乘以接近0.99的系数,还是稳居高位。而30天前那条,次数算下来大约是0.8乘以0.98的30次方,约等于0.44。这个衰减速度不算快,但足以让近一周的重要信息保持竞争力,同时让一个月前的细碎记忆慢慢淡出。

你可能会问:为什么不去做绝对过期删除?因为我的判断是:很多信息短期内看起来没用,但过了几周后反而重置了它的价值。比如用户随口说过“我不吃香菜”,这个记忆在下一次聚餐场景可能突然又变得重要了。与其硬删除,不如通过衰减让它的排名自然沉底,这样既不会长期占据高质量召回位,又不会永久丢失。

3.3 语义检索为什么要用向量:关键词匹配的根本局限

记忆检索如果只靠关键词匹配,会出现一个很典型的问题:用户当初问的时候用词是“我感觉今天状态不太好,可能是昨晚没睡好”,后来再次关联的场景却是“为什么要喝咖啡提神”。这两句话之间几乎没有重合关键词——“状态”“没睡好”和“咖啡”“提神”在字面上完全不沾边。但人类一看就知道它们在语义上是强关联的。要让机器也具备这种关联能力,就必须把文本变成向量,然后通过向量距离来判断相似度。

我在实现里用的方案是:给每条记忆生成一个embedding向量(我用的是768维的浮点数组),每次用户发来新问题,就给这个新问题也生成一个向量,然后计算它与库里所有记忆向量的余弦相似度,取Top K。余弦相似度的公式很直接:

cosine_similarity = dot_product(vecA, vecB) / (norm(vecA) * norm(vecB))

这个值越接近1,代表两个向量指向越趋同,语义相关性越高。它的好处是对“换一种说法提问”非常宽容,比如用户一周前说“我准备面试”,一周后问“复习方案该怎么规划”,这两句话在关键词层面交集很少,但向量层面会被判定为高度相关。这条特性完美解决了我最担心的记忆召回漏检问题。

3.4 记忆召回流程:从提问到返回Top K

把记忆召回流程完整展开一次,总共分四步:

第一步,对用户输入做向量化,得到查询向量。

第二步,遍历整个记忆表,对每条记忆计算一个复合得分。复合得分的公式我在3.2已经写了,就是相似度和时间衰减相乘,这里我再加一个修正项:如果记忆的kind恰好是task且importance大于4,它会在基础得分上额外加0.1。原因很朴素:任务型记忆是用户主动要求记住的概率更高,需要给一点“保送名额”。否则一个紧急待办很容易被近期高频噪声淹没。

第三步,按复合得分从高到低排序。

第四步,截取前K条(我实际用的是前5条),把内容拼装成下面这样一段文本,在调用大模型时注入到system消息里:

[辅助信息:以下内容来自用户的长期记忆,仅供你回答问题时参考,不要主动提及“基于记忆”之类的话] - 1周前:用户正在准备Python后端开发岗位的面试 - 2天前:用户说自己对Django的ORM还不太熟 - 3小时前:用户询问过如何复习数据库索引 [辅助信息结束]

这段文本的措辞是我反复调过的。注意我特意加了一句“不要主动提及基于记忆之类的话”,因为这会让对话变得更自然。如果AI每次回答都要声明“根据你的历史记忆”,用户的使用沉浸感就会被打断。

4. 核心代码实现:记忆的写入、检索与衰减

4.1 储存层:为什么用SQLite而不是JSON文件

我在写存储层时曾经在“JSON文件”和“SQLite数据库”之间摇摆过。JSON文件的好处是直观,人类直接打开就能看,适合玩具项目。但一旦数据量上了几千条,每次都要把整个JSON文件读进内存、遍历、筛选、再全量写回,性能开销和并发冲突风险都很明显。SQLite虽然要写SQL,但它自带索引、事务、数据持久化,关键是Python自带的sqlite3库零依赖开箱即用,不需要额外安装任何组件。对于一个本地命令行工具来说,这就是最优解。

我这里给出了建表语句,字段和3.1里设计的完全对应:

CREATE TABLE IF NOT EXISTS memory_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('fact', 'preference', 'event', 'task')), importance INTEGER NOT NULL DEFAULT 3, timestamp REAL NOT NULL, embedding TEXT NOT NULL, source_conversation TEXT ); CREATE INDEX idx_memory_kind ON memory_entries(kind); CREATE INDEX idx_memory_timestamp ON memory_entries(timestamp);

embedding字段存的是向量的JSON数组字符串。我在实际代码里是把768个浮点数序列化成JSON字符串存进去的,取出来的时候再json.loads还原回列表。如果只看表结构不看代码,可能觉得这个字段有点浪费——但拿它去做语义检索时才感受到值回票价。

4.2 记忆写入:利用大模型自动提炼对话要点

“记忆写入”不能把用户和AI的每句对话原文都存起来,那样信息冗余太大,且噪声会污染后期检索质量。我的做法是把一次连续对话的完整文本包交给大模型,让它做一次结构化的“记忆抽取”。

具体实现很直接,就是一段模板提示词加response_format指定JSON输出:

def extract_memories_from_conversation(conversation_text: str) -> list[dict]: prompt = f""" 你是一个记忆提取器。请阅读下面的对话记录,提取其中值得长期记住的信息。 值得记住的信息包括:用户的口头偏好、正在进行的任务、重要事件、长期目标等。 常识性对话(如天气、闲聊)不要提取。 请以JSON数组格式输出,每个元素包含content, kind, importance三个字段: - content: 一句话描述记忆内容 - kind: 取值只能是fact, preference, event, task之一 - importance: 从1到5的整数,5代表极其重要 对话记录: {conversation_text} """ response = call_model( system="你是一个严谨的记忆提取器,只输出JSON,不要输出任何解释。", user=prompt, response_format={"type": "json_object"} ) # 解析response,注意要做异常兜底 return parse_json_list(response.content)

这里有两个要点。第一,好的做法是将“哪些内容值得记忆”的判定完全交给模型,而不是用正则硬匹配。正则方法只能抓取“我叫xx”“我喜欢xx”这类显式表达,而语义层面的重要信息往往藏得很深。第二,我给模型设置了专用的system提示词且限定了输出必须为JSON,这是为了避免模型“自由发挥”吐出一大段文字。实践下来,用JSON格式约束输出是让记忆条目结构保持稳定的关键手段。

4.3 检索:纯Python实现余弦相似度计算

检索这一步,我不打算引入外部向量数据库工具,因为基于命令行工具的体量,用sqlite3配合CPU计算余弦相似度完全够用。内存中一次性加载几千条向量的成本很低。下面是我真正在项目里跑通的检索代码,我把核心逻辑抽出来了:

import sqlite3 import json import math import time def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float: dot_product = sum(x * y for x, y in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(x * x for x in vec_a)) norm_b = math.sqrt(sum(y * y for y in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot_product / (norm_a * norm_b) def retrieve_memories(query_embedding: list[float], top_k: int = 5): conn = sqlite3.connect("memory.db") cursor = conn.cursor() cursor.execute("SELECT id, content, kind, importance, timestamp, embedding FROM memory_entries") rows = cursor.fetchall() now = time.time() scored = [] for row in rows: entry_id, content, kind, importance, timestamp, embedding_json = row memory_embedding = json.loads(embedding_json) similarity = cosine_similarity(query_embedding, memory_embedding) hours_elapsed = (now - timestamp) / 3600 decay = 0.98 ** (hours_elapsed / 24) extra_bonus = 0.1 if (kind == "task" and importance >= 4) else 0 score = similarity * decay + extra_bonus scored.append((score, content, timestamp)) scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k]

逻辑已经很直白了,我补充两个实现上的细节。第一,embedding序列化到SQLite后,要记得用json.loads把它解码回向量对象,否则拿到的是一段字符串,没法直接参与数学计算。第二,time.time()返回的是Unix时间戳,入库时也统一用这个标准,这样后续做时间差计算时不用做格式转换。时间戳在记忆系统中的地位非常核心,如果存储的是“2026年1月20日”这种人类可读格式,衰减公式就不能直接用差值参与运算了。

4.4 助手主类:写周记忆和读记忆的完整闭环

把上面的模块串起来,就是整个助手的主类。我精简后呈现如下:

class MemoryAssistant: def __init__(self, db_path="memory.db"): self.conn = sqlite3.connect(db_path) self.init_db() self.conversation_history = [] self.memory_bank = [] def get_embedding(self, text: str) -> list[float]: # 调用嵌入模型接口,把文本转成向量 response = call_embedding_model(text) return response.vector def build_context(self, user_input: str) -> str: query_vec = self.get_embedding(user_input) top_memories = retrieve_memories(query_vec, top_k=5) memory_text = "以下内容来自用户的长期记忆,仅供你回答问题时参考,不要主动提及“基于记忆”之类的话。\n" for score, content, ts in top_memories: readable_time = time_str(ts) memory_text += f"- {readable_time}:{content}\n" memory_text += "辅助信息结束" return memory_text def process_input(self, user_input: str) -> str: context = self.build_context(user_input) full_messages = [ {"role": "system", "content": "你是一个对用户了如指掌的AI助手,回答自然友好。\n" + context}, {"role": "user", "content": user_input} ] ai_reply = call_model(messages=full_messages) # 写入记忆 self.conversation_history.append({"user": user_input, "assistant": ai_reply}) if len(self.conversation_history) % 6 == 0: combined_text = "\n".join( [f"用户说:{m['user']}\nAI说:{m['assistant']}" for m in self.conversation_history] ) new_memories = extract_memories_from_conversation(combined_text) for memory in new_memories: save_memory( content=memory["content"], kind=memory["kind"], importance=memory["importance"], embedding=self.get_embedding(memory["content"]) ) return ai_reply

我截取的是最核心的build_context和process_input两个方法。你注意看写入的触发条件:不是每句话都立刻写入,而是攒够6轮对话后再批量提取一次。为什么这么设计?一是单轮对话包含的信息量太少,强行提取只会产生大量垃圾记忆;二是批量提取可以降低调用大模型做记忆抽取的API成本,毕竟每一次额外调用都是真金白银。

5. 实测效果对比与前端视角的优化

5.1 有记忆和没记忆的区别,一试便知

我不能光说效果好,得让你看到真实的对比。我模拟了两轮会话,分别对应无记忆和有记忆状态下的回答差异。

场景是这样:第1天用户说“帮我定期提醒:这周四下午三点要开会”。第3天用户再问“我周四下午的安排是什么”。

无记忆(v1)的回答效果是:

AI:抱歉,我没有之前的对话记录,无法查询你的历史安排。

有记忆(v2)的回答效果是:

AI:你周四下午三点有一个会议,需要我帮你提前准备会议材料吗?

这个差距无需多言了。第二个这个回答所用的信息不是用户当天输入的,而是检索系统从历史记忆里捞出来的。作为对比,我把两个版本的调用参数都打了出来,核心差异就在system消息里多了一段从记忆库召回的内容。

5.2 记忆召回质量观察:不是每次都能精准命中

当然,实测也不是每次都能完美命中。我跑了一整个下午的会话,发现召回准确率和“当前问题与历史记忆的语义距离”强相关。当问题直接指向某个明确实体时(比如“我Python面试是几号?”),召回基本能命中。但问题比较泛时,比如“我最近有什么任务?”——这个查询向量跟所有任务类记忆的相似度都分布得比较平均,最后的Top 5里可能混入一些不相关的记忆。

这里我就用上了importance字段做二次修正:如果两条记忆的相似度接近,重要性更高的那条会有更大几率被排进Top K。这也是为什么在设计记忆条目时,我没有只存content和embedding,而是把kind和importance都一并保留。一个丰富的记忆条目结构,能在检索阶段提供多维度的辅助判断条件。

5.3 前端背景在优化中帮上的大忙

整个项目写下来,我发现自己的前端经验其实转化为了一些隐性优势。第一个是组件化思维,我把记忆系统、AI调用、CLI交互拆成三个独立模块,这就像前端的组件边界一样清晰;如果一开始就用面条式代码写在一起,后面加任何功能都点得动一发牵动全身。第二个是数据状态管理经验,前端里天天跟state、props、store打交道,对“状态从哪里来、到哪里去”有天然的敏感度。v2里最关键的“用户输入→检索记忆→更新系统上下文→模型输出→异步写入记忆”这条状态流转,我几乎不用额外思考就理清了。

还别说,前端里处理异步请求的心得也用上了。最开始我写完记忆写入逻辑,发现体验很差:用户每说一句话,整个程序都要卡住几秒钟,因为“调用模型生成记忆向量”和“写入数据库”都是同步操作,直接在对话主线程里运行了。后来我借鉴了前端处理异步接口fail-fast的思路,把记忆写入丢到了一个独立线程中执行,对话响应变得流畅了。这段优化让我确信,从前端转AI不只是“换个语言”,更多是把客户端开发里积累的交互体验敏感度迁移到AI应用设计里来。

6. 踩坑记录:记忆系统的三个深坑

6.1 坑一:记忆污染,捡回来的都是噪声

第一个坑是记忆污染。我最初把“过去6轮对话”直接作为记忆输入,没有做任何提炼加工,结果发现AI的回答反而变笨了。原因很直白:把大量碎片化对话原样塞入,会让上下文里混入太多无效信息,真实意图反而被稀释了。比如用户曾经随口说“今天天气不错”,这条记录就会被当作一条记忆存起来,后面所有对话检索时都有可能被召唤出来,占据宝贵的召回名额。

修法我刚才也提到了:引入独立的记忆提取步骤,每次批量对话后由模型提炼关键信息,只把结构化、有价值的内容存进记忆库。天气闲聊这类数据会在提取阶段直接被过滤掉,根本没有机会污染记忆库。

6.2 坑二:上下文爆炸,用top K截断还不够

第二个坑非常现实。我刚开始检索时是把所有相似度高于阈值的记忆全部塞进system消息,结果很快触发了大模型的上下文长度上限。报错信息一句话点醒我:“你传进去的文本太长了。”

我刚开始还挺不解,因为自己一共也只存了一百来条记忆。后来算了笔账:每条记忆平均50个字,100条就是5000字,再算上系统提示词和用户输入,直接就把上下文窗口撑爆了。于是我把策略改成:无论记忆库有多大,召回阶段只取Top 5。这招效果立竿见影,上下文开销瞬间可控。代价是有些相关性稍弱的记忆可能被遗漏,但这种“精准召回5条”的策略显然比“模糊召回全部”更适合对话场景。

6.3 坑三:编码问题和数据格式的暗坑

第三个坑没什么技术含量,但卡了我整整一个下午——中文文本的嵌入向量在序列化和反序列化过程中的编码问题。我最初用str(embedding)直接转字符串存入SQLite,结果从库里读出来后,Python把它解析成了一个带方括号的字符串,而不是向量对象,余弦相似度计算出错,检索质量变得一塌糊涂。后来我把存储方案统一为json.dumps(embedding)入库、json.loads出库,才彻底解决。

6.3.1 调试方法论:二分定位法

这里分享一个排查思路。当时我看到检索出的Top 5乱七八糟,第一反应是“vector检索逻辑有问题”,差点就要重置embedding算法了。但我冷静下来之后做了一次二分排查:先打印出库后的type(embedding),立刻发现它是个字符串而非列表。这就把问题范围从“算法层”缩小到了“存储层”,剩下的工作就是改序列化方式。这个排查过程教会我一个通用方法:任何一次AI应用出了问题,先确认数据在每层流转时的类型和格式是否符合预期,再去怀疑算法和模型。数据流正确性优先于算法正确性排查。

6.4 给马上要动手做记忆系统的你:四个实操建议

这段是给想照着我这个项目做一遍的朋友们的个人建议,都是踩完坑之后沉淀下来的。

第一,记忆条目的importance和kind一定要从一开始就设计好,不要等积累了上千条数据以后再回头加。加字段和改字段在数据量小的时候很简单,数据量大了之后,跑迁移脚本的过程能把人逼疯。

第二,别偷懒跳过“记忆提取”环节。直接用原始对话做记忆,结果就是收到一堆噪声,不如不做。

第三,检索的Top K参数一定不要设太大。宁缺毋滥。5条是最平衡的选择,多的记忆注入只是占用上下文窗口,并不能带来多少信息增量。

第四,时间衰减系数别写死了,最好做成可配置项。我在项目里把衰减底数和衰减周期都放进了配置文件,方便针对不同场景调整。如果做的是高频使用工具(比如每天大量对话),衰减要适当加速;如果是低频工具,衰减就要放慢,否则间隔一次使用,所有记忆就都没用了。

7. 后续还能怎么扩展:从命令行到小生态

我写代码时总会不自觉地想一件事:这样一个记忆系统,它的能力边界在哪里,还能长出什么东西来?不是空想,而是基于今天已经落地的数据结构,自然衍生出的一些可能性。

第一个方向是“记忆可视化”。既然记忆条目里有kind和importance,那就可以在命令行里加一个“记忆盘点”指令,让AI输出一张按分类和重要度排序的记忆清单。比如输入/mem list --kind task,就能把当前所有任务型记忆列出来。这相当于给自己的AI助手加了一个“备忘板书架”。

第二个方向是“主动记忆触发”。也就是说,当用户提出的问题与某条记忆高度相关时,AI不会只是把记忆拼进上下文回答,而是主动提示用户,例如:“我注意到你周二有个发布会,你需要提前准备PPT吗?”这种主动行为会让AI助理完成从“被动工具”到“主动伙伴”的进化。这个功能目前的记忆系统完全支撑得起,只差一层触发逻辑。

第三个方向是“记忆分享与跨设备同步”。因为记忆都存在本地SQLite文件里,导出、导入就变得非常轻量。我可以写一个/export指令,把记忆库序列化成一个JSON文件,再在另一台设备上用/import导入。对开发者来说,一个随身携带自己AI记忆的CLI工具,本身就是一件很酷的事情。

不过说实话,这些扩展功能我暂时不打算全都做。学习计划里第15天到第20天还有别的主题要覆盖,这一版的核心目标是把“记忆系统”从概念变成能跑的代码。已经达标了,后续有精力再迭代优化。

8. 最后再分享一点个人心得

这个项目做完之后,我有个很大的感触:记忆系统的难点从来不在“存储”,也不在“调用大模型”,而是“如何筛选并召回那些真正有用的信息”。当对话量小的时候,所有方案看起来都很美好;一旦对话量上来,噪声、爆炸、衰减、协同过滤这些问题全都会冒出来。你今天看到的这套方案——结构化记忆提取、向量化召回、时间衰减、Top K截断——不一定是最优解,但它是当前阶段能稳定跑通、也足够优雅的一套设计。

如果你也想照着搭一个,我的建议是从最小可用版本开始。先跑通“存一条记忆、检索一条记忆”的最小闭环,再逐步加上分类、衰减、重要性修正这些进阶功能。不要一上来就想做个完美系统,那样很可能卡在第一步连对话都无法顺畅进行。代码逻辑清晰、能应对你日常的真实需求,才是最重要的。

现在这个命令行AI助手已经成了我的日常小工具。今天这篇文章里的所有截图和数据,也都是它的实际输出。下一步我准备试试给它加上工具调用的能力,让它不只“会聊天”,还能真正去执行一些终端命令。暂时就先写到这里,我得去补一觉了,今天写完代码确实有点上头。

返回列表