☰
三层记忆与断点续跑:企业级问答系统的关键设计
2026/10/21 17:49:40 网站建设 项目流程

第14章的这个话题,是被生产环境逼出来的。我原计划这一章只写“记忆”,但真在企业里跑了一轮之后发现,记忆和断点续跑必须放在一起讲,不然系统只能“记得住”,却“干不完”。企业级问答系统一旦接入真实业务,就不再是“问一句答一句”的搜索引擎,而是要从上一轮对话、上一个任务接着干。很多团队把大模型接进去之后,短期对话还行,一遇到跨轮引用、多步任务中断就露馅。我在某客服项目的日志里看到用户连续追问七轮,最后问“我刚才说的那个问题解决了没有”,系统直接回复“我不太理解您的意思”。这种体验比回答错了更伤。

这一章要解决的就是两件事:三层记忆,以及基于记忆的断点续跑。如果你正在做客服机器人、内部知识助手,或者任何带流程编排的问答系统,这套方案可以直接参考。我把数据怎么存、状态怎么写、任务断了怎么恢复、上线前会踩哪些坑,全部拆开讲,不绕弯子。

1. 为什么单轮问答撑不起企业场景:记忆缺失的真实代价

1.1 用户“失忆”的不是用户,而是系统

单轮问答的核心假设是“用户每次提问都是完整且独立的”。但在真实业务里,这个假设几乎不成立。

我在某客服项目的日志里看过大量这样的对话:用户说“我收到了两张发票,但金额都对不上”,下一条问“第二张是怎么回事”,系统直接把第二句话当成全新问题去知识库检索,结果返回一堆无关文档。原因很简单,系统没有记住上一轮里“第二张”指的是什么。这类跨轮指代在客服场景里占比非常高,不是偶发情况。

更严重的是多步任务场景。用户让系统“帮我把上个月的销售数据整理成图表,再写一段结论发到群里”,系统刚执行到第二、三步,后端服务因为发布重启了一下,整个任务直接丢失。用户再打开对话时,什么上下文都没了,只能重新输入一遍。一次失败对话的实际成本不只是多花几次模型调用,还包括用户重复输入的耐心成本、人工客服兜底的费用,以及业务方对系统技术能力的信任损失。记忆缺失不是锦上添花的问题,是真正影响项目能否落地的硬伤。

1.2 三层记忆的分工:一次说清短期、长期与工作区

我在方案里把记忆拆成三层,而不是只做一种“万能记忆桶”。

短期记忆负责“当前会话内”。用户刚才说了什么、系统上一轮回答了什么、当前正在讨论的实体是什么,都放在这一层。它的特点是更新快、生命周期短,用户关掉会话或者一段时间不活跃之后,就可以过期。

长期记忆负责“跨会话沉淀”。用户的偏好、身份信息、常用设备型号、历史偏好,这些不会因为一次会话结束就消失的信息,单独抽出来存。这样用户下次再来,系统不用重新认识一遍。

任务工作区负责“当前任务的状态容器”。一次多步任务的目标、计划步骤、每一步执行到哪、中间结果存放在哪,都写在这层。它和短期记忆的区别在于:短期记忆面向“对话语义”,工作区面向“任务执行”。

三层的类比很好记:短期记忆是聊天时摊在桌上的便签,长期记忆是档案柜里的用户资料卡,工作区是电脑桌面上打开的那份项目文档。真正要恢复一个中断的任务时,重点看工作区;要让下一轮对话接得上,重点看短期记忆;要让用户下次再来时不用重复自我介绍,重点看长期记忆。

1.3 与其无限叠上下文,不如让记忆分层

我见过一些团队的做法很简单粗暴:把所有历史对话全部拼到大模型prompt里,认为上下文越长记得越多。结果就是输入token爆炸,费用飙升,准确率反而下降。

算一笔简单的账:假设平均每轮对话约500token,用户聊了100轮,如果全部拼进去就是5万token。商用大模型接口按token计费,一次请求就要付出几十倍于单轮的成本。更麻烦的是,实践中真去测试“大海捞针”式的长文本召回,上下文越长,模型对关键信息的关注度反而越差。中间段容易被淹没,开头的指令容易被末尾的新内容稀释。

