[bug][ai-chat][performance] Чат виснет во время «размышления» после Read page: свёрнутый ReasoningBlock ре-парсит весь растущий markdown на каждый дельта-апдейт (O(n²)) #302

Open
opened 2026-07-03 04:53:31 +03:00 by agent_vscode · 0 comments
Collaborator

Симптом

После того как агент читает страницу (Read page) и переходит к «размышлению» (блок «Thinking · N tokens»), интерфейс AI-чата начинает тормозить и полностью зависает. Наблюдается на длинных reasoning-стримах (в репро — блок на ~8500 токенов). Main-thread насыщается, окно чата перестаёт откликаться, дельты перестают отрисовываться.

Воспроизведение

  1. Открыть AI-чат, выбрать роль с reasoning-моделью (в репро — «Корректор»).
  2. Дать задачу, требующую чтения крупной страницы (инструмент Read page).
  3. После завершения tool-call агент входит в длинную фазу reasoning — появляется свёрнутый блок «Thinking · N tokens», счётчик растёт.
  4. По мере роста reasoning UI начинает лагать и в итоге виснет ещё до появления ответа.

Корневая причина

Файл: apps/client/src/features/ai-chat/components/reasoning-block.tsx

const html = useMemo(
  () => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
  [trimmed],   // зависит ТОЛЬКО от текста, не от open
);

Механизм:

  • Reasoning-текст стримится и растёт с каждым дельта-апдейтом. useChat троттлит ре-рендеры до ~20 Гц (STREAM_THROTTLE_MS = 50, см. chat-thread.tsx:42), но на каждом таком ре-рендере trimmed — новая (более длинная) строка, поэтому useMemo пересчитывается.
  • Каждый пересчёт прогоняет collapseBlankLines + renderChatMarkdown (marked + DOMPurify) по всему растущему reasoning-тексту. Для ~8500 токенов это ~34 КБ, парсимых ~20 раз/сек по мере роста → классический O(n²)-шторм, который забивает main-thread и морозит UI.
  • Ключевой момент: блок свёрнут по умолчанию (const [open, setOpen] = useState(false)), то есть весь этот тяжёлый парсинг идёт для невидимого контента. Пользователь его даже не видит.

Что здесь не виновато (проверено):

  • ToolCallCard не рендерит большой вывод страницы — только лейбл и цитаты, поэтому объём прочитанной страницы в рендер не попадает.
  • Ответ (MarkdownPart в message-item.tsx) во время фазы «размышления» ещё пуст — парсить нечего.
  • messageSignature дешёвая (.length вместо сериализации, output проверяется через !== undefined).
  • estimateTokens(text) — O(1) (text.length / 4).

То есть на фазе thinking единственный доминирующий расход — полный ре-парсинг markdown растущего reasoning в свёрнутом (невидимом) блоке.

Исторический контекст: троттлинг и мемоизация уже добавлялись ровно против «quadratic CPU storm that pins the main thread and freezes the UI» (см. комментарий у STREAM_THROTTLE_MS), но для ответа. Reasoning-блок остался с полным ре-парсингом на каждый апдейт — да ещё и в свёрнутом состоянии.

Предлагаемый фикс

Парсить markdown reasoning только когда блок реально раскрыт (open). Это убирает весь скрытый O(n²)-парсинг во время «размышления» — то есть ровно тот сценарий, что вызывает зависание. При раскрытии блок один раз распарсит текущий текст (мгновенная операция по клику пользователя), а при дальнейшем стриминге в открытом виде — обычный append-рендер, как у ответа.

В reasoning-block.tsx:

