对话长了就想截断上下文——Hy Memory 2.0 的记忆淘汰该怎么定?

文章导读
先别急着定“保留几轮”或“保留多少字”。Hy Memory 2.0 里更稳的做法是:先把一轮请求的实际历史规模量出来,再按内容性质选淘汰口径。轮数口径最容易实现,但会误伤长会话里的早期约定;长度口径能卡住成本和延迟,却容易把一次工具调用的请求和响应切成两半;重要性口径最贴近“别丢关键信息”,代价是标记漏了就等于没标。建议的顺序是:长度做兜底、重要性做优先级、轮数只当硬上限,三者组合,而不是只挑一个
📋 目录
  1. 一 先量一下现在一轮请求里塞了多少历史
  2. 二 列出三种淘汰口径:轮数、长度、重要性
  3. 三 给关键信息打标记,让它不被淘汰
  4. 四 用同一段长对话对比淘汰前后的回答
  5. 五 把策略参数固化并留回滚开关
A A

先别急着定“保留几轮”或“保留多少字”。Hy Memory 2.0 里更稳的做法是:先把一轮请求的实际历史规模量出来,再按内容性质选淘汰口径。轮数口径最容易实现,但会误伤长会话里的早期约定;长度口径能卡住成本和延迟,却容易把一次工具调用的请求和响应切成两半;重要性口径最贴近“别丢关键信息”,代价是标记漏了就等于没标。建议的顺序是:长度做兜底、重要性做优先级、轮数只当硬上限,三者组合,而不是只挑一个。

如果只能先上一个口径,建议先用长度预算兜底,把历史条数、字符数和估算 token 打进日志;再给关键约束打显式标记,让淘汰时优先保留被标记的消息。轮数上限只作为防失控的硬阈值,不作为主策略。这样即使重要性标记有遗漏,也有长度预算兜底;但标记规则需要人工维护,配置参数也必须能一键回滚到不淘汰的旧行为。

先量一下现在一轮请求里塞了多少历史

“太长了”不是一个可操作的说法。先把它拆成三个数:历史消息条数、历史字符数、按所用分词器估算的 token 数。这三个数不必都精确,但必须能持续看到。

最容易落地的记录位置是构造请求的那一层:在把 messages 数组发给模型之前,打一行结构化日志。也可以放在网关或代理侧,好处是客户端不需要改。日志字段建议至少包含:会话标识、当前轮次、历史条数、历史字符数、估算 token、预算值、淘汰后保留条数。下面这行只是字段示意,数值请以你自己的日志为准:

[mem] session=s-xxx turn=12 hist_msgs=46 hist_chars=18342 est_tokens=9120 budget_tokens=6000 evict=length kept=31 dropped=15 pinned=3

如果已有请求样本落盘,也可以离线统计,不必改动线上代码:

# 统计单个请求样本里的历史条数
jq -r '.messages | length' payload.json

# 粗略看历史文本总长度
jq -r '[.messages[].content | if type=="string" then . else tostring end] | join("") | length' payload.json

连续观察若干真实会话后,通常会看到一个分布:多数会话的历史很短,少数长会话贡献了大部分体积。淘汰策略要针对的是这批长会话,而不是把所有会话的预算压到最小值。这一步只做测量,不改行为,风险最低。

列出三种淘汰口径:轮数、长度、重要性

三种口径不是互斥的,但先要把各自的取舍写清楚,避免配置时混用导致行为不可预期。

口径适用场景副作用可实现方式
轮数短会话为主、问题多为连续追问的问答场景长轮次会话里早期给出的约束(称呼、格式要求、已确认的参数)会被裁掉,模型可能反复追问按 user/assistant 配对计数,从头部丢弃,保留最近 N 轮;实现最简单
长度模型上下文有硬限制,或需要控制单次请求体积的场景可能正好截在一次工具调用的中间,造成 messages 结构不完整;也可能切掉回答的开头从尾部向前累加字符数或 token 估算值,超出预算就丢头部;需要按消息成对丢弃
重要性任务型长对话,有明确目标、约束、已确认结论依赖标记质量;标记漏掉的关键信息会被正常淘汰,且很难事后发现给每条消息附带 metadata(如 pinned、score、ttl),淘汰时先按分数、再按时间次序