所以记忆分层的核心逻辑是:不是把所有历史都递给模型,而是把“当前最需要的那几块”组织成简短的记忆摘要。用最少的token,保留最关键的信息。这个思路贯穿整个设计,后面每一层的实现都在为“少而准”服务。

2. 三层记忆的数据结构与落库设计

2.1 短期记忆:会话窗口与滚动摘要的落地细节

短期记忆最常见的实现是固定窗口加滚动摘要,重点说下落地细节。

以会话ID作为主键,在内存键值库里存一个队列,只保留最近N轮,N一般取10到20轮。每轮消息包括角色和内容,例如{"role":"user","content":"..."}。当对话轮次超过窗口上限时,把最早的2到3轮内容交给LLM生成一段会话摘要,替换掉原文继续保留在上下文里。

这一步的关键是摘要时机。我实测下来,不要在每次用户提问后都对全部历史做一次摘要,那样等于每轮多花一次模型调用,成本直接翻倍。正确做法是:只在窗口溢出时触发一次摘要,未溢出的轮次原样保留。压缩后的摘要控制在100到200token,并且要保留本轮出现的核心实体,比如订单号、人名、设备型号、金额数字。这些实体的丢失比丢失几句话更致命。

我自己的落地习惯是:短期记忆在内存键值库里设置30分钟无操作自动过期,用户离开一段时间后再回来,重新开始一段会话。这样既避免了长期堆积,也让“上一会话的事”从语义上自然断掉。

提示:不要试图把整个文档或大段资料塞进短期记忆。那种内容应该放进下一节的任务工作区,短期记忆只存文件ID和一句索引说明。

2.2 长期记忆:用户画像与事实槽位怎么抽

长期记忆最容易踩的坑是“什么都存”。我的做法是把它做成一张结构化的用户事实表,而不是存对话原文。

每一轮或每个会话结束后,会有一个异步抽取过程,把可以长期保留的信息提取出来。抽取分成三类方式。第一类是用规则和正则做槽位填充,比如用户主动报出的设备型号、城市、会员等级,这些格式相对固定,用规则成本低、准确率高。第二类是用LLM抽取,从对话文本中识别并输出结构化JSON,比如设备型号、偏好表述、身份信息。第三类是批处理,每天夜里把当天多条会话统一过一遍,做增量合并。

重点在过滤规则:情绪化表达、临时性状态、时效性内容一律不写进长期记忆。“我今天特别着急”不是事实,不存;“我现在用的是某型号的设备”则值得存。“我打算换一台新电脑”在用户确认购买前,最多记成“有意向”,不能写成“已拥有”。

长期事实以“槽位+值”的形式存储,对同一个槽位的新值要覆盖旧值,并保留更新时间。如果同一个槽位出现冲突,后出现的明确事实优先。槽位数不贪多,我建议每用户最多64条有效事实,超出之后按“最近使用时间”和“命中次数”淘汰最不常用的。

2.3 任务工作区:为断点续跑准备的状态容器

任务工作区是整个断点续跑方案的基石。它不关心“用户上次聊了什么”,只关心“这个任务现在执行到哪一步”。

实际存储我建议用关系型数据库里的JSONB字段,或者任何支持事务和行锁的持久化存储。不要只放在进程内存里,也不要用一个全局字典存,万一进程崩溃什么都不会剩下。

工作区里的内容大致长这样:

{ "task_id": "task_20250101_001", "goal": "生成上季度销售分析报告", "plan": ["拉取数据", "清洗数据", "生成图表", "写结论", "发送邮件"], "step_states": { "拉取数据": "done", "清洗数据": "done", "生成图表": "running", "写结论": "pending", "发送邮件": "pending" }, "intermediate_outputs": { "清洗数据": "/data/tmp/cleaned_sales.csv" }, "created_at": "2025-01-01T09:00:00Z", "updated_at": "2025-01-01T09:05:30Z" }

