Agent 上下文越塞越乱 / OpenViking 到底该存会话记忆还是文档切片?

文章导读
把会话记忆和文档切片塞进同一个库、共用一条检索路径,通常是上下文变乱的开端。判断标准不是“哪类更重要”,而是这条数据来自哪里:会话记忆按会话取,文档切片按相似度取,工具输出按调用标识取。先确认写入侧能不能按来源分开计数,再决定谁进长期库、谁只在当次请求里存在。
📋 目录
  1. Ⅰ 按来源把上下文分成三类
  2. Ⅱ 判断哪一类需要长期保留
  3. Ⅲ 确认每一类的取回方式
  4. Ⅳ 把已经混在一起的数据拆开
  5. Ⅴ 划出不该长期保留的内容边界
A A

把会话记忆和文档切片塞进同一个库、共用一条检索路径,通常是上下文变乱的开端。判断标准不是“哪类更重要”,而是这条数据来自哪里:会话记忆按会话取,文档切片按相似度取,工具输出按调用标识取。先确认写入侧能不能按来源分开计数,再决定谁进长期库、谁只在当次请求里存在。

会话记忆、文档切片、工具输出的来源和取回方式不同,混在一个库、一条检索路径里,召回结果互相干扰几乎是必然。通常做法:会话记忆按 session 时间窗取、会话结束后按 TTL 淘汰;文档切片按向量相似度加版本过滤取、随文档版本替换淘汰;工具输出按 call_id 取、大 body 只留摘要。分流先加来源标记并只写不读,再按工具输出、文档切片、会话记忆的顺序重排,用分组计数和抽样回放确认没丢数据。

按来源把上下文分成三类

动手拆之前,先看手里到底有哪几种数据。很多“上下文越塞越乱”的现场,实际是三种写入方都往一张表里写,字段却只有一段文本和一个向量。可以按下面的维度先盘一遍:

类型来源更新频率典型长度
会话记忆对话层的用户与 Agent 往返消息每轮追加,高频单轮几十到几百字,整个会话几百字到几万字
文档切片上传文档或知识库文件切分后的 chunk文档版本变化时才更新,低频单片几百字,整库常见到万级片数
工具输出函数调用、HTTP 请求的返回体调用即产生,频率跟随工具调用几十字节到数 MB 都可能

盘点的依据是写入日志或落库记录里有没有来源字段。grep -c "source" app.log 这类命令只能看个大概,更稳妥的是直接对上下文表做分组统计:select source, count(*) from context_store group by source;。如果结果只有一行,说明写入时就没有区分来源,这就是干扰的根因,而不是检索参数调得不好。

判断哪一类需要长期保留

区分一次性上下文和可复用上下文,回答三个问题就够了:这条数据会不会跨会话被再次问到?丢掉之后能不能低成本重建?留错会不会直接影响答案正确性?三个都是“会”的,才值得进长期库。

Agent 上下文越塞越乱 / OpenViking 到底该存会话记忆还是文档切片?

淘汰由什么条件触发,也要按类写清楚。会话记忆通常由会话结束加上一段 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 不是缓存策略,把它当成“省空间”的手段容易误删需要复用的记忆。

确认每一类的取回方式

不同数据不该共用同一种检索入口,这是分流里最容易省掉又最容易出问题的一步。

Agent 上下文越塞越乱 / OpenViking 到底该存会话记忆还是文档切片?
  • 按会话取:用 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),
    }

验证方式:在返回结果里带上来源标记,看一次回答实际用了哪几类数据;如果某类从没出现过,要么是过滤条件写死了,要么是这一类根本没写进去。

把已经混在一起的数据拆开

已经混在一起的库,不建议一次性重排,按下面的顺序推进更可控:

Agent 上下文越塞越乱 / OpenViking 到底该存会话记忆还是文档切片?
  1. 补来源标记。只写不读,让新数据先带上 source,观察各来源的实际写入量。这一步不改检索逻辑。
  2. 分离写入路径。三类数据各走各的写入函数,写入时就把原始 body 和摘要分开存。
  3. 先动工具输出。它量最大、特征最明显(长 body、结构化返回),丢掉损失也最小。
  4. 再动文档切片。重新切分、重建索引,注意带上版本字段。
  5. 最后动会话记忆。它最怕丢,前面几步稳定之后再迁移。

确认没丢数据的做法是前后对比加抽样回放:迁移前记录 select source, count(*) from context_store group by source;,迁移后跑同样的语句对比;再随机抽几条原始记录,走一遍取回路径,看能不能取到内容。旧表建议保留一段只读时间,确认新路径稳定后再下线。

划出不该长期保留的内容边界

有几类内容即使写进来方便,也不适合放进长期库:

  • 工具原始响应体,尤其是大 body、二进制和 base64。这类内容通常可以重新调用获得,存摘要和调用标识就够。
  • 凭证类数据,包括 token、cookie、私钥。一旦进入可检索库,泄露面会随检索接口一起放大。
  • 未脱敏的个人信息。既影响合规,也会污染相似度检索,让召回结果频繁命中无关记录。
  • 模型的中间推理和草稿。它不是事实来源,回灌进上下文会被当成已知事实继续引用。
  • 时效性极强的状态快照,比如当前余额、当前库存。存下来很快就错,应以实时查询为准。
  • 同一份文档在多个会话里的重复副本。版本一旦不一致,检索到哪一份就变成随机事件。

判断理由可以统一成三条:能否低成本重建、丢弃后是否影响答案正确性、是否属于敏感面。三条里占了两条的,通常就不该长期保留。边界划完之后,再回头看第一张表,三类数据的写入和取回路径就各自清楚了。