Skip to content

[Bug]: Markdown cache is keyed by a row id that moves between hosts (item.id: p3 -> he:<entryId>) / Markdown 缓存仍以会随宿主迁移的 row id 为键(#9573 只修了一半) #10142

Description

@Linearl

[Bug]: Markdown cache still keyed by a row id that moves between hosts / Markdown 缓存仍以会随宿主迁移的 row id 为键

Verification baseline(复核基线)

  • Release tag:desktop-v1.38.6 = e2145b031deef603d9ec1acc9d30f9a1e1ac9325(2026-09-11)
  • PR 分支基线:upstream/main-v2 = fbc8a8f36(领先 v1.38.6 共 17 个提交)
  • 复核方式:下方每一条 file:line 均以 git show desktop-v1.38.6:<path> 逐项核对过;对 main-v2 的差异另在本节末尾标注。

main-v2 相对 v1.38.6 的差异(已核查):transcriptStore.ts:212 在 main-v2 上引入了第三种 id 形态 ——

const id = m.messageId && (m.role === "assistant" || m.role === "user")
  ? `m:${m.messageId}`
  : `he:${rec.entryId}`;

id 体系还在继续分化(v1.38.6 只有 he:,main-v2 又加了 m:),而 live 侧的 p3/c1/e12 与工具侧的 he:<entryId>:tc<N> 两版都存在。结论不受影响,反而更强:每一种新形态都是「又一个随上下文变化的 id」,而缓存键正挂在它们身上。

TL;DR(中文摘要)

问题:#9573 报的「缓存永不命中」被 #9565 以「统一缓存键」修掉了一半 —— 上游把键从 he: 前缀改为无条件的 cacheKey={item.id},消除了 h<seq> 与 he: 这一种具体错配,但没有改变 item.id 本身会随渲染宿主迁移而变化这件事。

根因:item.id 不是身份,而是「哪条渲染路径在读它」的函数。

来源 id 形态 位置
live(控制器持有) 单字母前缀 + 序号(u7 / p3 / c1 / s2 / e12) useController.ts:945 / :1853 / :1855 / :1870 / :1975
history(store 投影) he:<entryId> transcriptStore.ts:221
工具调用 toolCallId 优先,否则 he:<entryId>:tc<N> transcriptStore.ts:201 / :310

上游代码自己就在按前缀区分这两类:

// Message.tsx:804
data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}

触发迁移的时机:切换会话 / 上滑翻页 / 应用重启 / 合并 recovery 副本(entryId 空间整体换掉) / remote-serve 水合 —— 任意一次之后,item.id 换掉,cacheKey 随之换掉,整条 transcript 的解析结果全部作废。

为什么现在提:hydrateHistoryApply.ts:227-234 专门写了「按签名匹配、保留 live id」的对账逻辑 —— 说明 id churn 上游已知,但缓解只做在了水合这一条路径上;切会话、翻页、重启、副本合并仍然换 id。

方案方向:缓存键挂在内容上,而不是挂在身份上。解析结果是源文本的纯函数(worker 唯一输入就是文本,revision 已是其 FNV-1a 指纹),所以 md:${revision} 就足以定位条目,且天然跨宿主、跨会话切换、跨副本合并命中。

优先级:P2(体验问题,不崩溃;但每次宿主迁移都代价一次全量重解析,长会话体感明显)。


Background

#9573 修掉了什么、没修掉什么

修掉了:h<seq> 行不再因为不是 he: 前缀而被 cachedBlocks 直接判为无 entryId。现在 Message.tsx:817 对每个 assistant 行无条件传 cacheKey={item.id},MarkdownHistory 用 cacheKey ?? entryId 解析键 —— 于是 h<seq> 行有键了,与 he: 行不再互斥。

没修掉:item.id 仍然不是稳定身份。它随「这条消息现在由哪个宿主渲染」而变:

  • live 阶段(控制器持有、正在流式或刚完成):id 来自 useController 的 reducer,是短前缀 + 序号。
  • history 阶段(保存后重新载入、翻页、重启、水合并入历史):id 来自 transcriptStore.convertRecord,是 he:<entryId>。

两者是不同的字符串。所以 cacheKey={item.id} 只是把「一种具体错配」换成了「一类结构性错配」:任何一次宿主迁移都会换键 → 全量 miss → 全量重解析。

Code path (file:line, desktop-v1.38.6)

