DeepSeek Harness Web UI 空闲占用 50%:每帧 145 次布局的定位与修复

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH性能优化Web UICSS 动画layout thrashingResizeObserver渲染性能
DSH Web UI 无输入、无流式输出时主线程仍占约 50%,改窗口尺寸明显卡顿。实测布局约 145 次/秒(≈每帧一次),远超 rAF 的 24 次/秒。二分后根因是 5 处 left 扫光 + 2 处 background-position 微光每帧走布局/重绘路径。本文给 CDP 采集、探针脚本与两条修复。

DSH Web UI 在无用户输入、无流式输出的空闲状态下,主线程占用仍有约 50%;拖拽窗口边缘改尺寸时窗口边界明显跟不上鼠标,agent 流式输出期间更严重。 用 CDP 采集 10 秒窗口:TaskDuration 5119ms / 10000ms = 51.2%,其中布局 LayoutDuration 410ms 发生在 1453 次里——约 145 次/秒,几乎每一屏幕帧都在布局;而同一窗口的 rAF 回调只有 144 次 / 6 秒(24 次/秒)。布局频率远高于 rAF 频率,说明布局不是 rAF 驱动的(#6427)。逐组暂停动画二分后,根因收敛到两组核心动画:5 处动画 left 的绝对定位扫光(走布局路径)与 2 处叠在 background-clip: text 上的 background-position 微光(走绘制路径)——暂停它们后主线程从 60.4% 直落到 2.0%、布局从 1443/10s 掉到 10/10s。而拖拽卡顿是另一条独立机制:ResizeObserver 回调在每次 resize step 写约 5 次内联尺寸样式,叠加这个 profile 里昂贵的样式重算(单次 10.3ms,是空闲的约 19 倍),把主线程打满。本文按「先分诊 → 两条机制 → 源码级定位与修复 → 排查注意事项」展开。

先分诊:空闲占用与 resize 卡顿是两条独立机制

先别把它们当成一个问题修。 空闲占用来自动画,resize 卡顿在动画开不开都卡——判据、根因、修法都不同。

判据空闲态高占用拖拽 resize 卡顿
触发条件无输入、无流式输出时持续存在只有拖动窗口边缘时出现
暂停全部动画后大幅下降(60.4% → 2.0%)仍卡(与动画无关)
主线程热点布局 / 绘制(动画属性昂贵)样式重算(RecalcStyleDuration 飙升)
关键数字布局 ≈145 次/秒单次 restyle 10.3ms(空闲 0.55ms,≈19×)
根因侧CSS 动画属性落在 layout/paint 路径ResizeObserver 写内联样式 × 昂贵 restyle
修复left → transform、微光 → steps()批量化写入 / 缩小失效范围

采集方法(可复用的干净测量)

所有数字都用 CDP 采,避免「任务管理器看个大概」。固定窗口取差分:

text
Performance.getMetrics() 10 秒窗口差分:
  TaskDuration       5119 ms / 10000 ms  = 51.2%   ← 主线程忙碌
  ScriptDuration      311 ms                        ← JS 执行
  RecalcStyleDuration 289 ms(1572 次 → 0.18 ms/次)
  LayoutDuration      410 ms(1453 次 → ≈145 次/秒)

trace(devtools.timeline,6 秒)进一步印证:Layout 157 次、UpdateLayoutTree 143 次、Paint 113 次,没有 BeginMainFrame / DrawFrame 事件;rAF 回调只有 144 次 / 6 秒。CPU Profile(8 秒)显示 (idle) 4.5s、(program) 3.5s(原生渲染工作,非 JS),JS 侧热点是 toBottom 79.8ms、getBoundingClientRect 58.6ms、closest 11.6ms。

这些数据一起指向一个结论:开销在渲染管线(布局 + 绘制),不在 JS 逻辑。 这决定了后面的排查方向——去查 CSS 动画,而不是去查代码里的循环。

机制一:5 处 left 扫光 + 2 处 background-position 微光在每帧布局/重绘

一句话:这些动画改的属性本身就在布局或绘制路径上,所以每一帧都要重新布局或重新光栅化字形,主线程自然被吃满;will-change 救不了,因为它救的是「缺合成层」而不是「属性昂贵」。

二分:两组动画占掉几乎全部开销

逐组用 document.getAnimations() + pause() 暂停,同一台机器实测(0.1.5-rc.2,Electron 桌面壳):

暂停的组主线程LayoutCount / 10s
什么都不暂停(基线)60.4%1443
8× 第三方皮肤 maidAtelierSessionJewelChase51.8%1493
+ dsh-tool-row-sweep 与 dsh-turn-status-shimmer2.0%10
恢复全部61.6%1497

读法:这组核心动画占了约 50 个百分点、以及那约 145 次/秒布局的几乎全部;第三方皮肤自带的 8 个动画只值约 8.6 个百分点,且不产生布局。

will-change: opacity 加在动画的 rect / 其 svg 上什么都没改变(65.0% / 64.8% / 64.6%,都在噪声内)——这与「单纯每帧重绘、而非图层提升」一致。问题不是缺合成层,是所动画的属性本身就在昂贵路径上。

源码级定位:7 处,两个成因

逐文件核对后,这 7 处就是全部(行号基于 master 0d1f50007f):

布局路径——5 个「运行行扫光」(绝对定位的 300px 光带动画 left,每帧触发布局):

文件animation / @keyframes 0% 行
packages/client/ui-chat/src/client/chat/ReasoningRow.module.css44 / 49
packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css23 / 28
packages/client/ui-skill/src/client/SkillRow.module.css32 / 37
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css36 / 41
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css110 / 115

绘制路径——2 个「状态微光」(background-position 叠在 background-clip: text 上,每帧重绘字形):

  • ChatView.module.css 的 .turnStatus(animation 行 111、关键帧 125)
  • MessageItem.module.css 的 retry-shimmer(animation 行 256、关键帧 317)

并且把 @keyframes / infinite 全量清点了一遍:client 包共 38 个关键帧、22 个 infinite,除了这 7 处,其余全是 transform 或 opacity,都不触发布局(只有终端视图的 terminal-cursor-blink 每秒改一次 background-color)。也就是说,把扫光 / 微光这类开销清完之后,没有同类残留。

复现脚本(空会话也能确定性触发那 5 处扫光)

不用等 release,也不用造一条有工具行的会话——从页面已加载的编译产物里取出选择器、拼出运行行即可:

js
const css = [...document.querySelectorAll('style')].map(s => s.textContent).join('\n')
const rules = [...css.matchAll(/([^{}]+)\{[^{}]*animation:2\.6s ease-out infinite [A-Za-z0-9_-]*row-sweep[^{}]*\}/g)]
  .map(m => m[1].trim())
const mk = s => {
  const el = document.createElement('div')
  for (const c of s.match(/\.[A-Za-z0-9_-]+/g) ?? []) el.classList.add(c.slice(1))
  for (const a of s.match(/\[[^\]]+\]/g) ?? []) {
    const [k, v] = a.slice(1, -1).split('='); el.setAttribute(k, v ?? '')
  }
  return el
}
const host = document.createElement('div'); host.id = 'sweep-probe'
host.style.cssText = 'position:fixed;left:0;top:0;width:720px;z-index:99999'
for (let i = 0; i < 60; i++) for (const sel of rules) {
  const parts = sel.replace(/:after$/, '').trim().split(/\s+/).map(mk)
  if (parts[1]) parts[0].appendChild(parts[1])
  host.appendChild(parts[0])
}
document.body.appendChild(host)
// 之后照常用 CDP 的 Performance.getMetrics 取 LayoutCount / TaskDuration;收尾 host.remove()

