把会话记忆和文档切片塞进同一个库、共用一条检索路径,通常是上下文变乱的开端。判断标准不是“哪类更重要”,而是这条数据来自哪里:会话记忆按会话取,文档切片按相似度取,工具输出按调用标识取。先确认写入侧能不能按来源分开计数,再决定谁进长期库、谁只在当次请求里存在。
会话记忆、文档切片、工具输出的来源和取回方式不同,混在一个库、一条检索路径里,召回结果互相干扰几乎是必然。通常做法:会话记忆按 session 时间窗取、会话结束后按 TTL 淘汰;文档切片按向量相似度加版本过滤取、随文档版本替换淘汰;工具输出按 call_id 取、大 body 只留摘要。分流先加来源标记并只写不读,再按工具输出、文档切片、会话记忆的顺序重排,用分组计数和抽样回放确认没丢数据。
按来源把上下文分成三类
动手拆之前,先看手里到底有哪几种数据。很多“上下文越塞越乱”的现场,实际是三种写入方都往一张表里写,字段却只有一段文本和一个向量。可以按下面的维度先盘一遍:
| 类型 | 来源 | 更新频率 | 典型长度 |
|---|---|---|---|
| 会话记忆 | 对话层的用户与 Agent 往返消息 | 每轮追加,高频 | 单轮几十到几百字,整个会话几百字到几万字 |
| 文档切片 | 上传文档或知识库文件切分后的 chunk | 文档版本变化时才更新,低频 | 单片几百字,整库常见到万级片数 |
| 工具输出 | 函数调用、HTTP 请求的返回体 | 调用即产生,频率跟随工具调用 | 几十字节到数 MB 都可能 |
盘点的依据是写入日志或落库记录里有没有来源字段。grep -c "source" app.log 这类命令只能看个大概,更稳妥的是直接对上下文表做分组统计:select source, count(*) from context_store group by source;。如果结果只有一行,说明写入时就没有区分来源,这就是干扰的根因,而不是检索参数调得不好。
判断哪一类需要长期保留
区分一次性上下文和可复用上下文,回答三个问题就够了:这条数据会不会跨会话被再次问到?丢掉之后能不能低成本重建?留错会不会直接影响答案正确性?三个都是“会”的,才值得进长期库。
淘汰由什么条件触发,也要按类写清楚。会话记忆通常由会话结束加上一段 TTL 触发;文档切片由文档删除或版本替换触发;工具输出最省事的做法是调用返回后短时间内没人引用就丢,只保留结构化摘要。下面是一份通用配置骨架,字段名可按自己的存储替换:
retention:
session_memory:
keep: 30d
drop_on: session_closed
doc_chunk:
keep: until_doc_version_replaced
filter_by: [doc_id, doc_version, tenant_id]
tool_output:
keep_raw: false
summarize_over_bytes: 8192
drop_on: no_reference_within: 1h
这份配置放在写入侧的调度任务里执行,验证方式是跑一次清理任务,再按 source 分组看计数有没有按预期下降。注意 TTL 不是缓存策略,把它当成“省空间”的手段容易误删需要复用的记忆。
确认每一类的取回方式
不同数据不该共用同一种检索入口,这是分流里最容易省掉又最容易出问题的一步。
- 按会话取:用 session_id 加时间窗,再按轮次顺序还原。适合会话记忆,不需要向量化。
- 按相似度取:向量检索加元数据过滤(doc_id、版本、权限)。适合文档切片。
- 按调用标识取:用 call_id 或 trace_id 直接取。适合工具输出。
反例一:把会话记忆也做向量化后用相似度召回,结果把几天前一次无关对话拉了进来,Agent 顺着错误前提往下答。反例二:把工具返回的整段 JSON 当切片入库,召回时命中半截对象,解析直接报错。反例三:文档切片按会话取,同一份文档在多个会话里各存一份,版本一对不上就开始互相矛盾。一个可用的分流骨架大致是这样,函数名按实际存储替换即可:
def fetch_context(query, session_id, call_ids, top_k=8):
return {
"memory": fetch_by_session(session_id, limit_turns=20),
"docs": vector_search(query, top_k=top_k,
filter={"doc_version": current_version}),
"tools": fetch_by_call_ids(call_ids),
}
验证方式:在返回结果里带上来源标记,看一次回答实际用了哪几类数据;如果某类从没出现过,要么是过滤条件写死了,要么是这一类根本没写进去。
把已经混在一起的数据拆开
已经混在一起的库,不建议一次性重排,按下面的顺序推进更可控:
- 补来源标记。只写不读,让新数据先带上 source,观察各来源的实际写入量。这一步不改检索逻辑。
- 分离写入路径。三类数据各走各的写入函数,写入时就把原始 body 和摘要分开存。
- 先动工具输出。它量最大、特征最明显(长 body、结构化返回),丢掉损失也最小。
- 再动文档切片。重新切分、重建索引,注意带上版本字段。
- 最后动会话记忆。它最怕丢,前面几步稳定之后再迁移。
确认没丢数据的做法是前后对比加抽样回放:迁移前记录 select source, count(*) from context_store group by source;,迁移后跑同样的语句对比;再随机抽几条原始记录,走一遍取回路径,看能不能取到内容。旧表建议保留一段只读时间,确认新路径稳定后再下线。
划出不该长期保留的内容边界
有几类内容即使写进来方便,也不适合放进长期库:
- 工具原始响应体,尤其是大 body、二进制和 base64。这类内容通常可以重新调用获得,存摘要和调用标识就够。
- 凭证类数据,包括 token、cookie、私钥。一旦进入可检索库,泄露面会随检索接口一起放大。
- 未脱敏的个人信息。既影响合规,也会污染相似度检索,让召回结果频繁命中无关记录。
- 模型的中间推理和草稿。它不是事实来源,回灌进上下文会被当成已知事实继续引用。
- 时效性极强的状态快照,比如当前余额、当前库存。存下来很快就错,应以实时查询为准。
- 同一份文档在多个会话里的重复副本。版本一旦不一致,检索到哪一份就变成随机事件。
判断理由可以统一成三条:能否低成本重建、丢弃后是否影响答案正确性、是否属于敏感面。三条里占了两条的,通常就不该长期保留。边界划完之后,再回头看第一张表,三类数据的写入和取回路径就各自清楚了。