mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
perf(webui): pulse 端点不再全量扫描图库;修星图每帧全量开销
用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上):
## 1. 后端:/memory/graph/pulse 名为轻量、实为全量
该端点自称轻量(注释写着「只回窗口内变动的实体,与完整图谱差两个数量级」),
但实现是**先调 GraphData() 取全量快照 → json.Marshal 约 3.3MB →
反序列化 → 才按时间过滤**。生产实测与全量端点**同价**:
/memory/graph 135-147ms
/memory/graph/pulse 144-157ms ← 每 10s 被轮询一次
即每 10 秒白付一次全量序列化。生产库 3190 块 / 2692 边。
改法:新增 `GraphDB.BlocksChangedSince(since, limit)` —— 库侧 SQL 过滤
(`WHERE created_at >= ? OR updated_at >= ?` + LIMIT),响应体只含窗口内
的块。limit 上限保护:窗口内可能有很多块(批量迁移),而星图只需知道
「有新东西」。
接口走内置 `sdk.MemoryAPI`(非 pubsdk),与既有 GraphData 同理,
**不影响外部插件**。
## 2. 前端:星图渲染循环每帧全量遍历 + 永不停止
三处,都是我上一批星图改动引入的:
- **RAF 循环从不停**:切到聊天/终端页签、甚至页面不可见时,仍以 60fps
跑着每帧 3190 节点的遍历。改用显式开关 + `starmapWake()` 重新点火
(判据问「星图容器是否可见」,不用 document.hidden —— 星图可能在
未激活的页签里,那时页面可见但星图看不到)。
- **starmapTickMorph 每帧扫全部节点**:3190 × 60fps = 每秒 19 万次
userData 读取与分支,而绝大多数帧里「正在形变」的节点是 0 个。
改用 `starmapMorphSet` 活跃集合,空集时 O(1) 返回。
- **标签层每帧重建 plan + measureText**:加 100ms 节流(文字层 10fps
肉眼无差别,3D 图形仍 60fps)与 measureText 缓存(同步文字布局很贵,
同文字同字体宽度不变)+ 缓存上限保护。
## 验证
- `blocks_changed_test.go`(新增):窗口语义、空窗口恒 0(否则星图会每次
轮询重放生长动画)、limit 真的封顶
- 既有 pulse 判据全过(`OnlyRecent` / `SinceWidens` / `NoVector` /
`IgnoresFrozenLegacyNodes`)—— 改写没破坏语义。
测试替身 `fakeGraphMemory` 同步实现新方法,且**忠实反映过滤语义**
(偷懒返回全部会把「窗口过滤」判据架空)
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
This commit is contained in:
@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
@ -30,6 +31,30 @@ func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) {
|
||||
}
|
||||
func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil }
|
||||
|
||||
// BlocksChangedSince 是 pulse 端点现在的数据源(不再走 GraphData 全量 + 过滤)。
|
||||
//
|
||||
// 桩要**忠实反映过滤语义**:只回窗口内的块。若这里偷懒返回全部,
|
||||
// 就把「窗口过滤」这条判据架空了——测的是桩,不是产品。
|
||||
func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.MemoryBlockView, error) {
|
||||
blocks, _ := f.data["memory_blocks"].([]memory.MemoryBlock)
|
||||
var out []sdk.MemoryBlockView
|
||||
for _, b := range blocks {
|
||||
if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
out = append(out, sdk.MemoryBlockView{
|
||||
ID: b.ID, Modality: string(b.Modality), Text: b.Text,
|
||||
PayloadDigest: b.PayloadDigest, MIME: b.MIME,
|
||||
Source: b.Source, Tool: b.Tool, Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// graphFixture 造一份与生产实例同构的图谱快照:
|
||||
// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。
|
||||
func graphFixture() map[string]interface{} {
|
||||
|
||||
@ -229,42 +229,23 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request)
|
||||
since = time.Now().Add(-time.Duration(sec) * time.Second)
|
||||
}
|
||||
}
|
||||
data, err := h.memory.GraphData()
|
||||
// ★ 直接查「窗口内变动的块」,不再走全量 GraphData()(2026-10-08 修性能)。
|
||||
//
|
||||
// 原实现先 GraphData()(全量 3190 块 + 2692 边)→ Marshal 约 3.3MB →
|
||||
// 反序列化 → 才按时间过滤。实测与全量端点**同价**(145ms vs 135ms),
|
||||
// 而本端点每 10s 被星图轮询一次 —— 「轻量端点」名不副实。
|
||||
// 现在在库侧过滤,响应体只含窗口内的块(差两个数量级)。
|
||||
blocks, err := h.memory.BlocksChangedSince(since, 200)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := graphDataForVisual(data)
|
||||
// 过滤出窗口内变动过的节点。
|
||||
//
|
||||
// ★★★ 必须按 **memory_blocks** 过滤(2026-10-05 修)
|
||||
//
|
||||
// 原实现取 `out["nodes"]`,而 GraphData 的 nodes 是从**旧表 entities** 读的
|
||||
// (memory/graph.go:1445 `SELECT ... FROM entities ORDER BY mention_count DESC`)——
|
||||
// 旧表在停双写后不再增长,生产里最后更新停在 2026-10-03。
|
||||
// 而 pulse 的窗口默认 900s(15 分钟)⇒ **1294 个节点永远落在窗口之外**
|
||||
// ⇒ 恒返回 {"nodes":[]}。
|
||||
//
|
||||
// ★ 症状形态与本仓那批「静默失效」完全一致:200 + success:true,
|
||||
// 无任何报错,前端只是「星图不跟着 agent 动」。
|
||||
//
|
||||
// 为什么该按块过滤:星图渲染用的就是 memory_blocks
|
||||
// (graphDataForVisual 返回的 memory_blocks 是节点来源),
|
||||
// 且块表是活的(生产最新写入 = 当天)。
|
||||
//
|
||||
// 用块做过滤后仍保持**轻量**响应:不返回整个图谱,只回窗口内变动的块。
|
||||
rawBlocks, _ := json.Marshal(out["memory_blocks"])
|
||||
var blocks []graphBlockView
|
||||
_ = json.Unmarshal(rawBlocks, &blocks)
|
||||
pulse := make([]map[string]interface{}, 0, 8)
|
||||
pulse := make([]map[string]interface{}, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
pulse = append(pulse, map[string]interface{}{
|
||||
"id": b.ID,
|
||||
"name": b.Text,
|
||||
"modality": b.Modality,
|
||||
"modality": string(b.Modality),
|
||||
"payload_digest": b.PayloadDigest,
|
||||
"mime": b.MIME,
|
||||
"tool": b.Tool,
|
||||
@ -278,6 +259,8 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request)
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"nodes": pulse},
|
||||
})
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
// defaultGraphPulseWindow 是 /memory/graph/pulse 不带 since 时的回看窗口。
|
||||
|
||||
Reference in New Issue
Block a user