这段会建 300 个运行行(5 个选择器 × 60)。在 0.1.5-rc.1 上实测 107.6 layouts/s、5 秒 1607ms 主线程任务;与「移除探针」的基线各测一次,就能把那 5 处扫光单独摘出来。

一个细节:lightningcss 会给动画名加 CSS 模块哈希,真机上看到的是 lcKema_dsh-reasoning-row-sweep、o3BgMG_dsh-tool-row-sweep 这种名字。document.getAnimations() 里拿到的是哈希后的名字,二分时按后缀匹配(/sweep|shimmer/)仍然有效。

修法一:扫光改成 transform,微光改成 steps()

思路:保持视觉完全不变,只把动画属性从主线程路径上挪走。 扫光用「不重复背景图块 + transform」替代「动画 left」;微光只把时间函数换成 steps() 来把每周期重绘降一个数量级。

社区分支 fix/client-composited-row-animations(commit b09ba216e,基于 0d1f50007f)做了两件事:

  1. 扫光(5 处):伪元素改成 width: 100%,300px 光带作为不重复的背景图块(background-size: 300px 100%; background-repeat: no-repeat),关键帧改成 transform: translateX(-300px) → translateX(100%)。包含块未变,一个光带宽度的行程仍等于一个行宽——视觉语义不变。
  2. 微光(2 处):只把时间函数改成 steps(10, end),background-position 与 background-clip: text 都不动。