这个结构把任务目标、计划、步骤状态、中间产物全部显式写出来。任何一个新启动的服务实例,只要拿到task_id,就能从这份JSON里知道接下来还差哪些步骤,哪些结果可以直接复用。

2.4 存储选型:不同记忆层级用什么介质最合适

三层记忆对存储介质的要求差异很大,选错了后期会很别扭。

记忆层级推荐存储原因
短期记忆内存键值库读写延迟低,天然支持过期TTL,会话级数据丢失可接受
长期记忆向量数据库加键值存储需要既支持精确查询又支持相似度检索,事实槽位用KV,记忆片段可向量化召回
任务工作区关系型数据库JSONB需要事务、行锁、崩溃恢复;一个任务的状态不能被并发执行弄乱

我自己初版图省事,把短期记忆和长期记忆全放内存键值库,结果一重启全没;任务工作区放在进程内字典里,结果一次发布直接导致所有进行中的任务消失。后来才改成上面的组合。前期花半天切换存储,省掉了后面几十次“任务神秘丢失”的排查。

3. 断点续跑:从“内存状态”到“可恢复状态”

3.1 可恢复任务的核心:把执行过程变成可回放记录

断点续跑最容易误解的地方在于:它不是“从头重试一遍”,而是“从最后一次可靠进度继续往下走”。

要做到这一点,关键是把进程内不可恢复的状态,变成可查询、可回放的数据。一个Python对象的成员变量、一个生成器的内部位置、一段正在执行的代码栈,这些在进程退出之后就永远消失了,属于不可恢复状态。而数据库里的一行任务记录、一条事件日志、一份工作区JSON,重启后依然在,属于可恢复状态。

我推荐用“快照加事件回放”的组合。工作区JSON就是快照,记录“目前已经推进到哪一步”。事件表记录每一步的关键动作,比如“步骤A已开始”“步骤A已完成”。服务重启后,先读取快照,再查事件表,对状态为“已开始但未完成”的步骤做特殊处理。

这里要特别注意:恢复不等于重新执行所有步骤。如果某一步“拉取销售数据”已经成功完成并写入了中间结果,恢复时就不应再重复拉取一次。事件的回放是为了还原进度,不是为了重放副作用。

3.2 状态机与幂等:断点续跑的两个硬性前提

第一,任务要有明确的状态机。我常用的一套很简单:pending -> running -> waiting_input -> done / failed。waiting_input表示任务需要用户提供更多信息才能继续。服务重启后,只恢复那些停留在running或waiting_input超过一定时间阈值的任务。pending还没真正开始,可以直接入队;done和failed则不需要恢复。

第二,每一步都要具备幂等性。幂等的意思是“同一个操作重复执行多次,结果和只执行一次相同”。比如给某条知识库记录打标,重复执行没问题;但发送邮件、创建订单、扣减库存这类操作,重复执行就是事故。

我的标准做法是:每一步执行前生成一个request_id,写入事件表后再调用外部动作。外部动作返回成功之后,再更新事件状态为完成。这样即使调用后进程崩溃,重启后看到“步骤已记录但事件状态未完成”,也能根据request_id查询外部服务,确认这次调用到底有没有成功,然后决定是复用结果还是安全重试。

3.3 从“中断现场”恢复执行的处理流程

我落地之后的恢复流程比较固定,分享给你直接参考:

  1. 服务启动时,扫描任务表中状态为running或waiting_input,并且updated_at早于当前时间5分钟以上的任务。5分钟阈值用来过滤“正在正常执行”的任务,避免把活任务误杀。
  2. 读取对应任务的工作区JSON,拿到目标、计划、步骤状态。
  3. 从步骤事件表中找出状态为“已开始但未确认完成”的步骤。
  4. 检查该步骤的request_id,确认是否已经有成功结果。有就直接复用;没有就重置为待执行,重新入队。
  5. 把恢复点的信息追加到短期记忆摘要中,让LLM知道当前是“续跑”而不是“新任务”,向用户说明“任务之前进展到哪一步,现在继续”。

恢复过程里有一个很容易忽略的点:不要把一个用户的任务同时交给两个服务实例去跑。恢复前先对任务记录加锁,确保只有一个实例负责推进这个任务。

