给 Agent 做长期记忆,写入粒度和检索过滤不是两个可以分开调的旋钮。粒度决定每条记忆里塞了多少上下文,过滤决定召回时允许跨多少上下文;粒度粗而过滤少,就会把旧会话、别的话题一起捞回来;粒度细而过滤严,该记住的偏好和约束又可能被切碎。OpenViking 这层如果只改写入不改读回,日志里只能看到“召回不准”,分不清是写多了、写少了还是过滤条件太松。建议先把写入粒度定下来,再让检索过滤跟粒度对齐,最后用一组边界用例压一遍。
写入粒度先按“单轮对话”作为常用档,会话标识和时间范围作为必带过滤;如果 Agent 需要跨会话复用事实,再单独给标签或实体键。验证方式是用一条已知会话写进去,按目标过滤条件读回,确认只返回该粒度下的记录。风险边界是:粒度越细,检索越依赖过滤条件;过滤越少,越容易把无关历史带进上下文。调整时优先改配置参数,不改调用代码。
把一次多轮对话拆成可写入的最小单元
三种粒度都可以通过配置切换,但字段里要能看出它属于哪一层。下面用伪 YAML 表示字段示意,不绑定具体接口。
# 整段会话
session_record:
session_id: <session-id>
start_ts: <ISO8601>
end_ts: <ISO8601>
summary: <summary-text>
raw_messages_ref: <storage-key>
tags: [<tag>]
# 单轮
turn_record:
session_id: <session-id>
turn_id: <turn-id>
ts: <ISO8601>
user_text: <user-text>
assistant_text: <assistant-text>
tags: [<tag>]
# 单条消息
message_record:
session_id: <session-id>
message_id: <message-id>
turn_id: <turn-id>
role: user|assistant
text: <message-text>
ts: <ISO8601>
tags: [<tag>]整段会话适合总结型查询,比如“这个项目上个月怎么定的”,写入少、检索快,但细节容易被压缩掉。单轮是多数 Agent 的默认选择,既保留当轮问答的上下文,又不会把整段会话一次塞进检索结果。单条消息适合需要精确锚点的场景,比如用户明确纠正的实体值、金额、偏好;代价是片段多,检索时必须靠会话标识和时间范围收窄。取舍依据可以先问一句:读回时最小需要多大上下文才够用。如果查询通常是“上次说的预算”,单轮就够;如果查询是“这个项目整体怎么推进”,整段会话更合适。
设计检索时要带上的过滤维度
过滤字段至少要覆盖会话、时间、标签三类。会话标识用于防串味,时间范围用于挡过期内容,标签用于跨会话复用事实。组合取值可以写成下面这种骨架,具体字段名按 OpenViking 实际配置替换。
filter:
agent_id: <agent-id>
session_id: <session-id> # 跨会话检索时置空
time_from: <ISO8601>
time_to: <ISO8601>
tags: [<tag-1>, <tag-2>]
memory_type: turn # 与写入粒度对齐
top_k: <n>session_id 和时间范围通常至少给一个;只给 tags 会把召回面放大。跨会话检索不要默认带 session_id,否则永远只看当前会话。时间区间是闭区间还是半开区间,要在配置里写明,否则边界记录会飘。标签不要写得太泛,比如“重要”这种标签几乎等于没有过滤。
写一段最小写入与读回验证
验证不需要完整链路,先写一条已知记录,再按目标过滤读回。调用骨架如下,字段占位,不写具体接口路径。
write(payload) -> memory_id
search(filter, query, top_k) -> [{memory_id, score, text, session_id, ts, tags}]
payload:
agent_id: <agent-id>
session_id: <session-id>
turn_id: <turn-id>
role: user
text: <message>
ts: <ISO8601>
tags: [<tag>]
memory_type: turn读回后至少断言这几项:返回条数不超过 top_k;每条 session_id 与 filter 一致,除非 filter 显式跨会话;每条 ts 落在 time_from 和 time_to 之间;如果 filter 带了 tags,返回记录至少命中一个标签;memory_id 能对应到刚写入的那条记录。只要有一项不满足,先看写入侧字段是否齐全,再看读回侧过滤是否被默认值覆盖。
用三个边界用例压一遍当前配置
用例一:跨会话同名问题。输入是在 session-A 和 session-B 都写入“预算 10 万”,读回时 filter 只给 session_id=session-A。期望只返回 session-A 的记录;如果返回 session-B,说明过滤没生效,或者写入时 session_id 被覆盖。用例二:超长会话截断。输入是一条超过 max_chars 的单轮消息,写入后读回。期望要么写前截断并记录原始长度,要么拒绝写入并返回错误;不能静默只存一半还标记为完整。用例三:时间过滤边界。输入是一条 ts 正好等于 time_from 或 time_to 的记录。期望按配置的区间语义返回或剔除,日志里能看出边界规则;如果边界记录随机出现,先检查 ts 格式是否一致。
把粒度与过滤抽成可回滚的参数
为了后续调整不用重写调用代码,把下面这些项外置成配置。默认值只是起点,需要结合环境确认。
write_granularity: turn # turn | message | session
session_scope: current # current | cross_session
default_time_window: 7d
default_tags: []
max_chars_per_record: <n> # 按模型上下文留余量
search_top_k: 5
time_boundary: half_open # closed | half_open改动后要重新验证的项目:改 write_granularity 后,读回断言里的 memory_type 要同步;改 session_scope 后,跑跨会话同名用例;改 default_time_window 或 time_boundary 后,跑时间过滤边界用例;改 max_chars_per_record 后,跑超长会话截断用例。每次改完至少写入一条已知记录,按目标过滤读回,看日志里的实际 filter 和返回条数是否一致。