刻意保留的设计元素:不加 will-change;渐变、2.6s ease-out、关键帧百分比、prefers-reduced-motion 全部不改。

实测 A/B(把补丁直接打在本机 client bundle 上)

用页面里真实注入的 CSS 选择器拼出 300 个运行行,探针挂在真实页面上,每档取 5 秒窗口:

状态Layouts/sRecalcStyle/sLayoutDurationTaskDuration
原样107.6120327ms1607ms
打补丁后07.2–8.20146–163ms(两次)
空闲基线0004–5ms

等价性验证(视觉不变是硬要求)

把动画暂停在固定进度、读真机 CSS 上 ::after 的计算值:tx = -300 / -34.45 / 198.12 / 457.06 / 642.99 / 720 px(对应周期的 0 / 0.15 / 0.3 / 0.5 / 0.7 / 0.9),与原 left 轨迹差 ≤0.015px;5 个 @keyframes …row-sweep 全部是 transform,left 版本 0 条。

仓库自己的关卡也过了:pnpm run test:gui 全绿(391 文件 / 5579 用例 / 0 失败);DSH_SNAPSHOT=replay pnpm run test:web 与「把这 7 个文件回退到 0d1f50007f 重新 build」的对照组跑出逐字相同的 15 个失败文件(macOS 沙箱拒 PTY、dev:web HMR、前置超时导致的 llm-replay fixture 未消费完、Linux 录制的 golden)——补丁零新增失败、零 golden 位移。

第三方插件侧的同型问题:thinkShimmer

一位报告者对本机第三方插件做 A/B 时发现,better-display 的两处 background-position 微光(thinkShimmer)是同一个模式:把它静音后,主线程 JS 从 723ms/10s 降到 530ms(−27%)。同一条最省的修法可以直接搬——只把时间函数换成 steps(10, end),一行改动,background-clip 的渲染方式不动。

另一个旁证来自第三方皮肤 maid-atelier:它早期也踩过同类坑(每帧写内联样式 + 强制布局,空闲主线程约 51%),作者「削减每帧写入」修完后,实测降到 3.3%(1453 layouts/10s → 2)。「动画只碰合成层属性、不写样式」这条思路,对宿主和第三方插件同样成立。

机制二:resize 卡顿是 ResizeObserver 写内联样式 × 昂贵 restyle

一句话:resize 时每个 ResizeObserver 回调写内联尺寸样式,每次写入都让子树样式失效;而这个 profile 有 178 个样式表 / 约 8.7k 条规则,Blink 重算的范围远超被改动的节点,于是「N 次写入 × 昂贵重算」把主线程打满,窗口边界追不上鼠标。

用真实窗口 resize(Win32 SetWindowPos,20 个宽度步进 @60ms,1920 → 1760px)测出的数字:

空闲resize 期间
帧间隔中位数6.1 ms (164 fps)12.1 ms (83 fps)
帧间隔 p956.2 ms90.8 ms
> 33 ms 的帧316(23%)
主线程—~100%(1630 ms / 1600 ms)

每次 sweep 的开销拆解(20 步):

  • RecalcStyle:130 次 × 10.3 ms——空闲时是 0.55 ms/次,即 resize 期间贵约 19 倍;
  • 布局很便宜(合计约 40 ms):贵的是样式重算,不是布局。

是谁在 resize 时写样式

