Skip to content

Insight transcript 10k事件session加载~10s性能优化 #118

Description

@physercoe

现象

打开 Insight 页面查看一个有 ~10k 事件的 session 时,transcript log 加载需要接近 10 秒。

加载路径全景

打开 Insight 时,SessionAnalysisView (lib/widgets/session_analysis_view.dart:113-118) 并行触发三条请求:

请求 端点 数据量
Digest GET /sessions/{id}/digest session 级聚合摘要
Turns GET /sessions/{id}/turns 全量 turn 列表
Transcript GET /agents/{id}/events?tail=true&limit=200 最新 200 条事件

Transcript 本身只取 200 条,很快。瓶颈在 digest + turns 路径,两者都调用 ensureAgentDigest()

瓶颈 #1ensureAgentDigestbackfillAgentDigest 全量回填

digest_store.go:374-399 — 当 digest 不存在或过期时,触发 backfillAgentDigest (digest_store.go:408-443):

1a. loadFoldEvents 加载全量行 + 完整 payload (digest_store.go:446-449)

SELECT seq, session_ordinal, kind, ts, producer, session_id, payload_json
  FROM agent_events WHERE agent_id = ? ORDER BY seq ASC

对 10k 事件,这条查询从 SQLite 读出所有行,包括每条事件的完整 payload_json(text 事件的 payload 可能很大,因为携带累计文本)。

1b. JSON 反序列化 (digest_store.go:484)

_ = json.Unmarshal([]byte(payload), &e.Payload)

10k 次 json.Unmarshalmap[string]any,text/thought 的大块文本全部解析。

1c. computeAgentDigest 全量折叠 (digest_fold.go:272-282)

func computeAgentDigest(agentID, teamID string, events []foldEvent) (*agentDigest, []turnRow) {
    f := newDigestFolder(newAgentDigest(agentID, teamID))
    for _, e := range events {
        f.step(e)  // 逐事件处理
    }
    ...
}

O(n) 扫描全部 10k 事件。step() 内部对每个事件做 kind dispatch、payload 字段提取、turn 边界判定、错误分类等。

瓶颈 #2sessionAgentIDs 全扫描 (handlers_agent_digest.go:116-138)

SELECT agent_id FROM agent_events
 WHERE session_id = ?
 GROUP BY agent_id
 ORDER BY MIN(ts) ASC

每次读取 session digest 都会扫描该 session 的全部事件来做 GROUP BY + MIN(ts) 排序。即使 digest 已经是最新的(不需要回填),这个查询也会跑。

瓶颈 #3:延迟折叠导致读修复频繁触发

digest_worker.go 的增量折叠是最终一致性设计:

  • markDigestDirty (line 77) — ingest 热路径 O(1) 标记
  • runDigestFold (line 126) — 触发条件:turn 关闭 / 累计 32 事件 / 750ms 到期
  • digestIsStale (line 321) — 读取时检查 MAX(seq) > watermark

当增量折叠还没追上来、但读取先到达时,digestIsStale 返回 true → 退化到全量 backfillAgentDigest

对于已完成的历史 session,增量折叠应该已经跑完,digest 应该是最新的。但 digestIsStale 每次都要执行 SELECT COALESCE(MAX(seq), 0) FROM agent_events WHERE agent_id = ? 来验证,虽然走 idx_agent_events_agent_seq 索引(O(log n)),但仍是一次额外查询。

对于仍有事件流入的 live session,读修复可能在 race 中反复命中,导致每次 Insight 打开都触发全量回填。

瓶颈 #4:schema version 升级引发全局回填

digest_fold.godigestSchemaVersion = 6。每次 bump(e.g. ADR-042 加了 session_ordinal、v6 加了对触发事件的锚点重定向)都会让 ensureAgentDigest 对所有已有 digest 的 agent 重新回填——因为 d.SchemaVersion < digestSchemaVersion。这意味着 hub 升级后,所有 agent 的首次 Insight 访问都会经历一次全量 O(n) 回填

现有索引(可以支撑当前查询)

索引 定义
idx_agent_events_agent_seq (agent_id, seq)
idx_agent_events_session (session_id, ts) WHERE session_id IS NOT NULL
ux_agent_events_session_ordinal (session_id, session_ordinal) WHERE session_id IS NOT NULL
idx_agent_turns_agent_idx (agent_id, idx)
idx_agent_turns_agent_seq (agent_id, start_seq)

建议优化(按优先级)

1. sessionAgentIDs 结果落地到 session row(高影响、低风险)

sessions 表加 agent_ids_json 列,在 ingest agent_events 时(首次出现新 agent_id 的 session)追加更新。读取时直接拿 JSON 数组,消除全事件扫描。sessionAgentIDs 每次 digest 读取都被调用,对 10k 事件就是一次全扫描。

2. session digest 读时内存缓存(高影响、低风险)

首次 handleGetSessionDigest 后,LRU 缓存合并后的 digest map,TTL ~5s。对于短时间内连续的 digest+turns 请求(当前 provider 会发两个独立请求),第二个直接命中缓存。live session 的 pull-to-refresh 绕开缓存。

3. loadFoldEventsjson_extract 替代完整 payload 加载(中等影响)

回填时 fold 只用到每个事件的少数字段(name, input_tokens, output_tokens, model, cost_usd, is_error, status 等),不需要 text/thought 的大块 body。改为:

SELECT seq, session_ordinal, kind, ts, producer, session_id,
       json_extract(payload_json, '$.name')         as tool_name,
       json_extract(payload_json, '$.input_tokens') as input_tokens,
       json_extract(payload_json, '$.output_tokens') as output_tokens,
       ...
FROM agent_events WHERE agent_id = ? ORDER BY seq ASC

可减少 ~80% 的 payload I/O(text body 占了绝大部分 payload 体积)。

4. session close 时同步 fold(低影响、防御性)

handleCloseSession 中显式调一次 fold,确保会话关闭时 digest 是最新的。这样后续 Insight 打开走 O(1) 读路径。对历史 session(已关闭)的用户体验改善最大。

5. 客户端单次遍历 lens 计数(极低影响)

insight_transcript.dart:1419-1431lensCountsvisible 做了 5 次遍历(FeedLens.values 有 5 个值)。可改为单次遍历累加。实际影响很小(200 条 × 5 = 1000 次),但代码更干净。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions