现象
打开 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()。
瓶颈 #1:ensureAgentDigest → backfillAgentDigest 全量回填
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.Unmarshal 到 map[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 边界判定、错误分类等。
瓶颈 #2:sessionAgentIDs 全扫描 (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.go 的 digestSchemaVersion = 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. loadFoldEvents 用 json_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-1431 的 lensCounts 对 visible 做了 5 次遍历(FeedLens.values 有 5 个值)。可改为单次遍历累加。实际影响很小(200 条 × 5 = 1000 次),但代码更干净。
现象
打开 Insight 页面查看一个有 ~10k 事件的 session 时,transcript log 加载需要接近 10 秒。
加载路径全景
打开 Insight 时,
SessionAnalysisView(lib/widgets/session_analysis_view.dart:113-118) 并行触发三条请求:GET /sessions/{id}/digestGET /sessions/{id}/turnsGET /agents/{id}/events?tail=true&limit=200Transcript 本身只取 200 条,很快。瓶颈在 digest + turns 路径,两者都调用
ensureAgentDigest()。瓶颈 #1:
ensureAgentDigest→backfillAgentDigest全量回填digest_store.go:374-399— 当 digest 不存在或过期时,触发backfillAgentDigest(digest_store.go:408-443):1a.
loadFoldEvents加载全量行 + 完整 payload (digest_store.go:446-449)对 10k 事件,这条查询从 SQLite 读出所有行,包括每条事件的完整
payload_json(text 事件的 payload 可能很大,因为携带累计文本)。1b. JSON 反序列化 (
digest_store.go:484)10k 次
json.Unmarshal到map[string]any,text/thought 的大块文本全部解析。1c.
computeAgentDigest全量折叠 (digest_fold.go:272-282)O(n) 扫描全部 10k 事件。
step()内部对每个事件做 kind dispatch、payload 字段提取、turn 边界判定、错误分类等。瓶颈 #2:
sessionAgentIDs全扫描 (handlers_agent_digest.go:116-138)每次读取 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.go的digestSchemaVersion = 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 NULLux_agent_events_session_ordinal(session_id, session_ordinal) WHERE session_id IS NOT NULLidx_agent_turns_agent_idx(agent_id, idx)idx_agent_turns_agent_seq(agent_id, start_seq)建议优化(按优先级)
1.
sessionAgentIDs结果落地到 session row(高影响、低风险)在
sessions表加agent_ids_json列,在 ingestagent_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.
loadFoldEvents用json_extract替代完整 payload 加载(中等影响)回填时 fold 只用到每个事件的少数字段(
name,input_tokens,output_tokens,model,cost_usd,is_error,status等),不需要 text/thought 的大块 body。改为:可减少 ~80% 的 payload I/O(text body 占了绝大部分 payload 体积)。
4. session close 时同步 fold(低影响、防御性)
在
handleCloseSession中显式调一次 fold,确保会话关闭时 digest 是最新的。这样后续 Insight 打开走 O(1) 读路径。对历史 session(已关闭)的用户体验改善最大。5. 客户端单次遍历 lens 计数(极低影响)
insight_transcript.dart:1419-1431的lensCounts对visible做了 5 次遍历(FeedLens.values有 5 个值)。可改为单次遍历累加。实际影响很小(200 条 × 5 = 1000 次),但代码更干净。