mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
docs: 删除 7 份已完结的执行计划,把教训与数据提出来
## 为什么删
执行计划在实施完结后**会变成误导性读物**:它记录的是"当时打算做什么",
而读者会读成"现在是什么"。
这在本仓已经造成真实误读 —— `prod-migration-checklist.md` 写着
「状态:HologramAgent 已停机」,那是一次性迁移(2026-10-04)当场的状态,
但照着读会以为服务现在还停着。
## 删了 7 份,删前提取三类内容
| 删掉的文档 | 行数 |
|---|---|
| `memory-restructure-plan.md` | 168 |
| `memory-blockflow-repair-plan.md` | 450 |
| `memory-usability-plan.md` | 309 |
| `scene-memory-fix-plan.md` | 271 |
| `toolcall-parallel-execution-plan.md` | 578 |
| `prod-migration-checklist.md` | 157 |
| `entities-retirement-plan.md` | 118 |
**提取去向**:
1. **教训 → 新建 `engineering-lessons.md`**
这些是它们唯一还有长期价值的部分。汇总成一篇,并按形态归类:
- 判据类(5 条):结构判据不能替代质量判据 / 测「接线之后」≠测「有没有接线」/
验证通过≠架构正确 / fixture 照臆测字段编 / 先改代码再写测试
- 接线类(3 条):机制≠接线 / 依赖图是唯一保证顺序的地方 / 单数改了复数漏了
- 权限与生命周期(3 条):「接上了」有三层 / 以调用方名字为键的表要清理 /
提权口子:让调用方声明自己是谁
- 流程类(3 条):长任务纪律 / 数字要现场取 / 计划不进仓库
文末点出它们共同的形态:**某种"看起来对了"的信号与"真的对了"之间存在缺口。**
2. **实测数据 → 并入 `toolcall-contract-and-sequence-design.md` 附录 A**
那是新旧内核的对比压测(288 条输入 × 两版),核心结论值得留:
```
场景 旧版中位/工具数 新版中位/工具数
slowbatch8 0.220s / 0 个 0.409s / 8 个
```
★ **旧版"更快"是假的** —— 它的 `openai.lua` 缺 `stream_index` 透传,
多个分片并到槽 0 ⇒ 每个工具报参数错误而一个都没真跑。
这是「只看耗时会被骗」的典型。
另附 stage 锁告警的完整归因(不是内核缺陷,成因在插件进程侧运行时)。
3. **未完成的项 → `future-plan.md` §六**
尤其 `entities-retirement-plan` 那条:方案是"三表全退",但 2026-10-31
实测生产库 `entities 1292 / relations 978 / sentences 66` 行**仍在用**。
这个事实不能随文档一起消失。
## 保留两份(它们不是计划,是依据)
- `legacy-table-retirement.md` —— 被 **4 处生产代码注释**引用(`graph.go` ×2、
`commit_blocks_test.go`、`probe_prod_test.go`),是退役判断的依据
- `interrupt-subsystem-split-plan.md` —— 探测事实被 `interrupt_invariant_test.go`
引用;拆分本身确实未实施
## 索引重写
`docs/zh/README.md` 现在是「四类文档」的规则说明:
| 类型 | 回答什么 | 命名 |
|---|---|---|
| 设计文档 | 为什么这样设计 | `*-design.md` |
| 操作手册 | 怎么做 | `*-runbook.md` |
| 教训 | 踩过什么坑 | `engineering-lessons.md` |
| 未做登记 | 还剩什么没做 | `future-plan.md` |
并明确写「**不放执行计划**」及例外条件。
## 顺带修掉的引用
删除后 4 处引用指向不存在的文件:
- `deploy-runbook.md` → 改指 `toolcall-contract-and-sequence-design.md`
- SDK 仓 `guide/stream-tool-call-index.md` / `guide/parallel-tool-declaration.md`
→ 同样改指(这两个在 SDK 仓,随后单独提交)
## 验证
- docs/zh 27 篇,索引里列的全部存在、无遗漏
- 全仓无指向已删文档的引用(代码 + 文档)
- 代码块全部配平
- go build 通过
This commit is contained in:
@ -1,140 +1,156 @@
|
||||
# docs/zh 文档索引
|
||||
|
||||
> 建立于 2026-10-31。目的:**让读者知道该信哪一篇、哪些是历史记录**。
|
||||
> 建立于 2026-10-31。目的:**让读者知道该信哪一篇**。
|
||||
>
|
||||
> 此前本目录 32 篇文档混着三种时态 —— 当时实测、历史推演、设计规划 ——
|
||||
> 外观完全相同,读者无从分辨。曾有文档写着「`main.go` 仍走串行代码」
|
||||
> 而实际早已接完(滞后一整轮发布)。
|
||||
>
|
||||
> 所以先分类,再读内容。
|
||||
> 此前本目录混着三种时态 —— 当时实测、历史推演、执行计划 —— 外观完全相同。
|
||||
> 曾有文档写着「`main.go` 仍走串行代码」而实际早已接完(滞后一整轮发布)。
|
||||
|
||||
---
|
||||
|
||||
## 一、怎么判断一篇文档的可信度
|
||||
## 一、本目录只放四类文档
|
||||
|
||||
每篇文档首部应有**状态行**,取值只允许这四种:
|
||||
| 类型 | 回答什么问题 | 有长期价值 | 命名 |
|
||||
| --- | --- | --- | --- |
|
||||
| **设计文档** | 为什么这样设计 | ✅ | `*-design.md` |
|
||||
| **操作手册** | 怎么做 | ✅ | `*-runbook.md` |
|
||||
| **教训** | 踩过什么坑 | ✅ | `engineering-lessons.md` |
|
||||
| **未做登记** | 还剩什么没做 | ✅ | `future-plan.md` |
|
||||
|
||||
| 状态 | 含义 | 能不能当事实用 |
|
||||
|---|---|---|
|
||||
| **已实现并部署** | 文中描述与代码一致,关键处给了取数命令 | ✅ 可以直接信 |
|
||||
| **部分实现** | 先说清哪部分生效、哪部分没有(附实测调用点) | ⚠️ 只信标了"生效"的部分 |
|
||||
| **设计 / 未实现** | 是方案,不是现状。代码里可能没有 | ❌ 只能当设计意图 |
|
||||
| **归档说明** | 历史记录,**故意保留当时的写法** | ❌ 不要改它,也不要信它 |
|
||||
### ❌ 不放:执行计划
|
||||
|
||||
### 判据名的可信度规则(重要)
|
||||
执行计划(`*-plan.md` / `*-checklist.md`)**会随实施完结变成误导性读物** ——
|
||||
它记录"当时打算做什么",而读者会读成"现在是什么"。
|
||||
|
||||
文档里出现的 `TestXxx` 名字,**不保证现在还存在**:
|
||||
2026-10-31 删除了 9 份已完结的计划,删前把两类内容提出来:
|
||||
|
||||
- **已实现并部署**类文档里的判据名 → 应当存在。找不到就是缺陷(报告它)
|
||||
- **设计 / 未实现**类文档里的判据名 → 是**规划的名字**,可能从未实现
|
||||
- **归档说明**类文档里的判据名 → 记录的是当时的事实,可能已改名或删除
|
||||
| 内容 | 去向 |
|
||||
| --- | --- |
|
||||
| 决策与教训 | `engineering-lessons.md` |
|
||||
| 未完成的项 | `future-plan.md` |
|
||||
| 实测数据 | 并入对应设计文档(如压测数据进 `toolcall-contract-and-sequence-design.md` 附录 A) |
|
||||
|
||||
历史上有过一次真实漂移:`BootCtx` 改名为 `KernelState` 后,
|
||||
`TestBootCtxCloseAllIsReverseOrder` 随之变成
|
||||
`TestKernelStateCloseAllIsReverseOrder`,而文档没跟上。
|
||||
**唯一例外**:`legacy-table-retirement.md` 与 `interrupt-subsystem-split-plan.md` 保留 ——
|
||||
前者被 4 处生产代码注释当作**退役依据**引用,后者记录的探测事实仍被判据引用。
|
||||
它们不是"执行计划",是**依据**。
|
||||
|
||||
**核对命令**(任何时候都可以自己跑):
|
||||
---
|
||||
|
||||
## 二、怎么判断一篇文档的可信度
|
||||
|
||||
每篇首部都有**状态行**,取值只允许这四种:
|
||||
|
||||
| 状态 | 含义 | 能当事实用吗 |
|
||||
| --- | --- | --- |
|
||||
| **已实现并部署** | 描述与代码一致,关键处给了取数命令 | ✅ 直接信 |
|
||||
| **部分实现** | 先说清哪部分生效(附实测调用点) | ⚠️ 只信标了"生效"的 |
|
||||
| **设计 / 未实现** | 是方案,代码里可能没有 | ❌ 只能当设计意图 |
|
||||
| **归档说明** | 历史记录,**故意保留当时写法** | ❌ 不要改、也不要信 |
|
||||
|
||||
### 判据名的可信度规则
|
||||
|
||||
文档里的 `TestXxx` **不保证现在还存在**:
|
||||
|
||||
- **已实现并部署**类文档里的 → 应当存在。找不到就是缺陷
|
||||
- **设计 / 未实现**类文档里的 → 是**规划名**,可能从未实现
|
||||
- **归档说明**类文档里的 → 记录当时事实,可能已改名或删除
|
||||
|
||||
真实案例:`BootCtx` 改名 `KernelState` 后,
|
||||
`TestBootCtxCloseAllIsReverseOrder` → `TestKernelStateCloseAllIsReverseOrder`,
|
||||
文档没跟上。
|
||||
|
||||
**核对命令**:
|
||||
|
||||
```bash
|
||||
# 列出本目录引用的全部判据名里,仓库中找不到的那些
|
||||
for n in $(grep -rhoE '`Test[A-Z][A-Za-z0-9_]+`' docs/zh/*.md | tr -d '`' | sort -u); do
|
||||
grep -rq "func $n(" --include="*_test.go" internal cmd 2>/dev/null || echo "缺失: $n"
|
||||
done
|
||||
```
|
||||
|
||||
跑出来有缺失**不一定**是缺陷 —— 先看它出在哪类文档里。
|
||||
有缺失**不一定**是缺陷,先看它出在哪类文档里。
|
||||
|
||||
**当前基线(2026-10-31)**:27 条缺失,分布如下,全部已知且已说明:
|
||||
**当前基线(2026-10-31)**:27 条缺失,全部已知:
|
||||
|
||||
| 篇数 | 文档 | 性质 |
|
||||
|---|---|---|
|
||||
| 18 | `commondev-design.md` / `commondev-agent-runtime-design.md` | 「设计 · 未实现」类,判据名是**规划名** |
|
||||
| 7 | `kernel-modularization-design.md` | §2.7.3 的**对照表左栏**(有意的历史记录),其中 4 条已登记 future-plan §2.3 |
|
||||
| --- | --- | --- |
|
||||
| 18 | `commondev-*.md` | 「设计 · 未实现」类,判据名是**规划名** |
|
||||
| 7 | `kernel-modularization-design.md` | §2.7.3 的**对照表左栏**(有意的历史记录) |
|
||||
| 2 | `multimodal-space.md` | 视频能力未纳入契约,判据为规划名 |
|
||||
|
||||
⇒ **在「已实现并部署」类文档的正文里(非历史对照表)找不到判据名,才是缺陷。**
|
||||
⇒ **在「已实现」类文档正文里找不到,才是缺陷。**
|
||||
|
||||
---
|
||||
|
||||
## 二、文档清单(按状态分类)
|
||||
## 三、文档清单
|
||||
|
||||
### 2.1 已实现并部署 —— 可以直接当事实
|
||||
### 3.1 设计文档 —— 可以直接当事实
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| [`kernel-modularization-design.md`](./kernel-modularization-design.md) | **装配架构权威**:依赖声明、BFS 分层、Group 0、实例化粒度 |
|
||||
| [`kernel-secret-firewall-design.md`](./kernel-secret-firewall-design.md) | 安全防火墙(**部分实现**,见文首接线状态表) |
|
||||
| [`input-scheduler-design.md`](./input-scheduler-design.md) | 两类别输入 + L1–L4 中断 + 可抢占 |
|
||||
| [`resident-subagent-design.md`](./resident-subagent-design.md) | 驻留子:轻量内核、两级记忆、父子 L3/L4 |
|
||||
| [`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md) | 工具调用契约与顺序 |
|
||||
| [`ci-cd-runbook.md`](./ci-cd-runbook.md) | CI/CD 操作手册 |
|
||||
| [`deploy-runbook.md`](./deploy-runbook.md) | 部署手册 |
|
||||
| [`site-infra-runbook.md`](./site-infra-runbook.md) | 站点基础设施 |
|
||||
| [`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md) | 工具调用契约(**附录 A 是压测数据**) |
|
||||
| [`commondev-design.md`](./commondev-design.md) | 主管式多仓库开发流水线(**设计 · 未实现**) |
|
||||
| [`commondev-agent-runtime-design.md`](./commondev-agent-runtime-design.md) | commondev 运行时与内核接入 |
|
||||
| [`context-overflow-l4-design.md`](./context-overflow-l4-design.md) | 上下文超页 L4(归档,术语过时) |
|
||||
| [`triple-distill-llm-design.md`](./triple-distill-llm-design.md) | 三元组蒸馏(已实现并**部分否决**) |
|
||||
|
||||
### 2.2 参考 / 实测报告 —— 数据可信,结论看日期
|
||||
### 3.2 操作手册
|
||||
|
||||
- [`ci-cd-runbook.md`](./ci-cd-runbook.md) — CI/CD
|
||||
- [`deploy-runbook.md`](./deploy-runbook.md) — 部署
|
||||
- [`site-infra-runbook.md`](./site-infra-runbook.md) — 站点基础设施
|
||||
|
||||
### 3.3 参考 / 实测报告
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [`kernel-performance.md`](./kernel-performance.md) | 性能实测数据 |
|
||||
| [`production-recall-validation.md`](./production-recall-validation.md) | 生产召回验证(含未解决的 3 条遗留边) |
|
||||
| [`recall-as-association.md`](./recall-as-association.md) | 召回作为关联的机制说明 |
|
||||
| [`recall-capability-tiers.md`](./recall-capability-tiers.md) | 召回能力分层(含 2 个未补的缺口) |
|
||||
| --- | --- |
|
||||
| [`engineering-lessons.md`](./engineering-lessons.md) | **踩过的坑与共同形态**(推荐先读) |
|
||||
| [`kernel-performance.md`](./kernel-performance.md) | 性能实测基线 |
|
||||
| [`production-recall-validation.md`](./production-recall-validation.md) | 生产召回验证(「探针 PASS 但结论是假的」) |
|
||||
| [`recall-as-association.md`](./recall-as-association.md) | 召回作为关联 |
|
||||
| [`recall-capability-tiers.md`](./recall-capability-tiers.md) | 召回能力分层(含 2 个未补缺口) |
|
||||
| [`multimodal-space.md`](./multimodal-space.md) | 多模态向量空间 |
|
||||
| [`vector-provider-decision.md`](./vector-provider-decision.md) | 向量 provider 选型决策 |
|
||||
| [`vector-provider-decision.md`](./vector-provider-decision.md) | 向量 provider 选型(已定) |
|
||||
| [`abstention-criterion.md`](./abstention-criterion.md) | 弃答判据 |
|
||||
| [`triple-extract-attention.md`](./triple-extract-attention.md) | 三元组抽取与注意力 |
|
||||
|
||||
### 2.3 设计 / 未实现 —— 只能当设计意图
|
||||
### 3.4 依据类(不是计划,是退役/拆分的判断依据)
|
||||
|
||||
| 文档 | 状态 |
|
||||
|---|---|
|
||||
| [`commondev-design.md`](./commondev-design.md) | 标题即写明「**设计 · 未实现**」;架构已变更,见其 §12 |
|
||||
| [`commondev-agent-runtime-design.md`](./commondev-agent-runtime-design.md) | 架构已按前篇 §12 调整,部分未落地 |
|
||||
| [`interrupt-subsystem-split-plan.md`](./interrupt-subsystem-split-plan.md) | 探测结论 + 实施计划 |
|
||||
| [`toolcall-parallel-execution-plan.md`](./toolcall-parallel-execution-plan.md) | 并行化执行计划(含遗留项) |
|
||||
| [`memory-blockflow-repair-plan.md`](./memory-blockflow-repair-plan.md) | 记忆块流修复计划 |
|
||||
| [`memory-restructure-plan.md`](./memory-restructure-plan.md) | 记忆重构计划 |
|
||||
| [`memory-usability-plan.md`](./memory-usability-plan.md) | 记忆可用性计划 |
|
||||
| [`scene-memory-fix-plan.md`](./scene-memory-fix-plan.md) | 场景记忆修复计划 |
|
||||
| [`legacy-table-retirement.md`](./legacy-table-retirement.md) | 遗留表退役(收尾中) |
|
||||
| [`entities-retirement-plan.md`](./entities-retirement-plan.md) | 实体表退役 |
|
||||
| [`prod-migration-checklist.md`](./prod-migration-checklist.md) | 生产迁移清单 |
|
||||
| [`triple-distill-llm-design.md`](./triple-distill-llm-design.md) | 已实现并**部分否决** |
|
||||
| 文档 | 为什么保留 |
|
||||
| --- | --- |
|
||||
| [`legacy-table-retirement.md`](./legacy-table-retirement.md) | 被 4 处生产代码注释引用;剩余读方清单在 §八/§十二 |
|
||||
| [`interrupt-subsystem-split-plan.md`](./interrupt-subsystem-split-plan.md) | 探测事实被判据引用;拆分本身未实施 |
|
||||
|
||||
### 2.4 归档说明 —— 历史记录,不要改也不要信
|
||||
### 3.5 归档说明 —— 历史记录,不要改也不要信
|
||||
|
||||
| 文档 | 说明 |
|
||||
|---|---|
|
||||
| [`context-overflow-l4-design.md`](./context-overflow-l4-design.md) | 术语已过时(`memory_*` 等旧工具名保留原样) |
|
||||
| [`live-run-evidence-2026-10-04.md`](./live-run-evidence-2026-10-04.md) | 某次实跑证据快照 |
|
||||
| [`legacy-issues.md`](./legacy-issues.md) | 遗留问题清单 |
|
||||
- [`live-run-evidence-2026-10-04.md`](./live-run-evidence-2026-10-04.md) — 某次实跑证据快照
|
||||
- [`legacy-issues.md`](./legacy-issues.md) — 遗留问题清单(部分条目已过时)
|
||||
|
||||
> **为什么归档文档里有旧工具名却不改**:它们是**证据快照**。
|
||||
> 把 `memory_recall` 改成 `stablemem_recall` 等于篡改当时的记录,
|
||||
> 让后来的人无法判断"这条结论是在哪个版本上得出的"。
|
||||
> 归档文档里的旧工具名(`memory_recall` 等)**故意不改** —— 它们是证据快照,
|
||||
> 改了就无法判断"这条结论是在哪个版本上得出的"。
|
||||
|
||||
### 2.5 唯一未做登记处
|
||||
### 3.6 单一登记处
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| [`future-plan.md`](./future-plan.md) | **全仓唯一的「尚未实现 / 已知缺口」登记处** |
|
||||
|
||||
**规则**:任何设计文档都不再自己维护「未做」小节,一律登记到 future-plan。
|
||||
历史上各文档各自维护,结果滞后过 —— 已完成仍写未做,未做被当成已完成。
|
||||
|
||||
---
|
||||
|
||||
## 三、写文档的三条纪律
|
||||
## 四、写文档的三条纪律
|
||||
|
||||
1. **数字类断言必须给取数命令**(如模块数用
|
||||
`go run ./cmd/homed-depgraph -print`),**不要凭记忆写数字**。
|
||||
本文档集历史上写错过一次(17 模块 vs 实际 20)。
|
||||
`go run ./cmd/homed-depgraph -print`),**不要凭记忆**。
|
||||
本文档集写错过一次(17 模块 vs 实际 20)。
|
||||
|
||||
2. **区分「机制存在」与「接线生效」**。本项目的头号缺陷形态是
|
||||
机制写对了、判据全绿、生产里零调用方。描述能力时写**实测的生产调用点**,
|
||||
不要只写"已实现"。
|
||||
2. **区分「机制存在」与「接线生效」**。本项目头号缺陷形态是
|
||||
机制写对了、判据全绿、生产里零调用方。描述能力时写**实测的生产调用点**。
|
||||
|
||||
3. **判据要能红**。写判据时先问:把对应实现删掉,这条判据会红吗?
|
||||
本仓踩过两次:`security_toolgate_test.go` 手动调 `SetSecurity`
|
||||
把缺失的接线补上了;`retention_test.go` 手写合规文件名,
|
||||
从没跑过真实产物。两者都永远绿。
|
||||
|
||||
详见 [`engineering-lessons.md`](./engineering-lessons.md)。
|
||||
|
||||
@ -356,7 +356,7 @@ journalctl -u homeagent.service --since "-10 min" | grep -E "finish_reason=lengt
|
||||
先看**是不是内核的兜底在工作**。例:`重复申请 stage 锁` 是 SDK 模板在每个
|
||||
stage handler 入口自动加锁 + 锁不可重入所致,**插件侧问题**;内核的强制释放
|
||||
是有意设计(避免后续插件死锁),不要当内核缺陷去修。详见
|
||||
[`toolcall-parallel-execution-plan.md`](./toolcall-parallel-execution-plan.md) 末节。
|
||||
[`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
179
docs/zh/engineering-lessons.md
Normal file
179
docs/zh/engineering-lessons.md
Normal file
@ -0,0 +1,179 @@
|
||||
# 工程教训
|
||||
|
||||
> 这个仓库踩过的坑,以及它们共同的形态。**写在这里是因为它们比实现细节更耐用** ——
|
||||
> 代码会改,缺陷形态会重复出现。
|
||||
>
|
||||
> 来源:这些条目原本散落在各份执行计划(`memory-*-plan.md`、
|
||||
> `toolcall-parallel-execution-plan.md` 等)里。计划本身已随实施完结而删除,
|
||||
> 但教训被提出来了 —— 那是它们唯一还有价值的部分。
|
||||
>
|
||||
> 建立于 2026-10-31。
|
||||
|
||||
---
|
||||
|
||||
## 一、判据类(最贵的教训,占了一半)
|
||||
|
||||
### 1.1 「结构判据」不能替代「质量判据」
|
||||
|
||||
**症状**:16 条判据、6 个变异**全部通过**,而功能完全不可用。
|
||||
|
||||
**当时的场景**:记忆检索改造。判据验的是「字段非空」「条数正确」「不 panic」,
|
||||
而实际检索结果是:
|
||||
|
||||
```text
|
||||
查询 "doc 层召回是怎么设计的" → score=0.7834
|
||||
查询 "hmap 插件安装验签" → score=0.7922
|
||||
查询 "QQ 头像" → score=0.7601
|
||||
```
|
||||
|
||||
**分数全挤在 0.75~0.79,区分度 ≈ 0,命中内容与查询毫无关系。**
|
||||
|
||||
⇒ 「全绿」证明的只是**我实现了我以为的东西**,不是它可用。
|
||||
|
||||
**判据要问**:这条判据在"功能完全失效"时会绿吗?会绿就不是质量判据。
|
||||
|
||||
### 1.2 判据测「接线之后」≠ 测「有没有接线」
|
||||
|
||||
这是本仓出现频率最高的缺陷形态(已攒够 5 例,见 `future-plan.md` §〇)。
|
||||
|
||||
两处实测:
|
||||
|
||||
| 判据 | 它做了什么 | 为什么永远绿 |
|
||||
| --- | --- | --- |
|
||||
| `security_toolgate_test.go` | 手动调 `a.SetSecurity(rt)` 再断言拦截 | **判据自己把缺失的接线补上了** |
|
||||
| `retention_test.go` | 手写 `month_2026-01.tar.gz` 再断言被删 | 从没跑过 `compressMonthly` 的**真实产物**(那产出的是 `month_2026-27`,解析必失败) |
|
||||
|
||||
两者都全绿,两个功能在生产里都完全没生效。
|
||||
|
||||
**判据要问**:**这条判据在生产装配下也会绿吗?**
|
||||
|
||||
### 1.3 「验证通过」≠「架构正确」
|
||||
|
||||
**当时的场景**:`630d3f4` 的 16 三元组端到端验证全部通过。
|
||||
但那次验证的是**拆分质量**(能不能把句子拆成主谓宾),
|
||||
不是**正确的落库形态**(拆出来的三元组该以什么结构存)。
|
||||
|
||||
⇒ 验证通过之后才发现,整个蒸馏路线方向错了,要整体否决。
|
||||
|
||||
**判据要问**:我验证的,是不是我真正想保证的那件事?
|
||||
|
||||
### 1.4 fixture 照臆测字段编
|
||||
|
||||
本仓 T23 教训:判据用的 fixture 是
|
||||
**手写臆测的字段名**,而真实结构体字段不同 ⇒ 判据测的是一个不存在的契约。
|
||||
|
||||
⇒ 判据的输入应当来自**真实产物**,不是人脑里的想象。
|
||||
|
||||
### 1.5 先改代码再写测试
|
||||
|
||||
原本记在记忆块流修复计划里(该计划已随实施完结删除):先改代码再补测试,
|
||||
测试就只会证明「我改的是我以为的」。
|
||||
|
||||
⇒ 这个顺序本身会让判据失去独立价值。
|
||||
|
||||
---
|
||||
|
||||
## 二、接线类
|
||||
|
||||
### 2.1 机制 ≠ 接线
|
||||
|
||||
见 `future-plan.md` §〇。一句话总结:
|
||||
|
||||
**策略表存在、判定函数正确、日志里一条拒绝记录都没有** —— 看起来像「没人违规」。
|
||||
|
||||
判断方法:`codegraph_callers(符号名)`。0 个生产调用方就是没接线。
|
||||
|
||||
### 2.2 依赖图是唯一保证顺序的地方
|
||||
|
||||
`agent` 模块读了 `ks.Security` 却没在 `Requires()` 里声明 `FacSecurity`。
|
||||
「恰好」能用(security 在第 1 层、agent 在第 4 层),但那是**偶然**:
|
||||
一旦 security 加了自己的依赖被推后,就会静默拿到 nil。
|
||||
|
||||
⇒ 读了谁的字段,就必须声明对谁的依赖。**不要靠层号碰巧对。**
|
||||
|
||||
### 2.3 单数路径改了,复数路径漏了
|
||||
|
||||
`StopRTChild`(单数)注销入站登记,`StopRTChildren`(复数)漏了。
|
||||
残留随 create/destroy 单调累积。
|
||||
|
||||
⇒ 写这类对称函数时,判据要**同时**钉两条路径。本仓已有判据专门做这件事。
|
||||
|
||||
---
|
||||
|
||||
## 三、权限与生命周期类
|
||||
|
||||
### 3.1 「接上了」有三种层次,容易停在第二层
|
||||
|
||||
| 层次 | 内容 |
|
||||
| --- | --- |
|
||||
| 1 | 机制存在(`CheckTool` / `ScanContent` 都写了) |
|
||||
| 2 | 接到了执行链(`securityDecisionFor` 真的调它) |
|
||||
| 3 | **模型正确** |
|
||||
|
||||
第三层的典型错误:把「子 agent 继承父的权限」当成「父为子声明策略」。
|
||||
两者都"接上了",但权限模型完全不同 —— 前者意味着子等于父,后者意味着子更窄。
|
||||
|
||||
**判断标准**:**这条策略是谁的意图?** 权限的意图只能来自委派者(父),
|
||||
不能来自被委派者(子),也不能来自内核默认(全局)。
|
||||
|
||||
### 3.2 以「调用方给的名字」为键的表,必须在生命周期结束时清理
|
||||
|
||||
两个实例:
|
||||
|
||||
- `rtClosedTranscripts[id]` —— 以模型给的名字为键,全仓 0 处删除。
|
||||
结果:同名重建的 rtchild 读到上一通电话的原文
|
||||
- `scopesByAgent[agentID]` 的 `longtimeProfile` —— 不释放则同名重建的 agent
|
||||
继承上一代的约束
|
||||
|
||||
⇒ 键来自外部(模型、调用方)就不能假设它唯一。
|
||||
|
||||
### 3.3 提权口子:让调用方声明自己是谁
|
||||
|
||||
`Kind: "resident"` 由 agent 自己写 —— 它同时意味着「我是内部主体」
|
||||
(豁免 HardPrivateTools)和「我不受收窄」。调用方说自己是内部就拿到豁免。
|
||||
|
||||
⇒ 类别必须由安全模块从**事实**(谁在调、有没有父)推出,不能由调用方声明。
|
||||
|
||||
---
|
||||
|
||||
## 四、流程类
|
||||
|
||||
### 4.1 长任务纪律
|
||||
|
||||
导出、回填等内存密集任务**绝不与测试并行**(同一会话里踩了三次 OOM)。
|
||||
|
||||
### 4.2 数字类断言要现场取,不要凭记忆
|
||||
|
||||
文档里写「17 模块 6 层」,实际是 **20 模块**。给读者的是错的坐标。
|
||||
|
||||
⇒ 数字必须附取数命令(如 `go run ./cmd/homed-depgraph -print`)。
|
||||
|
||||
### 4.3 计划文档不要进仓库
|
||||
|
||||
执行计划会在实施完结后变成**误导性读物**:它记录的是"当时打算做什么",
|
||||
而读者会读成"现在是什么"。
|
||||
|
||||
本仓删掉了 9 份已完结的计划(`memory-*-plan.md` 等),保留:
|
||||
|
||||
- **设计文档**(`*-design.md`):为什么这样设计,有长期价值
|
||||
- **教训**(本文):踩过什么坑
|
||||
- **未做登记**(`future-plan.md`):还剩什么没做
|
||||
- **操作手册**(`*-runbook.md`):怎么做
|
||||
|
||||
---
|
||||
|
||||
## 附:这些教训的共同点
|
||||
|
||||
回头看,几乎每一条都是同一个形态的变体:
|
||||
|
||||
> **某种"看起来对了"的信号,与"真的对了"之间存在缺口。**
|
||||
|
||||
- 判据全绿 ≠ 功能可用
|
||||
- 机制存在 ≠ 接线生效
|
||||
- 层号碰巧对 ≠ 依赖声明正确
|
||||
- 单数路径对 ≠ 复数路径也对
|
||||
- 名字相同 ≠ 是同一个东西
|
||||
- 计划写完 ≠ 事情做完
|
||||
|
||||
**所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号,
|
||||
在什么情况下会是假的?**
|
||||
@ -1,118 +0,0 @@
|
||||
# entities 退场迁移方案
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**方案已定,执行未完成**(2026-10-31 复核)。
|
||||
>
|
||||
> 方案是「三表全退」(`entities` / `relations` / `sentences` 并入
|
||||
> `memory_blocks`)。迁移代码在(`internal/memory/migrate_entities.go`),
|
||||
> 但**生产库仍在用旧表**:
|
||||
>
|
||||
> ```
|
||||
> entities 1292 行
|
||||
> relations 978 行
|
||||
> sentences 66 行
|
||||
> ```
|
||||
>
|
||||
> ⇒ 本计划的「执行中」不能读成"快完成了",而是**迁移工具就绪、
|
||||
> 生产尚未切换**。切换前的剩余读方清单见
|
||||
> [`legacy-table-retirement.md`](./legacy-table-retirement.md) §八 / §十二。
|
||||
> 范围:含生产实例 ${HA_DATA}(用户 2026-10-02 确认迁移)
|
||||
> 前置:docs/zh/memory-restructure-plan.md(重构计划)
|
||||
> 原则:一次迁移,删除所有 entities 遗留,避免后续误导。
|
||||
|
||||
## 〇、执行前必须确认的一个决策
|
||||
|
||||
**relations / sentences 两张表跟不跟 entities 一起退?**
|
||||
|
||||
现状:三元组 = entities + relations + sentences 三表联写(graph.go Commit)。
|
||||
块形态:节点 = memory_blocks,边 = memory_block_edges。
|
||||
|
||||
- **方案 A:三表全退**(彻底)—— relations 是第二套边系统,留着必然误导;
|
||||
sentences 的"原句"职责由 text block 承担(memory_block_edges 已支持
|
||||
sentence 端点 kind,但句子本身该是块)。
|
||||
- **方案 B:只退 entities**(保守)—— relations/sentences 暂留,
|
||||
改成引用 block id。
|
||||
|
||||
**已确认方案 A**(用户 2026-10-02):留着 relations 就等于留着第二套边,
|
||||
两套边系统并存正是"浆糊"的来源。波及面:68 处 SQL(26+29+13)+ 10 个测试文件。
|
||||
生产实例纳入迁移范围,在第 3 步单独执行并快照回滚。
|
||||
|
||||
## 一、依赖面清单(已核实)
|
||||
|
||||
### 内核(internal/memory)
|
||||
|
||||
| 文件 | 依赖 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| graph.go | 26 处 SQL,Commit/Recall/merge/delete 全链 | **重写**:Commit→PutMemoryBlocks+edges;Recall→块向量召回 |
|
||||
| noise.go | IsNoiseEntity / NoiseEntities | 改为块判噪或删 |
|
||||
| scene.go | TagSceneByEntityGlob 等 6 处 | 改挂块节点 |
|
||||
| migrate.go | LegacyMediaEntityDigest(旧→块迁移) | 改名/扩为全文实体迁移 |
|
||||
| block.go | 1 处(端点校验引用 entities) | 改:端点只认 block |
|
||||
| indexer.go | vectorSearchEntities / buildIndexSummary | 改为块索引 |
|
||||
|
||||
### 接口面(对外契约,不能断)
|
||||
|
||||
| 调用方 | 现状 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| memoryface.go GraphMemory 接口 | Recall/RecallSorted/Commit | **签名保留**,实现换块 |
|
||||
| sdk/memory_impl.go (Recall/Commit) | 外部插件走这条 | 同上,返回类型适配 |
|
||||
| corehandler_memory.go | 插件 RPC | 同上 |
|
||||
| cli / webui / healthcheck 插件 | mem.Recall(...) | 签名不变则无感 |
|
||||
| toolcall.go memory_recall 工具 | g.RecallSorted | 输出格式改块(模型侧 description 同步) |
|
||||
|
||||
### 测试(10 个文件)
|
||||
|
||||
recall_order_test / noise_test / graph_test / light_memory_test /
|
||||
scene_test / scene_dedupe_test / reclaim_test / graph_readonly_test /
|
||||
indexer_test / medialive_test —— 随实现重写,判据保留(变异自证的成果不能丢)。
|
||||
|
||||
## 二、迁移步骤(每步可编译可测试)
|
||||
|
||||
### 第 1 步:块召回先行(不动 entities)
|
||||
|
||||
- GraphDB.RecallBlocks(queryVec, topK)——vector.SearchScored 走块
|
||||
- memoryface 加方法(不改旧 Recall 签名)
|
||||
- toolcall 的 memory_recall 输出切到块结果
|
||||
- **验收**:真库跨维度探针 ≥4/5(用已验证判据)
|
||||
|
||||
### 第 2 步:Commit 换块(写入侧切换)
|
||||
|
||||
- distill 产出 → 原句块+字段块+contains 边(重构计划第 2 步)
|
||||
- memory_commit 工具同样改块
|
||||
- **旧 Commit 保留但标记 Deprecated**,仅存量读取用
|
||||
- **验收**:新写入 0 行进 entities(用计数断言)
|
||||
|
||||
### 第 3 步:存量实体迁移为块
|
||||
|
||||
- 沿 MigrateLegacyMediaEntities 方向写 MigrateLegacyEntities:
|
||||
实体名→text block(带向量),关系→block edges
|
||||
- 可回滚:先快照 graph.db,迁移后探针验证,确认后清理
|
||||
- 孤儿(49 个无关系无原句)→ 原样转为孤立块,不编造连接
|
||||
- **验收**:迁移后跨维度 ≥4/5;快照可回滚
|
||||
|
||||
### 第 4 步:物理删除(最后一步,不可逆)
|
||||
|
||||
- 删 entities/relations/sentences 三表 + graph.go 全部相关 SQL
|
||||
(26+29+13 处)+ noise.go/scene.go/indexer.go 的实体路
|
||||
- 删 Entity/Relation/RecallResult 类型,接口面统一块类型
|
||||
- 删相关测试,保留判据重写为块版
|
||||
- **验收**:`grep -r entities internal/` 零命中;全量测试绿;
|
||||
知识库 content.md 的记忆系统描述同步更新
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- 每步一个 commit,第 4 步前任意一步可 revert
|
||||
- 第 3 步动真库前必须快照(cp graph.db graph.db.bak-<ts>)
|
||||
- 生产实例 ${HA_DATA} **不在本次范围**(需人工确认后单独做)
|
||||
|
||||
## 四、明确不变
|
||||
|
||||
- memory_recall 工具名与参数(调用方模型无感知)
|
||||
- pkg/embedding provider SPI(内核无感边界)
|
||||
- scene 机制(改挂块节点,机制本身保留)
|
||||
- vector.Store / qwen3vl 双模式权重(直接复用)
|
||||
@ -372,13 +372,12 @@ retention 修好之后这些会被逐步清掉,但不会立刻(要等跨月
|
||||
|---|---|---|
|
||||
| **弃答:泛指词检测未实现** | `abstention-criterion.md` §未完成 | 🟡 拒答主路径已接(`RecallBlocksGuarded`),泛指词这一支没做 |
|
||||
| **弃答:n=10 判据样本偏小** | 同上 | ⚪ 方向确定(确定性事实+完全分离),样本可扩 |
|
||||
| **遗留表退役:生产仍在用旧三表** | `entities-retirement-plan.md`、`legacy-table-retirement.md` | 🔴 迁移工具已就绪,生产未切换(entities 1292 / relations 978 / sentences 66 行仍在) |
|
||||
| **场景:现网垃圾场景清理 + 收口验证** | `scene-memory-fix-plan.md` 步骤 4 / 7 | ⚪ 属现场操作,代码侧 5/7 步已完成 |
|
||||
| **遗留表退役:生产仍在用旧三表** | `legacy-table-retirement.md` | 🔴 迁移工具已就绪(`internal/memory/migrate_entities.go`),生产未切换。2026-10-31 实测:entities 1292 / relations 978 / sentences 66 行仍在。方案原文已随计划文档删除,剩余读方清单见 `legacy-table-retirement.md` §八/§十二 |
|
||||
| **场景:现网垃圾场景清理 + 收口验证** | `future-plan.md`(原计划已删) | ⚪ 代码侧 7 步里 5 步已完成(归一化/ScenePolicy/R3/R5/R4 覆盖面);剩「清现网垃圾场景」与「收口验证」属现场操作 |
|
||||
| **中断子系统独立化** | `interrupt-subsystem-split-plan.md` | ⚪ 计划未实施;当前中断实现仍在 `agent/core`(`interrupt.go` + `scheduler.go`) |
|
||||
| 测试模板库(压力/接口/编译预置模板) | `commondev-design.md` §4「尚未做」 | ⚪ 机制已落地,模板按需补 |
|
||||
| 记忆召回「形态 3 / 形态 4」两个缺口 | `recall-capability-tiers.md` §「待补的能力」 | 🟡 需新实验,方向已列 |
|
||||
| 判据口径与契约对齐 | `abstention-criterion.md` §「待办」 | 🟡 |
|
||||
| 工具并行化的遗留项 | `toolcall-parallel-execution-plan.md` §「遗留项状态」 | ⚪ |
|
||||
| 生产召回验证的 3 条遗留 sentence→块 边 | `production-recall-validation.md` | ⚪ 非迁移产生 |
|
||||
| 遗留问题清单 | `legacy-issues.md` §二「真正待办」 | ⚪ 长期清单 |
|
||||
|
||||
|
||||
@ -1,450 +0,0 @@
|
||||
# 三层记忆系统修复计划:块化重构后的失效面
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**已实施**(2026-10-22)。实施记录见文末 §九。
|
||||
> 原计划写作于同日,下述前提与失效面均经源码与生产数据核实。
|
||||
> 触发:块化统一(`docs/zh/memory-restructure-plan.md`)重构了底层数据结构,
|
||||
> 但上层「层的流动」没有跟着改,导致 L1↔L2 这条链出现结构性失效。
|
||||
>
|
||||
> 本文只写**经源码与生产数据核实**的失效点。每条都标了证据位置,
|
||||
> 不含推测、不含性能断言。
|
||||
|
||||
---
|
||||
|
||||
## 〇、设计前提(不可动摇,任何修法违反即错)
|
||||
|
||||
### 前提 A:记忆块是唯一的物理载体
|
||||
|
||||
`docs/zh/memory-restructure-plan.md` §〇 纠错表原话:
|
||||
|
||||
> | 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。
|
||||
> **blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 |
|
||||
|
||||
`MemoryBlock` 是 union:文本块负载在 `Text`,媒体块在 `Data`(配 MIME/宽高/尺寸)。
|
||||
**同一个块 ID 在 context → doc → graph 全程不变。层是持有关系,不是数据种类的划分。**
|
||||
|
||||
### 前提 B:L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
|
||||
|
||||
这是**层与层的定义**,不是实现细节:
|
||||
|
||||
| | L1 Context | L2 Document |
|
||||
|---|---|---|
|
||||
| 承载单元 | 事件(内联正文 + 块) | 事件(内联正文 + 块) |
|
||||
| 结构 | 同构 | 同构 |
|
||||
| 与模型的关系 | **直接投喂**(进 system prompt 时序) | **不直接投喂**(需经召回才进入 L1) |
|
||||
| 容量 | 最近 n 条 / 上限 m 条 | 无上限 |
|
||||
| 流动性 | 超限时按相似度迁出 | 冷到阈值后蒸馏进 L3 |
|
||||
|
||||
⇒ L2 **不是**「L1 的摘要层」,也**不是**「另一种数据形态」。
|
||||
它就是**同样的事件 + 同样的块**,只是不直接进模型视野。
|
||||
召回 = 把块(连同它所属事件)从"不投喂"的持有层**迁回**"投喂"的持有层。
|
||||
|
||||
### 前提 C:正文(含多媒体正文)内联在块里
|
||||
|
||||
文本块的 `Text` 就是正文;媒体块的 `Data` 就是字节。
|
||||
**不存在"块 + 外部正文"的分离形态**,也不存在靠渲染字符串承载原文的路径。
|
||||
事件是块的容器,不是块的替代品。
|
||||
|
||||
### 前提 D:任何一层都不压缩
|
||||
|
||||
原文必须完整保留。视图(检索摘要、给模型看的渲染)可以是压缩的,
|
||||
但**视图不是记忆**。把视图当记忆存,等于在存储端就丢了原文。
|
||||
|
||||
### 前提 E:Graph 顶层仍是三元组,块只是它的节点实现
|
||||
|
||||
`internal/memory/sentence_block.go` — `putTripleBlocksTx`:
|
||||
|
||||
```go
|
||||
src := MemoryBlock{ID: TripleBlockID(t.Subject), Text: t.Subject, SemanticType: t.SubjectType}
|
||||
dst := MemoryBlock{ID: TripleBlockID(t.Object), Text: t.Object, SemanticType: t.ObjectType}
|
||||
// 边:src --edge_type=Relation--> dst,落在 memory_block_edges
|
||||
```
|
||||
|
||||
三元组 =【节点】【关系边】【节点】,这个**顶层结构没有变**。
|
||||
变的是节点的物理表示:`entities` 表 → `memory_blocks` 表。
|
||||
`entities` 退场不等于三元组退场。
|
||||
|
||||
### 前提 F:内核不得硬编码任何插件行为
|
||||
|
||||
这是**铁律**。任何依赖具体插件名(`agentcli` / `terminal` / `qq` …)的内核分支都是错的。
|
||||
插件的行为差异一律由插件自己**声明**:`ToolDef.NoMemory`、`ToolDef.Cleaner`、
|
||||
`ChannelDef.NoMemory`、`ChannelDef.Cleaner`、`InjectOptions`。
|
||||
|
||||
### 前提 G:SDK 已通过一套等价接口实现同等能力
|
||||
|
||||
公开 SDK(`third_party/homeagent-sdk/sdk/memory.go`)已提供与内核同构的表达:
|
||||
|
||||
| SDK 接口 | 对应的内核概念 |
|
||||
|---|---|
|
||||
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
|
||||
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
|
||||
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
|
||||
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
|
||||
| `MediaAttachment{Digest, MIME, Data}` | 块引用:带 `Data` 为新内容,只带 `Digest` 为引用已有 |
|
||||
| `TextMemoryAPI.Append(TextEvent)` | 时间序文本日志 |
|
||||
|
||||
⇒ **SDK 契约不需要改动**。修的是内核实现没跟上契约语义,不是契约本身。
|
||||
|
||||
---
|
||||
|
||||
## 一、失效面清单(经核实)
|
||||
|
||||
### 失效 1:L2 只存了事件的渲染视图,事件本体被丢弃
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `ContextToDoc`
|
||||
|
||||
`Prune` 交给 L2 的是结构完整的 `[]ContextEntry`(Timestamp/Source/Content/
|
||||
Response/ToolsUsed/ToolResults/Blocks),但落库时被拍平成一个字符串:
|
||||
|
||||
```go
|
||||
for _, e := range entries {
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) // 正文 → 视图
|
||||
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
```
|
||||
|
||||
只有 `Doc.Content` 被保存。**后果**:事件的时间戳只剩 `HH:MM`,
|
||||
`Response` 只剩 100 rune,工具输出只剩 200 rune,`ToolsUsed` 完全不存。
|
||||
|
||||
这直接违反前提 B(L1/L2 同构)与前提 C(正文内联在块里)——
|
||||
L1 里是完整事件,L2 里却退化成一段文本。
|
||||
|
||||
**生产证据**(`/home/newqqagent/memory/documents/`,1000 篇归档文档):
|
||||
|
||||
| 度量 | 实测值 |
|
||||
|---|---|
|
||||
| response 段恰好 103 字符(=100+`...`) | 311 / 2094 段 = **15.1%** |
|
||||
| 工具输出段恰好 203 字符(=200+`...`) | **7593 / 13261 段** |
|
||||
|
||||
⇒ 原文**在落库那一刻就不存在了**。这不是"召回时没取全",是存储端丢失。
|
||||
|
||||
### 失效 2:L2 里没有文本块,L1/L2 不同构
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `blocksFromEntries`
|
||||
|
||||
块的 `modality` 实测分布(同一批生产文档):
|
||||
|
||||
```
|
||||
L2 块总数 444:image 437 / audio 7 / text 0
|
||||
```
|
||||
|
||||
**后果**:L2 里"块"只承载媒体,文本内容全靠 `Doc.Content` 这个视图。
|
||||
违反前提 B/C——L1 的事件正文 + 块,到 L2 变成了"字符串 + 仅媒体块"。
|
||||
(对照:L3 的文本是有块的,`putTripleBlocksTx` 的主语块/宾语块都是 `BlockText`。)
|
||||
|
||||
### 失效 3:召回时新建合成事件,而非把块按原时序迁回
|
||||
|
||||
**位置**:`internal/agent/core/toolcall.go` — `executeDocTool` 的 `doc_query` 分支
|
||||
|
||||
```go
|
||||
docs := a.docStore.Consume(query, topK) // 读 + 从 L2 删除
|
||||
...
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt, // ← 归档时刻,非事件时刻
|
||||
Source: "cold_storage", // ← 新造来源
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content, // ← 渲染视图,且再截 2000
|
||||
})
|
||||
// ← 完全没有把 d.Blocks 挂回去
|
||||
```
|
||||
|
||||
三处偏离设计(`ARCHITECTURE.md` L107-108、L178):
|
||||
|
||||
> doc_query → Consume(读取并删除)
|
||||
> → **逐条** context.Append{Timestamp: **原始时间戳**, Source: 原来源}
|
||||
> → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
|
||||
**生产证据**(块自带 `created_at`,与归档时刻差近两小时):
|
||||
|
||||
```
|
||||
媒体块 created_at = 2026-10-12 11:09:51 ← 事件发生时刻(captureBlockMedia 里 time.Now())
|
||||
doc.created_at = 2026-10-12 13:04:54 ← 归档时刻
|
||||
```
|
||||
|
||||
443/443 个媒体块都带有效 `created_at`。**块自带它该回到哪个时刻的坐标,现在没人用。**
|
||||
|
||||
### 失效 4:块在 Consume 时两层同时消失
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `Consume` → `removeDoc`
|
||||
|
||||
```go
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
...
|
||||
os.Remove(filepath.Join(s.dir, id+".json")) // 块的字节只在这个文件里
|
||||
}
|
||||
```
|
||||
|
||||
`doc_query` 调用 `Consume` 后,文档(含 `d.Blocks`)从内存与磁盘同时删除;
|
||||
而 `toolcall.go` 不回填 `Blocks`。**后果**:这些块既不在 L2(已删)也不在 L1(没挂),
|
||||
只剩局部变量引用,函数返回后即不可达。
|
||||
|
||||
**生产风险敞口**:L2 现有 444 个块,其中 **396 个不在 L3**(`graph.db` 里查不到),
|
||||
原始字节约 **248 MB**。一旦被 `doc_query` 命中即永久丢失。
|
||||
|
||||
### 失效 5:时序渲染用固定 120 rune 截断正文
|
||||
|
||||
**位置**:`internal/agent/core/process.go` — `formatMergedTimeline`
|
||||
|
||||
```go
|
||||
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120)))
|
||||
```
|
||||
|
||||
上下文预算可以是几百 K token(`ComputeTokenBudget`),但每条正文**永远只显示前 120 字**。
|
||||
原文即使用前提 D 完整保存在库里,模型也看不到——这是"不压缩"在**渲染环节**的漏口。
|
||||
|
||||
**生产证据**(`context.json`):2 条 `cold_storage` 事件的 response 分别 1541 / 1209 字符,
|
||||
渲染出来都是 123 字符。
|
||||
|
||||
### 失效 6:`Prune` 的归档过滤要求本身违反前提 F
|
||||
|
||||
**位置**:`ARCHITECTURE.md` L13 写「过滤掉 `agentcli` 和 `terminal` 来源后归档」。
|
||||
|
||||
`agentcli` / `terminal` 是**插件名**。把它们写进内核归档规则 = 硬编码插件行为,违反前提 F。
|
||||
|
||||
**当前代码**:`Prune` 里没有任何源名过滤,该要求**未实现**;
|
||||
生产库里有 325 行 `agentcli` 归档(全部是终端状态行)。
|
||||
|
||||
⇒ 这不是"补上过滤",而是**该要求应作废,改由插件声明驱动**(前提 F)。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复顺序(按依赖)
|
||||
|
||||
失效 1/2 是存储形态,3/4 是召回形态,5 是渲染,6 是文档纠错。
|
||||
**必须先修存储**——存储里没有事件本体,召回就没有东西可放回。
|
||||
|
||||
| # | 动作 | 依赖 | 涉及文件 |
|
||||
|---|---|---|---|
|
||||
| ① | L2 存储形态改为「事件 + 块」(与 L1 同构) | — | `memory/document/document.go` |
|
||||
| ② | 文本正文以块承载(消灭 `Content` 作为唯一文本载体) | ① | 同上 + `agent/core/context.go` |
|
||||
| ③ | 召回按块/事件原始时序迁回 L1 | ①② | `agent/core/toolcall.go` |
|
||||
| ④ | 时序渲染改为按预算分配 | 独立 | `agent/core/process.go` |
|
||||
| ⑤ | 作废失效 6 的文档要求(改由 `NoMemory` 声明驱动) | 独立 | `ARCHITECTURE.md` |
|
||||
|
||||
### ① 的具体形状
|
||||
|
||||
`Doc` 与 `RelevanceContext` 的事件列表**同构**:
|
||||
|
||||
```
|
||||
L1: events []ContextEvent // Timestamp/Source/Input/Response/ToolsUsed/ToolResults/Blocks
|
||||
L2: events []ContextEvent // 同一结构,同一字段
|
||||
```
|
||||
|
||||
- `Doc` 保留 `ID` / `Summary` / `Vector` / `DenseVec` / `DenseFP` / `AccessCount` 等
|
||||
**检索元数据**(这些是 L2 作为"可检索容器"的附加属性,L1 不需要)
|
||||
- 事件列表**内联正文**(`Response` 完整,不截断)+ 持有的块
|
||||
- `Doc.Content` 降级为**纯渲染视图**(供摘要/人看/兼容 SDK 的 `Doc.Content`),
|
||||
不再作为原文载体;原文从事件读
|
||||
|
||||
> ⚠️ 注意与「已否决做法」的区别:不是新造一个异构字段把 `ContextEvent` 旁挂上去,
|
||||
> 而是让 **L2 的承载结构就是 L1 的那一套**。事件列表是文档的主体,不是附件。
|
||||
|
||||
### ② 的具体形状
|
||||
|
||||
- 归档时,每条事件的正文(`Input` / `Response`)生成对应文本块(`Modality=BlockText`,
|
||||
`Text` 即正文),块 `CreatedAt` = 事件时间戳
|
||||
- 媒体块照旧(`Data` 内联字节)
|
||||
- 事件 `Blocks` 里同时含文本块与媒体块 → L1/L2/L3 三处同构
|
||||
|
||||
### ③ 的具体形状
|
||||
|
||||
```
|
||||
doc_query → 取出 L2 事件列表(迁移语义:取出后 L2 不再持有)
|
||||
→ 逐条按各自 Timestamp 插回 L1 时序
|
||||
→ 事件持有的块随之迁回(同一块 ID,向量不重算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、判据(先写判据再改,避免自证)
|
||||
|
||||
> 教训(本次已发生):先改代码再写测试,测试就只会证明"我改的是我以为的"。
|
||||
> 判据必须**从设计前提推导**,且必须能在改动前**红**。
|
||||
|
||||
### ① 存储形态(L1/L2 同构)
|
||||
|
||||
- [ ] 归档后能从 L2 取回**逐条**事件的原始 `Timestamp`(完整时刻,非 `HH:MM`)、
|
||||
`Source`、`Response`(完整,非截断)、`ToolsUsed`、`ToolResults`
|
||||
- [ ] 变异:把 `Response` 改回 `truncate(..,100)` ⇒ 红
|
||||
- [ ] 变异:丢掉 `ToolsUsed` ⇒ 红
|
||||
- [ ] 变异:时间戳改回 `Format("15:04")` ⇒ 红
|
||||
|
||||
### ② 文本块
|
||||
|
||||
- [ ] 归档文档的文本块 `Text` 与事件正文逐字一致
|
||||
- [ ] 文本块 `CreatedAt` == 事件 `Timestamp`
|
||||
- [ ] 变异:不生成文本块(只存 `Doc.Content`)⇒ 红
|
||||
|
||||
### ③ 召回
|
||||
|
||||
- [ ] `doc_query` 后事件按**各自原始时间戳**回到 L1
|
||||
- [ ] 块 `ID`/`Vector`/`CreatedAt` 全程不变(迁移而非重建)
|
||||
- [ ] 块的持有层从 L2 变为 L1,**不同时留在两层**
|
||||
- [ ] 变异:用 `d.CreatedAt` 当时间戳 ⇒ 红
|
||||
- [ ] 变异:不回填 `Blocks` ⇒ 红
|
||||
|
||||
### ④ 渲染
|
||||
|
||||
- [ ] 预算宽裕时正文完整可见(含结尾哨兵)
|
||||
- [ ] 预算紧张时仍保留开头(不能整条消失)
|
||||
- [ ] 单条超长正文不得把其余事件整段挤掉
|
||||
|
||||
### ⑤ 文档纠错
|
||||
|
||||
- [ ] `ARCHITECTURE.md` 不再出现任何插件名作为内核归档规则
|
||||
- [ ] 判据:`grep -rn '"agentcli"\|"terminal"' internal/agent/core internal/memory`(非测试)为空
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项(此前已犯,写下来防复发)
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 给 `Doc` 加异构字段旁挂事件快照(如 `Entries []ContextEntry`) | L2 主体就是事件列表本身,不是"Doc + 附件" |
|
||||
| 改 `truncateStr` 的常数当作"修复压缩" | 视图截断不是记忆压缩问题;根因在存储形态 |
|
||||
| 任何插件名字面量进内核分支 | 违反前提 F |
|
||||
| 编造不存在的 API 再补注释解释它"应该"怎样 | 实现必须以真实存在的设施为准 |
|
||||
| 先按自己的理解写测试再改代码 | 会把误解固化成判据 |
|
||||
| 改公开 SDK 契约 | 前提 G:SDK 已等价,修内核实现 |
|
||||
|
||||
---
|
||||
|
||||
## 五、已否决的做法
|
||||
|
||||
### 否决 1:新增 `Doc.Entries` 存事件快照(2026-10-22,已实现又撤销)
|
||||
|
||||
错在**异构旁挂**:`Doc` 仍是字符串主体,事件列表被当成附件挂上去。
|
||||
正确形态是 §二①:事件列表**成为 L2 的主体**,与 L1 同构。
|
||||
|
||||
### 否决 2:把渲染层截断常数调大(2026-10-22,已撤销)
|
||||
|
||||
根因在存储形态,不在渲染常数。§二④ 单独处理,且应是**按预算分配**而非改常数。
|
||||
|
||||
---
|
||||
|
||||
## 六、已拍板的决定
|
||||
|
||||
### 1. 事件本体在 L2 的形态(2026-10-22 拍板)
|
||||
|
||||
**L2 就是 doc 存储,本身与 Context 同构,区别只在于是否被直接投喂给模型。**
|
||||
|
||||
⇒ 不是“复用 `ContextEvent` 当旁挂字段”,也不是“把事件也变成块”这种额外抽象。
|
||||
**事件列表就是 L2 文档的主体**,与 L1 事件列表同一结构。
|
||||
|
||||
### 2. 正文形态(2026-10-22 拍板)
|
||||
|
||||
**正文(包括多媒体正文)本身就内联在块里。**
|
||||
|
||||
⇒ 文本块的 `Text` 是正文,媒体块的 `Data` 是字节。
|
||||
不存在“块 + 外部正文”的分离形态,也不靠渲染字符串承载原文。
|
||||
|
||||
### 3. SDK(2026-10-22 拍板)
|
||||
|
||||
**记忆部分 SDK 已通过一套接口实现等价功能** ⇒ **不改公开 SDK 契约**。
|
||||
|
||||
对照表(`third_party/homeagent-sdk/sdk/memory.go`):
|
||||
|
||||
| SDK 接口 | 等价的内核概念 |
|
||||
|---|---|
|
||||
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
|
||||
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
|
||||
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
|
||||
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
|
||||
| `MediaAttachment{Digest, MIME, Data}` | 块引用(带 Data 为新内容,仅 Digest 为引用) |
|
||||
|
||||
⇒ 修的是内核实现没跟上契约语义,不是契约本身。
|
||||
|
||||
### 4. 存量数据(2026-10-22 拍板)
|
||||
|
||||
**等待系统自然冲刷**,不做迁移脚本。
|
||||
|
||||
约束:
|
||||
- 新代码必须能**读旧格式**(缺事件列表/缺文本块时降级),不得 panic、不得静默丢内容
|
||||
- 降级路径要有明确日志,便于判断存量退出进度
|
||||
- 旧格式文档被召回时按旧行为处理,**不伪造成“有原始事件”**
|
||||
|
||||
---
|
||||
|
||||
## 七、待确认的细节(实现层面,不阻塞开工)
|
||||
|
||||
1. **文本块与事件是一对一还是一对多?**
|
||||
一条事件生成一个文本块(`Input` + `Response` 合在一个 `Text` 里),还是各一块(便于分别检索)?
|
||||
|
||||
2. **`Doc.Content` 保留为渲染视图,还是彻底删除?**
|
||||
若保留,与事件列表信息重复;若删除,SDK 的 `Doc.Content` 则由事件列表现场渲染填充。
|
||||
|
||||
3. **L2 事件的检索向量怎么算?**
|
||||
现在 `denseFor` 用 `Summary + Content`。改为事件列表后,
|
||||
是用 `Summary + 各事件正文拼接`,还是用文本块自己的向量做融合(与媒体块一致)?
|
||||
|
||||
---
|
||||
|
||||
## 八、验收口径
|
||||
|
||||
- `go build ./...` 通过
|
||||
- 全量 `go test ./... -count=1` 0 失败(当前基线:58 包通过 / 0 失败)
|
||||
- §三 每条判据有对应测试,且**变异测试**各自能被抓住
|
||||
- 生产侧(可在副本库验证):`doc_query` 召回后块不消失;新旧两种格式文档都能正确召回
|
||||
|
||||
---
|
||||
|
||||
## 九、实施记录(2026-10-22)
|
||||
|
||||
### 已落地
|
||||
|
||||
| # | 动作 | 落地位置 |
|
||||
|---|---|---|
|
||||
| ① | L2 存储形态改为「事件 + 块」 | `document.Doc.Events []DocEvent`;`ContextToDoc` 落盘事件列表 |
|
||||
| ② | 正文各自成文本块 | `docBlocksForEntry` / `entriesToEvents`;Input、Response 各一块(`BlockText`) |
|
||||
| ③ | 召回按原始时序迁回 | `Agent.restoreDocEvents`;`DocEvent.InOut()` 从块读回正文 |
|
||||
| ④ | 蒸馏改用原文 | `documentOriginalText`(有 Events 用块内原文,存量退回 Content) |
|
||||
| ⑤ | 文档纠错 | `ARCHITECTURE.md` 删除「过滤 agentcli/terminal」,改为「由插件声明驱动」 |
|
||||
|
||||
### Content 的定位(按拍板)
|
||||
|
||||
`Doc.Content` **保留为渲染视图**,由 `contentFromEvents` 生成:
|
||||
|
||||
- 供摘要、人工阅读、SDK 的 `Doc.Content` 字段使用
|
||||
- **不是**原文载体 —— 原文在 `Events[i].Blocks` 里文本块的 `Text`
|
||||
- 视图也不主动截断(存储层不替渲染层做决定)
|
||||
|
||||
### 存量
|
||||
|
||||
按拍板**不做迁移**,等系统自然冲刷。降级路径:
|
||||
|
||||
- `Doc.HasEvents() == false` ⇒ 召回时整篇渲成一条 `cold_storage` 事件(旧行为)
|
||||
- 不伪造成「有原始事件」;有日志可观测
|
||||
|
||||
### 判据与变异
|
||||
|
||||
新增两组判据(各条都在改动前设计、改动后可跑):
|
||||
|
||||
| 文件 | 判据数 | 覆盖 |
|
||||
|---|---|---|
|
||||
| `internal/memory/document/blockflow_test.go` | 7 | 存储形态、文本块粒度、逐条保留、视图定位、幂等、存量兼容、原文可用性 |
|
||||
| `internal/agent/core/recalldoc_test.go` | 5 | 逐条回填、原始时间戳、块迁回、回填不截断、存量降级 |
|
||||
|
||||
**变异测试(全部被抓住)**:
|
||||
|
||||
| 变异 | 被哪条抓住 |
|
||||
|---|---|
|
||||
| 正文落库时 truncate(100) | `TestL2_ArchivePreservesFullText` / `TestL2_ContentIsRenderedViewNotSource` |
|
||||
| Input/Response 合成一块 | `TestL2_InputAndResponseAreSeparateTextBlocks`(及 3 条连带) |
|
||||
| 召回用 `d.CreatedAt` 当时间戳 | `TestRecallByDoc_RestoresEachEventAtOriginalTimestamp` |
|
||||
| 召回不回填 Blocks | `TestRecallByDoc_MigratesBlocksBackToContext` |
|
||||
| 召回再截 2000 字 | `TestRecallByDoc_TextIsNotTruncatedOnTheWayBack` |
|
||||
|
||||
### 未做(明确不做)
|
||||
|
||||
- **不改公开 SDK 契约**:`TextEvent` / `DocMemoryAPI` / `InsertWithMedia` 已等价
|
||||
- **不迁移存量文档**:等自然冲刷
|
||||
- **不改渲染层截断常数**(失效 5):根因已在存储形态修掉;渲染按预算分配是独立议题,
|
||||
避免与本次修复混在一起、互相掩盖
|
||||
@ -1,168 +0,0 @@
|
||||
# 记忆系统重构计划:块节点统一载体
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**已执行完毕(2026-10-04)**。本文是当时的执行计划,保留作为决策记录。
|
||||
> 日期:2026-10-02(计划) / 2026-10-04(完成)
|
||||
>
|
||||
> ## 执行结果摘要(2026-10-04 回填)
|
||||
>
|
||||
> ★ **读法**:下面「三、分步执行」里各步骤仍写着 `✗ 未开始` —— 那是
|
||||
> **计划当时的原始快照**,有意不改(改了就看不出计划与实际的差距)。
|
||||
> **以本摘要为准。**
|
||||
>
|
||||
> | 步骤 | 状态 | 关键提交 |
|
||||
> |---|---|---|
|
||||
> | ① 重写设计文档 | ✅ | `6a0d6b7` `971cd72` `7248558` |
|
||||
> | ② distill 沉库改块节点 | ✅ | `4b54c03` `1c260e3` `0586f6b` |
|
||||
> | ③ memory_recall 加块召回路 | ✅ | `688ad08` `40a6acb` |
|
||||
> | ④ memory_commit 止血 | ⚠️ 部分(见下) | `3bafe1c` |
|
||||
> | ⑤ 存量迁移 | ✅ | `09f31e2` `e0a234c` `5a70452` |
|
||||
> | ⑥ scene 挂接 | ❌ **取消**(见下) | — |
|
||||
>
|
||||
> ### 计划与实际的偏差(都是实测纠正的)
|
||||
>
|
||||
> **⑥ scene 挂接取消**:计划假设 blocks 是新层、scene 挂在它上面。
|
||||
> 但 blocks 不是新层而是图节点升级体,scene 无处可挂。
|
||||
>
|
||||
> **蒸馏路线整体否决**(990b824):生产库 429 条实体里 **424 条(99%)无主语**,
|
||||
> 而 `Split` 的第五道闸门「无主语不写悬空属性」会把 LLM 已拆对的字段全丢弃。
|
||||
> 蒸馏命令已改为**只对有主语的实体跑**(b21410b),实际处理 5 条。
|
||||
>
|
||||
> **注意力网络路线否决**(`6a0d6b7` ~ `57eca02`):分域后 token F1 0.918
|
||||
> 超过门槛,但**跨域不可泛化**(域外零字段),而通用 Agent 无法分域。
|
||||
>
|
||||
> ### 计划里未被采纳的替代方案
|
||||
>
|
||||
> - 「给块补语义描述」→ 实验否定(中性描述反而更差,排名 26 → 96,`7248558`)
|
||||
> - 「给 URL 类事实换更强向量」→ 未测,但裸 URL 无文本可供语义匹配(`971cd72`)
|
||||
>
|
||||
> ### 现行架构
|
||||
>
|
||||
> ```
|
||||
> memory_blocks 统一节点载体(文本/媒体都进这里)
|
||||
> memory_block_edges source_kind → target_kind 多态端点,唯一边表
|
||||
> graph_centroid 中心向量(迁移/蒸馏后自动重建)
|
||||
> ```
|
||||
>
|
||||
> 原句由 `blk_src_<sha256>` 原句块承载(内容派生、幂等、不带向量),
|
||||
> `sentences` 表的**写入点已清零**(迁移与蒸馏都不再写)。
|
||||
>
|
||||
> 详见 `production-recall-validation.md`(三十三~三十八节)、
|
||||
> `recall-capability-tiers.md`(能力分层)、
|
||||
> `vector-provider-decision.md`(provider 决策与缺口)。
|
||||
> 背景:本会话前期的记忆系统改动(630d3f4 / 978bada / 69150d2 / 25d35f8)建立在对记忆架构的错误认知上,经用户逐条纠正后全部重审。本计划是重审后的执行路线。
|
||||
> 前置事实核查:全部基于代码与真库实测,标注来源。
|
||||
|
||||
## 〇、认知纠错记录(为什么推翻重来)
|
||||
|
||||
| 错误认知(当时的做法) | 正确认知(用户纠正 + 代码核实) |
|
||||
| --- | --- |
|
||||
| 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。**blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 |
|
||||
| 给 entities 加向量字段(graph-vector-design.md) | **entities 该退场**。memory_blocks 已是带 vector+fingerprint 的节点载体(46f833c) |
|
||||
| LLM 拆句产物 Commit 成 entities+relations(630d3f4) | 拆出的节点放入 block,保留原句;媒体引用是已删除的老设计(mediaref.go 只剩注释) |
|
||||
| 三元组 = 我发明的 (主语,维度,值) | 三元组 =【节点】【关系边】【节点】;三元组设计文档在知识库 assets/knowledge/homeagent_architecture/content.md |
|
||||
| provider 是蒸馏的配件 | provider 是内核的模型接入接口,内核对 tfidf 等传统算法与向量化 LLM **无感**,payload 一致才能动态接入 |
|
||||
| 多模态要"引用"进图 | 多模态节点由 LLM 多模态能力拆分入库 |
|
||||
| 场景/scene 表忽略不看 | scene 是第三种召回:条件型记忆不吃词法/向量相似,按场面**前缀匹配**直接取回(scene.go:12-38) |
|
||||
|
||||
沉痛教训:630d3f4 的 16 三元组端到端验证——验证的是拆分质量,不是正确的落库形态。**验证通过 ≠ 架构正确。**
|
||||
|
||||
## 一、目标形态(一句话)
|
||||
|
||||
```
|
||||
入库:拆解器(provider 接口,内核无感)把对话/文档/媒体拆成
|
||||
memory_blocks 节点(modality/text_content/vector/fingerprint)
|
||||
+ memory_block_edges 结构边;原句保留为块。
|
||||
召回:块向量检索为主 + scene 场景召回补条件型记忆;
|
||||
entities+LIKE 是存量过渡路径,逐步退场。
|
||||
```
|
||||
|
||||
## 二、既有设施盘点(不重复实现)
|
||||
|
||||
| 能力 | 已有 | 位置 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 块节点存储 | `PutMemoryBlocks` / `MemoryBlocks` | internal/memory/block.go:67/144 | ✔ 可用 |
|
||||
| 结构边 | `AddMemoryBlockEdge`(端点校验,测试 453/491 行) | block.go:199 | ✔ 可用 |
|
||||
| 块检索 | `BlocksForNode`(按端点反查) | block.go:254 | ✔ 可用 |
|
||||
| 向量检索 | `vector.Store`(Insert/SearchScored/Remove) | internal/memory/vector/store.go | ✔ 可用 |
|
||||
| provider SPI | `pkg/embedding`(Embed/Info/Close,Fingerprint) | pkg/embedding/embedding.go | ✔ 可用 |
|
||||
| VL 权重双模式 | qwen3vl(Embed 出向量)+ qwen3vlgen(tied head 生成) | 25d35f8 | ✔ 已验证 |
|
||||
| 文本拆解 | distill.Extractor(schema 约束+闸门,16 三元组/幻觉0) | internal/memory/distill | 拆分逻辑可复用,**沉库去向要改** |
|
||||
| 旧实体迁移先例 | `MigrateLegacyMediaEntities` | internal/memory/migrate.go:47 | ✔ 方向先例 |
|
||||
| scene 召回 | `RecallByScene` | internal/memory/scene.go:323 | ✔ 已有 |
|
||||
| 拆解生成模型 | ollama+qwen3:1.7b(schema 6/6 零幻觉) | providers/ollama | ✔ 端到端验证过 |
|
||||
|
||||
## 三、分步执行
|
||||
|
||||
### 第 1 步:重写两份设计文档 ✗ 未开始
|
||||
|
||||
- [ ] 删 `docs/zh/graph-vector-design.md`(给 entities 加向量——前提错误)
|
||||
- [ ] 重写 `docs/zh/triple-distill-llm-design.md`:拆解产物=块节点;entities 标注退场路径
|
||||
- [ ] 修正 `providers/qwen3vlgen` 与 `pkg/generation` 的定位注释:不是"蒸馏配件",是入库拆解器的模型面
|
||||
- **验收**:文档里的每张表名/函数名都能 grep 到;无 entities 加向量字段的任何残留表述
|
||||
|
||||
### 第 2 步:distill 沉库改块节点(核心) ✗ 未开始
|
||||
|
||||
- [ ] `distill.Extractor.Split` 产出改为块结构(新增 `distill.Blocks` 类型,保留 Triple 输出做对照期)
|
||||
- [ ] 原句 → text block(text_content=原句, vector, fingerprint)
|
||||
- [ ] 字段 → text block(text_content=字段内容, vector, fingerprint)
|
||||
- [ ] 边:原句 block —contains→ 字段 block
|
||||
- [ ] 向量来源走 `pkg/embedding`(默认 provider 由配置决定;无 provider 时 vector 留空+fingerprint 空,**不静默编造向量**)
|
||||
- [ ] pipeline.go 的 distillBatch 改调块写入
|
||||
- **判据**:
|
||||
- [ ] 拆一条真实记录后,MemoryBlocks/MemoryBlockEdges 能查到原句块+字段块+边
|
||||
- [ ] 断电安全:PutMemoryBlocks 是事务(block.go 已有端点校验事务先例)
|
||||
- [ ] 无 provider 时块仍入库(vector=''),检索退化为符号路,**不报错不编造**
|
||||
- **变异自证**:把"contains 边"删掉,块查询测试必须红
|
||||
|
||||
### 第 3 步:memory_recall 增加块召回路 ✗ 未开始
|
||||
|
||||
- [ ] GraphDB 增加 `RecallBlocks(queryVec, topK)`:走 vector.SearchScored
|
||||
- [ ] 工具层 `memory_recall`:块向量召回为主,entities LIKE 为存量兜底,输出统一格式
|
||||
- [ ] 工具名/参数不变(调用方模型无感知)
|
||||
- [ ] fingerprint 不匹配时**拒绝比较**(报"向量空间已变更,需回填"),绝不拿 2048 维查询比 512 维库
|
||||
- **判据**(真库 + 跨记录探针,用本会话已验证的判据):
|
||||
- [ ] 跨维度定位 ≥4/5(现状 LIKE 0/5)
|
||||
- [ ] 端口类数字串不退化(向量弱、符号强——混合必要性)
|
||||
- **变异自证**:去掉块召回路,跨维度判据必须退化到现状水平
|
||||
|
||||
### 第 4 步:memory_commit 同步止血 ✗ 未开始
|
||||
|
||||
- [ ] description 明确要求:拆解后走块形态;禁止 43-50 字符整句实体
|
||||
- [ ] 工具实现侧加闸门:超长 subject 拒绝并提示(与 distill 闸门同款)
|
||||
- **判据**:新写入的实体长度分布不再出现 >25 字符主体
|
||||
|
||||
### 第 5 步:存量迁移 ✗ 未开始
|
||||
|
||||
- [ ] 沿 `MigrateLegacyMediaEntities` 方向写 `MigrateLegacyTextEntities`:
|
||||
188 实体 → 原句块(带向量)+ 字段块 + contains 边
|
||||
- [ ] 独立命令入口(参照 homed-kb-migrate),**可回滚**:导出原库快照→迁移→对比→确认后替换
|
||||
- [ ] 孤儿实体(49/108 无关系无原句)保持原样,不编造
|
||||
- **判据**:迁移后跨维度探针 ≥4/5;原库快照可完整回滚
|
||||
|
||||
### 第 6 步:scene 挂接(可选,后置) ✗ 未开始
|
||||
|
||||
- [ ] 拆解块带 scene 键(memory_blocks.scene 字段已在 schema 里)
|
||||
- [ ] 验证 `RecallByScene` 对块节点生效
|
||||
- 前置:第 2-5 步稳定后再做
|
||||
|
||||
## 四、每步通用约束
|
||||
|
||||
1. **provider 边界**:内核不出现任何 provider 名分支;一切经 `pkg/embedding`/`pkg/generation` 接口
|
||||
2. **禁止静默错误**:fingerprint/维度不匹配必须显式报;无向量必须显式留空
|
||||
3. **回填必落盘**(document.go:137 陷阱:不落盘则判定条件永远成立、每次启动重算)
|
||||
4. **变异自证**:每条关键判据写测试后,手动破坏对应实现,测试必须变红
|
||||
5. **长任务纪律**:导出/回填等内存密集任务**绝不与测试并行**(本会话三次 OOM 教训)
|
||||
6. **验证通过 ≠ 架构正确**:每步完成后对照本计划第一节逐条自检
|
||||
|
||||
## 五、暂不做
|
||||
|
||||
- entities 表的物理删除(存量过渡期内仍服务 LIKE 兜底)
|
||||
- KV cache(qwen3vlgen 全量前向 O(N²),生成低频,已注释标明)
|
||||
- 多模态拆解(第 6 步之后,依赖块路稳定)
|
||||
- 切换生产实例 /var/tmp/ha-c 的 provider 配置(需人工确认)
|
||||
@ -1,309 +0,0 @@
|
||||
# 三层记忆可用性修复计划:统一向量化 + 提示词让 agent 真正理解
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**已实施**(2026-10-22 计划,2026-10-31 逐条复核确认)。
|
||||
>
|
||||
> 三条根因的实测结论(复核方法:读当前代码,不看本文的历史描述):
|
||||
>
|
||||
> | 根因 | 本文当时的判断 | 2026-10-31 实测 |
|
||||
> |---|---|---|
|
||||
> | R1 向量化输入是 jieba 词袋 | 需改为正文 | ✅ 已修 —— `coldcontext.go` 的 `denseFor` 明写「用事件正文(原文),不用 Summary(jieba 词袋)」 |
|
||||
> | R2 媒体块无语义 | 需补 | ✅ 已修(块 union 化,媒体即块,自带描述) |
|
||||
> | R3 提示词无引导 | 需补 L1/L2/L3 与回填语义 | ✅ 已修 —— `tooldefs.go` 明写 `coldcontext_query` **把历史事件插回时序**,并下沉到 kernel-memory skill |
|
||||
>
|
||||
> 本文保留作为**「结构判据不能替代质量判据」**的决策记录 ——
|
||||
> 那条教训(16 条判据 6 个变异全绿,而没有一条验证"检索结果与查询相关")
|
||||
> 比计划本身更值得留。
|
||||
> 前置:`docs/zh/memory-blockflow-repair-plan.md`(L1↔L2 结构同构,已实施)
|
||||
> 本文修的是**上一轮没修的那一层**:结构对了,但检索质量与提示词仍然不可用。
|
||||
|
||||
---
|
||||
|
||||
## 〇、起点:承认上一轮的判断错误
|
||||
|
||||
上一轮我修了 L1↔L2 的结构(事件列表 + 正文进块 + 按原时序回填),
|
||||
然后声称「机制已通」。**结构确实通了,但功能不可用。**
|
||||
|
||||
用生产数据实测(1054 篇文档、chineseclip 512 维)三个互不相关的查询:
|
||||
|
||||
```
|
||||
查询 "doc 层召回是怎么设计的"
|
||||
score=0.7834 来自 4 个来源的 61 条对话 (cold_storage, agentcli...)
|
||||
score=0.7795 来自 1 个来源的 2 条对话 (agent) 涉及: map, content, dsh, 占用
|
||||
score=0.7768 来自 1 个来源的 1 条对话 (agent) 涉及: 自检, OK
|
||||
|
||||
查询 "hmap 插件安装验签"
|
||||
score=0.7922 来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是, pi
|
||||
|
||||
查询 "QQ 头像"
|
||||
score=0.7601 来自 1 个来源的 1 条对话 (agent) 涉及: CQ, qq, ok
|
||||
```
|
||||
|
||||
**分数全挤在 0.75~0.79,区分度≈0,命中内容与查询无关。**
|
||||
|
||||
### 判据缺失(最该记的一条)
|
||||
|
||||
上一轮写了 16 条判据、6 个变异,全部通过。但**没有一条验证「检索结果必须与查询相关」**。
|
||||
所以「全绿」证明的只是我实现了我以为的东西 —— 不是它可用。
|
||||
|
||||
> ★ 教训:**结构判据不能替代质量判据**。
|
||||
> 「字段非空」「条数正确」「不 panic」都可能在检索完全失效时依然全绿。
|
||||
|
||||
---
|
||||
|
||||
## 一、根因(三条,按依赖排序)
|
||||
|
||||
### 根因 1:向量的输入是 jieba 词袋,不是正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```go
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil {
|
||||
```
|
||||
|
||||
而 `Summary` 由 `summarizeEntries` 生成,后者调用链是:
|
||||
|
||||
```
|
||||
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
|
||||
extractTags → 同上
|
||||
extractEntities → 同上
|
||||
```
|
||||
|
||||
`TokenizeContentWords`(`internal/memory/cut.go:256`)就是 `jieba.Tag()` 分词 + 停用词过滤,
|
||||
产出形如 `涉及: map, content, 是不是, pi`。
|
||||
|
||||
**这是与设计直接矛盾的**。`ARCHITECTURE.md` L131 / L174 明写向量层级:
|
||||
|
||||
```
|
||||
统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
```
|
||||
|
||||
`Summary` 是**主路径的输入**,却由**最末一级的词法分词**产出。
|
||||
全库所有文档的 Summary 因此高度同质(都是「来自 N 个来源的 M 条对话 涉及: …」),
|
||||
余弦相似度自然全挤在高位、无区分度。
|
||||
|
||||
### 根因 2:媒体块没有语义,且不需要单独生成
|
||||
|
||||
设计要求「doc 支持多模态摘要」。当前实现里:
|
||||
|
||||
- `Doc.Summary` 只有文本路径,**媒体块从不参与**
|
||||
- `crossModalMarkdown` 里媒体只渲染成 `[image/png blk_media_xxx]`(`blockLabel`:MIME + 短 ID)
|
||||
- 一篇带 20 张图的文档,摘要里没有「有图」的任何信息
|
||||
|
||||
**但「多模态摘要」这个提法本身是歧路**(用户 2026-10-22 指出):
|
||||
|
||||
> 媒体 doc 块一般是和文本紧邻的,所以对应的文本就足够了
|
||||
|
||||
`Doc.Events[i].Blocks` 里媒体块与文本块**在同一事件内**。媒体块的语义由
|
||||
**同事件的文本**天然覆盖,不需要为每个媒体单独生成描述(那正是 CAS 时代
|
||||
「描述式索引」被拆除的同一个错误)。
|
||||
|
||||
⇒ 正确做法不是「给媒体写摘要」,而是:**向量用事件正文算,媒体自动被覆盖**。
|
||||
|
||||
### 根因 3:提示词与注入文案没有任何引导
|
||||
|
||||
**(a)`doc_query` 的工具描述只有一句话**
|
||||
|
||||
```go
|
||||
toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", ...)
|
||||
```
|
||||
|
||||
逐条对照问题:
|
||||
|
||||
| 应有信息 | 现状 |
|
||||
|---|---|
|
||||
| L2 是什么 | ❌ 只说「文档记忆」,没有定义 |
|
||||
| 何时该用 | ❌ 无 |
|
||||
| 调用后会发生什么 | ❌ 「返回相关文档摘要」**与真实行为不符**(真实是逐条按原时序回填到对话时序) |
|
||||
| 与 `memory_recall` 的分工 | ❌ 无 |
|
||||
|
||||
对比同文件里 `【输出规则】`(6 条)、`【工具执行顺序】`(4 条)的详细程度,
|
||||
`doc_query` 是最敷衍的一个。
|
||||
|
||||
**(b)预填充注入文案没有引导**
|
||||
|
||||
`crossModalMarkdown` 输出:
|
||||
|
||||
```
|
||||
【跨模态相关记忆】
|
||||
[1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
|
||||
```
|
||||
|
||||
**没有一个字说「可以用 doc_query 取全文」**。模型看到这段只能忽略。
|
||||
|
||||
**(c)系统提示词从未解释三层记忆是什么**
|
||||
|
||||
`grep 记忆 internal/agent/core/tooldefs.go` 全量命中里,没有任何一段说明
|
||||
Context / Document / Graph 三者的分工与使用时机。agent 只能从工具名猜。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复步骤(按依赖,每步先写会红的判据)
|
||||
|
||||
### 步骤 1:判据先行 —— 检索质量
|
||||
|
||||
**先写测试,此时必须红。**
|
||||
|
||||
`internal/memory/document/retrieval_quality_test.go`:
|
||||
|
||||
- 构造真实语料(约 50 篇,内容取自生产副本的真实事件正文 + 人工标注的查询-答案对)
|
||||
- 断言:给定查询,**正确文档必须排第一**(top1 准确率 100%,或至少 top3 内)
|
||||
- 断言:互不相关的查询,top1 结果**必须不同**
|
||||
- 断言:正确命中的分数必须**显著高于**非命中(区分度阈值)
|
||||
|
||||
**变异**:把 `denseFor` 改回 `Summary + Content` ⇒ 必须红。
|
||||
**变异**:把正文替换成 jieba 词袋 ⇒ 必须红。
|
||||
|
||||
这一步是整个计划的地基。没有它,后面每一步都只是「我以为改对了」。
|
||||
|
||||
### 步骤 2:向量输入改为正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```
|
||||
现在:VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
改为:VectorizeDense(事件正文拼接)
|
||||
有 Events → 按事件顺序取 InOut() 的 input + response(文本块内容)
|
||||
无 Events(存量)→ 退回 Content
|
||||
```
|
||||
|
||||
**媒体如何被覆盖**:媒体块与文本块在同一事件内,事件的正文进入向量即覆盖它。
|
||||
不需要为媒体单独编码 —— 若将来要「按图搜图」,那是视觉路(`RecallBlocks`)的职责,
|
||||
已有独立通路,与文本路不冲突。
|
||||
|
||||
### 步骤 3:删掉 jieba 摘要路径
|
||||
|
||||
`Summary` / `Tags` / `Entities` 目前全部由 `ExtractKeywords`(jieba)产出。
|
||||
|
||||
- `Tags`:唯一消费点是 `doc_query` 返回值里的「标签: …」(`toolcall.go:1038`),
|
||||
**是词袋噪声,删除该行**
|
||||
- `Entities`:`Doc.Entities` 无任何非测试读取点(跨包的 `injected.Entities`
|
||||
是 `memory.Entity` 另一个类型,无关),**字段可删**
|
||||
- `Summary`:改为**纯展示**(人工阅读 / 日志),由正文首句截取生成,**不参与向量**
|
||||
|
||||
⚠️ 需先确认:`Doc.Tags` / `Doc.Entities` 是否有 WebUI / SDK 侧读取。
|
||||
(已知 `sdk/memory_impl.go` 的 `Doc` 只有 `ID/Title/Content`,无 Tags/Entities。)
|
||||
|
||||
### 步骤 4:预填充注入正文片段 + 引导
|
||||
|
||||
`crossModalMarkdown` 改为:
|
||||
|
||||
```
|
||||
【历史记忆(按相关度)】
|
||||
[1] 2026-10-12 11:09 · qq · 相关度 0.83
|
||||
紫蓝红三色带的配色参考,老大要求…(正文首 2 行)
|
||||
[2] …(含 2 张图:image/png, image/jpeg)
|
||||
|
||||
这些是**历史**片段(不是当前对话)。要看完整原文,
|
||||
调用 doc_query 把对应事件按原时间戳拉回对话时序。
|
||||
```
|
||||
|
||||
要点:
|
||||
- 去掉「来自 N 个来源的 M 条对话」这类统计描述
|
||||
- 给出**时间 + 来源 + 正文开头**(真实内容,让模型能判断相关性)
|
||||
- 媒体只报数量与类型,不编描述
|
||||
- **明确告知 doc_query 的作用**
|
||||
|
||||
### 步骤 5:`doc_query` 工具描述重写
|
||||
|
||||
按 `memory_*` 的详细度,必须覆盖:
|
||||
|
||||
- L2 是什么(历史事件层,不直接进当前对话)
|
||||
- 何时用(记得办过但想不起细节 / 跨会话跨天 / 需要原始过程而非结论)
|
||||
- 调用后发生什么(**把事件逐条按原时间戳回填到对话时序**,不是返回一段摘要)
|
||||
- 与 `memory_recall` 的分工(图记忆=关系与最新值;doc=整段过程与原文)
|
||||
- 副作用(从 L2 取出,取走后该文档不再在 L2)
|
||||
|
||||
### 步骤 6:系统提示词增加「三层记忆」段落
|
||||
|
||||
在 `buildSystemPrompt` 里加一段,说清三层是什么、怎么用:
|
||||
|
||||
```
|
||||
【三层记忆】记忆分三层,你现在看到的是第一层。
|
||||
- 对话时序(L1):最近的对话,已在你的上下文里。超过窗口的会被移出。
|
||||
- 历史记忆(L2):移出后的事件与原文仍完整保存。自动预填充会给你相关片段;
|
||||
要给看全文/完整过程,用 doc_query —— 它会把那些事件按原时间戳插回对话时序。
|
||||
- 图记忆(L3):关系与事实的长期存储(谁是什么、什么等于什么、最新值是什么)。
|
||||
用 memory_recall 查;用 memory_commit 记。
|
||||
选择:要"原文与过程"→ doc_query;要"关系与当前值"→ memory_recall;
|
||||
要"成文知识"→ knowledge_search。
|
||||
```
|
||||
|
||||
**注意**:这段属于「内核运行时块」(提示词分层规范:内核行为由内核生成),
|
||||
不是工具用法(工具用法归 `description`)。它讲的是**内核的真实执行语义**
|
||||
(回填到时序、自动预填充),符合分层原则。
|
||||
|
||||
---
|
||||
|
||||
## 三、判据清单(每步都要能红)
|
||||
|
||||
| # | 判据 | 变异(必须红) |
|
||||
|---|---|---|
|
||||
| 1 | 检索质量:top1 命中 / 不相关查询 top1 不同 / 分数有区分度 | `denseFor` 改回 `Summary+Content`;或换回 jieba 词袋 |
|
||||
| 2 | 向量输入是正文:断言 `denseFor` 读的是 Events 正文(可用哨兵串) | 正文里埋哨兵,改回 Summary ⇒ 哨兵不出现在向量输入 |
|
||||
| 3 | 摘要不含 jieba 词袋:`Summary` 不出现「来自 N 个来源的 M 条对话」 | 恢复 `summarizeEntries` ⇒ 红 |
|
||||
| 4 | 预填充文案含正文与引导:必须含 `doc_query` 字样 + 正文片段 | 改回旧渲染 ⇒ 红 |
|
||||
| 5 | 工具描述覆盖五项(L2 定义/何时用/调用后行为/分工/副作用) | 删任一项 ⇒ 红 |
|
||||
| 6 | 系统提示词含三层记忆段落且语义与实现一致 | 回填行为改掉而提示词不改 ⇒ 红(跨文件一致性) |
|
||||
|
||||
**判据 6 特别说明**:提示词说「按原时间戳插回时序」这件事,
|
||||
必须与 `restoreDocEvents` 的真实行为绑在一条判据里 ——
|
||||
否则就是「提示词在说谎」,那比没说更糟。
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 用 jieba 词袋当向量输入或摘要内容 | 与「统一向量化」直接矛盾;全库同质,检索失效 |
|
||||
| 为媒体块单独生成文本描述 | CAS 时代的「描述式索引」错误;媒体语义由同事件文本覆盖 |
|
||||
| 只改结构不改提示词 | 机制改了模型不知道 = 等于没改 |
|
||||
| 用「字段非空 / 条数正确 / 不 panic」当检索判据 | 检索完全失效时这些也全绿 |
|
||||
| 声称「机制已通」而不给出生产数据的检索质量实测 | 上一轮的教训 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验证
|
||||
|
||||
1. `go test ./... -count=1` 全绿(现有 58 包基线)
|
||||
2. 新判据全绿,且 6 个变异各自被抓住
|
||||
3. **生产副本实测**:用真实数据跑检索,给出 top1/top3 命中率与分数分布
|
||||
(对比修复前的 0.75~0.79 无区分度)
|
||||
4. 生产部署后用真实对话观察:agent 是否在需要时调用 `doc_query`
|
||||
|
||||
---
|
||||
|
||||
## 六、标准流程(本次严格执行)
|
||||
|
||||
上次直接提交 `main` + rebase + amend + 绕过 CI,不符合 `docs/git-branching.md`。
|
||||
这次必须:
|
||||
|
||||
```
|
||||
1. git checkout -b feature/memory-usability main
|
||||
2. 按步骤 1→6 提交(每步独立 commit,先写判据再改)
|
||||
3. git push -u origin feature/memory-usability
|
||||
4. 开 PR
|
||||
5. 等 CI 10 项全部通过
|
||||
6. 合并 main(不绕过必需检查)
|
||||
7. 部署 + 生产验证
|
||||
```
|
||||
|
||||
⚠️ 远端仓库已改名 `JianFeeeee/HologramAgent`(origin 与 gitcode 均已更新)。
|
||||
|
||||
---
|
||||
|
||||
## 七、待确认
|
||||
|
||||
1. **`Doc.Tags` / `Doc.Entities` 是否可删?**
|
||||
我查到非测试读取点只有 `doc_query` 返回里的「标签:」一行(可删)。
|
||||
但需确认 WebUI / 外部插件是否依赖(SDK 的 `Doc` 无这两个字段)。
|
||||
|
||||
2. **判据 1 的语料从哪来?**
|
||||
建议从生产副本抽 50 篇真实文档 + 人工标注 10~15 个查询-答案对。
|
||||
是否允许我把这些真实文本(脱敏后)作为测试 fixture 入库?
|
||||
@ -1,157 +0,0 @@
|
||||
# 生产迁移执行清单
|
||||
|
||||
**日期**:2026-10-04
|
||||
**分支**:`feat/context-overflow-l4`(未 push)
|
||||
**状态**:⚠️ **归档(2026-10-04 的一次性操作清单)**
|
||||
|
||||
> 本文记录的是那次停机迁移的**执行现场**。其中「已停机」是当时的真实状态,
|
||||
> **不是现在的状态** —— 现在就别照着这份清单判断服务是否在跑。
|
||||
>
|
||||
> 现行部署操作见 [`deploy-runbook.md`](./deploy-runbook.md);
|
||||
> 服务当前状态用 `systemctl status hologramagent` 查。
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置条件(必须全部满足)
|
||||
|
||||
| 项 | 要求 | 实测 |
|
||||
|---|---|---|
|
||||
| 服务停机 | `homeagent.service` inactive | ✅ inactive + disabled |
|
||||
| WAL 已 checkpoint | `PRAGMA wal_checkpoint(TRUNCATE)` 返回 0\|0\|0 | ⬜ 执行前重查 |
|
||||
| 快照已存在 | 一致副本(非 `cp`,用 `.backup`) | ✅ `/var/tmp/ha-prod-migrate/before.db` |
|
||||
| 快照基线 | 1294/980/66/98/97 | ✅ 已核对 |
|
||||
| 副本验证 | 结构 7 项 + 召回 7/9 | ✅ 已通过 |
|
||||
| 代码提交 | 43 包全绿 | ✅ |
|
||||
| 磁盘空间 | 迁移后约 18MB(副本实测) | ⬜ 执行前确认 |
|
||||
|
||||
★ **副本用的就是生产快照**,所以"在副本验证"等价于"在生产数据形态上验证"。
|
||||
|
||||
---
|
||||
|
||||
## 1. 执行步骤
|
||||
|
||||
```bash
|
||||
# ① 停机确认(已停,此处防呆)
|
||||
systemctl is-active homeagent.service # 期望: inactive
|
||||
systemctl is-enabled homeagent.service # 期望: disabled
|
||||
|
||||
# ② WAL checkpoint
|
||||
sqlite3 ${HA_DATA}/memory/graph.db "PRAGMA wal_checkpoint(TRUNCATE)"
|
||||
# 期望输出: 0|0|0
|
||||
|
||||
# ③ 生成新快照(.backup,不是 cp)
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
sqlite3 ${HA_DATA}/memory/graph.db ".backup '/var/tmp/ha-prod-migrate/prod-$TS.db'"
|
||||
# 核对副本基线与源库一致
|
||||
sqlite3 /var/tmp/ha-prod-migrate/prod-$TS.db \
|
||||
"SELECT COUNT(*) FROM entities; SELECT COUNT(*) FROM relations; SELECT COUNT(*) FROM sentences;"
|
||||
|
||||
# ④ 先报告(不写库)
|
||||
go run -tags onnxruntime ./cmd/homed-graph-migrate \
|
||||
-db ${HA_DATA}/memory/graph.db \
|
||||
-embed-provider chineseclip \
|
||||
-model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx
|
||||
# ★ 核对:实体 1294,关系 966,块 98,块边 97
|
||||
# ★ 若数字对不上 → 停止,不要 -apply
|
||||
|
||||
# ⑤ 执行迁移(约 200s)
|
||||
go run -tags onnxruntime ./cmd/homed-graph-migrate \
|
||||
-db ${HA_DATA}/memory/graph.db -apply \
|
||||
-embed-provider chineseclip \
|
||||
-model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx
|
||||
|
||||
# ⑥ 验证
|
||||
bash scripts/verify-migration.sh ${HA_DATA}/memory/graph.db
|
||||
# 期望:✅ 结构验证全部通过
|
||||
|
||||
# ⑦ 召回探针
|
||||
PROD_SNAPSHOT=${HA_DATA}/memory/graph.db \
|
||||
go test -count=1 -tags onnxruntime ./internal/memory/ \
|
||||
-run 'TestProbe_生产规模召回' -v
|
||||
# 期望:casual 3/4, confusable 1/1, abstention 3/3, 合计 7/9
|
||||
```
|
||||
|
||||
★ 全程**串行**。迁移涉及 1294 个块的 embedding,
|
||||
并行会导致资源争用与长时间卡死(此前实测过 600 秒卡死)。
|
||||
|
||||
★ **旧表不删**。迁移只写块与边,`entities/relations/sentences`
|
||||
保持原样(验证脚本第 1 项逐表核对)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 期望产出(副本实测值)
|
||||
|
||||
```
|
||||
句子 +66,块 +1294,边 +980,scene_refs +718
|
||||
块 2752(带向量 1391),边 2371
|
||||
悬空端点 0
|
||||
向量维度唯一 512,fingerprint 唯一
|
||||
confidence 为 0 的关系边 0 条
|
||||
deleted 状态关系边 14 条(对应旧表 14 条)
|
||||
中心向量已重建(各向异性 detected,双边中心化启用)
|
||||
耗时约 200s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 回滚
|
||||
|
||||
### 回滚条件
|
||||
|
||||
迁移后出现以下任一情况 ⇒ 立即回滚:
|
||||
|
||||
- 验证脚本报 `❌`
|
||||
- 召回探针合计低于 4/9(迁移前基线)
|
||||
- 启动后 `memory_recall` 报「检索失败」
|
||||
- 健康检查异常
|
||||
|
||||
### 回滚方式
|
||||
|
||||
★ **旧表未被改动**,所以回滚很简单:
|
||||
|
||||
```bash
|
||||
# ① 恢复代码到迁移前的提交
|
||||
git checkout 75d4609 # 或 d4eda47 / 2fe4ca7 等任一已验证提交
|
||||
|
||||
# ② 恢复数据库(若需要)
|
||||
TS=<迁移时的快照时间戳>
|
||||
cp /var/tmp/ha-prod-migrate/prod-$TS.db ${HA_DATA}/memory/graph.db
|
||||
# ★ 生产库必须用 .backup 恢复,不要直接 cp 源库
|
||||
|
||||
# ③ 重建二进制并重启(需你确认后才启服务)
|
||||
go build -tags onnxruntime -o build/homed ./cmd/homed
|
||||
systemctl enable homeagent.service && systemctl start homeagent.service
|
||||
```
|
||||
|
||||
### 回滚安全性
|
||||
|
||||
| 保证 | 依据 |
|
||||
|---|---|
|
||||
| 旧表数据完好 | 迁移只 INSERT 块/边,验证脚本第 1 项核对 |
|
||||
| 迁移单事务 | 任一步失败整体回滚 |
|
||||
| 迁移自动快照 | 命令自身会生成 `.bak-<时间戳>` |
|
||||
| 代码可回退 | 全部改动已提交,未 push |
|
||||
|
||||
---
|
||||
|
||||
## 4. 迁移后仍存在的问题(不阻塞迁移,但要记录)
|
||||
|
||||
| 问题 | 状态 | 补法 |
|
||||
|---|---|---|
|
||||
| `coexist` 泛指提问召不回端口号 | 0/1 | BFS 接入 `RecallBlocksFused` |
|
||||
| 纯中文编造查询不拒答 | 契约外 | 需符号切分改进或换判定信号 |
|
||||
| 旧表仍在增长 | 待停双写 | 读方已全切,可摘 `commit()` 里的 INSERT |
|
||||
| `TestResidualKeepReturnsTasksToParent` | 既有 flake | 单独复验,勿误判 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 与旧快照的关系
|
||||
|
||||
```
|
||||
/var/tmp/ha-prod-migrate/before.db 本轮开始时的生产基线(1294/980/66/98/97)
|
||||
/var/tmp/ha-prod-migrate/probe.db 副本,已完整迁移并验证
|
||||
/var/tmp/ha-prod-migrate/base-copy.db 迁移前基线探针用
|
||||
```
|
||||
|
||||
★ `before.db` 与 `probe.db` 是**同一份数据的迁移前后对照**,
|
||||
两者都在,可随时重跑任何验证。
|
||||
@ -1,271 +0,0 @@
|
||||
# 场景式记忆修复 plan
|
||||
|
||||
> **状态:大部分落地(2026-10-31 复核)。** 7 个步骤里 5 步已完成。
|
||||
>
|
||||
> | 步骤 | 状态 |
|
||||
> |---|---|
|
||||
> | 1 修 R1+R2(归一化、时段污染主键) | ✅ 已完成 |
|
||||
> | 2 SDK 补 `ScenePolicy` 声明项 | ✅ 已完成(`sdk/plugin.go` 有 `ScenePolicyAuto` / `ScenePolicyNone`) |
|
||||
> | 3 修 R3(图整理覆盖全库) | ✅ 已完成 |
|
||||
> | 4 清理现网垃圾场景 | ❓ 未复核(属现场操作,不在代码里) |
|
||||
> | 5 修 R5(证据桶别全表清) | ✅ 已完成 |
|
||||
> | 6 修 R4 覆盖面(peer / 语义场景) | ✅ 已完成(`scene_emerge.go` 已区分 `peer:group_*` 与 `peer:user_*`) |
|
||||
> | 7 验证与收口 | ❓ 未复核 |
|
||||
>
|
||||
> 剩余不确定项已索引到 [`future-plan.md`](./future-plan.md) §六。
|
||||
> 未完成项已索引到 [`future-plan.md`](./future-plan.md) §六。
|
||||
|
||||
> 起点:2026-09-26 生产库实测(`${HA_DATA}/memory/graph.db`)。
|
||||
> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
|
||||
> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
|
||||
> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
|
||||
|
||||
---
|
||||
|
||||
## 根因(三条,逐条修)
|
||||
|
||||
### R1 建键不过归一化(病因)
|
||||
|
||||
`internal/memory/scene_emerge.go:329`
|
||||
|
||||
```go
|
||||
base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
|
||||
```
|
||||
|
||||
而同一层的另外三条路**都**过了归一化:
|
||||
|
||||
| 位置 | 是否归一化 |
|
||||
|---|---|
|
||||
| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
|
||||
| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
|
||||
| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
|
||||
| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
|
||||
|
||||
`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
|
||||
`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
|
||||
|
||||
实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
|
||||
|
||||
```
|
||||
auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
|
||||
auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
|
||||
auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
|
||||
auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
|
||||
auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
|
||||
auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
|
||||
```
|
||||
|
||||
### R2 `part`(时段,权重 0.2)污染主键与门槛
|
||||
|
||||
`Label(2)` 取权重最高的 2 个特征。特征顺序是
|
||||
`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
|
||||
只有当前 2 个强特征不足时 `part` 才会进键——于是出现
|
||||
`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
|
||||
|
||||
**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
|
||||
所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
|
||||
|
||||
实测证据(键名与实际指纹自相矛盾):
|
||||
|
||||
```
|
||||
场景键: auto:chan:qq+part:morning strength=270
|
||||
实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
|
||||
```
|
||||
|
||||
这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
|
||||
并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
|
||||
|
||||
### R3 图整理心跳漏扫 `scenes`
|
||||
|
||||
`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
|
||||
`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
|
||||
所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
|
||||
|
||||
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
|
||||
|
||||
### R4 场景身份只有「输入通道」一个可靠维度
|
||||
|
||||
现网 65 个场景键的维度分布(实测):
|
||||
|
||||
```
|
||||
auto:chan(涌现·通道主导) 36
|
||||
chan: (输入通道) 26
|
||||
tool: (工具) 3
|
||||
peer 主导 0
|
||||
topic 主导 0
|
||||
```
|
||||
|
||||
两个独立原因叠加:
|
||||
|
||||
1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找
|
||||
`peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput
|
||||
时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。
|
||||
⇒ 日志中 `peer` 特征出现次数为 **0**。
|
||||
2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重
|
||||
降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer,
|
||||
`Label(2)` 也轮不到它。
|
||||
|
||||
后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了
|
||||
场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。
|
||||
|
||||
**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达
|
||||
什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`,
|
||||
但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。
|
||||
|
||||
### R5 `situation_evidence` 全表清空
|
||||
|
||||
`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence`
|
||||
(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
|
||||
攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
|
||||
|
||||
### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口)
|
||||
|
||||
`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴:
|
||||
|
||||
```
|
||||
NoMemory 进不进记忆计算
|
||||
ContextPolicy 裁不裁上下文(破坏性,默认关)
|
||||
RecallPolicy 召不召回记忆(只读,默认开)
|
||||
```
|
||||
|
||||
**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**:
|
||||
`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征,
|
||||
没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类
|
||||
**纯内部信噪通道也在参与场面聚类**。
|
||||
|
||||
穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace),
|
||||
`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。
|
||||
|
||||
已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件
|
||||
在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。
|
||||
现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。
|
||||
|
||||
### R7 渠道本身可以覆盖多个场景
|
||||
|
||||
`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时,
|
||||
`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`)
|
||||
支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。
|
||||
|
||||
---
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成
|
||||
|
||||
- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey`
|
||||
- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景
|
||||
- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源)
|
||||
- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿)
|
||||
- 提交:`1fa9ef6`
|
||||
|
||||
### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动)
|
||||
|
||||
按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状):
|
||||
|
||||
- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"`
|
||||
- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认)
|
||||
- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty`
|
||||
- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认)
|
||||
- [ ] 内核接线:
|
||||
- `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy`
|
||||
- `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产——
|
||||
一个不参与场面识别的通道不该留下时段噪声)
|
||||
- 声明路 `sceneKeysFor` 同样受 `none` 约束
|
||||
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
|
||||
- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
|
||||
少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
|
||||
召回返回空;且 declared 场景**不进**相似度空间
|
||||
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
|
||||
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
|
||||
|
||||
### 步骤 3:修 R3(让图整理覆盖全库)✅ 已完成
|
||||
|
||||
- [x] `GraphDB.DedupeScenes()`:归一化后同名场景合成一个
|
||||
- [x] 接到 `mergeLoop` 尾部(不塞进实体的 O(n²) 双重循环)
|
||||
- [x] 判据 8 例(先红后绿)
|
||||
- 生产库副本实测:65 → 57,合并 8 组,`auto:chan:qq_part:morning`
|
||||
的 refs/rel/ent 一条没丢,strength 1 → 272
|
||||
- 提交:`758ec11`
|
||||
|
||||
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
|
||||
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
|
||||
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
|
||||
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
|
||||
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
|
||||
|
||||
### 步骤 4:清理现网垃圾场景,让它重新生成
|
||||
|
||||
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
|
||||
|
||||
> ⚠️ **硬约束:必须先部署含 R1 修复的二进制**。反过来的话,重新涌现出来的
|
||||
> 还是带 `+`/`#` 的旧键,清一场白清。
|
||||
|
||||
#### 部署前基线(2026-09-26 20:1x 实测)
|
||||
|
||||
```
|
||||
PID 92115 / 启动于 Sat 19:12:43
|
||||
子进程插件 25 个
|
||||
近 24h 异常日志(Fatal/panic/DATA RACE): 0
|
||||
场景数 65
|
||||
`homed --version` 不存在(flag 未定义)⇒ 纪律清单那条用 status 接口或 ps 核对
|
||||
```
|
||||
|
||||
#### 执行顺序
|
||||
|
||||
- [ ] 1. `make build`(默认 `HOMED_TAGS=onnxruntime`,cgo;校验 onnx/cgo 标记)
|
||||
- [ ] 2. 备份:`sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"`(**禁用 cp**,WAL 不一致)
|
||||
- [ ] 3. `install -m 0755 build/homed /usr/local/bin/homed`(**禁用 cp**,会写坏运行中进程映像)
|
||||
- [ ] 4. `systemctl restart homeagent`
|
||||
- [ ] 5. 后置验证:进程起来 / 子进程插件数恢复 / 异常日志为 0 / 真实对话跑通
|
||||
- [ ] 6. 清理:删 `origin='emergent'` 全部 + 关联 features/refs + `situation_evidence`
|
||||
- [ ] 7. 等 ≥2 次同类交互让场景重新涌现
|
||||
- [ ] 8. 复验:新键不含 `+`/`#`、有 features **且**有 refs、strength 合理
|
||||
|
||||
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
|
||||
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
|
||||
- [ ] 同步清 `situation_evidence`
|
||||
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
|
||||
- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs
|
||||
|
||||
> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在
|
||||
> `relations`/`entities` 表,与 `scenes` 无外键依赖。
|
||||
> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
|
||||
> 涌现重建期间它继续承担 QQ 场景召回。
|
||||
|
||||
### 步骤 5:修 R5(证据桶别全表清)✅ 已完成
|
||||
|
||||
- [x] `DELETE FROM situation_evidence` → `... WHERE label = ?`(桶键 = `sig.Label(2)`,
|
||||
**不带 `auto:` 前缀**——这点修的时候差点栽)
|
||||
- [x] 判据 3 例:判据实测「6 个通道各来 3 次只长出 2 个场景」
|
||||
- 提交:`758ec11`
|
||||
|
||||
### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
|
||||
|
||||
前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。
|
||||
`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。
|
||||
|
||||
- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么
|
||||
- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序),
|
||||
即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权
|
||||
- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
|
||||
|
||||
### 步骤 7:验证与收口
|
||||
|
||||
- [ ] `go build ./...` + 全仓 `go test ./...`
|
||||
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
|
||||
- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化
|
||||
**已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、
|
||||
老插件行为不变」
|
||||
- [ ] 现网观察:场景命中后能查到 refs(非 0)
|
||||
- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
|
||||
- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期)
|
||||
|
||||
## 不做的事(防反复挂账)
|
||||
|
||||
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
|
||||
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
|
||||
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
|
||||
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
|
||||
混进本次修复会让 diff 失焦。单独开条目。
|
||||
- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义:
|
||||
新增项是纯追加,不借机改旧行为。
|
||||
@ -780,3 +780,60 @@ if ret, err := parent.ExecuteTool(name, args); err == nil {
|
||||
|
||||
**存量插件不需改一行、不需重编**(新增方法/字段由**插件调用、内核实现**,
|
||||
不调就不受影响——这是 1.1.0 / 1.2.0 / 1.3.0 三次跃迁共同的结论)。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:更新前后压测结果(2026-09-27)
|
||||
|
||||
> 原本在执行计划文档里。计划随实施完结删除,这些**数据**有长期价值,故并入本文。
|
||||
|
||||
方法:两版内核(旧 `85e3d66` / 新 `d3eaff4`)在**隔离 netns** 内各起一个实例,
|
||||
共用同一个 mock LLM(保证 LLM 行为完全一致,消除变量),驱动脚本
|
||||
`scripts/kernel-stress/cmp.py`。规模 3 = 24 连接 × 12 输入 = 288 条。
|
||||
|
||||
### A.1 批内工具调用(核心差异)
|
||||
|
||||
| 场景 | 旧版 中位/工具数 | 新版 中位/工具数 |
|
||||
|---|---|---|
|
||||
| `!slowbatch2` | 0.221s / **0 个** | 0.380s / **2 个** |
|
||||
| `!slowbatch4` | 0.222s / **0 个** | 0.399s / **4 个** |
|
||||
| `!slowbatch8` | 0.220s / **0 个** | 0.409s / **8 个** |
|
||||
|
||||
★ **旧版"更快"是假的**:它的 `openai.lua` 缺 `stream_index` 透传,多个分片
|
||||
并到槽 0、argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」而**一个都没真跑**。
|
||||
|
||||
这正是「只看耗时会被骗」的典型 —— 所以 `cmp.py` 每轮都记录**处理数**
|
||||
并把它当作通过条件之一。
|
||||
|
||||
### A.2 并发 vs 强制串行(新版内部对照)
|
||||
|
||||
`!slowbatchN`(全部 ParallelSafe ⇒ 并发)vs `!serialbatchN`(混入
|
||||
`knowledge_create` ⇒ 整批降级串行):
|
||||
|
||||
| N | 并发 | 强制串行 | 加速 |
|
||||
|---|---|---|---|
|
||||
| 2 | 0.378s | 0.532s | 1.41× |
|
||||
| 4 | 0.389s | 0.864s | 2.22× |
|
||||
| 8 | 0.409s | 1.585s | **3.88×** |
|
||||
|
||||
### A.3 关于 stage 锁告警(不是内核缺陷)
|
||||
|
||||
压测中出现过 5 次 `stage.lock` 告警。**不要当内核缺陷去修**。链路:
|
||||
|
||||
1. SDK 生成的模板在每个 stage handler 入口**自动**调
|
||||
`callCoreVoid("stage.lock", nil)`(跨进程写锁,内核仲裁)
|
||||
2. `lock.go` —— 锁**不可重入**:`held && owner == plugin` 即报错
|
||||
3. 所以只要同一次 `before_toolcall` 被触发两次且首次未释放,就会命中
|
||||
|
||||
已排查并排除:QQ 的 `beforeToolcall` 函数体里只有 `ctx.Lock()`(SDK **数据**锁,
|
||||
与 proc stage 锁是两把锁)与纯本地调用,**无任何再次触发 stage 的路径**。
|
||||
|
||||
⇒ 成因在**插件进程侧的运行时**(编译进 `plugin.bin`),不在插件业务代码里。
|
||||
生产 `plugin.bin` 是独立构建产物、**不随 homed 部署** —— 要修需改 SDK 模板
|
||||
并重编该二进制。
|
||||
|
||||
**内核这边的行为是正确的**:插件持锁失败时内核强制释放("锁仲裁回内核"
|
||||
的自愈机制),目的是避免后续插件死锁;错误收进 `ctx.Errors` 而不中断流程,
|
||||
所以那一轮 212 秒正常跑完。
|
||||
|
||||
⇒ 5 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就会变成真死锁。
|
||||
|
||||
@ -1,578 +0,0 @@
|
||||
# 工具调用并行化改造:执行计划
|
||||
|
||||
> **设计依据**:`docs/zh/toolcall-contract-and-sequence-design.md`(本文只讲**怎么一步步做**,
|
||||
> 设计取舍与实证依据在那份文档里,不重复)。
|
||||
>
|
||||
> **状态:全部阶段已完成**(2026-10-31 复核)。
|
||||
>
|
||||
> 阶段 0 → 0.2 → 0.5 → 1 → 2 → 2.5(内核主线)与 3/4(序列插件)
|
||||
> 均已完成。原先这里写着「阶段 0.5 起为待办」,是计划初期的状态,已过时。
|
||||
> 遗留项状态见文末「遗留项状态」小节(全部已解决)。
|
||||
> **纪律**:每个阶段的「判据」必须**先写且必须能失败**,再改实现;
|
||||
> 判据通过前不进入下一阶段(见文末「阶段纪律」)。
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览与依赖
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| **0** | 修 map 迭代顺序(flush 乱序) | — | ✅ **已完成** |
|
||||
| **0.2** | **工具错误类型化(已完成)** | — | ✅ **已完成** |
|
||||
| **0.5** | 补多 tool_call 批内路径判据 | — | ✅ **已完成** |
|
||||
| **1** | 结果契约 + 参数预校验 | — | ✅ **已完成**(1a/1b/1c/1d) |
|
||||
| **2** | 并行执行层 | 1 | ✅ **已完成**(2a/2b/2c/2d) |
|
||||
| **2.5** | 提示词改为「默认并行」 | 2 | ✅ **已完成** |
|
||||
| **3/4** | 序列插件 | 2 | ✅ **已完成**(P1–P4,见下) |
|
||||
|
||||
**主线(内核)0 → 0.2 → 0.5 → 1 → 2 → 2.5 已全部完成**;
|
||||
**插件线 P1 → P2 → P3 → P4 也已完成**。两部分详见下文。
|
||||
|
||||
### ⚠️ 顺序不可调换的两处
|
||||
|
||||
- **2.5 必须在 2 之后**:先改提示词说「默认并行」而内核仍串行 = 提示词对模型说谎。
|
||||
- **1 必须在 2 之前**:并行化需要「诚实的 Success」与「结构化值」来判断结果与做条件;
|
||||
先并行后补契约,等于把两处改动叠在同一段逻辑上,回归时无法定位是哪一处引起。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0 ✅ 已完成
|
||||
|
||||
**问题**:`flushToolCall` 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
|
||||
⇒ 同一批并行 tool_call 进入 `resp.ToolCalls` 的顺序**每次运行都可能不同**。
|
||||
对 `output_send__` 这类用户可见通道 ⇒ 分段消息到达顺序不可复现。
|
||||
|
||||
**改动**:`process.go` —— 抽出闭包 `flushAll`,收集 index 后 `sort.Ints` 再 flush。
|
||||
两个调用点(`ch` 关闭、`ctx.Done()`)统一走它。
|
||||
|
||||
**判据**:`stream_flush_order_test.go` —— 8 工具 × 200 轮,断言输出严格按投递顺序。
|
||||
**已做变异验证**:退回 map 遍历后判据于 round 0 即 FAIL(实际顺序 `[tool_02…tool_00 tool_01]`)。
|
||||
修复后 core 包全绿。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0.2 ✅ 工具错误类型化(已完成)
|
||||
|
||||
**问题**(两条,第二个是静默 bug):
|
||||
|
||||
1. 内核用 `strings.Contains(err, "not found in any plugin")` 判别「工具不存在」
|
||||
(原 `toolcall.go:104`)——**约定**不是契约。插件错误文案若含该子串即被误判。
|
||||
2. ⚠️ `IOManager` 向父兜底时**吞掉父的执行失败**(`channel.go:655-660`),
|
||||
误报为「工具不存在」。后果放大:设备离线这类**本该 retry** 的失败被判为
|
||||
「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。
|
||||
|
||||
**改动**:
|
||||
|
||||
- `io/channel.go`:新增哨兵 `ErrToolNotFound` + `ToolNotFound(name)` + `IsToolNotFound(err)`
|
||||
(沿用仓内 `ErrInputChannelUnknown` 的先例)。`IOManager.ExecuteTool` 的父兜底
|
||||
改为**只传递「确实不存在」**,其余错误如实上抛。
|
||||
- `core/stages.go`:`StageHost` 的 not-found 改用 `agentIO.ToolNotFound`。
|
||||
- `core/toolcall.go`:字符串匹配 → `agentIO.IsToolNotFound`;「不存在」时给出
|
||||
**可执行**文案(提示 `get_plugin_tools` / `output_list_channels`),
|
||||
而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。
|
||||
|
||||
**判据**:`toolcall_error_test.go`(类型化 vs 诱饵子串、%w 穿透、执行期文案)+
|
||||
`channel_error_test.go`(父失败不吞、真的不存在仍可判别)。
|
||||
|
||||
**变异验证**:退回父兜底吞噬后,`TestExecuteTool_DoesNotSwallowParentFailureAsNotFound`
|
||||
FAIL(`父的执行失败被误报为『工具不存在』`);修复后全绿。
|
||||
|
||||
⚠️ **过程中的一次自伤**:我先改了 `channel.go` 却漏了 `stages.go` 的 import,
|
||||
导致整包 build 失败。已修。另:第一次写判据时只覆盖了类型化,**没覆盖父兜底吞噬**,
|
||||
变异后仍绿 —— 说明「判据通过」不等于「修的东西被测到」。补了 `channel_error_test.go` 才闭合。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0.5 ✅ 补批内路径判据(已完成)
|
||||
|
||||
**前提更正**(此前我写「无任何测试直接驱动批内路径」——**不准确**):
|
||||
`scheduler_critical_test.go:125` 的 `TestBatch_NotAbandonedWithoutPreemption`
|
||||
**已经**驱动了「同批两个工具按序执行」。漏查是因为我只 grep 了
|
||||
`PendingTools` / `ToolIdx` 这两个**字段名**,没查断言**内容**。
|
||||
|
||||
**真正缺的是**该测试**未覆盖**的三条(已核实全仓无对应断言):
|
||||
|
||||
| 缺口 | 风险 | 新增判据 |
|
||||
| --- | --- | --- |
|
||||
| `tool_call_id` 配对完整性 | 阶段 2 改消息落法时,配对一旦断裂上游直接报错 | `TestBatchToolCallIDsAllPaired` |
|
||||
| `ContentOnce` 批内语义 | 同一段 assistant 文本在批内重复 N 次,撑爆上下文 | `TestBatchAssistantTextAppearsOnce` |
|
||||
| denied 后**继续**批内 | 改成 abort 会丢掉本可执行的后续调用 | `TestBatchContinuesAfterDeniedTool` |
|
||||
|
||||
另加 `TestBatchExecutesEveryToolCall`(批内全序列 + 顺序 + ToolResults 完整性)。
|
||||
|
||||
**判据**:`toolbatch_test.go`(4 条)+ 复用既有 `TestBatch_NotAbandonedWithoutPreemption`。
|
||||
全部**确定性**断言(阶段 0 已消除 map 随机性)。
|
||||
|
||||
**变异验证**:令 `stepToolBegin` 跳过批内最后一个工具后,**4 条判据同时 FAIL**
|
||||
(既有那条也 FAIL),报错直指 `ToolsUsed = [tool_alpha]`、`tool_call_id "c2" 被声明 0 次`。
|
||||
|
||||
⚠️ **过程中三次自伤**(都靠"判据先写"暴露):
|
||||
|
||||
1. 臆造了不存在的 helper(`sdkToolDef` / `sdkStageCtx`)⇒ build 失败
|
||||
2. 把 `a.stageHost = nil` 后又用它注册 stage handler
|
||||
3. 给 `newTaskFrame` 传 `nil` ⇒ `stepPrepare` 于 `task.go:519` **nil 解引用 panic**
|
||||
⇒ 改用仓内既有的 `a.stageCtxFromInput(...)`(与 `scheduler_preempt_test` 一致)
|
||||
★ 顺带记录:生产路径两处 `newTaskFrame` 调用(`task.go:228/422`)都传真实 ctx,
|
||||
但 `stepPrepare` 对 `f.StageCtx` **无 nil 兜底**。本次不修(无生产触发路径),
|
||||
记为潜在健壮性缺口。
|
||||
|
||||
## 阶段 2 ✅ 并行执行层(已完成)
|
||||
|
||||
| 子项 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| **2a** | 消息落法:一条 assistant 带全部 tool_calls | ✅ `28b42bc` |
|
||||
| **2b** | `ConsumeToolBlocks` 改 per-call 归档 | ✅ `25f5c53` |
|
||||
| **2c** | `StageContext` 拆 per-tool | ✅ `3d12e82`(判据由 2d 闭合) |
|
||||
| **2d** | 批次调度与保序(`ParallelSafe` + 同通道保序) | ✅ `34c0df2` |
|
||||
|
||||
**改动要点**(细节见设计文档 §6):
|
||||
|
||||
- **2a** 现状每工具一对消息;改为一条 assistant 带全部 tool_calls。
|
||||
这本身是协议上更正确的形态(现状不表达「这是一批」)。
|
||||
- **2b** `ConsumeToolBlocks` 原本是 IOManager 级单队列,并发下会互相抢媒体 ⇒
|
||||
破坏「媒体必须紧跟自己 toolMsg」那条三轮实测的结论。改按 `call_id` 归档。
|
||||
- **2c** `f.StageCtx` 原本是单槽;改为每工具一份,`Extra` 逐份浅拷贝
|
||||
(`output_channel` 被 `stage.go:18` 依赖)。
|
||||
- **2d** 全批 `ParallelSafe` 才并发,否则**整批**降级串行(不做部分并发——
|
||||
收益不抵不可预测性);同 `output_send__<通道>` 多次发送**保序**。
|
||||
|
||||
**并发规则**(三条全满足才并发):批内 >1 个工具;**全部**声明 `ParallelSafe`;
|
||||
不含需保序的同通道输出发送。
|
||||
|
||||
**结构**:`StepToolBatch` —— fan-out(每工具一 goroutine,各写自己的
|
||||
`toolCtxs[i]`)→ join → **按索引顺序**串行收尾。收尾必须串行且按索引:
|
||||
`f.Msgs` 是共享切片,且按索引落才能让模型读到的上下文顺序与它自己发出的
|
||||
顺序一致。
|
||||
|
||||
⚠️ **修掉一个我自己引入的竞争**:`resolveTurnScenes` 会把结果记进**共享**的
|
||||
`f.sceneDone` / `f.turnScene`。最初在每个 goroutine 里各调一次 —— 既是数据
|
||||
竞争,又会各自触发一次 `EnterSceneWithHint`,**重复计入场景强度**
|
||||
(正是 `sceneDone` 注释警告过的问题)。改为 fan-out **之前**解析一次。
|
||||
|
||||
### 2c 判据缺口:已由 2d 闭合
|
||||
|
||||
2c 落地时做过变体验证(`toolCtxFor` 退回单槽),**两条判据仍全绿** ——
|
||||
因为串行路径下「单槽」与「per-tool」行为完全一致,差别只在并发下显现。
|
||||
当时据实记为「实现已就位、判据未闭合」。
|
||||
|
||||
2d 落地后补了**并发版**判据(`TestBatchConcurrentEachToolSeesOwnContext`):
|
||||
退回单槽时触发 **6 处 DATA RACE 报告 + 串味断言失败**。缺口至此关闭。
|
||||
|
||||
### 遇到的一处**既有**测试竞态(非本次引入,但会污染回归信号)
|
||||
|
||||
`TestResidualKeepReturnsTasksToParent` / `TestResidualDropNotifiesSyncCaller`
|
||||
偶发失败,报「应处置 2 条,实际 1」。
|
||||
|
||||
**根因**(已核实):`offload_test.go:473` 的 `SpawnResident` 会启动**子 agent 的
|
||||
调度器 goroutine**,而测试随后 `child.sched.enqueue(...)` 两条任务、
|
||||
立刻 `ApplyResidual` 去读同一队列——**全程无任何同步**。调度器与测试读并发
|
||||
同一份 `sched.queue`,条数可能已被取走。
|
||||
|
||||
**为什么以前没暴露**:干净基线(阶段 2 之前)连跑 3 次恰好全绿,是**运气**,
|
||||
不是确定性。`-race` 单跑该用例也过(无并发源)。本次改动让 core 包耗时略增、
|
||||
调度时序变化,才把它翻出来。
|
||||
|
||||
**处置**:属测试侧缺陷,不在本阶段范围内,**记为待修**(修法:测试里改用
|
||||
不启动调度器的子 agent,或给 enqueue/读取加同步)。记录在此以免后续误判为
|
||||
「并行化引入的回归」。
|
||||
|
||||
### 2c 的诚实记录:判据在串行下测不出差别
|
||||
|
||||
`toolCtxFor` 退回单槽后,两条 2c 判据**仍然全绿**。原因是:
|
||||
**串行路径下「单槽」与「per-tool」行为完全一致**——每个工具跑完才进下一个,
|
||||
不存在交错。差别只在**并发**下显现(互相覆写 / after 读到别人的结果)。
|
||||
|
||||
⇒ 因此 2c 的真正判据**必须与 2d 一起写**:并发执行批内多工具时,
|
||||
断言每个工具的 ctx 只带自己的 ToolCalls、after_toolcall 读到自己结果,
|
||||
并以 `go test -race` 确认无数据竞争。
|
||||
**在 2d 落地前,2c 只能算"实现已就位、判据未闭合"**,不得记为已验证。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1 ✅ 结果契约 + 参数预校验(已完成)
|
||||
|
||||
对应设计文档 §4。**动机**:`Success` 硬编码 `true`(`task.go:757` 唯一赋值点)、
|
||||
`required` 无消费方、结果被降级为 `string`——这三项让阶段 2/3 都缺地基。
|
||||
|
||||
### 1a. SDK 侧新增(纯新增,无签名变更)
|
||||
|
||||
`third_party/homeagent-sdk/sdk/plugin.go`:
|
||||
|
||||
```go
|
||||
// ToolError 描述失败原因。存在的理由:失败若只表达为文本,模型无法定位到字段,
|
||||
// 只能原样重试(实测 cmd_run 失败率 34%~48% 源于同一成因)。
|
||||
type ToolError struct {
|
||||
Field string `json:"field,omitempty"`
|
||||
Reason string `json:"reason"` // required/type/unauthorized/timeout/not_found
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- `ToolDef` 增 `ParallelSafe bool`(零值 `false` = 不可并行 = 现状行为,
|
||||
刻意让存量插件升级后得到**保守**行为)。
|
||||
- 仓内 `internal/sdk/plugin.go` 补对应别名再导出。
|
||||
|
||||
### 1b. 诚实化 Success
|
||||
|
||||
`Success = (err == nil) && !isToolError(result)`。
|
||||
|
||||
**判据必须同时覆盖新旧两种形态**:存量插件返回 `map[string]interface{}{"error": ...}`
|
||||
(如 `files/plugin.go:228`)要判为失败,而返回普通 map/string 仍算成功。
|
||||
**漏了后者 = 升级会把存量插件的成功误判成失败**,这是本阶段最大的回归风险。
|
||||
|
||||
### 1c. 参数预校验
|
||||
|
||||
在 `executeToolCallInner`(`toolcall.go:47`)分派**之前**执行:逐项查 `required`、
|
||||
逐项查 `properties.type`。失败返回结构化 `ToolError`,不进分派。
|
||||
|
||||
⚠️ **`getBool`(`utils.go:26`)的注释记载 bool/string/float 三种都出现过** ⇒
|
||||
校验**不能**把合法的 `"true"` 判为非法。判据必须覆盖这一点。
|
||||
|
||||
### 1d. 保留结构化值
|
||||
|
||||
`executeToolCall` 内部保留 `rawResult interface{}`(不降级为 string),
|
||||
渲染交给统一函数:**紧凑 JSON,禁止 `fmt.Sprintf("%v")`**(那会产出
|
||||
`map[status:sent id:123]` 这种模型读不懂的 Go 语法)。
|
||||
|
||||
**顺带修一个静默失效**:`task.go:771` 的 `ToolResults[0].Result.(string)` 断言
|
||||
几乎恒失败(插件返回的多是 map)⇒ `after_toolcall` 阶段插件对结构化结果的改写当前无效。
|
||||
|
||||
### 阶段 1 判据
|
||||
|
||||
- `toolerror_test.go`:`isToolError` 对新旧两种形态的判定(各 3 例:失败 map /
|
||||
成功 map / 成功 string)
|
||||
- `argvalidate_test.go`:缺 required、类型不符、`"true"` 宽松放行
|
||||
- 回归:core 包全绿;**存量插件 smoke**(`internal/plugins` 不得新增 FAIL)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2.5 ✅ 提示词改为「默认并行」(已完成)
|
||||
|
||||
⚠️ 有**硬性顺序约束**:必须在阶段 2 落地**之后**。反序(先说"并发"、内核仍
|
||||
串行)会让提示词**对模型说谎** —— 模型据此推断安全性,写出真正依赖顺序的
|
||||
调用。宁可晚改,不可错改。
|
||||
|
||||
新增【工具执行顺序】段,讲清四件事:
|
||||
|
||||
1. 同一条回复里的多个工具调用**默认并行**(同时跑),不是依次执行
|
||||
2. **不要依赖执行顺序** —— 参数依赖前一个结果就分两轮
|
||||
3. **例外一:同通道 `output_send__` 保序**(用户可见消息顺序敏感)
|
||||
4. **例外二:不并发安全的工具整批退回串行**(写类工具 / 未声明者)
|
||||
|
||||
措辞刻意与 `batchRunnable` 的**真实**判据一致,而不是理想化表述 ——
|
||||
**提示词与实现不符,比不说更坏**。
|
||||
|
||||
顺带改掉 `spawn_child` 的落空表述(原文「应并行 spawn,不要自己串行逐个执行」
|
||||
在并行化之前是落空的)。
|
||||
|
||||
判据 `prompt_parallel_test.go`(5 条),其中**负向**那条最关键:
|
||||
只讲并行、不讲例外 ⇒ FAIL("提示词说谎"的失败模式已被覆盖)。
|
||||
|
||||
## 阶段 3 / 4 ✅ 工具序列插件(已完成)
|
||||
|
||||
| 阶段 | 内容 | 提交 |
|
||||
| --- | --- | --- |
|
||||
| **P1** | AST 解析 + 静态校验 | `76030ae` |
|
||||
| **P2** | 执行引擎(组内并行 + 具名槽 + 条件求值) | `7532af7` |
|
||||
| **P3** | 存储 + 跨序列调用图 + missing 策略 | `3d75312` |
|
||||
| **P4** | 六个 `seq_*` 工具 + 插件装配 | `71c894c` |
|
||||
|
||||
**插件线复用内核并行面,但不要求内核开任何新接口**(见设计文档 §7 边界声明)。
|
||||
|
||||
### 顺带补上的内核两处缺口
|
||||
|
||||
P3 落地时暴露的**真实缺陷**(不是新需求):
|
||||
|
||||
- `GetAllTools` **丢 `ParallelSafe`** ⇒ 插件看到的设备工具一律"不可并发",
|
||||
设备工具的并发声明对插件**不可见**。
|
||||
- `ToolAPI` **缺按名查** ⇒ 新增 `ToolDefByName`。插件需在**运行前**判断
|
||||
目标是否存在(动态注册下"不存在"是常态),而 `GetAllTools` 只能拿全量列表。
|
||||
|
||||
### 本阶段解决的两个设计问题
|
||||
|
||||
1. **跨序列目标存在性**:`Save` 若要求"目标必须先存在",互调的两条序列
|
||||
谁也存不下来(A 要 B 先在、B 要 A 先在)——**依赖在设计上无解**。
|
||||
⇒ `Save` 只校验同序列内的 group 引用;跨序列目标由 `CheckGraph` 兜底。
|
||||
|
||||
2. **黑名单分层**:我一度把 `seq_call`/`seq_when_call` 也禁掉,但那正是
|
||||
本包的核心能力(按名调用),禁掉序列就退化成单层脚本。
|
||||
⇒ 黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条序列**;
|
||||
`seq_call` 系列留给序列内部组合,递归由 `maxCallDepth` + 环检测负责
|
||||
(设计文档 §8.3 本来就这么定,是我把两层混了)。
|
||||
|
||||
### 遗留项状态
|
||||
|
||||
| 项 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| **D4** | 设备授权闸在 `ToolAPI` 路径失效 | ✅ **已解决**(`994f198`)。`ToolAPI` 新增 `CanUse`,由 bootstrap 注入 agent 的授权判据;两条路径判定一致性由 `TestCanUseAgreesWithInnerPath` 钉住 |
|
||||
| 端到端 | 真机跑一次 `seq_create → seq_run` | ✅ **已完成**(`3d31037`)。并抓出「传参方式完全不可用」的真 bug |
|
||||
| 提权 | `seq` 是否默认启用 | ✅ **无需决策**:仓内已有 `IsPluginDisabled` / `AddDisabledPlugin` 机制,任何插件(含内置)都可被用户禁用,`seq` 无需特殊处理 |
|
||||
|
||||
### 仍需注意的两点
|
||||
|
||||
1. **缺 `device_id` 时是 fail-open**(放行)。这是内核既有语义
|
||||
(`TestDeviceToolAuth_*` 依赖它),本次**未擅自改**;已由
|
||||
`TestCanUseMatchesInnerFailOpenOnMissingDeviceID` 钉住现状。
|
||||
若要改成 fail-closed,**必须内核与 `ToolAPI.CanUse` 两处同时改**,
|
||||
否则两条路径判定不一致本身就是漏洞。
|
||||
|
||||
2. **`TestResidual*` 既有竞态**(`offload_test.go`):`SpawnResident` 起了
|
||||
子调度器 goroutine,而测试 enqueue 后无同步就读同一队列。
|
||||
与本次改动无关,已定位根因,待修。
|
||||
|
||||
## 阶段纪律 [沿用本仓既有教训]
|
||||
|
||||
1. **判据先写,且必须能失败**。写完先跑一次确认 FAIL,再改实现。
|
||||
2. **变异验证**:改完把修复回退一次,确认判据重新 FAIL。
|
||||
「判据全绿」不等于「判据有效」——本仓 T23 教训(fixture 照臆测字段编,
|
||||
判据全绿而实现全错)就是跳过这一步。
|
||||
3. **判据只断言代码真实产出的字段**。我本次就栽过一次:先写了
|
||||
`assert tc.StreamIndex == i`,而 `flushToolCall` 根本不给 `ToolCall` 赋 `StreamIndex`
|
||||
⇒ 恒假信号。判据必须对着**实际输出**写。
|
||||
4. **期望值由独立来源算出**,不引用被测代码。
|
||||
5. **每阶段结束跑全包** `go test ./internal/agent/core/ -count=1`,
|
||||
并检查存量插件 smoke 无新增 FAIL。
|
||||
|
||||
---
|
||||
|
||||
## 附:更新前后全面压测结果(2026-09-27)
|
||||
|
||||
方法:两版内核(旧 `85e3d66` / 新 `d3eaff4`)在**隔离 netns** 内各起一个实例,
|
||||
共用同一个 mock LLM(保证 LLM 行为完全一致,消除变量),驱动脚本
|
||||
`scripts/kernel-stress/cmp.py`。规模 3 = 24 连接 × 12 输入 = 288 条。
|
||||
|
||||
### 批内工具调用(核心差异)
|
||||
|
||||
| 场景 | 旧版 中位/工具数 | 新版 中位/工具数 |
|
||||
| --- | --- | --- |
|
||||
| `!slowbatch2` | 0.221s / **0 个** | 0.380s / **2 个** |
|
||||
| `!slowbatch4` | 0.222s / **0 个** | 0.399s / **4 个** |
|
||||
| `!slowbatch8` | 0.220s / **0 个** | 0.409s / **8 个** |
|
||||
|
||||
★ **旧版"更快"是假的**:它的 `openai.lua` 缺 `stream_index` 透传,多个分片
|
||||
并到槽 0、argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」而**一个都没真跑**。
|
||||
这正是「只看耗时会被骗」的典型,所以 cmp.py 每轮都记录**处理数**并把它当作
|
||||
通过条件之一。
|
||||
|
||||
### 并发 vs 强制串行(新版内部对照)
|
||||
|
||||
`!slowbatchN`(全部 ParallelSafe ⇒ 并发)vs `!serialbatchN`(混入
|
||||
`knowledge_create` ⇒ 整批降级):
|
||||
|
||||
| N | 并发 | 强制串行 | 加速 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | 0.378s | 0.532s | 1.41× |
|
||||
| 4 | 0.389s | 0.864s | 2.22× |
|
||||
| 8 | 0.409s | 1.585s | **3.88×** |
|
||||
|
||||
并发批耗时**几乎不随 N 增长**,串行批严格线性 ⇒ N 越大加速比越贴近上限。
|
||||
|
||||
### 通过率与稳定性
|
||||
|
||||
| 维度 | 旧版 | 新版 |
|
||||
| --- | --- | --- |
|
||||
| 调度器并发轰炸(288 输入) | 288/288 **100%** | 288/288 **100%** |
|
||||
| 连续稳定性(20 轮无错误) | 20/20 | 20/20 |
|
||||
| 内核队列 / 拒绝 / panic | 空 / 0 / 无 | 空 / 0 / 无 |
|
||||
|
||||
规模 1(32 输入)与规模 3(288 输入)结果**完全一致** ⇒ 可复现,非偶然。
|
||||
|
||||
### 本次改动与 ONNX 无关
|
||||
|
||||
79 个改动文件全部集中在:内核并发调度(22)、seq 插件(15)、Lua 适配器(17)、
|
||||
压测脚本(4)、SDK(3)、io(3)、cmd 插件(2)。**未触及** `distill.go` /
|
||||
`onnx.go` / `nlp`,而工具调用路径本身不经过 ONNX ⇒ 上面的结论对生产成立。
|
||||
|
||||
### ✅ 部署(已完成 2026-09-27)
|
||||
|
||||
生产实例已更新到 `d3eaff4`+ 并验证通过:
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 二进制 | 86,496,624 → **86,784,400** 字节(onnxruntime) |
|
||||
| 服务 | `active`、`kernel ready`、LLM 可达(unreachable=0) |
|
||||
| 多模态空间 | `provider=chineseclip dim=512 modalities=[text image]` |
|
||||
| `seq_*` 工具 | **7 个全部注册** |
|
||||
| 适配器 | md5 **完全一致**(`bf1dff86…`)—— 无 `.bundled` 清单 ⇒ 首次升级不覆盖已有文件 |
|
||||
| 备份 | `/var/tmp/homed-backup-20260927-194554`(含 `ROLLBACK.sh`) |
|
||||
|
||||
> 注:部署前生产二进制构建于**当天 06:36**,而 `seq` 引入于 `71c894c`(更晚)
|
||||
> ⇒ 旧实例的 `strings /usr/local/bin/homed | grep -c internal/plugins/seq` 为 **0**。
|
||||
> 它在 QQ 上如实回答"没有编排工具"**并不是说谎**,是确实没有。
|
||||
> 这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词。
|
||||
|
||||
### ⚠ 部署前置条件
|
||||
|
||||
生产二进制是 **`-tags=onnxruntime`** 构建(strip 后 75MB、`.rodata` 62.5MB),
|
||||
普通 `go build` 只有 28MB。`deploy/packaging/package-linux.sh:139` 会显式拒绝
|
||||
非 onnxruntime 构建:
|
||||
|
||||
if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
|
||||
echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包" >&2
|
||||
|
||||
⇒ **必须走 `deploy/packaging/build.sh`(需 `libonnxruntime.so` 与
|
||||
`CHINESECLIP_BUNDLE_DIR` 资产)才能部署**,否则依存句法分析与多模态向量化失效。
|
||||
|
||||
---
|
||||
|
||||
## 附:设备命令白名单改为可配置(2026-09-27)
|
||||
|
||||
与上面的 toolcall 并行是同一次排查的**另一条线**,记在这里是因为它同样属于
|
||||
"能力声明不该硬编码"这个主题。
|
||||
|
||||
### 起因
|
||||
|
||||
agent 通过 `device_ctl_cmdrun` 下发命令,命令在**设备侧**执行
|
||||
(`cmd/waiter/device.go` 的 `exec.CommandContext`),而白名单是**源码里
|
||||
硬编码的正则**(18 个命令:`ls/pwd/cat/df/…`)。`waiter.yaml` 里**没有任何键
|
||||
能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr` 这些排查问题最常用的
|
||||
**只读**命令一律被拒:
|
||||
|
||||
device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist
|
||||
|
||||
注意 `device_authorized` 当时**已经是 `true`**、两台 token 相同、进程正常 ——
|
||||
所以"没开启设备桥授权"这个判断是错的,问题在白名单。
|
||||
|
||||
### 改动
|
||||
|
||||
`waiter.yaml` 新增 `device_cmd_allowlist`(字符串数组):
|
||||
|
||||
```yaml
|
||||
device_cmd_allowlist:
|
||||
- ls
|
||||
- find
|
||||
- grep
|
||||
- sed
|
||||
```
|
||||
|
||||
- **替换**默认集而非追加:避免"以为加了 find、结果还留着 `python3 -c` 任意执行"
|
||||
- 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门)
|
||||
- 只取命令名**第一段**再整词匹配:`grep -rn x .` 能过,而 `grepXxx` / `mygrep`
|
||||
不会因 `contains` 蒙混过关
|
||||
|
||||
### ★ 一次真实的疏漏
|
||||
|
||||
waiter 有**两条**设备桥启动路径:
|
||||
|
||||
| 路径 | 场景 |
|
||||
| --- | --- |
|
||||
| `main.go` 的 `startDeviceBridge` | 交互 / 一次性模式 |
|
||||
| `daemon.go` 的 `startDaemonDeviceBridge` | **`waiter --daemon`(生产两台都这么跑)** |
|
||||
|
||||
最初只在 `main.go` 里赋值 ⇒ daemon 路径不经过那里 ⇒ 配置**完全不生效**。
|
||||
症状极难定位:**配置写了、启动也打了招呼、命令照样被拒** ——
|
||||
看起来像"配置没读到",实际是"那条路径没接线"。
|
||||
已加 `TestDaemonPathAppliesAllowlist` 守住。
|
||||
|
||||
### ★ 已知局限:只匹配命令名,不看参数
|
||||
|
||||
实测(22 条白名单下):
|
||||
|
||||
| 命令 | 结果 | 实际副作用 |
|
||||
| --- | --- | --- |
|
||||
| `find . -name x.go` | 放行 | 只读 ✓ |
|
||||
| `find . -delete` | **放行** | ★ 删文件 |
|
||||
| `find . -exec rm {} ;` | **放行** | ★ 执行删除 |
|
||||
| `sed -i s/a/b/ f` | **放行** | ★ 原地改文件 |
|
||||
| `sort -o out.txt in.txt` | **放行** | ★ 写文件 |
|
||||
|
||||
即:**白名单是"命令名清单",不是"只读保证"**。用户已知悉并选择先下发
|
||||
(`ship_now`),参数级拦截(拒绝 `-i` / `-delete` / `-exec` / `-o` / `> 重定向`)
|
||||
作为后续项。
|
||||
|
||||
⇒ 写文档时不要把这一层叫"只读白名单",那会让人以为写操作被挡住了。
|
||||
|
||||
### 部署
|
||||
|
||||
| | 106 (fnnas) | 30 (mainnas) |
|
||||
| --- | --- | --- |
|
||||
| 二进制 | 11,388,177 → **12,691,402** | 同 |
|
||||
| 版本 | 8月27日 → `1.4.0` | 同 |
|
||||
| 白名单 | 22 条(启动日志确认读到) | 同 |
|
||||
| 备份 | `waiter.bak-20260927-194730` | `waiter.bak-20260927-194750` |
|
||||
|
||||
**顺带解答了一个悬案**:106 此前一直没有 `online` 日志,而 30 正常。
|
||||
两台配置与 token 完全相同 ⇒ 差异只可能在旧 waiter 二进制。8月27日那版
|
||||
落在"未 bind 时收到 ping 会关连接"的缺陷窗口里 ⇒ 更新后已正常:
|
||||
|
||||
19:46:12 device waiter-fnnas online → 输出通道 device-waiter-fnnas
|
||||
19:47:30 device waiter-fnnas offline → online (更新二进制时重连)
|
||||
|
||||
### 部署脚本的一个坑
|
||||
|
||||
`deploy/scripts/deploy-waiter.sh 里 `ssh` 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉:
|
||||
|
||||
bash deploy/scripts/deploy-waiter.sh deploy <ip> <<< "yes" # 喂了 yes 却打印「已取消」
|
||||
|
||||
脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes 却什么也没发生"。
|
||||
已给 5 处 `ssh` 统一加 `-n`。
|
||||
|
||||
---
|
||||
|
||||
## 附:生产日志里的两个 toolcall 告警(2026-09-27 21:0x 排查)
|
||||
|
||||
部署后逐条核对了 `homeagent.service` 的告警。结论:**适配器无缺陷;
|
||||
toolcall 侧一个真缺陷(已修)、一个插件侧 bug(内核自愈,非内核缺陷)。**
|
||||
|
||||
### 适配器:干净
|
||||
|
||||
| 检查项 | 次数 |
|
||||
| --- | --- |
|
||||
| `finish_reason=length`(输出截断) | **0** |
|
||||
| `unmarshal unified response` 失败 | **0** |
|
||||
| 流式分片解析错误 | **0** |
|
||||
| `非法 JSON 帧` | 11,**全在 19:46:03–09 启动握手期**,此后 3 小时零发生 |
|
||||
|
||||
`stream_index` 透传亦已核实:生产 `adapters/openai.lua:122` 与仓库版一致。
|
||||
|
||||
### ① `has empty arguments` —— 真缺陷,已修
|
||||
|
||||
原始响应里参数**完好**:
|
||||
|
||||
```json
|
||||
"tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]
|
||||
```
|
||||
|
||||
根因:诊断条件用 `len(tc.Arguments)==0 && RawArguments==""`,而
|
||||
`parseToolArguments("{}")` 返回**非 nil 的空 map** ⇒ 零参数工具
|
||||
(`seq_list` / `*_list` / `seq_help`,其 `properties` 本就是 `{}`)全部误报。
|
||||
部署后共 **14 次**。
|
||||
|
||||
**危害不是"日志吵"**,而是这条诊断的本职是抓「上游/适配器真的丢了参数」——
|
||||
真发生时会被这堆噪音淹没。**诊断日志失去信噪比就等于没有。**
|
||||
|
||||
修法:新增 `argsLookDropped(rawArgs)`,判 `RawArguments` 原文而非解析后的 map:
|
||||
空串/空白 ⇒ 真丢;能解析成 JSON(哪怕是 `{}`)⇒ 没丢;解析失败(半截 JSON)⇒ 等同丢失。
|
||||
|
||||
判据 3 条,其中 `TestArgsLookDroppedEndToEnd` 用**日志里出现过的真实 body**
|
||||
走 `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对的情况,
|
||||
只有端到端才抓得到。
|
||||
|
||||
### ② `重复申请 stage 锁` —— 插件侧 bug,内核自愈正常
|
||||
|
||||
```
|
||||
stage.go:106 qq stage before_toolcall 失败后强制释放其持有的 stage 锁
|
||||
stages.go:260 before_toolcall handler error: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)
|
||||
```
|
||||
|
||||
**不要当内核缺陷去修。** 链路是:
|
||||
|
||||
1. `proc_main.go.tmpl:1569` —— SDK 生成的模板在**每个** stage handler 入口
|
||||
**自动**调 `callCoreVoid("stage.lock", nil)`(跨进程写锁,内核仲裁)
|
||||
2. `lock.go:51` —— 锁**不可重入**:`l.held && l.owner == plugin` 即报错
|
||||
3. 所以只要**同一次 `before_toolcall` 被触发两次且首次未释放**,就会命中
|
||||
|
||||
已排查并排除的可能:qq 的 `beforeToolcall`(`plugin.go:1303-1350`)函数体里
|
||||
只有 `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与
|
||||
`currentToolAllowed` / `clearPreviousDenial` 等纯本地调用,**无任何再次触发 stage 的路径**。
|
||||
|
||||
⇒ 成因在**插件进程侧的运行时**(编译进 `plugin.bin`),不在 example/qq 的业务代码里。
|
||||
生产 `plugin.bin` 是 **9月14日**的独立构建产物,**不随 homed 部署** ——
|
||||
要修需改 SDK 模板并重编该二进制,改动面比内核大得多。
|
||||
|
||||
**内核这边的行为是正确的**:`stage.go:102-106` 在插件持锁失败时强制释放,
|
||||
注释写明这是"锁仲裁回内核"的自愈机制(实验 9),目的正是**避免后续插件死锁**;
|
||||
`stages.go:258` 把错误收进 `ctx.Errors` 而不中断流程,所以那轮 212 秒正常跑完。
|
||||
|
||||
⇒ 5 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就是真死锁。
|
||||
Reference in New Issue
Block a user