Hy Memory 2.0 的写入频率和召回条数是同一条链路上的两个阀门:写得太勤,条目越堆越像;召回开得太大,历史片段就会盖住当前问题。处理顺序建议是先做写入侧的重复统计和触发条件,再动召回条数——反过来调,常常只是在噪音堆上换了窗口大小,看不出真实差别。
面对“每轮都写导致重复堆积、召回一多又冲淡当前问题”,可以按这个顺序走:先统计重复条目规模,确认是不是真问题;再给写入加轮次、信息类型、显式指令三类触发条件;最后把召回条数从默认值分三档对比。每轮只改一个参数,用固定问题集复测。重复条目降下来但回答仍跑偏时,才继续收紧召回条数。具体字段名、默认值和取值范围需按实际部署版本核对。
先数一遍重复条目到底有多少
凭感觉调参最容易踩的坑是:你以为在解决重复,实际重复只占很小一部分,真正的痛点是召回把无关条目塞进了上下文。所以先量化。统计口径建议固定在“最近一个可观察窗口 + 同一 scope”,跨会话和跨用户的记忆不要混在一起数。
如果记忆条目落在数据库里,可以先用关键字段去重。前提是写入时已经算过归一化指纹(去首尾空白、统一大小写、去掉时间戳和后缀);没有指纹字段的话,先按原文精确分组粗看,再抽样人工核对。下面的表名和字段名按实际库替换。
-- 规模:总量 vs 去重后数量
SELECT scope,
COUNT(*) AS total,
COUNT(DISTINCT content_hash) AS uniq_by_hash,
COUNT(*) - COUNT(DISTINCT content_hash) AS dup_rows
FROM memory_items
WHERE created_at >= now() - interval '14 days'
GROUP BY scope;
-- 最密集的重复簇,取前 20 组人工看
SELECT content_hash, COUNT(*) AS n,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM memory_items
WHERE created_at >= now() - interval '14 days'
GROUP BY content_hash
HAVING COUNT(*) > 1
ORDER BY n DESC
LIMIT 20;
内容相似但不完全相同的近重复,SQL 分组抓不住,用抽样替代:按时间倒序导出最近 100 条,人工标注“同一件事被记了几次”,标到 20 条左右就能判断重复是偶发还是系统性。如果记忆以 jsonl 这类文件形式落地,用命令粗看写入频率也很直接:
# 字段名按实际文件结构调整
awk -F'"created_at": "' '{split($2, a, "T"); print a[1]}' memory.jsonl \
| sort | uniq -c
判断依据:单日写入条数明显高于对话轮次,且重复簇集中在同一类信息(偏好、环境配置、任务背景),说明是“每轮都写”造成的,不是用户重复交代。
给写入加一个触发条件:值不值得记
减少无效写入比事后清理更省事。可选的触发条件通常有三类,可以叠加使用,也可以先只开其中一类观察:
- 轮次条件:不在每轮写,改为每 N 轮写一次,或会话结束时统一抽取。适合长对话,代价是中途中断会丢一部分上下文。
- 信息类型条件:只放行偏好、决定、约束、稳定事实;寒暄、确认、重复复述一律不写。这一条对降重复最有效,但需要确认你的版本是否支持按类型过滤。
- 显式指令:用户明确说“记住这个”时立即写入,不受轮次条件限制。这条要保留,否则会牺牲可控性。
配置位置通常在记忆模块的 write 段,字段名各版本不完全一致,下面的骨架只作对照,落到实际配置文件时逐个核对:
memory:
write:
enabled: true
trigger: on_turn_threshold # on_every_turn | on_turn_threshold | on_session_end
min_turns: 3
types_allow: [preference, decision, constraint, fact]
types_block: [greeting, smalltalk, ack]
honor_explicit: true # 显式指令优先
dedup_before_write: true # 写入前查同 scope 近邻
dedup_window: 50 # 只在最近 N 条内查重,控制写入开销
验证方式:改完配置后重放同一段对话,对比写入日志里的条数;确认“显式指令”路径仍然能写进去,避免把用户主动要求记的内容也拦掉。
限制召回条数并观察回答变化
召回数量参数一般叫 top_k,也有版本叫 limit 或 max_results,位置在检索/recall 段。改之前先确认当前默认值,很多人凭印象以为默认是 5,实际是 10 或更高。
memory:
recall:
top_k: 6 # 常见别名:limit / max_results
min_score: 0.3 # 低于阈值的条目直接丢弃
scope: [session, user] # 先 session 后 user,避免跨用户串味
rerank: false # 条目少时不必开,先看数量影响
三档取值的对比记录方式:准备 10~20 条固定问题(包含需要历史信息的和纯粹闲聊的),每档跑一遍,逐条记录四项——召回条数、召回的条目里是否包含回答问题所需的那条、回答有没有答非所问、是否需要追问才能补齐背景。用表格记,不要只写“感觉变好了”:
| 档位 | top_k | 召回是否覆盖所需信息 | 回答是否跑偏 | 是否需追问 | 备注 |
|---|---|---|---|---|---|
| 低 | 3 | 逐条填写 | 逐条填写 | 逐条填写 | 偏保守,容易漏掉早期关键信息 |
| 中 | 6 | 逐条填写 | 逐条填写 | 逐条填写 | 多数场景的起点 |
| 高 | 12 | 逐条填写 | 逐条填写 | 逐条填写 | 噪音明显上升时再往回降 |
判断方向:如果低档已经能覆盖所需信息,就没必要用高档;只有在低档明显漏信息、且重复条目已经清理过的情况下,才考虑上调。
写入和召回各改一项的对照实验
两个参数同时改,出问题时分不清是谁的锅。用同一段对话、同一组问题做两次单独变更,每次都回到基线再动:
| 组别 | 写入触发 | 召回条数 | 想验证什么 | 观察指标 |
|---|---|---|---|---|
| 基线 | 保持原样(每轮写) | 保持默认值 | 记录当前重复规模和回答质量 | 写入条数、重复簇数量、跑偏轮次 |
| A 组 | 只加上触发条件 | 与基线相同 | 写入收敛后,重复是否下降、关键信息是否丢 | 新增条目数、显式指令是否仍写入、回答缺信息轮次 |
| B 组 | 回到基线 | 只改为三档中的某一档 | 召回条数本身对回答的实际影响 | 召回条数、所需信息是否命中、回答是否离题 |
观察指标要能落到日志或页面上:写入侧看 memory write 日志的条数和去重拦截记录;召回侧看检索日志里返回的条目和分数。别用“整体感觉更聪明”当结论。
把稳定取值写回配置并记录依据
对照跑完后,把站得住的取值固化,并把“为什么是这个值”一起写进记录,否则过几周没人记得当初的取舍。建议按这个格式留档:
| 参数 | 改动前 | 改动后 | 对应现象 | 是否保留 |
|---|---|---|---|---|
| write.trigger | on_every_turn | on_turn_threshold, min_turns=3 | 写入条数下降,显式指令仍能写入 | 保留 |
| write.dedup_window | 未开 | 开启,查最近 50 条 | 同 scope 近重复被拦截 | 保留 |
| recall.top_k | 默认值 | 6 | 所需信息仍能命中,回答不再被旧片段带偏 | 待复测确认 |
复测通过标准建议写成可检查的几条:固定问题集上,需要的背景信息都能召回;不需要历史信息的问题不会被塞进旧条目;用户显式要求记住的内容确实写入并能在后续召回;重复条目不再按天线性增长。任何一条不满足,就回到对应的单项实验再调,不要同时改两项。
最后提醒边界:召回条数不是越小越好,写得越少也不等于越干净。写入过严会丢掉后续要用的约束条件,召回过窄会让多轮任务断片。取值最终取决于你的对话长度、信息密度和用户对“记错”和“忘掉”哪个更敏感,需要结合真实对话回流再确认。