merge: 架构节改参考文档写法

This commit is contained in:
JianFeeeee
2026-10-31 11:42:23 +08:00
2 changed files with 76 additions and 88 deletions

View File

@ -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`,
模型不再调工具、全部被拒、或达到上限时退出。
## 其余

View File

@ -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