issue #134: Knowledge差异化合并升级为同轮LLM融合 #141

Merged
Yushu merged 3 commits from feat/134-knowledge-llm-merge into main 2026-08-17 00:53:57 +00:00
Member

Closes #134

实现内容

ADR-0015 拍板的 Knowledge 合并是"同一次 Delta 压缩 LLM 调用产出的结构化融合 + 差异化保留",
而 origin/main 上 merge_knowledge 是一句确定性字符串拼接,且合并逻辑活在存储层、发生在 LLM
调用返回之后——这个位置本身就使"同一次调用产出融合结果"无法成立。核心是一次 port 契约反转:
把合并决策从存储层上提到编排层。

被推翻的初始设计

最初考虑给 _EXTRACT_MEMORIES_TOOL.knowledge[]target_entry_id 字段,让 LLM 显式指认
合并目标。设计阶段的四路独立对抗校验推翻了这个方向:

  1. 直接撞上 docs 分支 ADR-0015 2026-07-10 更新节明文"仍是同一次 LLM 调用产出结构化输出,
    零新增字段/新增机制"——这条约束当时是为差异化保留契约本身写的,同样覆盖"要不要为合并
    目标识别新开一个字段"这件事。
  2. write_knowledge 本就无条件做 find_matching_entry 查重(ADR-0015"拒绝了"节"写入盲目
    追加,不查重"),让 LLM 的判断结果失效时退回同一套关键词匹配兜底,等于新机制的"失败路径"
    与"不加这个机制"完全等价,用更粗的机制(关键词交集)去否决更精确的判断,方向也反了。

最终形状:不加任何新 LLM schema 字段,合并目标继续 100% 由 find_matching_entry(关键词
交集)在编排层决定;LLM 只借助新增的"已知话题记忆"prompt 输入块写出更准的 trigger_keywords
(自然复用/扩展旧关键词)与差异化保留的完整 canonical_meaning

具体改动

  • _compress_one_chat 压缩前先 get_knowledge 读该 chat 全部已有 Knowledge,用
    _select_relevant_knowledge(复用 trigger_injection.match_and_rankid() 回填映射
    ——TriggerEntry 带 list 字段不可哈希、且值相等可能碰撞)预筛喂给 LLM;查重决策对全部
    已有 Knowledge(不是预筛子集,否则 max_items 截断会让查重形同虚设)跑
    find_matching_entry,命中就 merge_knowledge 算最终字段值连同 target_id 传给
    write_knowledge;本地累积快照随每次写入更新,让同一批多条候选描述同一新话题时互相也能
    查重命中
  • KnowledgeCandidate 新增 target_id: str | None 字段——纯 Python 内部字段,不是 LLM 输出,
    由编排层填入
  • write_knowledge 两份实现(storage.py/sent_log.py)简化为按 target_id 插入/更新,
    不再自己查重;target_id 指向的行不存在时退化为插入新行,不静默丢弃候选
  • merge_knowledge 保留(未删除),但 canonical_meaning 逻辑换挡:信任 candidate. canonical_meaning 已经是差异化保留后的完整融合结果(整体替换,不再拼接),只有候选给出空
    canonical_meaning 时才退回"保留 existing 原样不动"(机械兜底,ADR-0009 纪律)
  • 新增 arise_knowledge_merge_context_max_items 配置项(默认 5,独立于
    arise_max_knowledge_per_turn——运行时注入 vs 压缩期预筛两个场景语义不同)
  • _build_prompt 新增"已知话题记忆"输入块 + 差异化保留 instruction;抽出共享的
    _render_transcript 辅助函数,避免"预筛看到的原文"和"LLM 实际看到的原文"不同步
  • 人设漂移守护送去判断的是 candidate.canonical_meaning(即将写入存储、以后会被触发注入进
    prompt 的那份内容)——不判"新增部分",与 persona_drift_guard.py/drift_veto.py 既有的
    "候选描述应该等于通过后会被落库的内容"这条接口契约一致
  • skill.py::merge_skill 按 AC 显式未动;DeltaCompressor 输入端仍然只喂纯文本,未触碰
    ADR-0028 媒体路由边界

测试与验证

  • 变异测试验证 5 处新增/改动守卫全部可被单独抓住;其中"查重范围用全量而非预筛子集""批内累积
    快照更新"两条第一次尝试变异后发现原有测试没有真正覆盖到,已各自补一条回归测试,重新变异
    验证确认现在能抓住
  • 全量 1804 passing,ruff 干净
  • ty 检查(本地临时装包核验)47 条诊断全部落在本票未触及的文件,storage.py 那两条经行号
    偏移核对确认是 issue #133 就已存在的旧噪音

两轴 review

Standards 维度发现并经对抗校验确认三处真实问题(均已修正):

  • config.py/delta.py 两处 docstring 把"合并目标"判断误归给 LLM,与最终裁决矛盾
  • _select_relevant_knowledge 里的 KnowledgeEntry→TriggerEntry 映射与 runtime_loop.py
    已有逻辑重复,已抽成共享的 knowledge.py::to_trigger_entry
  • compress() 新增的 current_knowledge 参数缺一条对称于既有 current_profile 模式的
    单元级 prompt 渲染测试,已补齐

文档

docs/adr/0015-topic-memory-knowledge-and-persona-lore.md 已补更新节(docs 分支本地提交,
按约定不推送)。design.md/CONTEXT.md"技能自改进复用 Knowledge 查重合并模式"补了一句分道说明
——避免读者误以为技能自改进也跟着升级为 LLM 差异化融合。

🤖 Generated with Claude Code

Closes #134 ## 实现内容 ADR-0015 拍板的 Knowledge 合并是"同一次 Delta 压缩 LLM 调用产出的结构化融合 + 差异化保留", 而 origin/main 上 `merge_knowledge` 是一句确定性字符串拼接,且合并逻辑活在存储层、发生在 LLM 调用返回之后——这个位置本身就使"同一次调用产出融合结果"无法成立。核心是一次 port 契约反转: 把合并决策从存储层上提到编排层。 ### 被推翻的初始设计 最初考虑给 `_EXTRACT_MEMORIES_TOOL.knowledge[]` 加 `target_entry_id` 字段,让 LLM 显式指认 合并目标。设计阶段的四路独立对抗校验推翻了这个方向: 1. 直接撞上 docs 分支 ADR-0015 2026-07-10 更新节明文"仍是同一次 LLM 调用产出结构化输出, **零新增字段/新增机制**"——这条约束当时是为差异化保留契约本身写的,同样覆盖"要不要为合并 目标识别新开一个字段"这件事。 2. `write_knowledge` 本就无条件做 `find_matching_entry` 查重(ADR-0015"拒绝了"节"写入盲目 追加,不查重"),让 LLM 的判断结果失效时退回同一套关键词匹配兜底,等于新机制的"失败路径" 与"不加这个机制"完全等价,用更粗的机制(关键词交集)去否决更精确的判断,方向也反了。 **最终形状**:不加任何新 LLM schema 字段,合并目标继续 100% 由 `find_matching_entry`(关键词 交集)在编排层决定;LLM 只借助新增的"已知话题记忆"prompt 输入块写出更准的 `trigger_keywords` (自然复用/扩展旧关键词)与差异化保留的完整 `canonical_meaning`。 ### 具体改动 - `_compress_one_chat` 压缩前先 `get_knowledge` 读该 chat 全部已有 Knowledge,用 `_select_relevant_knowledge`(复用 `trigger_injection.match_and_rank`,`id()` 回填映射 ——`TriggerEntry` 带 list 字段不可哈希、且值相等可能碰撞)预筛喂给 LLM;查重决策对**全部** 已有 Knowledge(不是预筛子集,否则 `max_items` 截断会让查重形同虚设)跑 `find_matching_entry`,命中就 `merge_knowledge` 算最终字段值连同 `target_id` 传给 `write_knowledge`;本地累积快照随每次写入更新,让同一批多条候选描述同一新话题时互相也能 查重命中 - `KnowledgeCandidate` 新增 `target_id: str | None` 字段——纯 Python 内部字段,不是 LLM 输出, 由编排层填入 - `write_knowledge` 两份实现(`storage.py`/`sent_log.py`)简化为按 `target_id` 插入/更新, 不再自己查重;`target_id` 指向的行不存在时退化为插入新行,不静默丢弃候选 - `merge_knowledge` 保留(未删除),但 `canonical_meaning` 逻辑换挡:信任 `candidate. canonical_meaning` 已经是差异化保留后的完整融合结果(整体替换,不再拼接),只有候选给出空 `canonical_meaning` 时才退回"保留 existing 原样不动"(机械兜底,ADR-0009 纪律) - 新增 `arise_knowledge_merge_context_max_items` 配置项(默认 5,独立于 `arise_max_knowledge_per_turn`——运行时注入 vs 压缩期预筛两个场景语义不同) - `_build_prompt` 新增"已知话题记忆"输入块 + 差异化保留 instruction;抽出共享的 `_render_transcript` 辅助函数,避免"预筛看到的原文"和"LLM 实际看到的原文"不同步 - 人设漂移守护送去判断的是 `candidate.canonical_meaning`(即将写入存储、以后会被触发注入进 prompt 的那份内容)——不判"新增部分",与 `persona_drift_guard.py`/`drift_veto.py` 既有的 "候选描述应该等于通过后会被落库的内容"这条接口契约一致 - `skill.py::merge_skill` 按 AC 显式未动;`DeltaCompressor` 输入端仍然只喂纯文本,未触碰 ADR-0028 媒体路由边界 ## 测试与验证 - 变异测试验证 5 处新增/改动守卫全部可被单独抓住;其中"查重范围用全量而非预筛子集""批内累积 快照更新"两条第一次尝试变异后发现原有测试没有真正覆盖到,已各自补一条回归测试,重新变异 验证确认现在能抓住 - 全量 1804 passing,ruff 干净 - ty 检查(本地临时装包核验)47 条诊断全部落在本票未触及的文件,`storage.py` 那两条经行号 偏移核对确认是 issue #133 就已存在的旧噪音 ## 两轴 review Standards 维度发现并经对抗校验确认三处真实问题(均已修正): - `config.py`/`delta.py` 两处 docstring 把"合并目标"判断误归给 LLM,与最终裁决矛盾 - `_select_relevant_knowledge` 里的 `KnowledgeEntry→TriggerEntry` 映射与 `runtime_loop.py` 已有逻辑重复,已抽成共享的 `knowledge.py::to_trigger_entry` - `compress()` 新增的 `current_knowledge` 参数缺一条对称于既有 `current_profile` 模式的 单元级 prompt 渲染测试,已补齐 ## 文档 `docs/adr/0015-topic-memory-knowledge-and-persona-lore.md` 已补更新节(docs 分支本地提交, 按约定不推送)。design.md/CONTEXT.md"技能自改进复用 Knowledge 查重合并模式"补了一句分道说明 ——避免读者误以为技能自改进也跟着升级为 LLM 差异化融合。 🤖 Generated with [Claude Code](https://claude.com/claude-code)
port契约反转——查重合并的决策从存储层上提到编排层:

- knowledge.py: KnowledgeCandidate 新增 target_id 字段(纯 Python 内部
  字段,不是 LLM schema 输出);write_knowledge 不再内部查重合并,改为
  按 target_id 机械插入/更新;merge_knowledge 保留(ADR-0009 机械兜底
  纪律),但 canonical_meaning 逻辑从"确定性拼接"改为"信任 candidate
  已经是差异化保留后的完整融合结果",只在候选给出空 meaning 时才退回
  拼接前行为
- storage.py/sent_log.py: write_knowledge 两份实现同步简化为按 target_id
  持久化,find_matching_entry/merge_knowledge 调用点移出
- delta.py: _compress_one_chat 压缩前先 get_knowledge 读该 chat 全部
  已有 Knowledge,用 trigger_injection.match_and_rank 对当轮 delta 原文
  预筛出相关子集(新增 arise_knowledge_merge_context_max_items 配置项
  控制上限,独立于 arise_max_knowledge_per_turn——两个消费场景语义不同)
  喂给 LLM;_build_prompt 新增"已知话题记忆"输入块 + 差异化保留
  instruction,顺手修了"两类结构化候选"这句与实际 schema(四类+)不符
  的措辞(本次改动同一段文本里新增第三类说明,不修会自相矛盾);查重
  决策落回 knowledge 候选写入循环,对该 chat 全部已有条目(不局限于预筛
  子集,避免 max_items 截断让查重形同虚设)跑 find_matching_entry 算
  target_id,merge_knowledge 算最终字段值,同批多条候选描述同一新话题
  时本地累积快照保证互相能查重命中

设计阶段经四路独立对抗校验:最初设计的 target_entry_id LLM 输出字段
方案被推翻——ADR-0015 2026-07-10 更新节明文"仍是同一次 LLM 调用产出
结构化输出,零新增字段/新增机制",且 write_knowledge 本就无条件查重,
让 LLM 显式指认合并目标只是用更粗的机制去否决更精确的判断。改为纯
prompt 侧引导(喂已有条目+差异化保留 instruction),合并目标继续由
find_matching_entry 关键词交集决定。

人设漂移守护判定对象裁决:送去判断的是 candidate.canonical_meaning
(即将写入存储、以后会被触发注入进 prompt 的那份内容)——不是"新增
部分",与 persona_drift_guard.py/drift_veto.py 既有的"candidate_
description 应该等于通过后会被落库的内容"这条既有接口契约一致,且
AC 给的 schema 本就只有一个 canonical_meaning 出口,没有额外的"新增
部分"字段可判。

全量1800 passing,ruff干净。skill.py::merge_skill 按 AC 显式声明未动。
ADR-0028 媒体路由边界未触碰(DeltaCompressor 输入仍纯文本)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
变异测试把 _compress_one_chat 里"每次写入后把结果并回本地累积快照"这
一步删掉后,既有测试全绿——此前没有任何用例覆盖"同一次 LLM 响应里两条
候选描述同一个全新话题"这个场景。新增测试构造这个场景,钉住第二条候选
真的能查重命中第一条候选刚创建的行,不是巧合合并成一条。

变异测试全部 5 处新增/改动守卫(merge_knowledge 的 canonical_meaning
替换逻辑、storage.py/sent_log.py 两份 write_knowledge 的 target_id 分支、
查重范围用全量已有 Knowledge 而非预筛子集、批内累积快照更新)均可被
单独抓住,全部文件已用哈希核验确认变异已完整还原。全量 1801 passing,
ruff 干净,ty 的 47 条诊断经行号偏移核对(净增 8 行,2068→2076、
2354→2362 完全吻合)确认是 issue #133 就已存在的旧噪音,非本票引入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
两轴review的Standards维度独立发现并经对抗校验确认三处真实问题:

- config.py/delta.py 两处新增 docstring 把"合并目标"判断说成是喂给
  LLM 帮它决定的,与本片最终拍板的设计(合并目标 100% 由
  find_matching_entry 关键词交集决定,不给 LLM 判断权,那条路线已被
  四路对抗校验推翻)正面矛盾——同一片里 knowledge.py/delta.py 其它处
  的 docstring 都写对了,唯独这两处措辞没跟着最终形状同步
- delta.py::_select_relevant_knowledge 里 KnowledgeEntry→TriggerEntry
  的字段映射与 runtime_loop.py 里已有的同一段映射逐字段完全重复——
  本片正好是引入第二个调用点、该抽取共享辅助函数的时机,否则以后两处
  字段形状变化容易只改一处漏改另一处,悄悄分叉。抽成
  knowledge.py::to_trigger_entry,两处调用点改为复用
- compress() 新增的 current_knowledge 参数没有像既有 current_profile
  那样在 test_delta_compressor.py 里获得对称的单元级直测(覆盖此前只
  落在整周期测试里)——补齐 TestCurrentKnowledgeFeedsThePrompt

全量1804 passing,ruff干净。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yushu merged commit e93a45e437 into main 2026-08-17 00:53:57 +00:00
Yushu deleted branch feat/134-knowledge-llm-merge 2026-08-17 00:53:58 +00:00
Sign in to join this conversation.
No description provided.