TanStack Query 与 SSE 流式数据集成
AI 对话中将 TanStack Query 的 durable server-state projection 与 SSE/Runtime 的 transient projection 分离,并通过 durable event replay、精确 cache patch 与 invalidation 收敛。
[!info] related notes
- 所属 MOC: TanStack Query 知识地图
- 基础: TanStack Query 服务端状态
- 基础: 前端 SSE 消费设计
- 架构: 四层状态架构
- 缓存: TanStack Query 缓存失效模式
- Runtime projection: BodySense Active Turn State Machine
- Durable recovery: Durable SSE Recovery with after_seq
TanStack Query 与 SSE 流式数据集成
范围
AI 对话页面通常同时存在:
TanStack Query
→ 已持久化/可重新获取的 server read-model projection
Streaming Runtime
→ 当前 run 的低延迟 transient projection
本文关心的是两者怎样协同而不抢 ownership,并在断线、重放、Mutation 和终态时收敛到服务器事实。
最重要的修正:流式期间前端也不是 Durable Truth
旧的简化说法:
流中以前端为准
流结束后以后端为准
不够准确。
更正确的模型是:
Server durable state / Runtime Event Log
= authority
Live SSE / replay
= delivery mechanisms
ActiveTurn
= current transient projection
TanStack Query
= durable server read-model 的 client cache/projection
即使流正在进行,前端也只是根据事件维护即时 projection;业务 authority 并没有转移到浏览器。
为什么 Streaming Runtime 不应该每个 token 都写 Query Cache
当前生成中的 text delta 具有:
- 高频;
- 短生命周期;
- 尚未最终收敛;
- 可能需要 replay/dedup;
- 会和历史 durable messages 有不同生命周期。
因此更自然的是:
message.text.delta
→ ActiveTurn reducer
→ streaming UI
而不是:
每个 token
→ setQueryData
→ durable-history query cache
这样可以避免频繁 cache 写、历史与 current-run state 混杂,以及 replay 时难以判断哪一层负责去重。
当前更完整的数据路径
Agent / Runtime
↓
Durable Runtime Event Log
├────────→ Live SSE
│
└────────→ after_seq Replay
↓
validated StreamEvent
↓
same reducer
↓
ActiveTurnState
↓
streaming UI
Run terminal / durable state changed
↓
patch or invalidate relevant TanStack Query entries
↓
fetch/reconcile durable read model
关键点是:Live 和 Replay 应尽量进入同一套 validation/reducer semantics。 Transport 来源不同,不应该产生不同 UI truth。
Query 与 Runtime 的 ownership 边界
TanStack Query 适合
conversation list
persisted thread/messages
HealthWorkspace
historical diagnosis/treatment/read models
这些值的客户端副本可以被 refetch、invalidate 和 cache。
Streaming Runtime 适合
current text delta
current tool lifecycle
current citation/red flag
current pending interaction
current run status projection
它回答“这一轮此刻发生到哪里”,终态后应被 durable read model 接管并最终 reset/reconcile。
SSE 事件何时可以 setQueryData
并不是“SSE 事件都不该碰 Query Cache”。如果一个事件携带了足够稳定、明确的 server fact,可以选择精确 patch cache,例如:
title.generated
→ 已知 authoritative title
→ patch list/detail projection
但条件仍然是:
客户端是否有足够证据构造正确的新 projection?
如果一个 event 只是部分变化,而后端还会计算其他字段,则更稳的是:
apply current-run projection
→ invalidate affected durable query
→ refetch full read model
cancelQueries + setQueryData 是一个模式,不是系统总架构
旧实现/一些场景会采用:
cancel in-flight query
→ setQueryData with known stream update
它可以避免旧 fetch 返回覆盖刚写的 projection,尤其适合乐观更新或明确 cache patch。
但不要将其提升为通用原则:
Streaming active
→ Query must always be disabled
→ every SSE event must cancel query
→ every event must write cache
更重要的是保持:
stable ownership
+ correct query identity
+ durable recovery
+ idempotent projection
+ final reconciliation
断线时为什么不能靠 Query Cache 补当前 Run
TanStack Query 的 persisted-thread/read-model query 未必包含当前 run 所有高频 runtime event。SSE 连接断线后,如果只 refetch普通页面数据,可能看不到尚未 finalise 的:
tool lifecycle
interaction.required
partial delta
runtime terminal transition
因此 production Agent streaming 需要独立的 durable runtime recovery:
Live consumed seq 1..5
network drop
server continues 6..10
client remembers cursor=5
→ GET events?after_seq=5
→ replay 6..10
见 Durable SSE Recovery with after_seq。
Replay 与 Query Cache Update 都需要幂等边界
恢复可能出现 overlap:
live received 1..5
replay returns 4..8
live reconnect also receives 7..10
如果每个事件直接 append Query data,会很容易重复。
更合理:
(run_id, seq) guard
+ keyed reducer/upsert
+ declared effects
+ cache patches designed to converge
BodySense 当前 ActiveTurn reducer 用 run-aware sequence guard,Live/Replay 都通过同一事件语义投影。
Terminal 后如何与 Query 收敛
终态通常应形成:
run.completed / run.failed / run.cancelled / stream.done
→ finish current runtime projection
→ update/invalidate affected durable queries
→ refetch/reconcile authoritative read model
→ reset transient ActiveTurn when safe
这里不能把“socket EOF”当成终态。网络断开只说明 transport 不可用,不证明 run 已结束。
Query Cache 竞态仍然存在
例如:
Q1 starts fetching old thread
stream event patches cache
Q1 old response later arrives
如果架构选择直接 patch Query Cache,就必须考虑旧 fetch 覆盖问题,可以通过:
- 取消/避免冲突的 in-flight query;
- 不在 stream 中直接写该 cache,而只维护 ActiveTurn;
- 使用 revision/version 证据;
- terminal 时统一 refetch;
- 设计精确 cache patch 与 invalidation。
重点是根据 ownership 和 evidence 选策略,而不是机械调用某个 API。
BodySense 当前心智模型
POST consultation
→ fetch + ReadableStream consumes SSE
→ parse/validate StreamEvent
→ ActiveTurn reducer
→ low-latency UI
同时:
Runtime Event Log persists public events
→ after_seq recovery
→ same handlers/reducer
server-state projections
→ TanStack Query
terminal/domain changes
→ precise setQueryData and/or invalidate
→ reconcile server truth
对应的主要代码边界:
apps/web/src/features/consultation/hooks/useSSEProcessor.ts
apps/web/src/features/consultation/runtime/activeTurnReducer.ts
apps/web/src/features/consultation/runtime/durableRunRecovery.ts
apps/web/src/features/consultation/hooks/useThreadProjectionActions.ts
常见误区
| 误区 | 更准确的理解 |
|---|---|
| 流中“以前端为准” | 前端只是 live projection,authority 仍在服务器 |
| 每个 token 都写 Query Cache | 高频 current-run state 更适合 Runtime projection |
| SSE 断了就 refetch thread 即可 | active run 可能需要 durable event catch-up |
setQueryData 就是服务器更新 | 它只更新 client cache |
| Socket EOF = run completed | transport lifetime ≠ run lifetime |
| Live 和 Replay 可以各写一套 UI 逻辑 | 同一 event history 应收敛到同一 reducer/projection |
最短记忆
TanStack Query
= durable server read model 的客户端 cache
ActiveTurn
= current run 的 transient projection
SSE
= low-latency transport
Runtime Event Log
= disconnect 后的 durable recovery source
最终原则:
transport 可以断,cache 可以 stale,
但 client projection 必须重新收敛到 server truth。