用一个 setProperty hook 抓写入者,结果全是 ResizeObserver 回调在写内联尺寸样式,每个 resize step 约 5 次写入:

  • div.wSkVaW_root → --dsh-conversation-column-width、--dsh-chat-user-width(核心会话列)
  • div.P3OORG_panel → width: 864px(核心右侧面板,data-sidebar-right-panel)
  • div.dshwv-root → inset + --dshw-scale(一个浮动组件)
  • syncHeight(skin-center / web-all)→ 元素高度

单因子隔离:没有一个因子能单独解决

同一次 sweep 下逐个排除:

变体主线程耗时
原样2014 ms
backdrop-filter: none !important 作用于 *1654 ms(−18%)
暂停全部动画1567 ms(−22%)
禁用皮肤样式表1504 ms(−25%)

没有单一因子能修好它:禁用皮肤后单次 restyle 掉到 2.4 ms,但调用次数翻了约 2.4 倍(130 → 311),主线程仍在约 90%。修法方向是「减少写入次数、缩窄失效范围」而不是「关掉某个特性」:

  1. 批量化写入:每帧只写一次(把多个 ResizeObserver 的写入合并到一个 rAF / 一次任务里),而不是每个回调各写一次;
  2. 把 CSS 变量设在公共祖先上:让变量变更的失效范围收敛到一个共同的包含块,而不是各自触发独立子树重算;
  3. 改用容器查询(container queries):让布局由容器尺寸驱动,避免「测量 → 写内联样式 → 再测量」的回路;
  4. 缩小样式表规模 / 降低选择器复杂度:178 个样式表、约 8.7k 条规则是「单次重算贵」的放大器。

注意:这条机制与机制一互不相干,动画补丁不涉及它——报告者自己也在结论里明确标注了这一点。

排查注意事项

