ARTICLE DETAIL

资讯详情

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

命令行AI助手记忆系统实战:分层存储与按需召回

命令行AI助手记忆系统实战:分层存储与按需召回 命令行 AI 助手做到第二版最容易翻车的地方其实不是模型调用而是记忆。第一版我写了个能跑通的 CLI 对话工具问一句答一句关掉终端就失忆用起来跟一次性纸杯差不多。到了 Day 14 这个综合项目节点我决定给它加上记忆系统——不是那种把整段对话无脑塞进上下文的假记忆而是能分层存储、按需召回、控制 token 成本的真正可用的记忆。这篇就把整个 v2 的设计思路、代码结构、踩过的坑和实测数据完整摊开讲一遍。如果你也是从前端转 AI 方向或者正在做命令行工具、AI 代理助手这类项目这篇的实操细节应该能直接抄作业。我会重点讲清楚三件事记忆系统为什么要分层、每层怎么落地、以及怎么在命令行这个受限环境里把体验做顺。全程 Python 实现依赖极少本地跑没有任何门槛。1. 为什么命令行 AI 助手必须要有记忆系统1.1 一次性对话的体验天花板在哪先说说 v1 到底差在哪。v1 的结构很简单读用户输入拼一个 messages 数组调一次 API打印结果循环。每次循环 messages 都是全新的上一轮说了什么这一轮完全不知道。你问帮我写个 Python 函数解析日志它写完你接着说改成支持多文件它一脸茫然因为它根本不知道改成是改哪个。这种体验在单轮任务里还能忍但一旦涉及多轮迭代就彻底崩了。前端同学应该很熟悉这种感觉——就像你写了个 React 组件每次 setState 都把整个 state 重置成初始值那这个组件基本没法用。对话的连续性就是 AI 助手的 state没有它助手永远停在第一次见面的状态。更麻烦的是 token 成本。有人会说那我干脆把历史全带上不就行了理论上可行但实测下来问题很大。一次普通的技术对话来回十轮每轮平均 200 token加上系统提示词轻松突破 3000 token。如果你一天用几十次成本会线性膨胀。而且上下文窗口再大也有上限塞满了就得截断截断策略没设计好关键信息就丢了。1.2 记忆系统解决的三个核心问题我把记忆系统要解决的问题归纳成三条这也是我设计 v2 的出发点。第一是连续性。用户提到刚才那个函数上次说的方案助手得能接得住。这要求短期记忆必须保留最近若干轮的完整对话。第二是长期性。用户可能三天前告诉过助手我的项目用 FastAPI 不用 Flask一周后再问相关问题助手应该记得这个偏好。这要求有一个跨会话的持久化存储。第三是成本可控。不能因为要记忆就把所有历史都塞进上下文。需要一套召回机制只把当前问题相关的记忆捞出来其余的存在磁盘上不占 token。这三个问题对应三种不同的记忆形态混在一起做必然出问题。所以 v2 的核心设计就是分层记忆短期记忆管连续性长期记忆管持久性召回机制管成本。1.3 分层记忆的整体架构我最终落地的架构是三层层级存储位置生命周期作用工作记忆内存单次会话保留最近 N 轮完整对话会话摘要内存磁盘单次会话对早期对话做压缩摘要长期记忆磁盘JSON/SQLite跨会话存用户偏好、事实、关键结论工作记忆就是最近几轮的原始消息直接进上下文。会话摘要解决的是对话很长但工作记忆装不下的问题——把超出窗口的早期对话压缩成一段摘要替代原始消息进上下文。长期记忆则是跨会话的用关键词或向量检索的方式按需召回。这个分层不是拍脑袋定的是参考了人类记忆的工作机制你聊天时脑子里记着最近几句话工作记忆更早的内容记个大概摘要而我叫什么、我住哪这种是长期记忆随时能调出来。AI 助手用同样的逻辑token 利用效率会高很多。提示分层的关键是每层只做一件事。我见过有人把摘要和长期记忆混在一个存储里结果召回时不知道该返回原文还是摘要逻辑越写越乱。分层清晰代码才好维护。2. 工作记忆与摘要压缩的具体实现2.1 工作记忆的窗口大小怎么定工作记忆就是保留最近几轮的原始消息。窗口大小定多少直接决定上下文里有多少新鲜对话。我一开始拍脑袋定了 10 轮实测发现太占 token而且很多早期轮次其实已经不重要了。后来改成按 token 数控制而不是按轮数。具体做法是维护一个消息列表从最新往回累加 token累加到接近预算上限我设的是 2000 token就停剩下的进摘要。这样比固定轮数灵活得多——短对话能多留几轮长对话自动少留。计算 token 我用的是简单的字符估算中文按 1 字符约 1.5 token英文按 1 字符约 0.25 token 粗算。精确计算需要引入 tiktoken 之类的库但对命令行工具来说估算误差在 10% 以内完全够用还省了依赖。def estimate_tokens(text): # 粗略估算中文按字符数*1.5英文按字符数*0.25 chinese sum(1 for c in text if \u4e00 c \u9fff) other len(text) - chinese return int(chinese * 1.5 other * 0.25)这个估算函数是整个记忆系统的地基后面摘要触发、长期记忆召回都要用它。别小看这几行它决定了你的 token 预算准不准。2.2 摘要触发的时机与压缩策略摘要什么时候触发我的策略是当工作记忆的 token 超过预算的 1.5 倍时把最老的一半消息拿出来做摘要压缩后放回摘要区。这样避免频繁调用模型做摘要费钱也避免摘要区无限膨胀。摘要的 prompt 我调了好几版最终定成这样SUMMARY_PROMPT 请把以下对话压缩成简洁的摘要保留 1. 用户提到的具体需求和技术选型 2. 已经确认的结论和方案 3. 未解决的问题 去掉寒暄、重复内容和中间推理过程。控制在 200 字以内。 对话内容 {conversation} 关键是去掉中间推理过程这句。早期对话里大量是 AI 的思考过程这些对后续对话价值很低压缩掉能省很多 token。保留的是结论和待办这才是后续对话真正需要的。实测下来一段 1500 token 的对话压缩后大概 150 token压缩比 10:1。这个比例很划算意味着摘要区能装下很长的历史。2.3 摘要与工作记忆的拼接顺序拼接顺序有个坑。我一开始把摘要放在最前面工作记忆放后面结果模型经常忽略摘要内容。后来查了下原因是模型对上下文开头和结尾的关注度不同中间容易被忽略。摘要放在开头正好落在容易被忽略的位置。调整后的顺序是系统提示词 → 工作记忆最近对话→ 摘要早期对话→ 当前用户输入。把摘要放在靠近当前输入的位置模型引用它的概率明显提高。这个细节很小但实测效果差异挺明显。def build_context(system_prompt, working_memory, summary, user_input): messages [{role: system, content: system_prompt}] messages.extend(working_memory) if summary: messages.append({ role: system, content: f以下是本次会话早期的摘要供参考\n{summary} }) messages.append({role: user, content: user_input}) return messages注意摘要我用的是 system 角色而不是 user这样模型会把它当背景信息而不是用户发言引用时更自然。3. 长期记忆的存储结构与召回逻辑3.1 长期记忆存什么、不存什么长期记忆最容易犯的错是什么都存。我第一版把每轮对话都往长期记忆里塞结果检索时噪音极大召回的内容经常不相关。后来我定了个原则只存事实和偏好不存过程。具体来说这几类内容值得存用户的技术栈偏好我用 FastAPI 不用 Flask项目背景信息我在做一个日志分析工具明确的结论数据库选 PostgreSQL用户的习惯回答尽量简洁不要长篇大论而这几类不存一次性的问答这个报错怎么解决AI 的推理过程寒暄和确认性回复判断标准很简单这条信息在三天后还有用吗有用就存没用就丢。这个原则帮我砍掉了 80% 的存储量召回准确率反而上去了。3.2 用 JSON 还是 SQLite 做存储存储介质我纠结过一阵。JSON 文件简单读写直观但数据量大了检索慢SQLite 检索快支持全文搜索但引入了一个依赖而且并发写要处理锁。最后我选了 JSON 内存索引的方案。理由是这个工具是单用户命令行场景数据量不会太大几千条记忆顶天了JSON 完全扛得住。启动时把 JSON 读进内存建索引检索在内存里做速度飞快。写入时整体覆盖文件简单可靠。class LongTermMemory: def __init__(self, pathmemory.json): self.path path self.memories self._load() self._build_index() def _load(self): if os.path.exists(self.path): with open(self.path, r, encodingutf-8) as f: return json.load(f) return [] def _build_index(self): # 简单的倒排索引关键词 - 记忆ID列表 self.index {} for i, mem in enumerate(self.memories): for kw in self._extract_keywords(mem[content]): self.index.setdefault(kw, []).append(i)如果哪天数据量真的上去了换成 SQLite 也就是改_load和save两个方法的事上层逻辑不用动。这就是分层的价值——存储和逻辑解耦。3.3 关键词召回 vs 向量召回召回方式我试过两种。向量召回需要 embedding 模型效果好但每次召回都要调一次 API延迟和成本都上去了。关键词召回零成本、零延迟但准确率依赖分词质量。对命令行工具来说我最终选了关键词召回为主向量召回作为可选增强。默认走关键词用户如果配置了 embedding 接口就自动切换到向量召回。这样既保证了开箱即用又给进阶用户留了口子。关键词召回的核心是分词和匹配。中文分词我用的是简单的 2-gram 切分不引入 jieba 这种重依赖。虽然不如专业分词准但对技术术语的召回效果够用——FastAPIPostgreSQL这种词 2-gram 也能切出来。def _extract_keywords(self, text): # 英文按空格切中文按2-gram切 words re.findall(r[a-zA-Z0-9_], text.lower()) chinese re.findall(r[\u4e00-\u9fff], text) for seg in chinese: for i in range(len(seg) - 1): words.append(seg[i:i2]) return set(words)召回时把用户当前输入也做同样的分词然后查倒排索引命中的记忆按命中关键词数量排序取前 3 条注入上下文。3.4 召回结果的注入方式召回的记忆怎么进上下文我试过两种一种是拼成一段文本放 system 消息里另一种是作为独立的 system 消息逐条插入。实测下来逐条插入效果更好因为模型能更清晰地识别每条记忆的边界。def inject_memories(messages, memories): for mem in memories: messages.insert(1, { role: system, content: f[相关记忆] {mem[content]} }) return messages加个[相关记忆]前缀很重要它告诉模型这段是背景知识不是用户当前说的话。不加前缀的话模型有时会把记忆内容当成用户的新指令闹出笑话。注意召回的记忆条数不要贪多。我实测超过 5 条后模型反而会因为信息过载而忽略重点。3 条是个比较稳的数字宁可少召回也不要塞满。4. 命令行交互层的体验打磨4.1 流式输出与记忆写入的时序命令行工具最影响体验的就是响应速度。v1 用的是等完整响应再打印用户要盯着空屏幕等好几秒。v2 改成了流式输出边生成边打印体感快很多。但流式输出和记忆写入有个时序冲突流式过程中你拿不到完整回复没法立刻做摘要或提取长期记忆。我的处理是流式只负责显示记忆写入放在流结束后异步做。用户看到回复的同时后台在提取记忆互不阻塞。def chat_stream(user_input): # 1. 构建上下文含召回的记忆 messages build_context(...) # 2. 流式输出 full_response for chunk in stream_api(messages): print(chunk, end, flushTrue) full_response chunk print() # 3. 流结束后处理记忆 update_working_memory(user_input, full_response) maybe_summarize() extract_long_term_memory(user_input, full_response)这个时序设计的关键是显示和存储解耦。显示要快存储可以慢。用户感知不到后台的记忆处理体验就很顺。4.2 记忆的查看与管理命令加了记忆系统后用户肯定想知道你到底记住了什么。我加了几个斜杠命令/memory查看当前所有长期记忆/forget 关键词删除匹配的记忆/summary查看当前会话摘要/clear清空当前会话的工作记忆和摘要这几个命令看着简单但极大提升了可控性。用户发现助手记错了东西能立刻删掉而不是干瞪眼。这种可干预的设计对建立信任很重要。def handle_command(cmd): if cmd.startswith(/memory): for i, mem in enumerate(ltm.memories): print(f{i}. {mem[content]}) elif cmd.startswith(/forget): kw cmd[len(/forget):].strip() removed ltm.forget_by_keyword(kw) print(f已删除 {removed} 条相关记忆) elif cmd /clear: session.clear() print(当前会话已清空)4.3 记忆提取的 prompt 设计长期记忆的提取是自动做的每次对话结束后调一次模型判断这轮对话里有没有值得长期保存的信息。这个 prompt 我改了很多版核心是让模型输出结构化结果方便程序解析。EXTRACT_PROMPT 分析以下对话提取值得长期记住的信息。 只提取事实和偏好不要提取一次性的问答。 如果没有值得记住的内容输出 NONE。 如果有每行一条格式为类型|内容 类型只能是偏好、事实、结论 对话 用户{user} 助手{assistant} 输出用|分隔程序按行解析简单可靠。用 JSON 也行但模型偶尔会输出格式错误的 JSON解析容易崩。用简单的分隔符反而更稳。实测下来大概 30% 的对话轮次会提取出长期记忆其余都是 NONE。这个比例合理说明提取逻辑没有过度存储。5. 实测数据与踩坑记录5.1 token 成本对比我拿同一段 20 轮的对话做了对比测试结果如下方案平均每轮 token20 轮总 token记忆连续性v1 无记忆3507000无全量历史280056000完整v2 分层记忆62012400完整v2 的 token 消耗只有全量历史的 22%但记忆连续性跟全量历史一样完整。这个数据是我最满意的部分——分层记忆的价值就体现在这里。5.2 摘要丢信息的坑踩过最大的坑是摘要丢信息。有一次用户说用 Redis 做缓存这句话在早期对话里被摘要压缩成了讨论了缓存方案。后来用户问缓存用什么来着助手答不上来因为摘要里没保留具体的技术选型。修复方法是改摘要 prompt明确要求保留具体的技术名词和选型。改完之后摘要里会保留RedisPostgreSQL这类具体词召回时就能命中。这个坑的教训是摘要不是越短越好关键信息必须保留。压缩比 10:1 是理想情况如果对话里技术名词密集压缩比降到 5:1 也值得。5.3 长期记忆污染的处理另一个坑是长期记忆污染。有次用户随口说了句我试试 Flask 吧被提取成了偏好用户使用 Flask。结果后面所有对话助手都推荐 Flask而用户实际用的是 FastAPI。修复方法是给提取 prompt 加了判断条件只提取用户明确确认的偏好试探性、假设性的表述不要提取。同时加了/forget命令让用户能手动清理。双管齐下污染问题基本解决。5.4 命令行环境的特殊处理命令行环境有几个特殊点要注意。一是中文输入某些终端对中文支持不好需要设置PYTHONIOENCODINGutf-8。二是CtrlC 中断要捕获 KeyboardInterrupt在退出前把当前会话的记忆落盘不然辛苦攒的记忆就丢了。try: main_loop() except KeyboardInterrupt: print(\n正在保存记忆...) session.save() ltm.save() print(已保存再见)三是长文本换行命令行宽度有限AI 回复长了会乱。我用 textwrap 做了自动换行宽度取终端实际宽度减 2。import shutil, textwrap width shutil.get_terminal_size().columns - 2 print(textwrap.fill(response, widthwidth))这些细节看着琐碎但正是它们决定了工具能不能日常用。6. 记忆系统的扩展方向6.1 从关键词到向量的平滑升级前面提到向量召回是可选的。具体怎么接我的设计是加一个EmbeddingProvider抽象默认实现是空走关键词用户配置了接口就换成真实实现。这样代码不用改只改配置。class EmbeddingProvider: def embed(self, text): return None # 默认不启用 class OpenAIEmbedding(EmbeddingProvider): def embed(self, text): # 调接口拿向量 ...召回时判断如果 embed 返回 None走关键词否则走向量相似度。这种优雅降级的设计让工具既能开箱即用又能按需增强。6.2 记忆的时效性管理记忆不是越老越好。三个月前的技术选型现在可能已经变了。我加了个timestamp字段召回时对老记忆做降权。具体是30 天内的记忆权重 1.030-90 天 0.790 天以上 0.4。排序时权重乘以命中数老记忆自然排后面。这个机制避免了助手抱着过时信息不放的问题。用户如果发现某条记忆过时了/forget删掉即可。6.3 多项目记忆隔离如果你同时做几个项目记忆混在一起会很乱。我加了project字段启动时通过--project参数指定当前项目召回时只召回同项目的记忆。这样 A 项目的技术选型不会污染 B 项目。python assistant.py --project log-analyzer这个设计对多项目开发者很实用也是我实际用下来觉得最值得加的功能之一。6.4 记忆的导入导出最后加了个/export和/import命令把记忆导出成 JSON 文件方便备份和迁移。换电脑时导出再导入记忆无缝衔接。这个功能实现简单但用户价值很高——毕竟记忆是攒出来的丢了很心疼。def export_memories(path): with open(path, w, encodingutf-8) as f: json.dump(ltm.memories, f, ensure_asciiFalse, indent2)到这里v2 的记忆系统就完整了。从工作记忆到摘要到长期记忆三层各司其职token 成本压到了全量历史的五分之一连续性还完整保留。这套架构我后面做其他 AI 助手项目也会直接复用因为它解决的是通用问题不限于命令行场景。如果你也在做类似的东西我的建议是先把工作记忆做扎实再考虑长期记忆。很多人一上来就想搞向量数据库、搞 RAG结果连最近三轮对话都接不住。记忆系统的价值不在花哨在于该记住的记住该忘的忘掉这个平衡点找到了工具就好用了。
返回列表