DeepSeek Harness Web UI 空闲占用 50%:每帧 145 次布局的定位与修复
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 采,避免「任务管理器看个大概」。固定窗口取差分:
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× 第三方皮肤 maidAtelierSessionJewelChase | 51.8% | 1493 |
+ dsh-tool-row-sweep 与 dsh-turn-status-shimmer | 2.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.css | 44 / 49 |
packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css | 23 / 28 |
packages/client/ui-skill/src/client/SkillRow.module.css | 32 / 37 |
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css | 36 / 41 |
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css | 110 / 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,也不用造一条有工具行的会话——从页面已加载的编译产物里取出选择器、拼出运行行即可:
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)做了两件事:
- 扫光(5 处):伪元素改成
width: 100%,300px 光带作为不重复的背景图块(background-size: 300px 100%; background-repeat: no-repeat),关键帧改成transform: translateX(-300px)→translateX(100%)。包含块未变,一个光带宽度的行程仍等于一个行宽——视觉语义不变。 - 微光(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/s | RecalcStyle/s | LayoutDuration | TaskDuration |
|---|---|---|---|---|
| 原样 | 107.6 | 120 | 327ms | 1607ms |
| 打补丁后 | 0 | 7.2–8.2 | 0 | 146–163ms(两次) |
| 空闲基线 | 0 | 0 | 0 | 4–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) |
| 帧间隔 p95 | 6.2 ms | 90.8 ms |
| > 33 ms 的帧 | 3 | 16(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%。修法方向是「减少写入次数、缩窄失效范围」而不是「关掉某个特性」:
- 批量化写入:每帧只写一次(把多个 ResizeObserver 的写入合并到一个 rAF / 一次任务里),而不是每个回调各写一次;
- 把 CSS 变量设在公共祖先上:让变量变更的失效范围收敛到一个共同的包含块,而不是各自触发独立子树重算;
- 改用容器查询(container queries):让布局由容器尺寸驱动,避免「测量 → 写内联样式 → 再测量」的回路;
- 缩小样式表规模 / 降低选择器复杂度:178 个样式表、约 8.7k 条规则是「单次重算贵」的放大器。
注意:这条机制与机制一互不相干,动画补丁不涉及它——报告者自己也在结论里明确标注了这一点。
排查注意事项
第一原则:先把「动画导致的高占用」和「resize 导致的卡顿」分开,再用 CDP 量化——不要凭体感去猜是代码循环还是渲染管线。这份报告给出的数字比结论更有价值。
- 先看布局频率与 rAF 频率的比值:布局 ≈145 次/秒、rAF 只有 24 次/秒,布局远高于 rAF 就说明不是 rAF 驱动,而应去查 CSS 动画 / 样式失效(#6427)。
- 用
document.getAnimations()做二分,而不是逐个改代码:(#6427)jsdocument.getAnimations() .filter(a => /sweep|shimmer/.test(a.animationName || '')) .forEach(a => a.pause()); // 主线程 60% → 2%,布局 1443 → 10 - 别把「加
will-change」当通用解药:它救不了「动画属性本身在布局/绘制路径上」这类问题(#6427)。 - 注意
infinite动画的「空转成本」:整包 22 个infinite里,其余都是transform/opacity,只有这 7 处落在昂贵路径上。清完就干净了,这也是为什么值得一次性核对(#6427)。 - resize 要看
RecalcStyleCount而不是LayoutCount:这条机制里布局很便宜、样式重算才是瓶颈(10.3ms vs 0.55ms)(#6427)。 - 第三方插件是常见放大器,但别默认它是唯一原因:本例禁用皮肤只回收约 25%,仍有 29% 的持续占用;A/B 建议在干净 profile 与含插件 profile 上各做一次(#6427)。
- 复核探针的适用边界:那 107.6 layouts/s 是「探针行」负载(CSS 与选择器是真实编译产物,负载是构造的),不等于「自然跑一轮会话」的采样;要后者需在真机上按 pause 前后对比复测(#6427)。
- 对修复的预期要合理:微光只降一个数量级、
style recalc仍是 60 次/秒,修完应落在 2.0% 与 60.4% 之间,而不是 2.0%(#6427)。
排查这类问题时,用 DSH Plugin Hub 的已安装列表逐个停用 / 卸载前端插件做 A/B——本例里第三方皮肤约占一半空闲开销,是很好的第一步对照;确认哪些插件在写样式、哪些只是装饰,再去动核心动画。

来源:Discussion #6427。
常见问题
不是 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)。
因为问题不在「缺合成层」,而在**所动画的属性本身就在布局/绘制路径上**。扫光动画的是绝对定位元素的 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 个选择器 × 60 = 300 个运行行)。注意 lightningcss 会给动画名加 CSS 模块哈希,真机上看到的是 lcKema_dsh-reasoning-row-sweep 这类名字,二分时按后缀 /sweep|shimmer/ 匹配仍然有效(Discussion #6427)。
不会。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
来源
- #6427 — [Bug][性能] 0.1.5-rc.2 Web UI 空闲态主线程占用约 50%、布局约 144 次/秒(≈每帧一次),拖拽窗口 resize 明显卡顿· deepseek-ai(GitHub Discussions)