mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
perf(memory): Introspect 507ms→11ms、ListPersons 130ms→3ms
继续清「非常卡」。上次修完 pulse 端点后,`/kernel` 与 `/runtime` 仍各约
600ms(每 15s 一次)。这次**逐项量**而不是猜(临时探针量各子收集器):
memDB.Introspect 507ms ← 主凶
social.ListPersons 129ms
textMem.FileCount 0.1ms
docStore.Stats 0.0ms
knowledge.List/Stats 0.0ms
## ① Introspect 507ms → 11ms(43×)
hotspots 的度数写成**相关标量子查询**:
COALESCE((SELECT COUNT(*) FROM memory_block_edges e
WHERE (e.source_id = b.id OR e.target_id = b.id) ...), 0)
EXPLAIN 是 `SCAN b`(3364 块)→ `CORRELATED SCALAR SUBQUERY` → `SCAN e`
(2829 边),即 **3364 × 2829 ≈ 950 万次行扫描**,且 ORDER BY 前必须算完
全部块的度数。
改成边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
边表只扫两遍而不是 3364 遍。**新旧查询输出逐字节一致**(已 diff 验证)。
## ② ListPersons 130ms → 3ms(38×)
它走 `Recall(nil, nil, 1, "")`,而那条 full-recall 路径为了填
`result.Entities` 会加载**全部块(blockColumns 含稠密向量)**:
Recall 全程 130ms:实体 3266 个、关系 30 条
即为了 30 条关系反序列化了 3266 个块的向量,而 ListPersons
**只看 result.Relations**。
新增 `GraphDB.RecentRelations(limit)`:与 full-recall 分支里的关系子查询
**逐字对齐**(同一 WHERE/ORDER/LIMIT,含 trait 边——过滤掉它会让特质
主体漏出人物表)。实测该查询单独 4ms。
等价性验证:新旧路径的人物列表 + 关系集**逐字节 diff 完全一致**
(56 人、同样的边)。旧路径输出是先 git stash 采样的,不是凭记忆比对。
## 验证
- `TestIntrospect_HotspotsQueryIsNotCorrelated`(源码形状)+
`TestIntrospect_LargeGraphIsFast`(2000 块/1500 边的耗时门)。
**两者各自独立可杀变异**(实测):
- 源码断言:退回相关子查询 → FAIL「必须用一次聚合」
- 耗时门:同变异 → FAIL「149ms 超过 60ms 门槛」(修复后 11ms)
为何源码断言要锚在返回值行而非 `Index`:注释里也提到 memory_hotspots,
用 Index 会截到注释处、取到的片段根本没有查询——第一版就这么写错了,
自己先红了一次才发现。
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
This commit is contained in:
@ -1766,7 +1766,6 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
|
|||||||
WHERE source_kind='block' AND target_kind='block'
|
WHERE source_kind='block' AND target_kind='block'
|
||||||
AND edge_type != 'contains'`).Scan(&relationsTotal)
|
AND edge_type != 'contains'`).Scan(&relationsTotal)
|
||||||
|
|
||||||
hotspots := []map[string]interface{}{}
|
|
||||||
// ★★ hotspots 改数**块**(2026-10-04)
|
// ★★ hotspots 改数**块**(2026-10-04)
|
||||||
//
|
//
|
||||||
// 旧实现读 entities 并按 mention_count 排序 ——
|
// 旧实现读 entities 并按 mention_count 排序 ——
|
||||||
@ -1776,13 +1775,46 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
|
|||||||
// ★ 排序口径换成**关系边度数**:一个块被越多关系边指向,
|
// ★ 排序口径换成**关系边度数**:一个块被越多关系边指向,
|
||||||
// 它在图里越重要 —— 这正是 hotspots 想回答的问题。
|
// 它在图里越重要 —— 这正是 hotspots 想回答的问题。
|
||||||
// mention_count 已无处可取(旧表专有)。
|
// mention_count 已无处可取(旧表专有)。
|
||||||
|
//
|
||||||
|
// ★★★ 查询形态:一次聚合,不是相关子查询(2026-10-08 修 507ms)
|
||||||
|
//
|
||||||
|
// 原实现把度数写成**相关标量子查询**(SELECT COUNT(*) FROM edges
|
||||||
|
// WHERE source_id=b.id OR target_id=b.id),EXPLAIN 是:
|
||||||
|
//
|
||||||
|
// SCAN b -- 3364 块
|
||||||
|
// CORRELATED SCALAR SUBQUERY -- 每块一次
|
||||||
|
// SCAN e -- 每块扫 2829 边
|
||||||
|
// USE TEMP B-TREE FOR ORDER BY
|
||||||
|
//
|
||||||
|
// 即 3364 × 2829 ≈ **950 万次行扫描**,且排序前必须算完全部块的度数。
|
||||||
|
// 实测(生产库 3364 块 / 2829 边):
|
||||||
|
//
|
||||||
|
// Introspect 总耗时 507ms,其中这一个查询就是绝大部分
|
||||||
|
//
|
||||||
|
// 改法:先把边表按端点拆开聚合一次(UNION ALL + GROUP BY),
|
||||||
|
// 再 LEFT JOIN 回块表。语义完全等价(都是用同一套 status 过滤数度),
|
||||||
|
// 但边表只扫**两遍**而不是 3364 遍。
|
||||||
|
//
|
||||||
|
// 实测同一份数据:**507ms → 11ms**(46×),且新旧查询输出逐字节一致。
|
||||||
|
//
|
||||||
|
// 为何不建索引就了事:`source_id=b.id OR target_id=b.id` 是**双向**条件,
|
||||||
|
// 单列索引各只能覆盖一半,SQLite 仍要走全扫或两边各自索引再合并;
|
||||||
|
// 而聚合形态天然要扫全边表一遍,JOIN 回块表时走 id 主键。
|
||||||
|
hotspots := []map[string]interface{}{}
|
||||||
rows, err := g.db.Query(
|
rows, err := g.db.Query(
|
||||||
`SELECT b.text_content,
|
`SELECT b.text_content,
|
||||||
COALESCE((SELECT COUNT(*) FROM memory_block_edges e
|
COALESCE(d.deg, 0) AS deg,
|
||||||
WHERE (e.source_id = b.id OR e.target_id = b.id)
|
|
||||||
AND COALESCE(e.status,'') != 'deleted'), 0) AS deg,
|
|
||||||
COALESCE(b.semantic_type, '') AS typ
|
COALESCE(b.semantic_type, '') AS typ
|
||||||
FROM memory_blocks b
|
FROM memory_blocks b
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id, COUNT(*) AS deg FROM (
|
||||||
|
SELECT source_id AS id FROM memory_block_edges
|
||||||
|
WHERE COALESCE(status,'') != 'deleted'
|
||||||
|
UNION ALL
|
||||||
|
SELECT target_id AS id FROM memory_block_edges
|
||||||
|
WHERE COALESCE(status,'') != 'deleted'
|
||||||
|
) GROUP BY id
|
||||||
|
) d ON d.id = b.id
|
||||||
WHERE b.text_content != ''
|
WHERE b.text_content != ''
|
||||||
ORDER BY deg DESC, b.created_at ASC
|
ORDER BY deg DESC, b.created_at ASC
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
@ -1810,6 +1842,65 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentRelations 返回最近的关系边(不含实体),供只关心关系的调用方使用。
|
||||||
|
//
|
||||||
|
// ★ 为何需要它(2026-10-08 修 ListPersons 的 130ms):
|
||||||
|
//
|
||||||
|
// ListPersons 需要「人物 = 关系边两端 ∪ trait 边源」,但它走的是
|
||||||
|
// Recall(nil, nil, 1, "") —— 那条路为了填 result.Entities 会加载
|
||||||
|
//
|
||||||
|
// t **全部块(blockColumns 含稠密向量)**。生产实测(3364 块):
|
||||||
|
//
|
||||||
|
// t Recall 全程 130ms:实体 3266 个、关系 30 条
|
||||||
|
//
|
||||||
|
// 即为了拿 30 条关系而反序列化了 3266 个块的向量。而 ListPersons
|
||||||
|
// **只看 result.Relations**,那些实体完全是白付的。
|
||||||
|
//
|
||||||
|
// 语义与 full-recall 分支里的关系子查询**逐字对齐**(同一 WHERE、
|
||||||
|
// 同一 ORDER、同一 LIMIT),所以调用方拿到的关系集不变;
|
||||||
|
// 实测该查询单独跑 4ms。
|
||||||
|
//
|
||||||
|
// 注意 trait 边**在内**(只过滤 status),因为 ListPersons 要靠
|
||||||
|
// trait 前缀判定「源是人物」—— 过滤掉它会让特质主体漏出人物表。
|
||||||
|
func (g *GraphDB) RecentRelations(limit int) ([]Relation, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
g.mu.RLock()
|
||||||
|
defer g.mu.RUnlock()
|
||||||
|
rows, err := g.db.Query(
|
||||||
|
`SELECT e.id, e.source_id, e.target_id, sb.text_content, tb.text_content,
|
||||||
|
e.edge_type, COALESCE(e.confidence, 0), COALESCE(e.status, ''),
|
||||||
|
COALESCE(e.session_id, ''), COALESCE(e.turn_id, 0), e.created_at,
|
||||||
|
COALESCE((SELECT b.text_content FROM memory_block_edges c
|
||||||
|
JOIN memory_blocks b ON b.id = c.source_id
|
||||||
|
WHERE c.target_kind = 'edge' AND c.target_id = e.id
|
||||||
|
AND c.edge_type = 'contains'
|
||||||
|
LIMIT 1), '')
|
||||||
|
FROM memory_block_edges e
|
||||||
|
JOIN memory_blocks sb ON sb.id = e.source_id
|
||||||
|
JOIN memory_blocks tb ON tb.id = e.target_id
|
||||||
|
WHERE e.source_kind = 'block' AND e.target_kind = 'block'
|
||||||
|
AND COALESCE(e.status, '') = 'active'
|
||||||
|
ORDER BY e.created_at DESC LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var rels []Relation
|
||||||
|
for rows.Next() {
|
||||||
|
var rel Relation
|
||||||
|
if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID,
|
||||||
|
&rel.SourceName, &rel.TargetName, &rel.RelationType,
|
||||||
|
&rel.Confidence, &rel.Status, &rel.SessionID,
|
||||||
|
&rel.TurnID, &rel.CreatedAt, &rel.SentenceText); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rels = append(rels, rel)
|
||||||
|
}
|
||||||
|
return rels, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// MergeEntities 合并两个实体:将 sourceName 的所有信息合并到 targetName
|
// MergeEntities 合并两个实体:将 sourceName 的所有信息合并到 targetName
|
||||||
// 1. sourceName 的所有关系重新指向 targetName
|
// 1. sourceName 的所有关系重新指向 targetName
|
||||||
// 2. targetName 的 mention_count 增加 sourceName 的计数
|
// 2. targetName 的 mention_count 增加 sourceName 的计数
|
||||||
|
|||||||
129
internal/memory/introspect_perf_test.go
Normal file
129
internal/memory/introspect_perf_test.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readSource(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Introspect 曾是内核状态页最大的单点开销(2026-10-08 修)。
|
||||||
|
//
|
||||||
|
// 生产实测(3364 块 / 2829 边):**507ms**。
|
||||||
|
// 根因:hotspots 的度数写成**相关标量子查询**——对每个块执行一次
|
||||||
|
//
|
||||||
|
// SELECT COUNT(*) FROM memory_block_edges
|
||||||
|
// WHERE source_id = b.id OR target_id = b.id
|
||||||
|
//
|
||||||
|
// EXPLAIN 是 SCAN b → CORRELATED SCALAR SUBQUERY → SCAN e,
|
||||||
|
// 即 3364 × 2829 ≈ **950 万次行扫描**,且 ORDER BY 前必须算完全部块的度数。
|
||||||
|
//
|
||||||
|
// 修法:边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
|
||||||
|
// 实测同数据 **507ms → 11ms**,且新旧输出逐字节一致。
|
||||||
|
//
|
||||||
|
// 为何用源码断言兜住「形状」而不只靠耗时:小库上两种写法都快,
|
||||||
|
// 只有大库才暴露;而形状(有没有相关子查询)是**静态可判定**的,
|
||||||
|
// 回归时立刻红,不必等库长大才发现。
|
||||||
|
func TestIntrospect_HotspotsQueryIsNotCorrelated(t *testing.T) {
|
||||||
|
src := readSource(t, "graph.go")
|
||||||
|
|
||||||
|
// 定位 hotspots 那条查询:用其返回值行的**最后**一次出现(前面的注释里
|
||||||
|
// 也提到 memory_hotspots,用 Index 会截到注释处,取到的片段里根本没有查询)。
|
||||||
|
i := strings.LastIndex(src, `"memory_hotspots": hotspots`)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("找不到 memory_hotspots 返回值")
|
||||||
|
}
|
||||||
|
seg := src[:i]
|
||||||
|
q := strings.LastIndex(seg, "g.db.Query(")
|
||||||
|
if q < 0 {
|
||||||
|
t.Fatal("找不到 hotspots 查询")
|
||||||
|
}
|
||||||
|
// 从查询起点到返回值:这段里就应当只有 hotspots 那一条 SELECT。
|
||||||
|
query := seg[q:]
|
||||||
|
if len(query) > 4000 {
|
||||||
|
t.Fatalf("提取的查询片段异常长(%d),定位可能错了", len(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 度数必须来自**预聚合**(GROUP BY),不是逐行子查询。
|
||||||
|
if !strings.Contains(query, "GROUP BY id") {
|
||||||
|
t.Error("hotspots 度数必须用一次聚合(GROUP BY)算出,不能是相关子查询:\n" +
|
||||||
|
" 相关子查询 = 每块一次边表扫描 = 3364 × 2829 ≈ 950 万次行访问\n" +
|
||||||
|
" 实测 Introspect 507ms → 11ms 就是这一处的差别")
|
||||||
|
}
|
||||||
|
// ② 明确禁止那个形态:括号里直接嵌 COUNT(*) 子查询。
|
||||||
|
if strings.Contains(query, "COALESCE((SELECT COUNT(*) FROM memory_block_edges e") {
|
||||||
|
t.Error("hotspots 又退回相关子查询了(COALESCE((SELECT COUNT(*) ... WHERE (e.source_id = b.id OR ...))")
|
||||||
|
}
|
||||||
|
// ③ 聚合形态必须双边都算(source 与 target 都是端点)。
|
||||||
|
if !strings.Contains(query, "SELECT source_id AS id") ||
|
||||||
|
!strings.Contains(query, "SELECT target_id AS id") {
|
||||||
|
t.Error("度数聚合必须同时含 source_id 与 target_id 两个端点,缺一个就会漏度")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntrospect_LargeGraphIsFast 用真实规模的库钉住耗时上限。
|
||||||
|
//
|
||||||
|
// 源码断言只能守形状;这条守「形状对了但仍慢」的情况(比如聚合子查询里
|
||||||
|
// 又嵌了别的全表扫)。规模按生产量级造(2000 块 / 1500 边),
|
||||||
|
// 上限留足余量但足以抓住相关子查询那档(2000×1500 ≈ 300 万次访问)。
|
||||||
|
func TestIntrospect_LargeGraphIsFast(t *testing.T) {
|
||||||
|
g := newPerfGraph(t, 2000, 1500)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := g.Introspect(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// 相关子查询版在这个规模约 250-500ms;聚合版 < 20ms。
|
||||||
|
// 60ms 的门槛既能抓住退化,又不会因 CI 机器慢而误报。
|
||||||
|
if elapsed > 60*time.Millisecond {
|
||||||
|
t.Errorf("Introspect 在 2000 块/1500 边上耗时 %v,超过 60ms 门槛。\n"+
|
||||||
|
" 相关子查询版本会落到几百毫秒;若本条变红,检查 hotspots 是否退回逐块计数。",
|
||||||
|
elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPerfGraph 造一个指定规模的图:nBlocks 个块、nEdges 条边(互相引用)。
|
||||||
|
func newPerfGraph(t *testing.T, nBlocks, nEdges int) *GraphDB {
|
||||||
|
t.Helper()
|
||||||
|
g, err := NewGraphDB(filepath.Join(t.TempDir(), "perf.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { g.Close() })
|
||||||
|
|
||||||
|
ids := make([]string, 0, nBlocks)
|
||||||
|
for i := 0; i < nBlocks; i++ {
|
||||||
|
id := "blk_" + strconv.Itoa(i)
|
||||||
|
ids = append(ids, id)
|
||||||
|
if err := g.PutMemoryBlocks([]MemoryBlock{{
|
||||||
|
ID: id, Modality: BlockText, Text: "实体 " + strconv.Itoa(i),
|
||||||
|
Source: "triple", SemanticType: "Concept",
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < nEdges; i++ {
|
||||||
|
a := ids[i%len(ids)]
|
||||||
|
b := ids[(i*7+3)%len(ids)]
|
||||||
|
if a == b {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := g.AddMemoryBlockEdge("block", a, "block", b, "关联"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
@ -317,13 +317,22 @@ func (s *SocialStore) ListPersons() ([]string, error) {
|
|||||||
//
|
//
|
||||||
// ★ 保留一个回退:若图里一条边都没有(纯空库),
|
// ★ 保留一个回退:若图里一条边都没有(纯空库),
|
||||||
// 返回空列表而不是全部 —— 那才是「没有人物」。
|
// 返回空列表而不是全部 —— 那才是「没有人物」。
|
||||||
result, err := s.db.Recall(nil, nil, 1, "")
|
// ★ 改走 RecentRelations(2026-10-08 修 130ms)
|
||||||
|
//
|
||||||
|
// 原实现用 Recall(nil, nil, 1, ""),而那条 full-recall 路径为了填
|
||||||
|
// result.Entities 会加载**全部块(含稠密向量)**。生产实测:
|
||||||
|
//
|
||||||
|
// Recall 全程 130ms:实体 3266 个、关系 30 条
|
||||||
|
//
|
||||||
|
// 即为了 30 条关系白付了 3266 个块的向量反序列化。而本函数只看
|
||||||
|
// result.Relations。改成只取关系(同一 WHERE/ORDER/LIMIT,4ms)。
|
||||||
|
rels, err := s.db.RecentRelations(30)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, r := range result.Relations {
|
for _, r := range rels {
|
||||||
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
||||||
// 特质:源是主体(人物),目标是特质值
|
// 特质:源是主体(人物),目标是特质值
|
||||||
seen[r.SourceName] = true
|
seen[r.SourceName] = true
|
||||||
|
|||||||
Reference in New Issue
Block a user