// Parse the reasoning markdown only while the block is EXPANDED. Collapsed is the
// default and the common case during a long "thinking" stream: parsing the full,
// growing reasoning text (marked + DOMPurify) on every throttled delta while it is
// invisible is an O(n^2) main-thread storm that freezes the UI (an ~8k-token thought
// re-parsed ~20x/s). Gating on `open` defers the cost to when the user actually
// expands it; a mid-stream expand then parses the current text and keeps updating.
const html = useMemo(
  () => (open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
  [open, trimmed],
);

Когда блок свёрнут, html === "", и внутри закрытого <Collapse in={open}> рендерится дешёвый raw-text fallback (<Text style={{ whiteSpace: "pre-wrap" }}>{trimmed}</Text>) — он скрыт (высота 0) и не требует парсинга markdown. Видимого регресса нет: пользователь всегда видит уже распарсенный markdown только после раскрытия. Заголовок «Thinking · N tokens» и его live-счётчик (estimateTokens) не затрагиваются.

Второстепенное (по желанию)

Если блок раскрыт во время стриминга очень длинного reasoning, ре-парсинг снова станет O(n²) (как у стримящегося ответа) — но это осознанное действие пользователя над видимым контентом и случай редкий. Основной фикс закрывает поведение по умолчанию (свёрнуто), которое и вызывает зависание. При желании позже можно добавить дебаунс парсинга для открытого стримящегося блока.

Затронутые файлы

  • apps/client/src/features/ai-chat/components/reasoning-block.tsx — гейтинг парсинга по open (правка useMemo).
  • apps/client/src/features/ai-chat/components/reasoning-block.test.tsx — существующий тест «renders the reasoning body (markdown or raw-text fallback)» остаётся зелёным (он уже допускает raw-text fallback). Желательно добавить тест: свёрнутый блок не вызывает renderChatMarkdown (мок), а после раскрытия — вызывает.

Критерии готовности (DoD)

  • Во время длинного reasoning-стрима (свёрнутый блок) markdown reasoning не парсится; main-thread не насыщается, UI не виснет.
  • Раскрытие блока (в т.ч. по ходу стрима) корректно показывает распарсенный markdown.
  • Заголовок «Thinking · N tokens» и live-оценка токенов работают как раньше.
  • Юнит-тест на ленивый парсинг (свёрнуто → нет парса, раскрыто → есть).
  • nx run client:test (или соответствующий таск) зелёный.

Диагностика проведена по коду ветки develop. Комментарии в коде — на английском по конвенции репозитория.

## Симптом После того как агент читает страницу (`Read page`) и переходит к «размышлению» (блок **«Thinking · N tokens»**), интерфейс AI-чата начинает тормозить и полностью зависает. Наблюдается на длинных reasoning-стримах (в репро — блок на ~8500 токенов). Main-thread насыщается, окно чата перестаёт откликаться, дельты перестают отрисовываться. ## Воспроизведение 1. Открыть AI-чат, выбрать роль с reasoning-моделью (в репро — «Корректор»). 2. Дать задачу, требующую чтения крупной страницы (инструмент `Read page`). 3. После завершения tool-call агент входит в длинную фазу reasoning — появляется **свёрнутый** блок «Thinking · N tokens», счётчик растёт. 4. По мере роста reasoning UI начинает лагать и в итоге виснет ещё до появления ответа. ## Корневая причина Файл: [`apps/client/src/features/ai-chat/components/reasoning-block.tsx`](apps/client/src/features/ai-chat/components/reasoning-block.tsx#L43-L46) ```tsx const html = useMemo( () => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""), [trimmed], // зависит ТОЛЬКО от текста, не от open ); ``` Механизм: - Reasoning-текст стримится и растёт с каждым дельта-апдейтом. `useChat` троттлит ре-рендеры до ~20 Гц (`STREAM_THROTTLE_MS = 50`, см. [`chat-thread.tsx:42`](apps/client/src/features/ai-chat/components/chat-thread.tsx#L42)), но на **каждом** таком ре-рендере `trimmed` — новая (более длинная) строка, поэтому `useMemo` пересчитывается. - Каждый пересчёт прогоняет `collapseBlankLines` + `renderChatMarkdown` (`marked` + `DOMPurify`) по **всему растущему** reasoning-тексту. Для ~8500 токенов это ~34 КБ, парсимых ~20 раз/сек по мере роста → классический **O(n²)**-шторм, который забивает main-thread и морозит UI. - Ключевой момент: блок **свёрнут по умолчанию** (`const [open, setOpen] = useState(false)`), то есть весь этот тяжёлый парсинг идёт для **невидимого** контента. Пользователь его даже не видит. Что здесь **не** виновато (проверено): - `ToolCallCard` не рендерит большой вывод страницы — только лейбл и цитаты, поэтому объём прочитанной страницы в рендер не попадает. - Ответ (`MarkdownPart` в [`message-item.tsx`](apps/client/src/features/ai-chat/components/message-item.tsx#L65-L88)) во время фазы «размышления» ещё пуст — парсить нечего. - `messageSignature` дешёвая (`.length` вместо сериализации, `output` проверяется через `!== undefined`). - `estimateTokens(text)` — O(1) (`text.length / 4`). То есть на фазе thinking единственный доминирующий расход — полный ре-парсинг markdown растущего reasoning в свёрнутом (невидимом) блоке. Исторический контекст: троттлинг и мемоизация уже добавлялись ровно против «quadratic CPU storm that pins the main thread and freezes the UI» (см. комментарий у `STREAM_THROTTLE_MS`), но для **ответа**. Reasoning-блок остался с полным ре-парсингом на каждый апдейт — да ещё и в свёрнутом состоянии. ## Предлагаемый фикс Парсить markdown reasoning только когда блок реально раскрыт (`open`). Это убирает весь скрытый O(n²)-парсинг во время «размышления» — то есть ровно тот сценарий, что вызывает зависание. При раскрытии блок один раз распарсит текущий текст (мгновенная операция по клику пользователя), а при дальнейшем стриминге в открытом виде — обычный append-рендер, как у ответа. В [`reasoning-block.tsx`](apps/client/src/features/ai-chat/components/reasoning-block.tsx#L43-L46): ```tsx // Parse the reasoning markdown only while the block is EXPANDED. Collapsed is the // default and the common case during a long "thinking" stream: parsing the full, // growing reasoning text (marked + DOMPurify) on every throttled delta while it is // invisible is an O(n^2) main-thread storm that freezes the UI (an ~8k-token thought // re-parsed ~20x/s). Gating on `open` defers the cost to when the user actually // expands it; a mid-stream expand then parses the current text and keeps updating. const html = useMemo( () => (open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""), [open, trimmed], ); ``` Когда блок свёрнут, `html === ""`, и внутри закрытого `<Collapse in={open}>` рендерится дешёвый raw-text fallback (`<Text style={{ whiteSpace: "pre-wrap" }}>{trimmed}</Text>`) — он скрыт (высота 0) и не требует парсинга markdown. Видимого регресса нет: пользователь всегда видит уже распарсенный markdown только после раскрытия. Заголовок «Thinking · N tokens» и его live-счётчик (`estimateTokens`) не затрагиваются. ### Второстепенное (по желанию) Если блок раскрыт **во время** стриминга очень длинного reasoning, ре-парсинг снова станет O(n²) (как у стримящегося ответа) — но это осознанное действие пользователя над видимым контентом и случай редкий. Основной фикс закрывает поведение по умолчанию (свёрнуто), которое и вызывает зависание. При желании позже можно добавить дебаунс парсинга для открытого стримящегося блока. ## Затронутые файлы - `apps/client/src/features/ai-chat/components/reasoning-block.tsx` — гейтинг парсинга по `open` (правка `useMemo`). - `apps/client/src/features/ai-chat/components/reasoning-block.test.tsx` — существующий тест «renders the reasoning body (markdown or raw-text fallback)» остаётся зелёным (он уже допускает raw-text fallback). Желательно добавить тест: **свёрнутый** блок не вызывает `renderChatMarkdown` (мок), а после раскрытия — вызывает. ## Критерии готовности (DoD) - [ ] Во время длинного reasoning-стрима (свёрнутый блок) markdown reasoning не парсится; main-thread не насыщается, UI не виснет. - [ ] Раскрытие блока (в т.ч. по ходу стрима) корректно показывает распарсенный markdown. - [ ] Заголовок «Thinking · N tokens» и live-оценка токенов работают как раньше. - [ ] Юнит-тест на ленивый парсинг (свёрнуто → нет парса, раскрыто → есть). - [ ] `nx run client:test` (или соответствующий таск) зелёный. --- _Диагностика проведена по коду ветки `develop`. Комментарии в коде — на английском по конвенции репозитория._
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#302