[perf][mcp] getPage — доминирующая нагрузка агентов (812 вызовов/2ч, p95 840 мс): конверсия PM→Markdown без кэша + последовательный fetch subpages #479

Closed
opened 2026-07-11 01:07:56 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Первые же сутки per-tool метрик (#402) назвали остаток «всё равно тормозит» после мержа перф-пакета. Что стало быстрым: updatePageMarkdown p95 182 мс, deletePage 96 мс (кэш сессий #400/#435 работает), трэшинга нет (33 doc-load за 2 ч), event loop чистый (max 2.97 с против 30 до пакета). Что осталось:

Инструмент Вызовов за 2 ч p95
getPage 812 840 мс
createPage 9 2350 мс
getTree / search 9 / 6 ~230 мс

getPageдоминирующая операция агентского цикла (агент перечитывает страницы постоянно: контекст, верификация после правок, сканирование). 812 × ~0.5–0.8 с — это и есть субъективное «агенты медленные», и заодно фоновая CPU-нагрузка на общий event loop.

Диагноз (по коду packages/mcp/src/client.ts getPage)

  1. Конверсия PM→Markdown на каждый вызов: convertProseMirrorToMarkdown(resultData.content, …) — полный обход ProseMirror-дерева (на больших страницах ~84 КБ+ JSON) при каждом чтении, кэша нет (единственный кэш в клиенте — collab-токен из #435). CPU тратится на том же event loop, что HTTP/collab.
  2. Последовательный второй fetch: «Always fetch subpages to provide context» — после получения страницы отдельный await за сабпейджами → +1 round-trip к каждому вызову, последовательно.
  3. При 812 read против 28 update — подавляющее большинство чтений приходится на неизменившиеся страницы: повторная конверсия того же контента в тот же Markdown.

Решение

1. Content-addressed кэш конверсии

LRU-кэш в MCP-хосте: ключ (pageId, page.updatedAt, optionsHash) → готовый Markdown. updatedAt уже приходит с контентом — инвалидация бесплатная и точная (страница изменилась → новый ключ). optionsHash обязателен: конверсия параметризована (dropResolvedCommentAnchors из #328 — getPage и export зовут с разными опциями). Размер LRU скромный (~50 записей / ограничение по суммарным байтам) — агенты работают с малым рабочим набором страниц.

Ожидание: p95 повторных чтений — до стоимости HTTP-fetch (~50–100 мс), CPU конверсии уходит из горячего цикла.

2. Параллелить subpages

Promise.all([pageFetch, subpagesFetch]) вместо последовательных await — минус целый round-trip с каждого вызова. (Вариант глубже — сделать subpages опциональными параметром инструмента — менять контракт инструмента не предлагаю, паралеллизации достаточно.)

3. createPage 2.35 с — только понаблюдать

9 вызовов, включает инициализацию collab-сессии нового дока — вероятно легитимно; после пп. 1–2 посмотреть на чистом профиле, отдельно не чинить.

Крайние случаи

  • Ключ кэша: updatedAt обновляется дебаунс-стором — окно, когда контент уже новый, а updatedAt старый? Нет: getPage читает контент и updatedAt из ОДНОГО ответа /pages/info — они консистентны между собой; кэш максимум отдаст тот же снапшот, что вернул бы сервер. Рассинхрон невозможен по построению.
  • Память: markdown больших страниц — сотни КБ; лимит LRU по байтам (например, 10 МБ суммарно), не только по числу записей.
  • Standalone-режим MCP — кэш работает одинаково (он в клиенте, не в хосте), ничего не гейтить.
  • Метрика попадания: добавить лейбл или счётчик mcp_getpage_cache_hits_total/misses_total (2 counter'а, bounded) — иначе эффект кэша будет невидим и хит-рейт не проверить.

Тесты / проверка

  • Юнит: hit по одинаковому (pageId, updatedAt, options), miss после смены updatedAt и смены опций; вытеснение LRU по байтам.
  • Существующие тесты getPage/конвертера — без изменений поведения (результат байт-в-байт тот же).
  • Прод: mcp_tool_duration_seconds{tool="getPage"} p95 < 200 мс при живом агентском цикле; hit-rate > 50% по новым счётчикам; алерт /mcp молчит на read-нагрузке.

Вне скоупа

  • createPage (наблюдение после пп. 1–2).
  • Кэш на серверной стороне /pages/info (ETag/304 из роадмапа) — отдельная тема, этот кэш живёт в MCP-клиенте и не зависит от неё.

План работ

  1. LRU-кэш конверсии + счётчики hit/miss.
  2. Promise.all для subpages.
  3. Тесты, пересборка packages/mcp/build/*.
  4. Прод-замер по mcp_tool_duration_seconds{tool="getPage"} до/после.
# Суть Первые же сутки per-tool метрик (#402) назвали остаток «всё равно тормозит» после мержа перф-пакета. Что стало быстрым: `updatePageMarkdown` p95 **182 мс**, `deletePage` 96 мс (кэш сессий #400/#435 работает), трэшинга нет (33 doc-load за 2 ч), event loop чистый (max 2.97 с против 30 до пакета). Что осталось: | Инструмент | Вызовов за 2 ч | p95 | |---|---|---| | **getPage** | **812** | **840 мс** | | createPage | 9 | 2350 мс | | getTree / search | 9 / 6 | ~230 мс | `getPage` — **доминирующая операция агентского цикла** (агент перечитывает страницы постоянно: контекст, верификация после правок, сканирование). 812 × ~0.5–0.8 с — это и есть субъективное «агенты медленные», и заодно фоновая CPU-нагрузка на общий event loop. # Диагноз (по коду `packages/mcp/src/client.ts` getPage) 1. **Конверсия PM→Markdown на каждый вызов**: `convertProseMirrorToMarkdown(resultData.content, …)` — полный обход ProseMirror-дерева (на больших страницах ~84 КБ+ JSON) при каждом чтении, кэша нет (единственный кэш в клиенте — collab-токен из #435). CPU тратится на том же event loop, что HTTP/collab. 2. **Последовательный второй fetch**: «Always fetch subpages to provide context» — после получения страницы отдельный await за сабпейджами → +1 round-trip к каждому вызову, последовательно. 3. При 812 read против 28 update — подавляющее большинство чтений приходится на **неизменившиеся** страницы: повторная конверсия того же контента в тот же Markdown. # Решение ## 1. Content-addressed кэш конверсии LRU-кэш в MCP-хосте: ключ `(pageId, page.updatedAt, optionsHash)` → готовый Markdown. `updatedAt` уже приходит с контентом — инвалидация бесплатная и точная (страница изменилась → новый ключ). `optionsHash` обязателен: конверсия параметризована (`dropResolvedCommentAnchors` из #328 — getPage и export зовут с разными опциями). Размер LRU скромный (~50 записей / ограничение по суммарным байтам) — агенты работают с малым рабочим набором страниц. Ожидание: p95 повторных чтений — до стоимости HTTP-fetch (~50–100 мс), CPU конверсии уходит из горячего цикла. ## 2. Параллелить subpages `Promise.all([pageFetch, subpagesFetch])` вместо последовательных await — минус целый round-trip с каждого вызова. (Вариант глубже — сделать subpages опциональными параметром инструмента — менять контракт инструмента не предлагаю, паралеллизации достаточно.) ## 3. createPage 2.35 с — только понаблюдать 9 вызовов, включает инициализацию collab-сессии нового дока — вероятно легитимно; после пп. 1–2 посмотреть на чистом профиле, отдельно не чинить. # Крайние случаи - **Ключ кэша**: `updatedAt` обновляется дебаунс-стором — окно, когда контент уже новый, а `updatedAt` старый? Нет: getPage читает контент и `updatedAt` из ОДНОГО ответа `/pages/info` — они консистентны между собой; кэш максимум отдаст тот же снапшот, что вернул бы сервер. Рассинхрон невозможен по построению. - **Память**: markdown больших страниц — сотни КБ; лимит LRU по байтам (например, 10 МБ суммарно), не только по числу записей. - **Standalone-режим MCP** — кэш работает одинаково (он в клиенте, не в хосте), ничего не гейтить. - **Метрика попадания**: добавить лейбл или счётчик `mcp_getpage_cache_hits_total`/`misses_total` (2 counter'а, bounded) — иначе эффект кэша будет невидим и хит-рейт не проверить. # Тесты / проверка - Юнит: hit по одинаковому `(pageId, updatedAt, options)`, miss после смены `updatedAt` и смены опций; вытеснение LRU по байтам. - Существующие тесты getPage/конвертера — без изменений поведения (результат байт-в-байт тот же). - Прод: `mcp_tool_duration_seconds{tool="getPage"}` p95 < 200 мс при живом агентском цикле; hit-rate > 50% по новым счётчикам; алерт `/mcp` молчит на read-нагрузке. # Вне скоупа - createPage (наблюдение после пп. 1–2). - Кэш на серверной стороне `/pages/info` (ETag/304 из роадмапа) — отдельная тема, этот кэш живёт в MCP-клиенте и не зависит от неё. # План работ 1. LRU-кэш конверсии + счётчики hit/miss. 2. `Promise.all` для subpages. 3. Тесты, пересборка `packages/mcp/build/*`. 4. Прод-замер по `mcp_tool_duration_seconds{tool="getPage"}` до/после.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#479