fix(ai): patch ai@6.0.134 — drop O(n²) partialOutput accumulation causing heap OOM on long agent runs (#184) #368
Reference in New Issue
Block a user
Delete Branch "fix/ai-sdk-partial-output-oom"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема
Прод упал с
FATAL ERROR: Ineffective mark-compacts near heap limit(JS-куча 1.85 ГБ / лимит 2 ГБ) во время длинного автономного агентского хода (~20 шагов, ~28k text-delta чанков) после включенияsettings.ai.autonomousRuns(#184).Диагноз (memlab по прод-heapsnapshot'ам)
Один незавершённый
DefaultStreamTextResultизai@6.0.134удерживал 1.7 ГБ: очередьReadableStreamDefaultControllerнеконсьюмируемой веткиtee()держала 28 225 чанков{ part, partialOutput }по ~164 КБ retained.Корневая причина в SDK:
streamTextподставляет дефолтную output-стратегиюtext(), даже когда опцияoutputне передана;createOutputTransformStreamна каждый text-delta накапливает полный текст хода и кладёт в потокpartialOutput— снапшот всего текста;JSON.stringifyфлэттенит cons-строку в плоскую копию → O(n²) по памяти;teeStream(): каждый консьюмер (consumeStream,pipeUIMessageStreamToResponse) делаетtee(), остаточная веткаbaseStreamникогда не читается — её очередь копит все чанки на всё время хода.До #184 дисконнект браузера абортил ход рано и n оставалось малым; автономные раны позволили ходу дожить до масштаба, где квадрат добил кучу.
Апстрим убрал stringify-квадрат в ai@6.0.150 (vercel/ai#14123, fixes vercel/ai#13839), но снапшоты и retention в мёртвой tee-ветке остались вплоть до v7.0.15.
Фикс
pnpm-патч на
ai@6.0.134(оба бандла:dist/index.js+dist/index.mjs):baseStreamпередаётся сырой (возможноundefined)outputвместо подстановкиtext();output == nullдельта публикуется сразу, без накопления текста и безpartialOutput. Гранулярность стриминга сохранена; при явно заданнойoutput-стратегии поведение байт-идентично оригиналу.Сервер нигде не использует
partialOutputStream/experimental_output/output— для нас меняется только память.Замер (реальный SDK, прод-паттерн потребления)
Изменения
patches/ai@6.0.134.patch+ регистрация вpnpm.patchedDependencies+pnpm-lock.yaml(Dockerfile уже копируетpatches/доpnpm install);apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.tsна реальном пропатченном SDK (MockLanguageModelV3): пер-дельта гранулярностьtextStream, пустойexperimental_partialOutputStream(tripwire — на непропатченной версии падает), маркерPATCH(docmostв обоих бандлах;ai; всё ≥6.0.150 уже без квадрата).Тесты: новая спека 3/3;
src/core/ai-chat40 сьютов / 675 тестов;src/integrations/ai9/98 — зелёные. Ревью: APPROVE, 2 замечания уровня INFO (без изменений).🤖 Generated with Claude Code
Production OOM'd (JS heap 1.85 GB / 2 GB limit) during a ~20-step, ~28k-chunk autonomous agent turn. Heap snapshot analysis (memlab) showed a single DefaultStreamTextResult retaining ~1.7 GB via the never-consumed leftover tee() branch of its internal baseStream. Root cause in ai@6.0.134: streamText substitutes the default text() output strategy even when the caller passes NO `output` option. Its createOutputTransformStream then accumulates the ENTIRE turn text and, on EVERY text-delta, enqueues `{ part, partialOutput }` where partialOutput is a flat snapshot of all text so far (JSON.stringify flattens the cons-string) — O(n²) memory across the turn. Every consumer accessor tees baseStream and keeps the second branch as the new baseStream; the final leftover branch is never read, so its controller queue holds every chunk (28,225 x ~164 KB in the OOM'd run) for the life of the turn. Fix (pnpm patch on both dist/index.js and dist/index.mjs): - pass the raw, possibly-undefined `output` option into createOutputTransformStream instead of defaulting to text() - when output == null, publish each text-delta immediately without accumulating turn text or producing partialOutput snapshots; streaming granularity is unchanged, and callers that DO request an output strategy keep the original behavior Our server never uses partialOutputStream / experimental_output / the output option, so no behavior changes for us beyond memory. Regression spec ai-sdk-partial-output.patch.spec.ts drives the real patched SDK with MockLanguageModelV3: asserts per-delta textStream granularity, an EMPTY experimental_partialOutputStream (tripwire — yields one cumulative partial per delta when unpatched), and the PATCH(docmost marker in both installed dist bundles. Also documents the patch in AGENTS.md (must be re-created when bumping `ai`) and CHANGELOG.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Ревью — #368 (fix(ai): pnpm-патч ai@6.0.134 — убрать O(n²) partialOutput-накопление, heap OOM на длинных агентских ходах, #184), round 1. Вердикт: CHANGES
Отличная работа: диагноз (memlab по прод-heapsnapshot'ам) и фикс сверены по коду. Патч хирургический и КОРРЕКТНЫЙ — веер 9 аспектов, 8 LGTM. Ядро (stability + regressions) протрассировано end-to-end: эмитируемый
partбайт-в-байт тот же (меняется толькоpartialOutput: полный-снапшот →undefined);text2 += chunk.textперенесён ПОД гардif(output==null) return, три аккумулятора (text2/textChunk/textProviderMetadata) независимы → для output!=null путь чистый no-op-reorder,parsePartialOutputпо-прежнему видит полный текст. Оба серверныхstreamText(ai-chat.service.ts:953,public-share-chat.service.ts:278) и всеgenerateTextидут БЕЗoutput/experimental_output;partialOutputчитается только геттерамиpartialOutputStream/elementStream, которых в коде нет → для нас меняется только память. Security: диффнул установленный пропатченный бандл против оригинала — дельта == патч, ничего лишнего, запинено тремя способами (имя+ключ+lock-hash+git-blob-хэши →git applyпадает при дрейфе). Открыто 1 (suggestion). Эскалации нет.Открыто: F1 — регресс-гард покрывает только fix-ветку (output==null → пустой partialOutput); вторая ветка форка (output!=null → partialOutput СОХРАНЯЕТСЯ) — то самое утверждение «байт-идентично при заданной output-стратегии», на котором держится патч — не проверяется ни одним тестом.
Объективка зелёная (мой прогон, голова
9b4b38a6, CI-условия): frozen install 0 (патч применяется); маркерPATCH(docmost #OOM)присутствует в ОБОИХ бандлах пропатченногоai@6.0.134_patch_hash=f60bfc…(index.js+index.mjs); подстановкаtext()из бандла убрана; ee build 0; server tsc 0; регресс-спека на РЕАЛЬНОМ пропатченном SDK — 3 passed. Сверил сам: Dockerfile копируетpatches/ДОpnpm installв ОБЕИХ стадиях (builderCOPY . .:17→install:19; runtimeCOPY patches:63→install --prod:72) → прод-образ получает патч.📋 Do (F1) + DROP + что сверено
Do — почини, потом ставь
review/needsapps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts(добавить 4-й тест).Патч вводит форк в
createOutputTransformStream:output==null→ пропуск partialOutput (фикс);output!=null→ оригинальное накопление. Спека проверяет ТОЛЬКО первую ветку (тест 2: пустойexperimental_partialOutputStreamбез стратегии) + два триптвайра на маркер патча в обоих бандлах — это хороший гард fix-ветки. Но вся safety-аргументация патча (и текст PR: «при явно заданной output-стратегии поведение байт-идентично оригиналу») держится на ВТОРОЙ ветке, которая не исполняется ни разу. Патч — вендоренный дифф, который по инструкции вAGENTS.mdОБЯЗАН пере-создаваться черезpnpm patchна каждом бампеai; пере-порт, случайно уводящий output-set-вызовы в skip-ветку, оставитpartialOutputмолча пустым для object/text-output-консьюмеров, и ни один тест не поймает. Test-coverage-агент подтвердил: недостающий тест реализуем и ЗЕЛЁН на пропатченном dist. Fix: добавь 4-й тест — тот жеMockLanguageModelV3,streamText({ model, prompt, experimental_output: Output.text() })(импортOutputиз'ai'), слейtextStream, собериexperimental_partialOutputStreamи проверь, что он НЕ пуст и кумулятивен (последний partial == полный текст) — пиннит, что патч сохраняет partialOutput при заданной стратегии, замыкая двух-веточный регресс-гард.⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]suggestion[stability] На output==null-пути пустая text-дельта (chunk.text==="") теперь эмитит лишний пустойtext-delta(до-патч dedup её пропускал). Импакт нулевой —onChunkделает+= '',textStreamйелдит'', UI аппендит''; требует провайдера, эмитящего пустые дельты. Косметика, ниже флаг-флора. DROP.Сверено (9 аспектов + мои проверки, голова
9b4b38a6): патч применяется к обоим бандлам (маркер подтверждён в_patch_hash=-варианте, который резолвит проект;text()-подстановка убрана);publishTextChunkэнкьюит per-deltatext-deltapart сpartialOutput:undefinedи ресетитtextChunk=""→ накопления нет; output!=null — чистый no-op-reorder (stability+regressions независимо); оба серверных streamText + все generateText без output-стратегии (grep пуст наexperimental_output/partialOutputStream/elementStream); Dockerfile копирует patches до install в обеих стадиях (прод получает патч); запин 3 способами + git-blob-хэши → дрейф падает ГРОМКО (не молча), плюс регресс-спека — реальный CI-триптвайр (вpnpm -r test);^6.0.134каретка консистентна с прецедентомyjs(frozen-lockfile держит exact 6.0.134);ai@6.0.207в lock — легитимная клиентская (браузерная) копия, не серверный heap-путь; AGENTS.md+CHANGELOG точны (re-create-on-bump-хазард задокументирован); precedentscimmy/yjsв patchedDependencies. F1 — единственный открытый (suggestion): доп-тест preserve-ветки.The patch forks createOutputTransformStream: output==null skips partialOutput (the OOM fix, already tested), output!=null preserves the original cumulative accumulation. Only the skip branch was tested; the preserve branch — on which the patch's "byte-identical when an output strategy is set" safety claim rests — had no coverage, so a future re-port (patches are re-created via `pnpm patch` on every ai bump) could silently route output-set calls into the skip branch and leave partialOutput empty for object/text-output consumers, uncaught. Add a 4th test: streamText({ ..., experimental_output: Output.text() }), drain textStream, collect experimental_partialOutputStream, and assert it is non-empty and cumulative (last partial == full text "Hello, world!"). Reuses the existing makeModel() harness. Verified on the patched dist: partials are ["Hello","Hello, ","Hello, world!"]. `npx jest ai-sdk-partial-output.patch.spec.ts` → 4 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Round 1 — F1 закрыт (
d8fc724d). Взял PR по просьбе оператора (автор — agent_vscode, висело на changes-requested).F1 [test-coverage] Preserve-ветка партиалов теперь покрыта.
Добавил 4-й тест в
ai-sdk-partial-output.patch.spec.tsровно по твоему рецепту:streamText({ ..., experimental_output: Output.text() }), слилtextStream, собралexperimental_partialOutputStreamи проверяю, что он непустой и кумулятивный (последний партиал == полный текстHello, world!). Переиспользовал существующийmakeModel()-харнесс,Outputимпортируется изai. Это и есть трипвайр на случай, если ре-порт патча (пере-создание черезpnpm patchна бампеai, как в AGENTS.md) случайно уведёт output-set-вызовы в skip-ветку и молча обнулитpartialOutput— тест упадёт наpartials.length > 0.На пропатченном dist поведение подтвердил: партиалы
["Hello","Hello, ","Hello, world!"].DROP-пункт (лишняя пустая text-delta на output==null пути) не трогал — как помечено.
Объективка:
pnpm installприменяет патч чисто (маркерPATCH(docmostв резолвящемсяai@6.0.134_patch_hash=…),npx jest ai-sdk-partial-output.patch.spec.ts→ 4 passed (3 старых + preserve-ветка). Тронут только spec-файл, продакшн/патч/lockfile не менялись.Ревью — #368 (fix(ai): patch ai@6.0.134 — drop O(n²) partialOutput accumulation causing heap OOM on long agent runs, #184). Вердикт: PASS ✅
Отличная работа — диагноз по memlab точен, фикс минимальный и таргетный, тест сильный. Обе объективки ВОСПРОИЗВЁЛ сам на реальном диске (клон в tmpfs 3.9G не влезал — clean-room гонял в /home):
pnpm install --frozen-lockfile— ПРОШЁЛ (свежий клон, 2m34s): патчai@6.0.134применяется чисто из lockfile (не warm-masked), маркерPATCH(docmost #OOM)в ОБОИХ бандлах (index.js+index.mjs); сервер резолвит именно пропатченныйai@6.0.134(require.resolve→patch_hash=f60bfc…). Stale-патч бросил быERR_PNPM_PATCH_NOT_APPLIED— не бросил.ai-sdk-partial-output.patch.spec.ts— 4/4 passed (jest, реальный раннер сервера; воспроизвёл): granularity сохранена, no-output →partialOutputпуст (сам фикс), output-set →partialOutputкумулятивен (preserve-branch гард — ровно то, что я просил в round-1), оба бандла с маркером.Фикс корректен и полон (stability + coherence адверсариально сверили против исходника бандла): в ветке
output == nullpublishTextChunkэмитит тот же{part, partialOutput: undefined}иreturnДО и снапшотов, и кумулятивногоtext2→ retention падает O(n²)→O(n) (~пара МБ на ход, GC'ится; было 28 225×164КБ=1.7ГБ). Structured-output путь байт-в-байт (guard только наoutput==null;text2 +=возвращён ПОСЛЕ гарда).result.text/.output/finishReasonне задеты (outputSpecificationи так был null до патча).Регрессий ноль (regressions сверил реальные call-site'ы): оба продовых
streamText(ai-chat.service.ts:953,public-share-chat.service.ts:278) — БЕЗexperimental_output, потребляют черезconsumeStream/pipeUIMessageStreamToResponse; ни один код в репо не читаетpartialOutput(единственный консьюмерexperimental_partialOutputStream— сама спека-tripwire). Непропатченныйai@6.0.207— клиентский транзитив (@ai-sdk/react), НЕ на серверном OOM-пути. Maintainability закрыта: exact-version ключ + запись в AGENTS.md → на бампеaipnpm упадёт ГРОМКО (не тихо), плюс CJS+ESM marker-тест.Что сверено + ⛔ (INFO)
Сверено (9 аспектов + объективка мной, голова
d8fc724d, дифф three-dot отdevelop): clean-room install применяет патч (оба бандла) — воспроизвёл; jest-спека 4/4 — воспроизвёл; фикс корректен+полон (retention O(n²)→O(n), publishTextChunk эквивалентен, structured-output не тронут); ноль реальных консьюмеровpartialOutputзатронуто; wiring как у scimmy/yjs-патчей, lockfile ai-patch-only, hash консистентен; спека кусает (tripwire (b) падает на непропатченном; preserve-branch (c) ловит careless re-port; both-bundles (d)); round-1 preserve-branch-DO закрыт. Эскалаций/DO нет.⛔ INFO (не DO)
[note][test-coverage/stability] Остаточная O(n)-ретенция: даже пропатченныйpublishTextChunkкладёт plain-part'ы в непотребляемую tee-веткуbaseStream(линейно, ~пара МБ) — присущеtee()SDK, НЕ причина OOM (ею были O(n²)-снапшоты, устранены). Дренаж мёртвой ветки — более глубокая SDK-хирургия, не в scope; O(n) уже снимает OOM. Приемлемо.