聊到七八轮后助手开始答非所问,通常不是模型突然变笨,而是记忆链路里有一段没走通:条目没写进去、写进了别的作用域、或者写进去了但检索没命中。这三种情况的处理方式差别很大,先别改提示词,也别急着调大召回条数,先做一次能重复的最小复现,再用写入侧记录和召回列表分别对照,就能把问题落在某一段上。
判断顺序建议固定为:先复现、再验写入、最后验检索。适用场景是多轮对话后出现记忆失效;操作动作是按固定轮次和固定提问重跑一次,同时保存每轮返回原文、写入侧新增记录和召回条目列表;验证方式是看同一标记能否在存储里查到、能否被召回;边界是先确认异步写入任务是否已完成,否则容易把检索没命中误判成写入丢失。
把一次丢记忆的对话压成最小复现
复现的目标不是“再聊一次看看”,而是让同一个问题每次都按同样方式失败,这样后面的对比才有意义。做法是:
- 轮次固定,建议 8 轮,中途不要改提问顺序,也不要临时插入无关话题。
- 每轮只植入一条带唯一标记的事实,标记用不易被模型改写的形式,例如 M07-班次-周四,避免用“周四我值班”这种会被同义改写的自然句。
- 每轮把完整返回内容原样存文件,文件名带轮次,例如 turn-03.json。只存你摘出来的那半句话,后面很难和日志对上。
- 最后一轮提一个只有依赖前面记忆才能答对的问题,例如“M07 的班次是周几”。
# 占位骨架:YOUR_CHAT_API 换成你环境里的对话接口封装
turns = [...] # 固定好的 8 轮文本
for i, text in enumerate(turns, 1):
resp = YOUR_CHAT_API.send(session_id=SID, message=text)
save(f'turn-{i:02d}.json', resp.raw) # 存原始响应,不要只存 reply 文本
save(f'turn-{i:02d}.meta.json', {
'memory_ids': resp.memory_ids, # 有就存,没有就留空
'trace_id': resp.trace_id
})
如果响应里带 trace_id 或 memory_id 之类的标识,一定要一起存下来。后面在写入侧查记录时能直接对上,比按内容去模糊搜索省事得多,也避免因为摘要改写导致搜不到。
在写入侧确认记忆条目是否真的落库
这一步只回答一个问题:那条记忆到底有没有被写进存储。先按会话 id 加时间窗口,查询本次复现期间新增的记录,再逐条对照字段:
- 内容原文:是否与用户说的意思一致,有没有被摘要压缩掉关键实体(编号、日期、人名)。
- 作用域字段:user_id、tenant、agent_id、session_id 是否与复现时完全一致。写错作用域是“写了但取不到”的常见原因,尤其是多轮对话里 session 被重新生成过的情况。
- 状态字段:pending、ready、failed 这类状态。异步抽取还没跑完时,检索为空是正常的,不能据此判断写入失败。
- 时间字段:created_at 是否落在预期的轮次时间范围内,确认不是上一次会话的旧数据。
- 索引或向量字段:是否为空。为空说明只落了原文没建索引,属于写入后半段的问题。
结论只有两种:按会话查不到任何新增记录,问题在写入侧;记录在、内容在、作用域也对,但检索拿不回来,问题在检索侧。中途状态未完成时,先等任务结束再判断,不要急着下结论。
在检索侧打印命中的条目列表
打印召回结果,重点不是看有没有返回,而是看返回的条目和当前问题是否相关、正确那条排在第几位。下面的骨架只做打印,不改业务逻辑:
# 占位:YOUR_RECALL 换成你的检索函数或 HTTP 调用
def debug_recall(query, top_k, filters):
hits = YOUR_RECALL(query=query, top_k=top_k, filters=filters)
for rank, h in enumerate(hits, 1):
print(rank, h.get('score'), h.get('id'), h.get('updated_at'))
print(' ', (h.get('content') or '')[:120])
return hits
hits = debug_recall('M07 的班次是周几', top_k=5, filters={'session_id': SID})
排序依据需要写清楚:多数实现是先按向量相似度取一批候选,再叠加时间衰减或重要性权重,最后按最低分阈值截断。如果打印出来的顺序和你理解的规则不一致,说明中间还有一层 rerank 或过滤在生效,得先确认这层,再谈调参。
把两侧观察合起来,用这张表归类:
| 现象 | 更可能在哪一侧 | 下一步动作 |
|---|---|---|
| 存储里查不到本次新增记录 | 写入侧 | 看写入日志报错、异步任务状态 |
| 记录存在但 session/tenant 与复现不一致 | 写入侧(作用域错) | 校正调用时传入的作用域参数 |
| 记录存在但索引/向量字段为空 | 写入侧(索引环节) | 补跑索引任务,确认嵌入服务可用 |
| 召回列表为空 | 检索侧 | 检查过滤条件与最低分阈值 |
| 正确条目没进候选 | 检索侧 | 确认该条目是否已被索引、是否被作用域过滤 |
| 正确条目在候选里但排名靠后或被截断 | 检索侧(排序/截断) | 调召回条数与阈值 |
| 召回内容正确但回答仍错 | 生成侧 | 检查上下文拼装与提示词 |
只改一个变量做对照实验
确认要动的是检索侧之后,再做对照。两组实验之间,问题文本、session、阈值、是否开 rerank、是否重新写入,全部保持不变,只改召回条数 top_k:
- A 组:top_k=3,打印召回列表,记录最终回答原文。
- B 组:top_k=10,其余条件完全一致,同样打印列表和回答原文。
两次都跑在已经落库的同一份数据上,不要重新对话写入,否则写入侧的随机因素会混进来。对比时看两件事:正确条目在 B 组是否进入列表、进入后排名第几;回答是否随 top_k 变化而变化。
如果 top_k 调大后回答变正确,说明正确条目本来就在库里,只是被截断或排序压到了候选之外,问题在检索侧。如果 top_k 调到 10 正确条目仍不出现,回到写入侧再查一遍索引与作用域,别继续在检索参数上花时间。如果正确条目一直在列表里而回答始终不对,那是上下文拼装或提示词的问题,与记忆写入和召回都无关。
把结论落回配置并复测
定位清楚后,把改动固定到配置里,而不是留在某次临时调用中。常见的可调项(名称按你的环境替换):
memory.write.mode: async # 写入是同步还是异步
memory.write.wait_ready: true # 追问前是否等待索引完成
memory.recall.top_k: 10 # 召回条数
memory.recall.min_score: 0.2 # 最低分阈值,具体取值需结合环境确认
memory.scope.strict: true # 是否强制按 session/tenant 过滤
复测用同一份 8 轮脚本重跑,手动核对以下几步:先看每轮 meta 里的 memory_ids 是否非空;再按会话查存储,确认每个标记只有一条记录,没有重复写入;最后提同样的问题,看召回列表里正确条目的位置和最终回答。
通过标准可以定得朴素一些:正确条目稳定出现在召回列表前几位;最终回答包含正确值;同一脚本连跑两次结论一致;改动前失败的那一轮,改动后能重现成功。任一条不满足,说明还有一段没对齐,继续回到写入侧或检索侧排查,不要同时改多个配置项。
需要留意的边界是:单条会话通过,不代表跨会话、跨天或并发写入时同样成立。建议再抽一两个真实会话按同样步骤跑一遍,确认结论没有只在这个复现脚本里成立。