环节 文件:行 说明
id 被判定为「会变」 components/Message.tsx:804 data-history-restore={item.id.startsWith("h") ? "" : undefined} —— 上游自己用前缀区分 live / history
live id 生成 lib/useController.ts:945 / :1853 / :1855 / :1870 / :1975 id: \${idPrefix}${seq}`/p${s.seq}/c${s.seq}/s${s.seq}/e${s.seq}`
history id 生成 lib/transcriptStore.ts:221 const id = \he:${rec.entryId}``
工具 id 生成 lib/transcriptStore.ts:201 / :310 itemIdForToolCall(tc.id, \he:${rec.entryId}:tc${callIndex}`)→return tcId || fallback`
id churn 已知的旁证 lib/hydrateHistoryApply.ts:227-234 按 itemSignature 匹配后返回 liveItems 的 id,只为让水合不换 id
legacy id 已换过代 __tests__/transcript-rows.test.ts:438 eq(historyEntryIdForItemId("h3-2"), undefined, "legacy item ids carry no entry")
解析值与文本一一对应 lib/transcriptSelectionText.ts:29 / :32 revision = contentRevision(sourceText);命中校验 cached.source === sourceText
只在非流式使用 components/Markdown.tsx:467 if (streaming || legacyMode) return committedView;

Reproduction

  1. 打开一个较长的本地会话(history 行 id 为 he:<entryId>),滚到顶部让历史分页加载完 —— 此时 Markdown 已解析并进缓存。
  2. 切到另一个会话,再切回来。
  3. 观察:切换后整条 transcript 重新走 worker 解析(长会话上表现为可见的明文 fallback → 逐块渲染)。

更明显的一种:对某个有 recovery 副本的会话执行「合并副本 / 以副本为主线」后切回 —— entryId 空间整体换掉,he:<entryId> 全变,缓存必然全废。

(这一条对我们尤其重要:我们用 recovery 副本恢复历史,合并会整体换 id 空间。)

Proposed direction

键应当是内容的函数,而不是行身份的函数。

// lib/transcriptMarkdownCache.ts
private key(_entryId: string | undefined, revision: number): string {
  return `md:${revision}`;   // revision 已是 source text 的 FNV-1a 指纹
}

为什么这是本质解:

  1. worker 是纯函数 —— 唯一输入 sourceText,输出 blocks / selectionText / selectionRevision 全部只依赖它。
  2. revision 已经足够定位条目 —— 它就是 contentRevision(sourceText)。
  3. 命中校验仍然存在 —— 读取时仍比对 cached.source === text,FNV-1a 碰撞或陈旧写入都会被挡下。
  4. MarkdownHistory 只在非流式使用(Markdown.tsx:467),所以 revision 稳定,不存在"流式期间每帧换键"的问题。
  5. 相同文本共享一条是正确的:相同 markdown 必然解析相同。

随之可以去掉两道守卫 —— 它们存在的唯一理由就是"没有稳定 id 时跳过缓存":

  • MarkdownHistory.tsx 的 if (stableCacheKey && initial.result)
  • transcriptSelectionText.ts 的 entryId ? … : undefined

我们在 fork 里已按此实现并验证(Linearl/DeepSeek-Reasonix,提交 624c90442;fork 基线 desktop-v1.38.3,PR 已 rebase 到 upstream/main-v2 @ fbc8a8f36 / v1.38.6 e2145b031):改 3 个文件,tsc --noEmit 干净,npm run build 全 PASS,markdown-history.test.tsx 56 passed。愿意提 PR 到 upstream/main-v2。

如果维护者更倾向保留身份键,那也可以只做兜底:key = identity ?? md:${revision} —— 但那只恢复了命中率,没有修正"键曾经是身份"这个概念错误,且仍会经过一次"先问身份"的分支。我们建议直接改为内容键。

Impact / risk

  • 影响面:任何宿主迁移(切会话 / 翻页 / 重启 / 副本合并 / 水合)后的首次渲染。长会话上是可感知的等待。
  • 风险:低。缓存读取仍做 source 比对;LRU 字节预算与 pin 语义不变;entryId 参数保留在签名里(调用方无需改动)。
  • 行为变化:内容相同的两条不同消息将共享一条缓存条目 —— 这是正确的,且降低内存占用。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    desktopWails desktop app (desktop/**)renderingTerminal rendering / flicker / repaint issueswindowsWindows-specific

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions