From bd9dd9e75f9a606666fa18b3f7f4c5c199fc68d3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 11:42:21 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9E=B6=E6=9E=84=E8=8A=82=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=8F=82=E8=80=83=E6=96=87=E6=A1=A3=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版还是论证体:四个小标题里三个是否定式断言(「上下文**不是**截断+摘要」 「三层记忆是迁移,**不是**复制」「记忆按场面分,**不按**会话」), 每段都以一句评论收尾。段落字数 62–128,几乎等长 —— 这是最明显的机器痕迹。 改成参考文档: ## 小标题换成名词 上下文不是「截断 + 摘要」 → 上下文窗口 三层记忆是迁移,不是复制 → 三层记忆 记忆按场面分,不按会话 → 场面 标题说明这节讲什么,不下判断。判断该由正文的数据支撑,不该写在标题里。 ## 删掉与假想敌的对比 「常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是丢什么由位置决定」 「普通 agent 通常一条会话一条历史,换个话题就失忆」 换成直接说我们怎么做的。读者不需要先被告知别人怎么错。 ## 段落长度拉开 之前 62–128 字(极差 66)。现在每节先给一行文件路径,正文长短不一: 32 字 `internal/agent/core/context.go` 127 字 这条约束来自一次实测。50k 窗口…… 51 字 N 默认 10,实际取值受预算约束:…… 129 字 场面由五个特征构成…… 极差 98 字。技术文档本来就是「一行路径 + 一段解释 + 一行数字」这种不均匀节奏。 ## 去掉两处破折号 「没有『取出即删除』的路径 —— 删除排在确认之后」 「不吸收整轮指纹 —— 吸收了会……」 改成句号。全文破折号归零。 ## 补上实现位置 原来只讲设计没给代码位置,改成每节标文件路径: `internal/agent/core/context.go`、`internal/memory/coldcontext/`、 `internal/memory/scene_emerge.go`、`internal/agent/core/stages.go`。 ## 核对 文中数字逐条对源码:`preemptPromotionCap = 2`、`minSceneEvidence = 2`、 `FindColdContexts(72*time.Hour, 2)` 均一致。两版 9 个标题对齐,无死链。 --- README.md | 75 +++++++++++++++++++++---------------------- README_EN.md | 89 ++++++++++++++++++++++++---------------------------- 2 files changed, 76 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 23158696..7ddcbcd2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。 ```text homed(内核) - LLM 编排 · 三层记忆 · 检索 · 调度 + LLM 编排 · 记忆 · 检索 · 调度 │ ┌──────────────┼──────────────┐ │ │ │ @@ -34,56 +34,51 @@ API 密钥在 WebUI(`http://localhost:8080`)里填。需要 Linux。 WebUI · QQ · 命令行 · 文件 · 浏览器 ``` -内核不发 HTTP、不读文件。所有接触外部世界的能力都在插件里,插件崩溃不影响内核, -重启退避 1s / 2s / 3s,5 分钟内第 4 次崩溃停止自动重启等人工介入。 +内核进程做编排、记忆、检索;HTTP、文件、设备都由插件负责(`internal/plugin`)。 +插件崩溃后内核重启它,退避 1s / 2s / 3s;5 分钟内第 4 次崩溃停止自动重启, +等人工介入(`cmd/homed/kmod_runtime.go`)。 -### 上下文不是「截断 + 摘要」 +### 上下文窗口 -常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是**丢什么由位置决定**。 -这里换成了按相关度取舍:用当前输入给每条历史事件打分,低分事件下沉到冷上下文层。 +`internal/agent/core/context.go` -打分用稠密向量余弦(与媒体、冷上下文共享同一向量空间),模型未就绪时退化到 TF-IDF。 -裁剪时无条件保住最近 N 条,但 N 本身受预算约束: +事件进窗口时算两套向量:稠密向量与媒体、冷上下文共用同一空间;词向量用 TF-IDF +作退路。裁剪时用当前输入给每条历史事件打分,保留 topK,最近 N 条不参与打分直接保留。 -```go -// 保护条数不超过预算能装下的条数 -budgetCap = ContextTokens / avgEventTokens -pCount = min(配置值, budgetCap) -``` +N 默认 10,实际取值受预算约束:`min(配置值, ContextTokens / 单条平均 token)`。 -这条约束来自一次实测:50k 窗口下单条事件约 5800 token(工具回灌型),预算约 40000, -「钉住 10 条」本身就装不下,于是每轮裁剪都撞保护下限,两轮后预算耗尽,任务直接终止。 -召回率从 44% 掉到 22%。现在窗口越小越自动收紧,1M 窗口下则能用满配置值。 +这条约束来自一次实测。50k 窗口、单条事件平均 5800 token、预算 40000,固定保留 10 条 +要占 58k,比预算本身还大。于是每轮裁剪都能达标却清不出空间,两轮后预算耗尽、任务终止, +召回率从 44% 降到 22%。现在小窗口会自动收紧,1M 窗口下用满配置值。 -被裁掉的事件不是丢弃,是迁移:事件连同它持有的媒体块一起进冷上下文层,块的 ID 不变、 -只换持有层。回灌时用 `coldcontext_query` 取回,两阶段(先 Peek 确认、再 Remove), -不用「先删后灌」——那会在回灌失败时永久丢数据。 +被裁掉的事件连同它持有的媒体块写入冷上下文层,块 ID 不变,只换持有层。 +取回用 `coldcontext_query`:先 `Peek` 确认,再 `Remove` 源副本。 +没有「取出即删除」的路径,删除排在确认之后。 -### 三层记忆是迁移,不是复制 +### 三层记忆 -```text -L1 上下文 Context 相关性裁剪,最近 N 条恒受保护 - ↓ 低分事件下沉(带媒体块) -L2 冷上下文 Cold Context 稠密检索,≤2 次访问且 72h 未访问则继续下沉 - ↓ 蒸馏成三元组 -L3 稳定记忆 Stable 实体关系 + 场面索引,支持按场面召回 -``` +`internal/agent/core/context.go` · `internal/memory/coldcontext/` · `internal/memory/graph.go` -块在任一层只有一份持有者,不靠引用计数保活。媒体不是附件:图片、音频是图里的一等节点, -有自己的向量,和文本在同一空间里检索。 +| 层 | 持有物 | 进模型的方式 | 冷化 | +|---|---|---|---| +| L1 上下文 | 事件 + 块 | 直接进 system prompt | 相关性低分下沉 | +| L2 冷上下文 | 事件 + 块(与 L1 同构) | 召回后插回 L1 时序 | ≤2 次访问且 72h 未读,蒸馏 | +| L3 稳定记忆 | 三元组,节点即块 | 召回后注入 | 不流出 | -### 记忆按场面分,不按会话 +一块在任一层只有一个持有者,没有引用计数。图片和音频是 L2/L3 里的块, +有自己的向量,与文本在同一空间检索。 -普通 agent 通常一条会话一条历史,换个话题就失忆,或者全塞进一个上下文里互相干扰。 -这里按「可观察的场所指纹」分:通道、对话对象、工具、话题、时段,权重 1.0/1.0/0.8/0.4/0.2。 +### 场面 -同一指纹出现到第 2 次才建场景(`minSceneEvidence = 2`),只发生一次的不算。 -归属阈值 0.5、唤起阈值 0.35,唤起比归属宽松——想不起来是损失,多想一条只是多几行上下文。 -场景每次重现强度 +1,久不重现按半衰期衰减。 +`internal/memory/scene_emerge.go` -两个来源并行:**声明**(插件在注入点说明"这是哪个场面")与**涌现**(指纹自己聚类)。 -声明场景不吸收整轮指纹,否则它会以接近 1.0 的相似度吃掉后续所有同类轮次, -被动路再也长不出更细的场面。 +场面由五个特征构成,权重各不相同:通道 1.0、对话对象 1.0、工具 0.8、话题 0.4、时段 0.2。 +同一指纹出现两次才建场面(`minSceneEvidence = 2`),一次性的交互不建。 +归属阈值 0.5,唤起阈值 0.35;重现时强度 +1,久不重现按半衰期衰减。 + +两个入口并行:插件在注入点声明(如 `chan:qq/peer:group_1`),或者指纹自行聚类。 +声明场景只从键本身解析特征,不吸收整轮指纹。吸收之后它会以接近 1.0 的相似度 +匹配后续所有同类轮次,自行聚类那条路就长不出更细的场面。 ### 七个阶段 @@ -99,8 +94,8 @@ L3 稳定记忆 Stable 实体关系 + 场面索引,支持按场面召 | `before_output` | 最终文本就绪 | 格式适配 | | `after_output` | 已发送 | 统计 | -`post_action → [before_toolcall → 执行 → after_toolcall] → post_action` 是内循环, -退出条件:模型不再调工具、全部被拒、或达到上限。 +内循环是 `post_action → [before_toolcall → 执行 → after_toolcall] → post_action`, +模型不再调工具、全部被拒、或达到上限时退出。 ## 其余 diff --git a/README_EN.md b/README_EN.md index e81b58de..db7b06f4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -35,66 +35,59 @@ startup order is resolved at build time, and plugins launch as soon as their fac web UI · QQ · CLI · files · browser ``` -The kernel makes no HTTP calls and reads no files. Everything touching the outside world is a -plugin. A plugin crash leaves the kernel running; restarts back off 1s / 2s / 3s, and the 4th -crash within 5 minutes stops automatic restarting for human intervention. +The kernel process handles orchestration, memory and retrieval; HTTP, files and devices belong to +plugins (`internal/plugin`). A crashed plugin is restarted with backoff 1s / 2s / 3s; the 4th +crash within 5 minutes stops automatic restarting pending human intervention +(`cmd/homed/kmod_runtime.go`). -### Context is not "truncate and summarize" +### Context window -The usual approach drops old turns or asks the model to summarize them. Both share a flaw: what -gets lost is decided by position. Here each historical event is scored against the current input, -and low scorers sink into the cold-context layer. +`internal/agent/core/context.go` -Scoring uses dense cosine similarity in the same vector space as media and cold context, falling -back to TF-IDF when the model isn't loaded. The most recent N events are always kept, but N is -itself bounded by budget: +Each event gets two vectors on entry: a dense vector sharing one space with media and cold +context, and a TF-IDF word vector as fallback. Pruning scores history against the current input +and keeps topK, with the most recent N events exempt from scoring. -```go -// protected count cannot exceed what the budget can hold -budgetCap = ContextTokens / avgEventTokens -pCount = min(configured, budgetCap) -``` +N defaults to 10, bounded by budget: `min(configured, ContextTokens / avg tokens per event)`. -That bound came from a measurement: with a 50k window, one event averaged 5800 tokens (tool -results), while the budget was 40000, so "pin 10 events" didn't fit. Every prune hit the -protection floor, the budget was exhausted after two rounds, and the task terminated. Recall -dropped from 44% to 22%. Smaller windows now tighten automatically, and a 1M window can use the -full configured value. +That bound came from a measurement. At a 50k window with 5800 tokens per event and a 40000 +budget, pinning 10 events needed 58k, more than the budget itself. Every prune met its target +without freeing space; the budget was gone after two rounds and the task terminated, with recall +falling from 44% to 22%. Small windows now tighten automatically; a 1M window uses the configured +value. -Pruned events aren't discarded, they migrate: the event and the media blocks it holds move into -the cold-context layer with block IDs unchanged, only the holding layer changes. Retrieval uses -`coldcontext_query` in two phases (Peek to confirm, then Remove) rather than delete-then-refill, -which would lose data permanently if the refill failed. +Pruned events move to the cold-context layer together with the media blocks they hold. Block IDs +stay the same; only the holding layer changes. Retrieval uses `coldcontext_query`: `Peek` to +confirm, then `Remove` the source copy. There is no fetch-and-delete path, since deletion follows +confirmation. -### Three layers migrate, they don't copy +### Three layers -```text -L1 Context relevance pruning, last N always protected - ↓ low scorers sink (with media blocks) -L2 Cold Context dense retrieval; ≤2 accesses and 72h stale means sink further - ↓ distilled into triples -L3 Stable entities, relations, scene index; recall by scene -``` +`internal/agent/core/context.go` · `internal/memory/coldcontext/` · `internal/memory/graph.go` -A block has exactly one holder at any layer, with no reference counting to keep it alive. Media -are not attachments: images and audio are first-class graph nodes with their own vectors, -searchable in the same space as text. +| Layer | Holds | Reaches the model | Cooling | +|---|---|---|---| +| L1 Context | events + blocks | directly, in the system prompt | low relevance sinks | +| L2 Cold Context | events + blocks (same shape as L1) | inserted back into L1 order on recall | ≤2 accesses and 72h idle, then distilled | +| L3 Stable | triples, nodes are blocks | on recall | never leaves | -### Memory is partitioned by scene, not by session +A block has exactly one holder at any layer, with no reference counting. Images and audio are +blocks in L2/L3 with their own vectors, searched in the same space as text. -Typical agents keep one history per session, so switching topics loses context or crams -everything into one window. Here memory is partitioned by observable scene fingerprint: channel, -conversation partner, tools, topic, time of day, weighted 1.0/1.0/0.8/0.4/0.2. +### Scenes -A scene comes into being only on the second matching fingerprint (`minSceneEvidence = 2`). -Join threshold 0.5, recall 0.35 — recall is looser because failing to recall loses information -while an extra scene costs a few lines of context. Each recurrence adds to scene strength; -scenes that stop recurring decay by half-life. +`internal/memory/scene_emerge.go` -Two paths run in parallel: **declared** (a plugin states at the injection point which scene this -is) and **emergent** (fingerprints cluster on their own). Declared scenes deliberately don't -absorb the full fingerprint, otherwise they'd score near 1.0 against every later turn and the -emergent path could never grow finer-grained scenes. +A scene is built from five weighted features: channel 1.0, conversation partner 1.0, tools 0.8, +topic 0.4, time of day 0.2. A scene is created on the second occurrence of the same fingerprint +(`minSceneEvidence = 2`); one-off interactions don't create one. Join threshold 0.5, recall 0.35. +Recurrence adds 1 to strength; scenes that stop recurring decay by half-life. + +Two entry points run in parallel: a plugin declares the scene at an injection point (e.g. +`chan:qq/peer:group_1`), or fingerprints cluster on their own. A declared scene derives features +only from its own key and does not absorb the full fingerprint. Absorbing it would make the +declared scene match nearly every later turn of that kind at close to 1.0 similarity, and the +clustering path could never grow narrower scenes. ### Seven stages @@ -110,7 +103,7 @@ emergent path could never grow finer-grained scenes. | `before_output` | final text ready | format adaptation | | `after_output` | after sending | statistics | -`post_action → [before_toolcall → run → after_toolcall] → post_action` is the inner loop. It exits +The inner loop is `post_action → [before_toolcall → run → after_toolcall] → post_action`. It exits when the model stops calling tools, all calls are denied, or the limit is reached. ## Everything else