第一原则:先把「动画导致的高占用」和「resize 导致的卡顿」分开,再用 CDP 量化——不要凭体感去猜是代码循环还是渲染管线。这份报告给出的数字比结论更有价值。

  1. 先看布局频率与 rAF 频率的比值:布局 ≈145 次/秒、rAF 只有 24 次/秒,布局远高于 rAF 就说明不是 rAF 驱动,而应去查 CSS 动画 / 样式失效(#6427)。
  2. 用 document.getAnimations() 做二分,而不是逐个改代码:
    js
    document.getAnimations()
      .filter(a => /sweep|shimmer/.test(a.animationName || ''))
      .forEach(a => a.pause());   // 主线程 60% → 2%,布局 1443 → 10
    
    (#6427)
  3. 别把「加 will-change」当通用解药:它救不了「动画属性本身在布局/绘制路径上」这类问题(#6427)。
  4. 注意 infinite 动画的「空转成本」:整包 22 个 infinite 里,其余都是 transform/opacity,只有这 7 处落在昂贵路径上。清完就干净了,这也是为什么值得一次性核对(#6427)。
  5. resize 要看 RecalcStyleCount 而不是 LayoutCount:这条机制里布局很便宜、样式重算才是瓶颈(10.3ms vs 0.55ms)(#6427)。
  6. 第三方插件是常见放大器,但别默认它是唯一原因:本例禁用皮肤只回收约 25%,仍有 29% 的持续占用;A/B 建议在干净 profile 与含插件 profile 上各做一次(#6427)。
  7. 复核探针的适用边界:那 107.6 layouts/s 是「探针行」负载(CSS 与选择器是真实编译产物,负载是构造的),不等于「自然跑一轮会话」的采样;要后者需在真机上按 pause 前后对比复测(#6427)。
  8. 对修复的预期要合理:微光只降一个数量级、style recalc 仍是 60 次/秒,修完应落在 2.0% 与 60.4% 之间,而不是 2.0%(#6427)。

排查这类问题时,用 DSH Plugin Hub 的已安装列表逐个停用 / 卸载前端插件做 A/B——本例里第三方皮肤约占一半空闲开销,是很好的第一步对照;确认哪些插件在写样式、哪些只是装饰,再去动核心动画。

DSH Plugin Hub · 确认卸载

来源:Discussion #6427。

常见问题

DSH Web UI 空着不动,主线程为什么还占 50%?

不是 rAF 驱动的,是 **CSS 动画在每帧走布局路径**。二分测量显示:把 dsh-tool-row-sweep(2600ms,infinite)和 dsh-turn-status-shimmer(1800ms,infinite)这两组动画暂停后,主线程从 60.4% 降到 **2.0%**,LayoutCount 从 1443/10s 降到 **10/10s**。它们占掉了那约 145 次/秒布局的几乎全部(Discussion #6427)。

给动画加 `will-change: opacity` 为什么没用?

因为问题不在「缺合成层」,而在**所动画的属性本身就在布局/绘制路径上**。扫光动画的是绝对定位元素的 left(每帧触发布局),微光动画的是叠在 background-clip: text 上的 background-position(每帧重绘字形)。will-change 只能促进图层提升,救不了「动画属性本身昂贵」这件事——实测 65.0% / 64.8% / 64.6% 在噪声内(Discussion #6427)。

拖拽窗口卡顿和空闲占用是同一个问题吗?

不是,是**两条独立机制**。空闲占用来自上面那两组动画;resize 卡顿在**动画开不开都卡**(与用户报告一致),根因是 ResizeObserver 回调在每次 resize step 里写约 5 次内联尺寸样式,而这次 restyle 贵得多(单次 10.3ms vs 空闲 0.55ms,约 **19×**)。用动画补丁解决不了 resize(Discussion #6427)。

没有工具行的空会话也能复现那 5 处扫光吗?

能,而且是确定性的。页面里的样式表已是真实编译产物,从中取出扫光选择器、拼出运行行即可(本文「复现脚本」一节给了完整代码,5 个选择器 × 60 = 300 个运行行)。注意 lightningcss 会给动画名加 CSS 模块哈希,真机上看到的是 lcKema_dsh-reasoning-row-sweep 这类名字,二分时按后缀 /sweep|shimmer/ 匹配仍然有效(Discussion #6427)。

修完之后空闲占用会回到 2% 吗?

不会。2.0% 是「把 sweep 与 shimmer **全部暂停**」的结果,而微光的修复只是把每周期重绘降一个数量级、background-position 的 style recalc 仍是 60 次/秒。修完的期望值应落在 **2.0% 与 60.4% 之间**。要让微光彻底离开主线程,得改成合成叠加层——那会改变状态文字的渲染机制,尚未做(Discussion #6427)。

相关术语

任务时长占比(TaskDuration ratio)
CDP `Performance.getMetrics` 里的 `TaskDuration` 是主线程忙碌总时长。在固定窗口(如 10 秒)里取差分再除以窗口长度,就得到「主线程占用率」。它比看任务管理器更准,因为可以在同一台机器上做「注入前 / 注入后」的干净 A/B,量化某个动画或某个插件的代价。— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
走布局路径的动画 vs 合成层动画
动画属性决定代价:`transform` 与 `opacity` 可交给合成器(compositor)在独立图层上跑,不动主线程;而 `left` / `width` / `height` / `top` 触发 layout,`background-position` / `background-color` 触发 paint,都在主线程上逐帧执行。把扫光的 `left` 换成 `transform`、把微光的时间函数换成 `steps()`,本质就是「把动画从主线程路径挪走」。— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
ResizeObserver 写样式 × 昂贵 restyle
resize 时每个 `ResizeObserver` 回调都会写内联尺寸样式,每次写入让子树样式失效、触发样式重算。当页面有 178 个样式表 / 约 8.7k 条规则时,Blink 重算的范围远超被改动的那个节点,于是「N 次写入 × 昂贵的单次重算」把主线程打满,窗口边界追不上鼠标。修法是批量化(每帧一次写入)、把 CSS 变量设在公共祖先上、或用容器查询缩小失效范围。— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
layout thrashing(强制同步布局)
在同一个任务里交替「写 DOM/样式」与「读几何信息(`getBoundingClientRect`、`offsetHeight` 等)」时,读操作会强制浏览器立刻把待处理的样式与布局变更全部刷新,否则拿不到正确值。高频交替就会反复强制布局,主线程被拖垮。本报告里 JS 侧热点正是 `getBoundingClientRect`(58.6ms / 8s)与 `toBottom`(79.8ms / 8s)。— https://github.com/deepseek-ai/deepseek-harness/discussions/6427

来源