组合时建议明确优先级顺序,例如:先保留被标记的消息,再按时间保留最近若干轮,最后用长度预算收口。优先级顺序本身就是配置项,写进配置文件而不是散在代码分支里,排障时才好读。

给关键信息打标记,让它不被淘汰

直接截断误伤关键内容,本质问题是没有区分“哪些消息丢了会出事”。标记有两条路,可以并存。

一条是应用侧字段:消息对象上附加元数据,由你自己的代码写入和读取。这种方式可控,不依赖模型配合。下面是配置骨架示意,字段名可以替换成你项目里的命名:

memory:
  budget:
    max_chars: 24000        # 单次请求历史字符上限,起始值需结合上下文窗口确认
    max_messages: 60        # 历史条数硬上限
    max_turns: 20           # 轮数硬上限,只做防失控
  eviction:
    order: [pinned, recent_turns, length]   # 先保标记,再保近期,最后按长度收口
    keep_pinned: true       # 标记消息不参与淘汰
    max_pinned: 8           # 标记数量上限,防止标记泛滥导致预算失效
    drop_unit: turn         # 按轮成对丢弃,避免拆散工具调用与响应
  markers:
    system_prefix: "[PIN]"  # 显式指令约定:模型输出以该前缀开头的消息自动标记
    min_score: 0.7          # 分数型标记的保留阈值

另一条是显式指令:在系统提示里约定,当用户确认了某个约束、参数或结论时,输出带上固定前缀或结构化字段,应用侧解析后写入 metadata。这条依赖模型遵守约定,不能假设百分之百生效,所以需要配合长度预算兜底,并定期抽查被标记的消息是否合理。

对话长了就想截断上下文——Hy Memory 2.0 的记忆淘汰该怎么定?

验证方式很直接:构造一段包含明显约束的长对话,看淘汰后 pinned 消息是否还在请求体里。可以在构造请求处加一行断言或日志,打印淘汰前后的消息 id 列表做比对。

用同一段长对话对比淘汰前后的回答

淘汰策略上线前,需要确认没有引入新的遗忘。做法是拿一段真实的长对话做回放,而不是现编几轮短对话。

对照组设置:A 组关闭淘汰(前提是上下文装得下),B 组启用新策略,两组使用完全相同的对话历史、相同的提问序列、相同的模型参数。提问至少覆盖三类:早期给出的约束(如输出格式、称呼、已确认的口径)、中期产生的中间结论、最近一轮的追问。每组各跑一轮,人工比对,不追求打分自动化。

观察点清单:

  • 被标记的关键信息,B 组是否仍能准确复述,还是开始反问用户
  • 回答里是否出现引用了已被淘汰内容的情况,或者明显与早期约定矛盾
  • 工具调用的参数是否完整,有没有出现只保留了一半的调用与响应
  • 回答长度和结构是否突然变化,例如格式要求丢失后输出变成纯段落
  • 同一问题重复提问时,B 组的回答是否稳定,以此判断淘汰是否引入了随机性

发现差异时,优先调整标记规则和预算,而不是直接放宽到不淘汰。放宽预算只是把问题往后推。

把策略参数固化并留回滚开关

淘汰逻辑一旦上线,参数就成了行为的一部分。把参数集中到一处,并保证能在不改代码的情况下退回原行为。

参数清单与起始默认值,均需结合你的上下文窗口和流量特征再确认:

  • enable_eviction:默认 false(先以只记录不淘汰的方式跑一段时间)
  • max_chars / max_messages / max_turns:起始值可以设得比当前常见历史规模略高,先保证不误伤
  • eviction.order:先 pinned,再 recent_turns,最后 length
  • keep_pinned:true;max_pinned:给一个不超过预算的较小值
  • drop_unit:turn,避免拆散工具调用
  • marker 前缀与阈值:与系统提示里的约定保持一致

回滚步骤建议按这个顺序执行:先把 enable_eviction 置回 false,让请求回到完整历史(若超出上下文则临时改用更短的会话或分段提问);观察日志里 hist_chars 是否回到淘汰前水平;确认回滚后同一段长对话的回答恢复到 A 组表现;最后再排查是预算过紧、优先级顺序写错,还是标记命中异常。回滚开关建议做成配置热更新或环境变量,避免为了回滚重新发版。

另外保留一份请求样本:每次策略变更前后各存几条完整 payload,出问题时能直接回放比对,比凭印象判断快得多。