3.4 最小实现:三张表加一个恢复调度

这里给一个可以直接迁移的最简方案,三张表足够。

CREATE TABLE task_runs ( task_id VARCHAR(64) PRIMARY KEY, goal TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', workspace_json JSONB NOT NULL, current_step VARCHAR(128), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE task_steps ( id BIGSERIAL PRIMARY KEY, task_id VARCHAR(64) NOT NULL, step_name VARCHAR(128) NOT NULL, status VARCHAR(16) NOT NULL, request_id VARCHAR(64), payload JSONB, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE task_events ( id BIGSERIAL PRIMARY KEY, task_id VARCHAR(64) NOT NULL, step_name VARCHAR(128) NOT NULL, event_type VARCHAR(16) NOT NULL, created_at TIMESTAMP NOT NULL );

恢复调度用伪代码来表示大概是这样:

def resume_tasks(): tasks = db.query( "SELECT * FROM task_runs " "WHERE status IN ('running', 'waiting_input') " "AND updated_at < now() - interval '5 minutes'" ) for task in tasks: workspace = load_workspace(task["task_id"]) recovery_point = decide_recovery_point(workspace) reset_to_pending(recovery_point) enqueue(task["task_id"], recovery_point)

这套最小方案也许不是最高性能的,但足够把“进程重启后任务全没了”这个最痛的问题解决掉。先把可靠的骨架立起来,再谈优化。

4. 记忆与RAG检索、Prompt编排的协同

4.1 记忆检索 + 知识检索的混合召回

企业问答系统一般都有知识库RAG,但知识库是“静态百科”,记忆是“用户现场”。只有知识库没有记忆,回答会正确但不够贴人;只有记忆没有知识库,系统就变成只会闲聊的空壳。两者需要配合。

我在实践中把召回分成三条线。第一条是从短期记忆里提取实体和指代关系,比如“第二张发票”中的“第二张”在上一轮里对应什么。第二条是从长期记忆里检索用户事实槽位,比如用户偏好、设备型号、历史承诺。第三条才是知识库文档检索。

然后把三条线的结果合成一段“记忆增强上下文”交给模型。混合重排时我通常给一条经验权重:知识相关性占0.5左右,长期记忆相关性占0.3,意图匹配度占0.2。这个权重可以根据业务调整,但原则是知识仍然占大头,记忆只负责“筛条件”和“补背景”。有一种情况例外:用户直接问“我之前设置的自动告警还在吗”,这时长期记忆的权重需要明显提高。

4.2 三个关键时机的写入与更新

记忆写入时机不对,要么写不进去,要么写进去一堆垃圾。我总结为三个时机。

第一个时机是用户每一轮说完之后。无论系统这轮回答成功与否,原始对话轮次都要写入短期记忆。哪怕回答崩了,这些内容也是后续“自救”的素材。

第二个时机是会话结束或出现关键事实时。系统可以用异步任务从本轮对话中抽取长期记忆,更新用户事实槽位。抽取动作放在后台,不要阻塞当前回答。

第三个时机是复杂任务的生命周期内。任务一开始创建工作区,每完成一步更新工作区状态,任务结束后把重要结果沉淀到长期记忆,例如“用户已完成销售报告生成,报告存储在某个位置”。这样下次用户再问“上次那份报告在哪”,系统可以直接回答。

4.3 带记忆的问答流程编排示例

把上面这些串起来,大致长这样:

  1. 根据用户ID加载长期记忆画像,得到用户事实。
  2. 根据会话ID加载短期记忆摘要,得到当前上下文。
  3. 如果当前有进行中的任务ID,从工作区加载任务状态。
  4. 把这三部分记忆按固定格式拼接进系统提示词,再注入当前用户提问。
  5. 模型生成回答。
  6. 回答完成后,异步触发长期记忆抽取。

一个简化的上下文模板是这样的:

system: 你是一个企业内部问答助手。请结合下面的记忆回答用户问题。 [短期会话摘要] {session_summary} [用户长期画像] {user_profile} [任务进行状态] {workspace_state} user: {user_query}

我想强调的是:记忆不是为了堆内容,而是为了控制模型“看到什么”。每一个进入prompt的记忆片段都应该有明确用途。如果一段记忆放进去之后,模型回答没有任何变化,那它就是噪声,应该被过滤掉。

5. 上线前必须避开的坑:实测中的翻车记录

5.1 坑一:记忆污染——历史里的随口一句话,成了永久人设

我最开始写长期记忆时,把用户“你这系统行不行啊”这种抱怨也存了进去。结果后续所有回答都被这条负面记录影响,模型每次都带着“用户在质疑系统”的预设去回答,语气变得特别防御。

后来我加了三个过滤器:一是提取内容必须包含明确的实体或可结构化槽位;二是情感倾向必须中性,抱怨吐槽不存;三是必须有明确主语,避免把否定句和假设句当成事实。“我可能会买”不会进用户画像,“我已经买了”才会。每条长期记忆还带了来源会话ID和置信度,低于阈值的记忆不参与检索。

5.2 坑二:断点续跑导致重复外部调用

这个坑我踩得很痛。当时有一个演示任务,用户要求“把报告发到邮箱”,系统在测试时把同一封邮件发了两遍。原因就是先调用邮件接口,后写事件状态。进程在调用后、状态更新前崩溃,恢复后系统不知道邮件已经发出,于是又发了一遍。

正确的顺序是先写“开始事件”,生成request_id,再调外部接口;外部接口返回成功后,再写“完成事件”。恢复时看到有开始事件但没有完成事件,先拿request_id查邮件服务状态,确认是否真的发过。对任何有副作用的操作,都要把“调用前记录”变成铁律。

5.3 坑三:长期记忆写入失控与召回噪声

长期记忆一开始写入太积极,没过多久每个用户的槽位就膨胀到了几百条。检索时一条用户问题能召回几十条记忆,prompt被无关历史塞满。

我加了三条硬约束:每用户最多64条事实;每条事实不超过64字;回答时只取Top3条记忆进上下文。一旦超过,按最近使用时间和历史命中次数淘汰。上线后我看了一个指标叫“记忆使用率”,也就是写入的记忆中被后续真实问答用到的比例。从不足20%提升到60%以上,才算真正把记忆做准了。

5.4 常见问题速查表

问题现象可能原因解决办法
用户问“我刚说的”系统答不上短期记忆未写入或压缩摘要丢失实体窗口溢出时摘要,保留实体白名单
服务重启后任务从零开始任务状态只存在内存里引入task_runs表和task_steps表
恢复后重复执行外部动作调用前没有记录request_id先写开始事件,再调外部API
长期记忆五花八门,什么都有抽取时没有过滤规则槽位化、情感中性过滤、置信度门槛
prompt越来越长,费用飙升直接拼全部历史对话滚动摘要加窗口截断

6. 这版方案落地后我保留的取舍与后续优化

这套方案落地之后,我保留了几个看起来“保守”的决定。第一个是只对短期记忆和工作区做了完整实现,长期记忆先跑槽位抽取,不上复杂向量检索。理由是长期记忆的质量需要足够的标注数据来验证,一开始铺太大会很难收敛。第二个是恢复任务时只自动恢复“无副作用或已幂等”的步骤,涉及邮件、订单这类操作,一律进入待确认状态,由用户确认后再继续。第三个是每层记忆都记录来源和更新时间,方便出了问题时回溯。

我个人的体会是,记忆不是把历史都留住,而是把最该记住的东西放在对的地方。三层记忆的设计,本质是在成本和准确率之间做取舍;断点续跑的本质,是把临时状态变成可以重建的数据。很多团队一开始想得很复杂,想一步到位支持几十种记忆策略,结果连最基础的会话记忆都没做好。先把一层跑明白,再往上加,比一开始设计一个庞大系统要靠谱得多。

这一章的内容在项目里跑了半年多,最大的价值不是技术多花哨,而是用户在第7轮追问的时候,系统终于知道他在说什么;服务重启之后,任务不会消失。做到这两点,企业级问答系统才算真正“能用”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询