Skip to Content
ДизайнRt OptimizationТехнический план оптимизации Qwen Code Agent Loop RT

Технический план оптимизации Qwen Code Agent Loop RT

1. Предпосылки и постановка задачи

1.1 Текущее состояние

Agent Loop в Qwen Code построен по строго последовательной модели:

User Prompt → [LLM решение] → Tool Execution → [LLM решение] → Tool Execution → ... → [LLM ответ] → Idle ~3-4с ~Xms-Ns ~3-4с ~Xms-Ns ~3-4с

Каждый вызов LLM (включая сетевой RTT + инференс модели) занимает около 3-4 секунд, что является основной составляющей сквозного времени отклика.

1.2 Фактические данные замеров

Тестовый сценарий: «Какие у меня рабочие пространства» (3 раунда agent loop, 2 вызова инструментов, однократный замер)

ЭтапВремяДоля
LLM Round 1 (решение вызвать skill)3,8 с28%
Выполнение Skill1 мс<1%
LLM Round 2 (решение вызвать shell)3,0 с22%
Выполнение Shell2,5 с19%
LLM Round 3 (текстовое резюме)3,8 с28%
Накладные расходы фреймворка (синхронизация состояния, рендеринг)0,3 с3%
Итого13,4 с100%

Вывод: Вызовы LLM занимают 78%, выполнение инструментов — 19%, фреймворк — 3%. Основная цель оптимизации — сократить количество вызовов LLM и снизить задержку каждого отдельного вызова LLM.

Примечание: однократный замер, единичный сценарий. 19% выполнения инструментов обусловлено медленным вызовом shell; в сценариях с интенсивным чтением выполнение инструментов может снизиться до <5%. Перед внедрением плана необходимо дополнить базу замеров по ≥3 классам сценариев (операции записи, меж-инструментальные рассуждения, восстановление после ошибок).

1.3 Ключевые ограничения текущей архитектуры

ОграничениеРасположение в кодеОписание
Нет пост-директив у результатов инструментовtools.ts интерфейс ToolResult (L422)Содержит только llmContent/returnDisplay/error, невозможно указать «пропустить LLM»
Результаты безусловно передаются обратно в LLMuseGeminiStream.ts handleCompletedTools (L2038) → submitQuery(ToolResult, …) (L2355)Все результаты инструментов, инициированных Gemini, возвращаются обратно
Планирование только после завершения потокаuseGeminiStream.ts processGeminiStreamEvents (L1365)scheduleToolCalls вызывается только после завершения цикла stream, инкрементального планирования нет
Выбор модели без стратегического слояclient.ts modelOverride ?? getModel() (L1305, L1598)Инфраструктура уже реализована до turn.run(model, …) (L1707), но вызывающая сторона использует её только при явном указании в skill

1.4 Уже готовая инфраструктура (активно используется в данном плане)

ВозможностьРасположениеСтатус
Конфигурация fastModel + /model --fast <id>config.ts:684, 1987, 2021Готово
SendMessageOptions.modelOverrideclient.ts:1421598turn.runСквозная интеграция до geminiChat.sendMessageStream(model, …)
Хуковый слой modelOverrideRef (для выбора модели в skill)useGeminiStream.ts:376, 2225, 1841Реализован
Примеры не-потоковых side-запросов на fast-моделиservices/toolUseSummary.ts:108 (через runSideQuery)Развёрнуто, доказывает работоспособность конфигурации fast-модели; но не-потоковый путь
Примеры потоковых запросов на fast-моделиfollowup/speculation.ts:224Развёрнуто, но использует forked chat (createForkedChat), изолированный от основного чата

Ключевой пробел: Нет ни одной строки продакшн-кода, которая запускала бы потоковую передачу на fast-модели в основном чате. Данный план в D2 станет первым случаем; необходимо сначала провести верификационный эксперимент (см. §3.2 Предварительные условия).


2. Принципы проектирования

  1. Универсальность: План не привязан к конкретному инструменту/skill
  2. Обратная совместимость: Существующие инструменты продолжают работать без изменений
  3. Постепенное внедрение + явные сигналы: Стратегия по умолчанию консервативна; авторы инструментов через явные поля opt-in к оптимизации
  4. Возможность отката: Все оптимизации контролируются feature-флагами; на уровне пользователя можно принудительно отключить
  5. Честный баланс: Чётко обозначены риски по качеству, стоимости и границы применимости

3. План оптимизации

3.1 Направление 1: Пост-директивы выполнения инструментов (ToolResult Post-Execution Directive)

Проблема

Текущий ToolResult не содержит никакой информации о том, «что делать дальше». Независимо от того, является ли результат инструмента самодостаточным, безусловно запускается следующий раунд LLM.

Дизайн

Расширение интерфейса ToolResult (packages/core/src/tools/tools.ts L422):

export interface ToolResult { llmContent: PartListUnion; returnDisplay: ToolResultDisplay; error?: { message: string; type?: ToolErrorType }; // Новое: пост-директива выполнения postExecution?: { /** * Результат инструмента не передаётся обратно в LLM, а отображается пользователю как окончательный ответ. * Подходит для сценариев, где результат полностью самодостаточен и не требует интерпретации моделью. * Является локальным свойством ToolResult. */ skipLlmRound?: boolean; /** * Результат инструмента «самодостаточен и может быть сразу показан пользователю» — то есть `returnDisplay` * уже является финальным видом, ожидаемым пользователем, и не требует обработки моделью. * Является локальным свойством ToolResult, **не** предсказывает «будет ли следующий раунд summary». * Связан с направлением 3 (разделение отображения): true → переход в состояние Summarizing, позволяющее ввод пользователя. */ resultIsTerminal?: boolean; }; }

Исправление дизайна: В ранней версии одно поле selfExplanatory одновременно отвечало за «свойство продукта инструмента» и «сигнал предсказания диалогового потока», но эти две концепции не совпадают (пример: промпт пользователя «прочитай X и отредактируй Y», вывод read_file самодостаточен, но следующий раунд явно не summary). Сигнал предсказания является глобальным свойством диалогового потока и не должен выражаться через поля инструмента — в D2 это полностью заменено на эвристику на основе диалогового потока (см. §3.2).

Изменение поведения

В handleCompletedTools добавлена новая логика:

Пакет инструментов завершён → Проверка: все ли инструменты в пакете имеют postExecution.skipLlmRound === true? → ДА: markToolsAsSubmitted, НЕ вызывать submitQuery, перейти в Idle → НЕТ: сохранить текущее поведение (submitQuery)

Важное ограничение: skipLlmRound срабатывает только если все инструменты в текущем пакете объявили skip. Смешанный пакет по-прежнему передаётся обратно.

Инвариант истории

После пропуска LLM история имеет вид: user → function_call → function_response → <нет assistant>.

  • Проверить repairOrphanedToolUseTurnsInHistory (вызывается при загрузке сессии) на допустимость такой формы
  • Проверить поведение auto-compaction при отсутствии текста assistant
  • PR #4176 недавно закрывал инвариант tool_use↔tool_result; перед внедрением необходимы unit-тесты для сценария «после skip приходит user message» и корректности чередования
  • API стиля Qwen / OpenAI допускает; Anthropic требует строгого чередования — если в будущем будет поддерживаться прямое подключение к Anthropic, потребуется запасной вариант (вставка пустого текста assistant в историю)

Единая точка исправления: Здесь и в §3.3 (D3 прерывание Summarizing) нарушается один и тот же инвариант истории. Исправление выбирается одно из двух (вставка пустого assistant / принятие толерантности Qwen), оба направления должны использовать одинаковый выбор.

Экосистема сигналов (работа Phase 2)

ИнструментskipLlmRoundresultIsTerminalПримечание
read_fileВ сочетании со сценарием query-onlytrueСодержимое файла и есть ответ
cat (через shell)В зависимости от сценарияtrueАналогично read_file
grep / glob / lsfalsefalse (по умолчанию)Результаты часто требуют фильтрации/сортировки/обобщения моделью; на уровне skill явно true в известных сценариях «чистого запроса»
git status / git log (через shell)falsetrueВывод уже отформатирован
Инструменты SkillРешает каждый skillРешает каждый skillЗапросные skill склоняются к true
MCP-инструментыfalse по умолчаниюfalse по умолчаниюЧерез allowlist явный opt-in

Сторонние/MCP-инструменты не заслуживают доверия, по умолчанию без пометок; включаются через config.toolPostExecAllowlist.

grep/glob/ls по умолчанию false — консервативный выбор: чтобы в D2/D3 не ошибиться в сценариях, где требуется обобщение/сортировка моделью.

Применимость и неприменимость

  • Применимо: Конечные запросы (типа read/cat/print), самодостаточные результаты (skill уже отформатировал вывод)
  • Неприменимо: Промежуточные шаги многошаговых задач, подтверждение операций записи, сложные логи, требующие интерпретации

Риски и смягчение

РискСерьёзностьСмягчение
Инструмент неправильно установил skipLlmRound, прервав многошаговую задачуСредняяСемантика на уровне пакета + llmContent остаётся в истории, можно восстановить
Злоупотребление сторонними инструментамиСредняяMCP по умолчанию отключён, включается через allowlist
Нарушение инварианта историиСредняяUnit-тесты перед внедрением; репликация при загрузке сессии
Несоответствие ожиданиям пользователя (ожидал обобщения, но не получил)НизкаяНастройка alwaysSummarize: true может перекрыть

Выгода

В сценариях конечного запроса экономится 3-4 секунды (пропуск последнего раунда LLM).


3.2 Направление 2: Стратегия маршрутизации summary-раунда на fast-модель

Позиционирование

Данное направление не вводит новый канал, но требует расширения интерфейса GeminiChat для поддержки переключения модели во время выполнения.

Инфраструктура из §1.4 предоставляет конфигурацию fast-модели и сквозную интеграцию modelOverride, но нет прецедента запуска fast-модели + потоковой передачи в основном чате. Необходимо:

  • Функция принятия решения: когда передавать config.getFastModel() в качестве override
  • Безопасный откат: новый интерфейс GeminiChat.retryStreamWithModel (обработка внутреннего состояния чата)
  • Экспериментальная верификация: переключение fast/primary в основном чате не нарушает compaction / history-recording

Область применения

D2 применяется только к:

  • useGeminiStream (основной путь TUI) — точка вызова sendMessageStream L1841
  • ACP Session (путь интеграции IDE) — acp-integration/session/Session.ts:1182, синхронная доработка в Phase 3

D2 не применяется к следующим путям, чтобы избежать дополнительных отказов в неинтерактивных или изолированных контекстах:

  • Среда выполнения subagent (agents/runtime/agent-core.ts:614): subagent уже имеет собственную конфигурацию модели
  • Turn по расписанию (Cron) (SendMessageType.Cron, client.ts:127): неинтерактивный, нет срочности по RT
  • Notification turn (SendMessageType.Notification, client.ts:129): аналогично

Ключевая сложность

При вызове submitQuery мы не знаем, увидит ли модель результат и инициирует новый инструмент или просто выдаст текст. Если использовать fast-модель, а модель на самом деле захочет вызвать инструмент — последствия тихие: fast может вызвать неправильный инструмент или с неправильными параметрами, ошибка не даст явного сигнала.

Никакое поле на уровне инструмента не может надёжно предсказать «будет ли следующий раунд summary», потому что это зависит от диалогового потока (промпт пользователя + накопленный контекст), а не от локального свойства результата инструмента. Пример:

Пользователь: «Прочитай utils.ts и замени в нём все console.log на logger.info» → Tool 1: read_file → результат самодостаточен → Но следующий раунд явно не summary

Поэтому D2 полностью использует эвристику на основе диалогового потока для предсказания, не полагаясь на поля инструмента.

Функция принятия решения: эвристика диалогового потока + отклонения

import { Kind, MUTATOR_KINDS } from '../tools/tools.js'; function selectContinuationTier( turn: Turn, userPrompt: string, batch: ToolCall[], ): 'fast' | 'primary' { // ===== Принудительное отключение на уровне пользователя (наивысший приоритет) ===== const userPref = config.getSummaryTierStrategy(); if (userPref === 'always_primary') return 'primary'; if (userPref === 'always_fast') return 'fast'; // всё ещё подчиняется runtime-ограничениям // ===== Отклонение по намерению пользователя ===== // 1. Промпт содержит глаголы действия → следующий раунд, вероятно, снова вызов инструмента if (requestImpliesFurtherAction(userPrompt)) return 'primary'; // 2. В этом раунде уже был мутатор → вероятно, будет верификация/чтение далее if (batch.some((c) => MUTATOR_KINDS.includes(c.tool.kind))) return 'primary'; // 3. В этом раунде или в истории есть неразрешённая ошибка → модели нужна primary для диагностики if (hasUnresolvedError(turn.toolResults, batch)) return 'primary'; // ===== Отклонение по сложности вывода ===== // 4. Промпт требует глубокого анализа (объяснить/сравнить/почему) if (needsDeepReasoning(userPrompt)) return 'primary'; // 5. Вызвано ≥3 разных инструментов → повествование через несколько результатов требует primary if (needsCrossResultReasoning(turn)) return 'primary'; // 6. Вывод инструмента слишком длинный → обобщение длинного контента требует primary if (estimateTotalToolOutputTokens(turn) > 4000) return 'primary'; // ===== Отклонение по технической возможности модели ===== // 7. Контекстное окно fast-модели недостаточно → переключение на fast вызовет compression // (compression сам требует вызова LLM, что замедлит и увеличит стоимость) if (wouldTriggerCompression(turn.history, config.getFastModel())) return 'primary'; // ===== Запасной вариант для многоязычности ===== if (!isPromptLanguageSupported(userPrompt)) return 'primary'; // ===== Запасной вариант по состоянию сессии ===== if (turn.justCompacted || turn.justCleared) return 'primary'; return 'fast'; }

Значения восьми условий отклонения:

  • requestImpliesFurtherAction: глаголы действия (изменить|удалить|добавить|заменить|исправить|реализовать|создать|fix|change|add|remove|implement|write|update) → многошаговая задача
  • MUTATOR_KINDS совпадение: в этом раунде уже была запись → вероятно, сразу последует чтение/проверка. Используется существующий MUTATOR_KINDS = [Edit, Delete, Move, Execute] из tools.ts:806 (свойство kind: Kind каждого экземпляра Tool является авторитетной классификацией, не изобретать isWriteTool)
  • hasUnresolvedError(turnResults, currentBatch): проверка в два этапа —
    • Любая ошибка в текущем пакете → всегда неразрешена (не предполагаем, что параллельные пакеты самокорректируются)
    • История дедуплицируется по (toolName, args fingerprint), последняя запись с ошибкой считается неразрешённой (только по toolName при одинаковом имени, но разных параметрах может быть ошибочно)
    • shell и другие должны правильно заполнять ToolResult.error (зависимость от качества данных перед внедрением)
  • needsDeepReasoning: содержит ключевые слова типа «анализировать/объяснить/почему/сравнить/диагностировать»
  • needsCrossResultReasoning: вызовы разных инструментов ≥3 (один и тот же инструмент с теми же параметрами считается одним вызовом)
  • Выходные токены > 4000: эмпирический порог, будет скорректирован после фактических замеров на fast-модели
  • wouldTriggerCompression: контекстное окно fast-модели обычно меньше, чем у primary, та же история на fast раньше вызовет tryCompress (geminiChat.ts:1418) — compression сам требует вызова LLM, может ухудшить RT и стоимость. Оценочная формула: estimateHistoryTokens(history) > fastModelContextWindow × COMPACTION_THRESHOLD — считается, что вызовет
  • Неподдерживаемый язык: проверяются только ключевые слова на китайском/английском; другие языки (японский, корейский и т.д.) по умолчанию primary
  • Мутация состояния сессии: первое продолжение после /compact или /clear → primary для восстановления ментальной модели

Отклонения смещены в сторону primary (лучше потерять 2 секунды, чем снизить качество).

Ключевая реализация: GeminiChat.retryStreamWithModel

Проблема: Прямой abort + вызов client.sendMessageStream нарушает состояние чата:

  1. geminiChat.ts:1428 при запуске stream сразу добавляет userContent в history; повторный вызов добавит его снова, что приведёт к дублированию function_response в истории
  2. sendPromise lock (geminiChat.ts:1392, 1398) — после abort нужно гарантировать вызов streamDoneResolver
  3. pendingPartialState и другие маркеры-инварианты из PR #4176 нужно корректно очистить
  4. Атрибут модели в telemetry span нужно обновить

Новый интерфейс (packages/core/src/core/geminiChat.ts):

/** * Повторная попытка уже выполняющегося или только что прерванного потокового send с другой моделью. * НЕ добавляет userContent повторно (сохраняется из исходного send). * Сбрасывает pendingPartialState; освобождает устаревший sendPromise; переоткрывает span. */ async retryStreamWithModel( model: string, signal: AbortSignal, ): Promise<AsyncGenerator<StreamEvent>>;

Контракт вызова:

  • Вызывается только после того, как исходный send уже был прерван (не одновременно)
  • prompt_id повторно используется (тот же запрос пользователя)
  • Ранее добавленный в историю userContent НЕ добавляется повторно

Объём реализации: примерно 1,5 дня плюс unit-тесты.

Runtime-защита

Если selectContinuationTier вернул 'fast', но в потоке появляется событие ServerGeminiEventType.ToolCallRequestнемедленно прервать текущий поток, вызвать retryStreamWithModel(primaryModel).

Это покрывает единственный сценарий тихой ошибки: «предсказано как summary, но на самом деле нужен инструмент». Цена: потраченные токены одного fast-вызова (учёт затрат см. §5.3).

Разделение с modelOverride от skill

useGeminiStream.modelOverrideRef (L376, L2225) в настоящее время несёт явно выбранную модель от skill — это «бизнес-семантика». Маршрутизация на fast в данном направлении — это «оптимизационная семантика». Их необходимо разделить:

// Новый отдельный ref const summaryTierRef = useRef<'fast' | 'primary' | undefined>(undefined); // Точка вызова (не использует modelOverrideRef повторно) const stream = geminiClient.sendMessageStream( finalQueryToSend, abortSignal, prompt_id!, { type: submitType, notificationDisplayText: metadata?.notificationDisplayText, modelOverride: modelOverrideRef.current ?? // явный выбор skill приоритетнее (summaryTierRef.current === 'fast' ? config.getFastModel() : undefined), }, );

Жизненный цикл:

МоментmodelOverrideRef (skill)summaryTierRef (fast-маршрутизация)
Новый user turn (!Retry && !ToolResult)ОчищаетсяОчищается
Инструмент skill возвращает поле modelOverrideЗаписываетсяБез изменений
Пакет инструментов завершён → selectContinuationTierБез измененийЗаписывается
Runtime-откат (увидел ToolCallRequest)Без измененийПовышается до 'primary'
Retry (пользователь вручную Ctrl+Y)СохраняетсяПовышается до 'primary' (fast не удался → больше не fast)

Явный выбор skill всегда выигрывает — явное намерение пользователя имеет приоритет над оптимизационной стратегией.

Исправление Telemetry

client.ts:1303 interaction span при запуске turn записывает атрибут model. При срабатывании отката модель фактически меняется, данные span искажаются. Необходимо:

// При срабатывании отката span.setAttribute('llm.model.requested', fastModel); span.setAttribute('llm.model.actual', primaryModel); span.setAttribute('llm.fallback.reason', 'tool_call_seen');

Кроме того, в addUserPromptAttributes различать модель requested и actual, чтобы избежать путаницы в биллинге/аудите.

Принудительное отключение на уровне пользователя

Новая настройка (packages/cli/src/config/settingsSchema.ts):

summaryTierStrategy: 'auto' | 'always_primary' | 'always_fast'; // default: 'auto'
  • 'auto': использовать selectContinuationTier (рекомендуется)
  • 'always_primary': полностью отключить оптимизацию D2 (чувствительные к качеству сценарии)
  • 'always_fast': пропускать все отклонения, всё ещё подчиняется runtime-ограничениям (для продвинутых пользователей)

Обоснование: D2 — это компромисс качества ради скорости; некоторые пользователи/сценарии должны иметь явное право отказаться.

Предварительные условия

  • config.getFastModel() должен быть настроен
  • Эксперимент по верификации fastModel-streaming в основном чате (1 день до кодирования):
    • Создать мок-инструмент с resultIsTerminal=true, многократно запускать summary-раунды в основном чате
    • Наблюдать, не срабатывает ли tryCompress ошибочно (fast-модель с меньшим контекстом может сработать раньше)
    • Проверить, нет ли несоответствия модели в выводе chatRecordingService
    • Убедиться, что после одного fast-вызова следующий primary-вызов может корректно читать историю
  • Базовые замеры кандидатов в fast-модели (1 день):
    • Запустить 100 summary-раундов (вход с function_response), измерить P50/P95 сквозной задержки и time-to-first-token
    • Измерить частоту срабатывания tryCompress P_compact, проверить, что чистая выгода RT = (1 - P_compact) × ΔRT − P_compact × compression_RT > 0
    • Включать только если fast P50 ≤ primary P50 × 0,5 и P95 ≤ primary P95 × 0,6
  • Fast-модель и primary-модель должны быть из одного семейства (чтобы избежать различий в кодировании function_response); кросс-семейный выбор должен отклоняться на уровне getFastModel()
  • Совместимость thinkingConfig:
    • Fast-модель должна совпадать с primary по поддержке thinkingConfig.includeThoughts; или
    • Путь fast принудительно устанавливает includeThoughts: false (как в sideQuery.ts:118-122)
    • Проверка: fast-модель корректно обрабатывает историю с thought-частей (не выдаёт ошибку, не воспринимает thought как пользовательский ввод)

Риски и смягчение

РискСерьёзностьСмягчение
Fast-модель тихо ошибается в tool-callingВысокаяЭвристика диалогового потока + runtime-защита ToolCallRequest abort
Fast-модель галлюцинирует на входе с ошибками, выдавая «ошибочный ответ, видимый пользователю»ВысокаяОтклонение hasUnresolvedError; мониторинг частоты переспросов пользователем (примечание: аналогичный риск у emitToolUseSummaries затрагивает только 60-токеновую метку, данный риск затрагивает окончательный ответ, масштаб выше)
Путь fast вызывает tryCompress → дополнительный вызов LLM, ухудшая RT и стоимостьВысокаяПредварительная проверка wouldTriggerCompression (gate #7 в функции принятия решения); предварительное измерение порога P_compact
Чья модель используется для compressionСредняяЕсли срабатывает compression, отказаться от маршрута fast (gate #7 запасной вариант); избежать проблем с ответами
Переключение модели в основном чате нарушает внутреннее состояние/recordingСредняяПредварительный эксперимент по верификации; тестирование воспроизведения при возобновлении сессии
D2 и emitToolUseSummaries одновременно запускают concurrent fast-вызовы, превышая rate-limitСредняяЛибо отключить emitToolUseSummaries при включённом D2 (заголовки не влияют на функциональность), либо использовать общий rate-limit token bucket
Несоответствие thinkingConfig между fast/primary приводит к ошибке при разборе историиСредняяОдно семейство + принудительно includeThoughts: false для fast (см. предварительные условия)
Путь отката оказывается дороже (потраченные токены fast + полный primary)СредняяМониторинг лога fast_tokens_consumed; автоматическое отключение флага при частоте откатов >20%
Искажение telemetry span modelСредняяРазделение requested / actual (см. исправление Telemetry)
Несовместимость формата контекста (кросс-семейство)СредняяgetFastModel() отклоняет кросс-семейный выбор
Конфликт семантики с modelOverride от skillСредняяОтдельный ref + приоритет skill
При переключении основной модели через /model решение summaryTierRef устареваетНизкаяПри обработке команды /model синхронно очищать summaryTierRef
Fast-модель оказывается медленнееНизкаяПри замерах измерять TTFT, а не только общий RT

Выгода (требует фактических замеров)

  • RT: summary-раунд экономит 2-3 секунды (не указывать в заголовке PR до замера)
  • Стоимость: цена fast-модели обычно значительно ниже, чем primary; в сценариях с частыми summary-раундами стоимость токенов может снизиться на 30-50%; однако потери на пути отката частично компенсируют выгоду, требуется подтверждение чистой выгоды по метрике fast_tokens_consumed

3.3 Направление 3: Разделение отображения результатов и взаимодействия (Presentation Decoupling)

Проблема

Пользователь от завершения инструмента до возможности повторного ввода вынужден ждать завершения round-обобщения LLM:

Инструмент завершён → [рендеринг результата] → [submitQuery] → [ожидание потокового ответа LLM 3-4с] → Idle → можно вводить ~~~~~~~~~~~~~~~~~~~~~~~~ Пользователь уже видит результат, но не может взаимодействовать

Дизайн

Добавление нового состояния StreamingState.Summarizing:

export enum StreamingState { Idle = 'idle', Responding = 'responding', WaitingForConfirmation = 'waiting_for_confirmation', Summarizing = 'summarizing', // новое }

Изменение конечного автомата

Инструмент завершён и результат отображён → Если все инструменты в пакете имеют postExecution.resultIsTerminal === true: → Переход в Summarizing (пользователь может вводить) → submitQuery выполняется асинхронно → Обобщение LLM добавляется в историю (или отменяется новым сообщением пользователя) → Иначе: → Остаётся в Responding (пользователь не может вводить)

Обработка нового сообщения пользователя

  • В состоянии Summarizing пользователь отправляет новое сообщение → прервать текущее обобщение → обработать новое сообщение
  • Частичный текст обобщения отбрасывается (не добавляется в историю), чтобы избежать загрязнения контекста половинными предложениями assistant
  • function_response остаётся в истории (модель знает, что инструмент выполнился)
  • followup suggestion и другие действия запускаются только после завершения Summarizing или его отмены

Контрольный список очистки частичного текста при Abort

Частичный текст разбросан по нескольким местам, необходимо очистить все одновременно, иначе состояние станет несогласованным:

РасположениеДействие по очистке
pendingHistoryItemRef.current (React-состояние useGeminiStream)Установить null, не вызывать addItem
Внутреннее накопление в GeminiChat.historyЕсли до abort уже был добавлен частичный контент assistant, откатить через новый интерфейс discardPendingAssistant()
Буферизованный turn в ChatRecordingServiceПометить как cancelled, не записывать в JSONL
dualOutput.emitText (если включён)Отправить abort sentinel, sidecar самостоятельно отбрасывает
Накопленные токены в loopDetectorRefСбросить счётчик текущего turn
Порядок выполнения: срабатывание abort-сигнала → сбор всех пяти мест очистки → только после этого новое сообщение пользователя может войти в `submitQuery`. Покрытие гонок: когда abort срабатывает ровно в момент получения последнего чанка. #### Условия применения У всех элементов batch `postExecution.resultIsTerminal === true`. #### Инвариант истории (аналогичен §3.1) Прерывание Summarizing может привести к:

[user_1, function_call, function_response, user_2] ↑ нет assistant-витка

**Это нарушает тот же инвариант, что и пропуск LLM-витка в §3.1**, поэтому необходимо использовать ту же стратегию исправления (вставка пустого assistant / использование толерантности Qwen). - Повторное использование тестов инварианта из D1 - Воспроизведение session-load (включая `repairOrphanedToolUseTurnsInHistory`) должно покрывать этот сценарий - Антропное чередование: при прямом подключении одновременно с D1 добавлять подстраховку #### Риски и смягчение | Риск | Серьёзность | Смягчение | | ---------------------------------------------- | ----------- | ---------------------------------------------------------------------- | | Неполный assistant в history при abort | **Средняя** | Явный сброс partial text; оставлять только function_response; тест гонок | | Нарушение инварианта истории (нет продолжения assistant) | **Средняя** | Проблема, общая с D1, единое исправление (см. §3.1 инвариант истории) | | Усложнение состояния UI | Средняя | Summarizing = Idle + фоновая задача; путь ввода повторно использует Idle | | Восприятие пользователя зависит от поведения | Низкая | Если пользователь не вводит 3 с, summary завершён → нет эффекта; но **не хуже** | #### Выгоды - **Теоретический максимум**: 3-4 с воспринимаемого RT (пользователь вводит сразу после завершения инструментов) - **Фактическая медиана**: зависит от интервала ввода пользователя — те, кто читает результат 2-5 с, не заметят разницы, но **точно не станет медленнее** --- ### 3.4 Направление 4: потоковое упреждающее планирование (Stream-Ahead Scheduling) #### Проблема `processGeminiStreamEvents` планирует вызовы инструментов пакетно только после полного завершения потока. Событие `ToolCallRequest` может появиться уже в середине потока. #### Дизайн При обработке событий потока для `ToolCallRequest` немедленно начинать **предварительную проверку** (без выполнения): ```typescript case ServerGeminiEventType.ToolCallRequest: toolCallRequests.push(event.value); scheduler.prevalidate(event.value, signal); // новое break;

CoreToolScheduler.prevalidate(request):

  1. Поиск регистрации инструмента
  2. Построение invocation
  3. Выполнение shouldConfirmExecute (кэширование результата)
  4. При schedule() непосредственное использование кэша

Контракт чистоты и Allowlist

prevalidate требует, чтобы shouldConfirmExecute была side-effect-free и результат не мог быть изменён извне между prevalidate и schedule, делая его недействительным.

Повторное использование CONCURRENCY_SAFE_KINDS из tools.ts:818:

export const CONCURRENCY_SAFE_KINDS: ReadonlySet<Kind> = new Set([ Kind.Read, Kind.Search, Kind.Fetch, ]);

Это уже существующая в проекте классификация “без побочных эффектов + конкурентно-безопасные”, которая точно соответствует требованиям prevalidate.

Kind инструментаВ allowlist?Обоснование
Read (read_file и т.д.)Чистое чтение
Search (grep / glob)Чистое чтение
Fetch (web_fetch и т.д.)Удалённое чтение, нет побочных эффектов записи
Edit (см. TOCTOU)shouldConfirmExecute чистое чтение, но diff может быть недействителен
Delete / Move / ExecuteMUTATOR_KINDS
ThinkСодержит неявные save_memory / todo_write и т.д.
MCP-инструментыНедоверенные

TOCTOU: почему Edit не в allowlist

Теоретически shouldConfirmExecute для Edit является чисто читающим (чтение файла, вычисление diff). Но между prevalidate и schedule существует временное окно:

T=0 поток получает Edit(file=a.ts, ...) → prevalidate T=10ms shouldConfirmExecute читает a.ts, кэширует diff_v0 T=300ms поток завершён, scheduler.schedule() T=305ms за это время другой инструмент/IDE/внешний процесс изменил a.ts T=310ms scheduler показывает diff_v0 пользователю T=320ms пользователь подтверждает на основе v0 T=330ms Edit применяет старые params к файлу v1 → повреждение / сбой merge

Это TOCTOU. Направления исправления:

  • A (рекомендуется): Edit не входит в allowlist, prevalidate покрывает только три категории CONCURRENCY_SAFE_KINDS. Цена: выгода снижается с “50-200ms (Edit доминирует)” до “50-100ms (только чтение)”
  • B (опционально): Edit входит в allowlist, но кэш сопровождается (mtime, size, content_hash); при schedule() проверять, не изменилось ли, и только тогда использовать кэш, иначе пересчитывать

В документации пока выбирается A.

Взаимодействие с существующим параллельным планированием

coreToolScheduler.attemptExecutionOfScheduledCalls (L2436+) использует partitionToolCalls для разделения инструментов на “конкурентно-безопасный batch” и “последовательный batch”, конкурентный batch выполняется через runConcurrently (L2473).

prevalidate должен соответствовать этой модели разделения:

  • Кэш индексируется по callId (не по (toolName, args), чтобы избежать конфликтов при одновременных вызовах с одинаковым именем)
  • Если prevalidate для call завершился неудачей → это не влияет на другие call; при schedule этот call идёт по исходному пути shouldConfirmExecute
  • При отмене потока по signal каскадно abort’ить все in-flight prevalidate

Риски

РискСерьёзностьСмягчение
Несоответствие закэшированного diff актуальному файлу при подтверждении (TOCTOU)ВысокаяВариант A: Edit не в allowlist; Вариант B: проверка (mtime, size, hash)
Сбой prevalidate влияет на планированиеНизкаяПри неудаче/тайм-ауте вернуться к исходному shouldConfirmExecute; отсутствие кэша ≡ не включено
Конкурентные prevalidate, разделяющие fd / ресурсная конкуренцияНизкаяQWEN_CODE_MAX_TOOL_CONCURRENCY уже ограничивает конкурентность (по умолчанию 10)

Выгоды

50-100ms/виток (только в рамках CONCURRENCY_SAFE_KINDS). Если выбран вариант B с Edit, теоретическая выгода 100-200ms.


4. Комплексная оценка и дорожная карта

4.1 Комплексная оценка

НаправлениеВыгода по RTСложность реализацииРиск качестваЗависимостиПриоритет
D1 Инструкция после инструмента3-4 с/финальный витокНизкая (2-3 д)НизкийНетP0
D2 Быстрая маршрутизация summary2-3 с/виток summary (требуется замер)Средняя-высокая (9 д)Средняя-высокаяЭвристика D2 + эксперимент в главном чате + синхронизация ACPP1
D3 Разделение отображения3-4 с улучшение восприятия (зависит от поведения пользователя)Средняя (3-5 д, включая исправление инвариантов)СреднийИсправление инварианта истории из D1P1
D4 Потоковое упреждающее планирование50-200ms/витокВысокая (5-7 д)Очень низкийНетP2

Разбивка работ по D2

ПодзадачаОценка
Эксперимент с fastModel-streaming в главном чате (включая измерение P_compact)1 д
Базовые измерения кандидатов быстрой модели (включая TTFT, P95, совместимость с thinkingConfig)1 д
Интеграция selectContinuationTier + summaryTierRef (useGeminiStream)0,5 д
Реализация эвристики (включая повторное использование MUTATOR_KINDS / оценку wouldTriggerCompression / мультиязычность / изменение состояния)1 д
Реализация интерфейса GeminiChat.retryStreamWithModel + discardPendingAssistant1,5 д
Переработка синхронизации сессии ACP (acp-integration/session/Session.ts)1 д
Исправление спанов телеметрии (разделение requested / actual)0,5 д
Интеграция пользовательской настройки summaryTierStrategy + JSON schema + /config0,5 д
Модульные тесты (гонки, тайминги abort, инварианты истории, пути fallback, пути ACP)2 д
Итого9 д

Примечание: Ранняя оценка в 6,5 д не включала путь ACP, шлюз wouldTriggerCompression, чеклист очистки, инженерные работы по schema settings и т.д.

4.2 План реализации

Phase 1: Инструкция после инструмента D1 (1 неделя)

  • Расширение ToolResult.postExecution (tools.ts L422): skipLlmRound + resultIsTerminal
  • Реализация сокращения skipLlmRound в handleCompletedTools (useGeminiStream.ts L2038)
  • Модульные тесты для инварианта истории
  • Phase 1 не использует resultIsTerminal (оставлено для Phase 3)

Phase 2: Создание экосистемы сигналов (2 недели, параллельно с Phase 4)

  • Постепенная маркировка встроенных инструментов skipLlmRound / resultIsTerminal (см. таблицу в §3.1)
  • Проверка покрытия маркировкой ≥60% (взвешенно по числу витков, не по числу вызовов)
  • Сбор production-данных, калибровка порогов шлюза veto из §3.2
  • В конце Phase 2 проведение эксперимента главного чата из §3.2 и базовые измерения

Phase 3: D2 + D3 (около 3 недель, включая синхронизацию ACP)

Исправление: Ранняя дорожная карта оценивала в 1 неделю, не включая эксперимент fastModel-streaming, реализацию retryStreamWithModel, единое исправление инвариантов, синхронизацию пути ACP.

  • До кодирования: завершить эксперимент главного чата + базовые измерения (включая совместимость P_compact с thinkingConfig)
  • Добавить summaryTierRef + selectContinuationTier (включая шлюз wouldTriggerCompression)
  • Добавить GeminiChat.retryStreamWithModel + discardPendingAssistant
  • Синхронизировать путь сессии ACP (acp-integration/session/Session.ts) с использованием той же функции принятия решений
  • Добавить StreamingState.Summarizing + повторное использование пути ввода + чеклист очистки abort
  • Единое исправление инварианта истории (общий источник D1+D3)
  • Feature flag experimental.summaryRoundFastModel: false, Release N по умолчанию выключен
  • Пользовательская настройка summaryTierStrategy
  • Исправление спанов телеметрии
  • Страховка времени выполнения (ToolCallRequest abort + retryStreamWithModel)

Phase 4: Потоковое упреждающее планирование D4 (может быть вставлено независимо)

  • CoreToolScheduler.prevalidate + allowlist
  • Инкрементальное планирование в processGeminiStreamEvents

5. Метрики, приёмка и ограничения

5.1 Показатели производительности

МетрикаБазовый уровеньPhase 1Phase 3
Сквозной RT P50 (3 витка loop)13,4 с<10 с<8 с (требуется замер)
Сквозной RT P95-<13 с<12 с (макс. путь fallback)
Воспринимаемое время первого результата P5013,4 с<10 с<5 с (с D3)
Воспринимаемое время первого результата P95-<13 с<8 с
Количество вызовов LLM (пропускаемые сценарии)322 (быстрее)

Примечание: Базовый уровень — однократная выборка, перед внедрением нужно дополнить ≥3 сценариями.

5.2 Показатели качества

МетрикаБазовый уровеньДопустимая деградация
Точность вызова инструментов (виток summary быстрой модели)100%≥98%
Частота ошибочного использования skipLlmRound (пользователь просит “подробнее”)-<1%
Частота fallback_triggered быстрой модели-<10% (>20% автоматически выключает флаг)
Попадание неполного assistant в history во время Summarizing00 (жёстко)

5.3 Показатели стоимости

МетрикаБазовый уровеньЦель Phase 3
Стоимость токенов на тысячу сессий (виток summary)100%<70%
Доля потраченных впустую токенов из-за fallback0<15% (частота fallback × токены одного fast / токены одного primary)

5.4 Схема журнала решений

Каждое ключевое решение selectContinuationTier и handleCompletedTools записывается структурированным логом:

{ turn_id, prompt_id, decision: 'skip' | 'fast' | 'primary', tier_requested: 'fast' | 'primary', // решение (до fallback) tier_actual: 'fast' | 'primary', // фактическое выполнение (после fallback) signal_skipLlmRound: bool, signal_resultIsTerminal: bool, user_strategy: 'auto' | 'always_primary' | 'always_fast', veto_reason: 'further_action' | 'write_tool' | 'unresolved_error' | 'deep_reasoning' | 'cross_result' | 'output_tokens' | 'lang_unsupported' | 'compact_or_clear' | null, tool_count, distinct_tool_count, has_write_tool: bool, has_error: bool, has_cancel: bool, output_tokens_est: int, user_prompt_classification: 'query' | 'action' | 'analysis', fast_ttft_ms, primary_ttft_ms, // двойной набор при fallback fast_tokens_consumed: int, // потраченные впустую токены при fallback (причина затрат) total_rt_ms, fallback_triggered: bool, fallback_reason: 'tool_call_seen' | 'timeout' | 'error' | null, }

Наблюдаемые показатели:

  • Частота срабатывания fast (ожидается 30-50%)
  • Частота fallback_triggered (ожидается <10%; >20% — сигнал к отключению default flag в следующем релизе)
  • Доля каждого veto (выявить слишком строгие/слабые)
  • fast_tokens_consumed × fallback_rate (риск обратной стоимости)
  • Частота запросов пользователя “подробнее” (сигнал регрессии качества fast)

Примечание по измерению fast_tokens_consumed:

При прерывании потока abort’ом высока вероятность не получить finishReason / usageMetadata — последний заполняется только при полном завершении потока. Реализация должна оценивать:

  • Приоритет: перед abort попытаться выполнить stream.return(), чтобы генератор прошёл по finally-пути, возможно, частичный usage
  • Запасной вариант: накопить длину текста полученных чанков × 4 для оценки output tokens; input tokens оценить по истории
  • Маркировка: в поле журнала добавить tokens_source: 'usage' | 'estimated', при последующем анализе различать

5.5 Методы проверки и стратегия выпуска

Проверка

  • Повторное использование фреймворка замеров /tmp/tool-timing.log
  • Добавление T_userIdle (момент, когда пользователь снова может вводить)
  • Добавление T_firstToken (момент первого токена в потоке)
  • A/B-тестирование для сравнения распределения RT и стоимости до и после каждого Phase

Стратегия выпуска (адаптировано для локального CLI)

Qwen Code — локальный CLI, не имеет возможности развёртывания во время выполнения — традиционное “5% / 25% / 100% канареечное развёртывание неприменимо”. Используется поэтапное продвижение релизов:

ЭтапТочка релизаЗначение feature flag по умолчаниюУсловие срабатывания
Phase 3a: dogfoodRelease NfalseВнутренние пользователи включают summaryTierStrategy=always_fast сами
Phase 3b: opt-in по умолчаниюRelease N+1 (≥2 недели)false (без изменений)Журнал решений dogfood соответствует: fallback <10%, чистый выигрыш по RT/стоимости >0
Phase 3c: включено по умолчаниюRelease N+2 (≥4 недели)trueНа уровне пользователей нет отчётов о регрессии качества
ОткатRelease N+3 (при необходимости)true → falseМассовый fallback >20% или деградация показателей качества

Механизм отката:

  • Нет развёртывания во время выполнения, откат = новый релиз с выключенным default flag
  • Пользовательская настройка summaryTierStrategy=always_primary всегда предоставляет канал “немедленно выйти”, не зависящий от нового релиза
  • fallback_rate / cost_regression из журнала решений оцениваются на каждом цикле релиза, определяя следующий шаг

5.6 Известные ограничения

  1. Базовые данные скудны: однократная выборка не охватывает все шаблоны задач, перед внедрением требуется дополнить сценарии
  2. Предпосылка быстрой модели: если не существует значительно более быстрой модели с достаточным качеством вызова инструментов в том же семействе → D2 не включается
  3. skipLlmRound — это компромисс качества на скорость: пропуск LLM = отказ от понимания и исправления моделью, применим только для высокоопределённых сценариев
  4. D2 — это компромисс качества+стоимости на скорость: качество быстрой модели ниже, чем primary; путь fallback, наоборот, дороже — необходимо измерить чистую выгоду по журналам решений
  5. Срабатывание tryCompress может ухудшить ситуацию: у быстрой модели меньше контекст, сам compression потребляет вызов LLM — шлюз wouldTriggerCompression является обязательной защитой
  6. Разделение отображения меняет модель взаимодействия: новая модель требует адаптации пользователя; фактическое восприятие выгоды зависит от поведения пользователя
  7. Сетевая задержка неконтролируема: данное решение уменьшает количество вызовов, а не оптимизирует отдельный вызов
  8. Прямое подключение Anthropic не покрыто: текущая толерантность чередования зависит от API стиля Qwen / OpenAI
  9. fastModel-streaming на главном чате впервые внедряется: нет производственных прецедентов, требуется независимый проверочный эксперимент
  10. Локальный CLI не имеет развёртывания во время выполнения: стратегия выпуска может быть только поэтапным продвижением релизов, не поддерживает быстрое канареечное регулирование
  11. D2 действует только на интерактивный путь: Subagent / Cron / Notification не получают выгоды, это намеренно
  12. Долгосрочное влияние смешанной модели на историю неизвестно: после включения D2 витки внутри сессии переключаются между fast/primary, возобновление длинных сессий и связность контекста требуют наблюдения
  13. Снижение выгоды D4: после исключения Edit из allowlist prevalidate покрывает только инструменты чисто чтения (выгода 50-100 ms); выгода 200ms с Edit требует механизма проверки mtime/hash по варианту B

5.7 Ключевые места в коде

ФайлКлючевые символыРасположение
packages/core/src/tools/tools.tsИнтерфейс ToolResultL422
packages/core/src/tools/tools.tsПеречисление Kind + MUTATOR_KINDS + CONCURRENCY_SAFE_KINDSL793, L806, L818
packages/core/src/tools/tools.tsDeclarativeTool.kind: Kind (каждый экземпляр Tool содержит)L165
packages/core/src/core/client.tsSendMessageOptions.modelOverrideL142
packages/core/src/core/client.tssendMessageStreamL1216
packages/core/src/core/client.tsmodelOverride ?? getModel()L1305, L1598
packages/core/src/core/client.tsturn.run(model, …)L1707
packages/core/src/core/geminiChat.tssendMessageStream(model, …)L1387
packages/core/src/core/geminiChat.tshistory.push(userContent)L1428
packages/core/src/core/geminiChat.tsблокировка sendPromiseL1392
packages/cli/src/ui/hooks/useGeminiStream.tsmodelOverrideRef (выбор модели для skill)L376, L2225
packages/cli/src/ui/hooks/useGeminiStream.tsprocessGeminiStreamEventsL1365
packages/cli/src/ui/hooks/useGeminiStream.tsточка вызова sendMessageStreamL1841
packages/cli/src/ui/hooks/useGeminiStream.tshandleCompletedToolsL2038
packages/cli/src/ui/hooks/useGeminiStream.tssubmitQuery(ToolResult, …)L2355
packages/core/src/services/toolUseSummary.tsбыстрый side-запрос быстрой модели (непотоковый прецедент)L108
packages/core/src/followup/speculation.tsпотоковая передача быстрой модели (прецедент с forked chat)L224
packages/core/src/config/config.tsfastModel + getFastModel + setFastModelL684, L1987, L2021
packages/core/src/core/coreToolScheduler.tsattemptExecutionOfScheduledCallsL2436
packages/core/src/core/coreToolScheduler.tsrunConcurrently + partitionToolCallsL2473
packages/cli/src/acp-integration/session/Session.tsточки вызова sendMessageStream (путь ACP / IDE)L705, L965, L1182, L1423
packages/core/src/agents/runtime/agent-core.tssendMessageStream Subagent (не подвержен влиянию D2)L614

6. Запись проверки обзора (2026-05-26)

6.1 Метод проверки

Для нескольких предположений о качестве данных и оценок выгоды, которые в проекте документа только декларированы, но не количественно оценены, запущено 4 параллельных Explore subagent для исследования только чтения кода. Каждый subagent отвечает только на один фактический вопрос, без суждений и предложений по оптимизации. Исследование основано на текущей ветке main (HEAD: 026f2f768).

Вопрос проверкиСвязанный раздел
Q3 Фактическая заполняемость поля ToolResult.error для всех инструментовПредпосылка §3.2 hasUnresolvedError
Q4 Фактическая доступность usageMetadata после abort потокаИзмерение fast_tokens_consumed в §5.4
Q5 Существование точек сбора “запросов пользователя / уточнений”Сигнал мониторинга регрессии качества fast из §5.2
Q6 Фактический объём IO shouldConfirmExecute для инструментов CONCURRENCY_SAFE_KINDSОценка выгоды D4 в §3.4

6.2 Находка 1: эвристика hasUnresolvedError имеет 32% слепых зон инструментов (влияет на D2)

Факты: Из 22 инструментов, имеющих путь ошибки, 15 (68%) корректно заполняют поле ToolResult.error (shell, read-file, write-file, edit, grep, glob, ls, web-fetch, mcp-tool, cron-* и т.д. — все ключевые I/O инструменты), 7 (32%) просто помещают ошибку в строку llmContent: askUserQuestion, monitor, skill, lsp, exitPlanMode, todoWrite и т.д.

Нет единого helper createErrorResult, каждый инструмент независимо реализует конструирование ошибки.

Влияние на дизайн:

  • Если veto hasUnresolvedError из §3.2 проверяет только поле ToolResult.error, то для этих 7 инструментов сбой никогда не вызовет “переключения на primary” — следующий виток всё равно будет направлен на быструю модель
  • Сбой инструмента skill, неверно обобщённый быстрой моделью, является высокоприоритетным сценарием риска (много рабочих процессов, управляемых skill’ами, будут затронуты)
  • Список “shell и т.д. должны корректно заполнять ToolResult.error (предпосылка качества данных)” в §3.2 слишком узок: shell уже корректно заполняет, реальные пропущенные — это skill / lsp / todoWrite и т.д.

Рекомендуемое исправление: Включить “Переделать 7 инструментов, передающих ошибки только через llmContent, для корректного заполнения поля error” как жёсткую предпосылку D2 (§3.2 условие), оценка ~2 дня; не принимать грязный путь “подстраховки через llmContent.match(/^Error:/i)” (высок риск ложных срабатываний).

6.3 Находка 2: стоимость реализации метрики fast_tokens_consumed недооценена (влияет на D2 / §5.3)

Факты:

  • Путь abort в turn.ts (L289-291) непосредственно return, нет блока finally, нет вызова stream.return() — то, что подразумевается в §5.4 документа “перед abort stream.return() для прохода генератора по finally”, в текущем коде отсутствует
  • Цикл for await в geminiChat.ts:processStreamResponse записывает turn только при полном обходе (L1286); прерывание abort’ом означает, что последний чанк usage-only (обычно содержит полную метадату) просто отбрасывается
  • В главном чате нет никакого накопления токенов на уровне чанков; только на уровне subagent (agent.ts:731-744) есть накопление, но его нельзя повторно использовать
  • Вывод: при abort usageMetadata получить невозможно, можно только оценивать по chars/4 (ошибка ±20%)

Влияние на дизайн:

  • В трёхуровневой схеме “приоритет / запасной вариант / маркировка” в конце §5.4 путь “приоритет” в текущем коде недостижим — нужно сначала изменить структуру генератора sendMessageStream, добавив finally, работа около 1 дня, проектная документация эту стоимость не отражает
  • §5.3 ставит целью “стоимость токенов на тысячу сессий <70%”, но если сама метрика имеет ошибку ±20%, то “70%” и “82%” находятся в пределах шума измерения

Рекомендуемое исправление:

  • Переформулировать §5.3 как трендовую метрику, не использовать как шлюз релиза; использовать комбинированное суждение по “частоте fallback_triggered из журнала решений + направлению fast_tokens_consumed
  • Дополнить §5.4: реализация fast_tokens_consumed требует сначала переработки пути abort в turn.ts с добавлением finally + stream.return(), как дополнение к объёму работ §3.2 (+1 день)

6.4 Находка 3: user_prompt_classification и точки сбора “запросов пользователя” нужно создавать заново (влияет на D2 / §5.2)

Факты:

  • packages/core/src/followup/ уже содержит speculation.ts / suggestionGenerator.ts / followupState.ts, но их телеметрия (PromptSuggestionEvent) записывает “системное предложение принято/проигнорировано”, а не “активный запрос пользователя”
  • ChatRecordingService хранит сообщения пользователя, но не навешивает метки классификации
  • По всему репозиторию grep нет user_prompt_classification, нет шаблонов соответствия русским/английским запросам пользователя, нет механизмов типа clarif* / intentDetect

Влияние на дизайн:

  • Поле user_prompt_classification: 'query' | 'action' | 'analysis' в схеме журнала решений §5.4 не имеет источника данных — его нельзя вывести из существующего PromptSuggestionEvent, ни прочитать из ChatRecord
  • Частота запросов пользователя “подробнее” из §5.2 — тот же сигнал мониторинга, ближайшая существующая точка привязки followupState.onOutcome непригодна для повторного использования
**建议修正**: - §3.2 前置条件中追加"用户输入分类器最小实现"(中英文模式匹配,~3d),否则 §5.4 决策日志的 `user_prompt_classification` 与 `requestImpliesFurtherAction` 都缺数据 - 或者**接受**在 Phase 3a dogfood 阶段没有这两个信号,仅靠 `fallback_triggered` 率监控质量回归——成本低但风险高 ### 6.5 发现 4:D4 设计内在矛盾——allowlist 与收益归因不对齐(影响 D4 / §3.4) **事实**: - `Kind.Read`(read_file)、`Kind.Search`(glob / grep)、`Kind.Fetch`(web_fetch)三类工具的 `shouldConfirmExecute` / `getConfirmationDetails`,**绝大多数继承 `BaseToolInvocation` 默认实现,做零 IO**(read_file / glob / grep 完全没 override,web_fetch 只做 5-10 行字符串解析 URL hostname) - 真正有 IO 的是 `Edit` / `WriteFile`(`calculateEdit` + `readTextFile` + `Diff.createPatch`,典型 ~20ms),但 §3.4 方案 A 把它们排除出 allowlist 以规避 TOCTOU - **结果**:留在 allowlist 里的三类工具,prevalidate 与不 prevalidate 工作量基本相同——allowlist 实际拦截的是"唯一有 IO 可省的 Edit",留下"本来就零成本的工具" **对设计的影响**: - §3.4 的"前置 IO 验证"叙事**不成立**:50-100ms 收益的真正来源是 **"stream 完全结束 → 才批量 schedule" 这段调度等待被消除**,与工具端 IO 几乎无关 - 收益归因错误会带来两个问题: 1. **allowlist 可以更宽**——凡是 idempotent prevalidate 的工具都行,不必绑定 `CONCURRENCY_SAFE_KINDS` 2. **5-7d 投入难以自洽**——如果真实收益只有调度模型改变的 ~50ms,Edit 又不在 allowlist 里,这笔投入的 ROI 比设计文档暗示的低 **建议修正**:§3.4 重写收益归因—— - 拆分为两部分:(a) 调度模型改变省下的 stream 等待 ~50ms,(b) 工具端 IO 前置可省的工作量 ~0ms(allowlist 内)/ ~20ms(若 Edit 入 allowlist) - 在 §4.1 综合评估表里把 D4 RT 收益从 "50-200ms" 改为 "30-80ms(方案 A,主要来自调度模型)/ 100-200ms(方案 B,含 Edit)" - 在 §4.2 路线图中把 D4 进一步降级——纯调度模型改造可独立做,不必强行绑定 prevalidate 概念 ### 6.6 对路线图的合并影响 | 章节 | 原估时 | 验证后估时 | 增量来源 | | ----------------------------- | ------ | ------------ | ------------------------------------------------------------------------------------------------ | | D2 §3.2 工作量(§4.1 细分表) | 9d | **14-16d** | +2d(发现 1 前置工具改造)+1d(发现 2 turn.ts finally 改造)+3d(发现 3 输入分类器,如取硬路径) | | D4 §3.4 综合评估 | 5-7d | 5-7d(不变) | 工作量不变,但 **RT 收益归因从"工具端 IO"改为"调度模型"**,投入 ROI 下调 | | Phase 3 总时长(§4.2) | ~3 周 | **~4-5 周** | D2 工作量上调 + 前置工具改造 PR 单独走 review 周期 | **对原路线图的修正建议**: 1. **保持 D1(P0)和 D3 紧随其后**——本次验证未触及它们的核心假设,ROI 判断不变 2. **D2 启动条件加严**——把发现 1/2/3 的前置工作(共 ~6d)作为 "D2 启动 gate",未完成不进入 §3.2 前置实验 3. **D4 重新评估优先级**——既然真实收益是调度模型改变而非工具端 IO,要么 (a) 接受 30-80ms 把 D4 降到 P3 后置,要么 (b) 考虑方案 B(Edit + mtime/hash)拿回 100-200ms 但额外 5-7d 4. **不修改 §1.2 单次采样基线**——但 §5.1 P95 一栏在 D1 落地、补完 ≥3 类场景基线之前不写具体数字 ### 6.7 验证未覆盖的追问点 以下追问点属于主观判断或作者意图问题,本次验证未通过 subagent 处理,留作后续 design review 讨论: - D2 实施次序应否后置于 D3(主观次序) - D1/D3 是否应合并到 Phase 1 一起做(实施策略) - §3.2 `needsCrossResultReasoning` 阈值 ≥3 是否反向拟合 §1.2 基线场景(作者意图) - §5.7 关键代码位置表的行号锚点是否应改为符号锚点(文档稳定性) --- ## 7. 浮油评估与下一步(2026-05-26 二次 review) ### 7.1 触发本次重排的事实 §6 验证之后,又发现两个**改变 ROI 判断的事实**: 1. **DashScope `cache_control` 已实装**(`packages/core/src/core/openaiContentGenerator/provider/dashscope.ts:172-181`) - streaming 请求标记 `system + 最后一条 message + 最后一个 tool definition` - 命中数据 `cached_tokens` 已采集到 `usageMetadata.cachedContentTokenCount`(`converter.ts:1124-1149`) - 这是 prefix cache 机制:Round N+1 自动命中 Round N 写入的前缀 - **summary 轮恰好是命中前缀最长的一轮** 2. **system prompt 已经稳态**(`prompts.ts` 审计结果) - 没有 cwd / timestamp / git status / 文件列表 / LSP 状态等"每 turn 都变"的硬伤 - `process.cwd()` 仅用作 `isGitRepository()` 开关,不写入 prompt 内容 - 唯一动态点:`save_memory` 工具触发 / `/model` 切换 / MCP 动态加载(均事件性,低频) ### 7.2 这两条事实改变了 D2 的 ROI 判断 §3.2 文档假设 "fast model 比 primary 快 ~2s",对照基线是 **primary uncached vs fast uncached**。 但现实运行中 primary 是 **cached**(summary 轮恰好命中最强),所以正确对照是: > primary cached vs fast uncached | 路由 | 估算延迟 | 备注 | | ----------------------------- | --------- | ------------------------ | | primary 命中 80% 前缀 cache | ~1.8-2.2s | summary 轮的当前实际表现 | | fast 无 cache(跨模型不共享) | ~1.5-2s | D2 切换后的实际表现 | **净差距:几百毫秒,甚至可能 fast 反而慢**。叠加 14-16d 工程成本 + 质量风险 + fallback 浪费,**D2 净收益接近 0 或负**。 §3.2 前置条件**必须新增**:基线测量必须对比 primary **cached** vs fast **uncached**,且 `T_primary_cached < T_fast_uncached × 1.5` 时 D2 不应启用。 ### 7.3 候选清单(按浮油性重排) **真·浮油(立刻动手,< 1d 投入,极低风险,确定收益)**: | 项 | 投入 | 收益 | 操作位置 | | ----------------------------- | ----- | --------------------------------- | --------------------------------------------------------------------------- | | 简洁回复指令 | 30min | ~2s/summary 轮(输出 token 减半) | `prompts.ts` Final Reminder 段加一句 | | 暴露 cache hit rate telemetry | 0.5d | 0s 直接,是后续决策 **enabler** | `cachedContentTokenCount` 已采集,缺暴露;并应识别 `save_memory` 后单独打标 | **近浮油(等数据决定,0.5-1d 投入)**: | 项 | 投入 | 收益 | 决策前置 | | ------------------------------- | --------------------- | --------------------------------------- | --------------------------------------------------------------------- | | summary 轮 `tool_choice='none'` | 0.5-1d | 0.3-1s(sampling 跳过 tool_call token) | 需"是 summary 轮"判定逻辑,错判风险低 | | summary 轮关 thinking | 1d | 0.5-2s | 仅对启用 thinking 的模型有意义(qwen3.5-plus、glm-4.7、kimi-k2.5 等) | | UI 渲染层 chunk batching | 0.5d 调研 + 0.5d 实施 | 待验证 | 假设:长 summary 的 `useGeminiStream` token 渲染累计开销不小 | **待调研(可能是大鱼)**: | 项 | 调研投入 | 潜在收益 | 关键未知 | | ------------------------------------ | ------------------------ | ------------------- | ------------------------------------------------------------------------------------------ | | ~~DashScope `scope: 'global'` 支持~~ | ~~0.5d 文档 + 0.5d A/B~~ | ~~跨 session 命中~~ | **已调研,结论 (c) 不可行**(见 §7.4 发现 B 调研结果)。此行保留作为决策记录,不要重启调研 | **中等改造(不算浮油,单独评估)**: | 项 | 投入 | 风险 | 收益 | | --------------------------------- | ---------------- | ---- | ----------- | | D1 `skipLlmRound`(终态查询场景) | 2-3d | 中 | 3-4s/终态轮 | | summary 轮工具结果裁剪(D5 子集) | 2d | 中 | 1-2s | | D3 `Summarizing` 状态 | 3-5d | 中 | 感知改善 3s | | system prompt 减肥 | 2-3d 含 A/B 测试 | 中 | 0.5-1s | **已废弃方向(不要再做)**: | 项 | 废弃原因 | | ------------------------------------------ | ------------------------------------------------------ | | D2 fast model 路由 | 被 DashScope cache 抵消,净收益接近 0 或负 | | D4 prevalidate | 收益归因错(真实仅 ~50ms 来自调度模型),5-7d 投入不值 | | system prompt 稳定化 | 已稳态,无事可做 | | 流式提前 terminal(提前 abort 收尾客套话) | 高误判风险,用户感知答案被切断 | ### 7.4 三个值得展开的新发现 #### 发现 A:`tool_choice='none'` 的真实机制 OpenAI / DashScope API 里 `tool_choice='none'` 不仅是"禁止调工具"——模型 sampling 阶段会**完全跳过 `<tool_call>` 特殊 token 的概率分配**,decoder 直接走自然语言生成路径。收益不在"省一两次 retry",而在 sampling 本身更快。 #### 发现 B:`scope: 'global'` 在仓库已有 Anthropic 先例 `packages/core/src/core/anthropicContentGenerator/converter.test.ts:85, 1543` 已有 `cache_control: { type: 'ephemeral', scope: 'global' }` 用法。但 `provider/dashscope.ts:288` 标 cache_control 时**没传 scope**: ```typescript cache_control: { type: 'ephemeral' }, // 没有 scope

若 DashScope 服务端识别 scope: 'global'

  • system + tools 升级为 global cache(TTL 远大于 ephemeral 的 5min)
  • 跨 session 命中,启动延迟也降
  • 单这一条收益可能超过原 D2 全部假设收益
调研结果(2026-05-26,结论:(c) 不可行,关闭此线)

通过查阿里云百炼官方文档 help.aliyun.com/zh/model-studio/context-cache 得到的事实清单:

问题结论证据
scope 字段支持不支持。仅识别 type: 'ephemeral',任何 scope/persistent/global 会被 silently dropped官方文档原文:“仅支持将 type 设置为 ephemeral
ephemeral 实际 TTL5 分钟滑动窗口(命中后重置)百炼文档明确说明
长 TTL / 全局机制无任何公有云 API 端机制。无 persistent type 值、无独立预上传 API、无 prompt_cache_key;唯一”全局持久”产品是 PAI 全局上下文缓存(自部署 + vLLM + 灵骏 + 共享 Redis),与 DashScope API 无关PAI 文档
跨 session 共享同账号 + 同模型 + 内容匹配 → 已经命中(这就是 ephemeral 已经在做的);不同账号绝对不共享百炼文档
定价cache write 125%、显式 cache read 10%、隐式 cache read 20%(无 cache_control 标记也能拿到隐式 20% 折扣)百炼定价文档
最小可缓存 prompt1024 tokens百炼文档
模型支持(显式 cache)qwen3.7-max / qwen3.6-plus / qwen3.5-plus / qwen3-coder-plus / qwen3-vl-plus / deepseek-v3.2 / kimi-k2.5 / glm-5.1 均显式列出。qwen3.6-plus 与 qwen3.7-max 同样享受 90% 显式 cache 折扣百炼模型列表(2026-05-26 重核)

几条副发现的连带意义

  1. TTL 滑动窗口 对 agent loop 是好消息——loop 内连续调用间隔通常 < 30s,cache 永远新鲜,不会 5min 失效
  2. 隐式 cache 20% 折扣 是免费红利——即使没标 cache_control 也能拿;但精细控制需要显式
  3. qwen3.6-plus 未在显式列表 —— 更正(2026-05-26):经重核,qwen3.6-plus 确实在显式 cache 列表里,享受 90% 折扣。前一轮报告此处错误,已于本节首张表更正
  4. dashscope.ts:288 当前做法已经是 DashScope 公有云 API 的能力上限——没有继续榨的空间

对 §7.2 D2 判断的连带加强

TTL 滑动窗口意味着 agent loop 内 summary 轮几乎 100% 命中 primary 的 cache(前几轮刚刚命中过、5min 内)。D2 切 fast model 不仅会打碎累计的 cache 写入链,还会让 summary 轮从”近 100% 命中”退化为”完全 miss”——净收益判断比 §7.2 原假设更明确为负。

发现 C:UI 渲染层是被忽视的盲区

§1.2 基线把”框架开销”标为 0.3s(3%),但这是粗估。Ink 7 + React 19.2 在每个 chunk 触发 setState → re-render,长 summary 累计可能 200-500ms。需要查 useGeminiStream 怎么处理 token 流,有没有 requestAnimationFrame / useDeferredValue 合并 chunk。

7.5 待数据 checkpoint —— 数据到了该看哪个决策

本节是这份文档的活动入口:后续有任何度量数据,对照下表决定该回看哪个决策。

Checkpoint 1:cache hit rate 数据出来后

触发条件:浮油”暴露 cache hit rate telemetry”上线 ≥3 天,决策日志含 cached_tokens / prompt_tokens 分布。

该看的数据

  • 整体命中率(cached / prompt)的 P50、P90 分布
  • 按轮次划分:Round 1 / Round 2 / Round 3 (summary) 各自命中率
  • save_memory 触发后下一轮命中率(应该接近 0)
  • /model 切换后下一轮命中率(应该接近 0)

决策路径

整体命中率含义行动
> 70%现状已经接近理论上限只做 #1 简洁指令 + 发现 B 调研;其余浮油按需
40-70%还有空间但来源不明分析按轮次命中率,找出哪一段在 miss
< 40%有动态点在打 cache重新审计 system prompt / userMemory 触发频率;可能 save_memory 比预期频繁

Checkpoint 2:DashScope scope: 'global' 文档调研结果 ✅ 已完成(2026-05-26)

结果完全不识别。详见 §7.4 发现 B 的”调研结果”段。

已执行行动:接受现状,跳过此项。dashscope.ts:288 维持现有 ephemeral 标记,无需改造。

后续不要重新启动此调研——除非 DashScope 官方公告新增持久化机制。

Checkpoint 3:UI 渲染层调研结果

触发条件:发现 C 调研完成(看 useGeminiStream token 流处理 + Ink/React DevTools 实测)。

决策路径

结果行动
长 summary stream 渲染累计 > 200ms改用 batching(useDeferredValue 或自定义节流)
渲染开销 < 100ms关闭此线索

Checkpoint 4:完成”真·浮油”后的二次基线测量

触发条件:#1 简洁指令 + Checkpoint 1/2/3 决策完成 ≥1 周。

该看的数据

  • 端到端 RT P50 与 §1.2 单次采样基线(13.4s)对比
  • summary 轮单独的 P50 / P95
  • 用户追问率(如果浮油 A 顺带做了用户输入分类)

决策路径

累计节省行动
> 4s(达到 9.6s 端到端 P50)评估 D1 skipLlmRound(再省 3-4s/终态轮)
2-4s接受现状,评估 D3 感知改善是否值得做
< 2s重新审视:是否浮油本身被高估,还是有未识别的瓶颈(网络 RTT、provider 端延迟)

7.6 与 §3 各方向的最终判定

基于 §6 验证 + 本节 ROI 重排:

方向§3 原优先级本节判定理由
D1 工具后置指令P0P0 保留,但等浮油完成后再评估ROI 仍然好,但不再”立刻就做”——先把更便宜的浮油拿掉
D2 summary fast 路由P1Defer / Won’t Fix被 DashScope cache 抵消,14-16d 投入换接近 0 收益
D3 展示解耦P1保留为可选,看 Checkpoint 4 数据感知改善确定,但绝对 RT 不变,依赖用户行为
D4 流式提前调度P2Defer收益归因错,真实 ~50ms 不值 5-7d

7.7 推荐执行顺序

Day 1(可单人单日完成):

  • prompts.ts 加简洁回复指令(30min)
  • cachedContentTokenCount 暴露到 telemetry + save_memory / /model 切换打标(0.5d)
  • ✅ 启动发现 B 调研:DashScope scope: 'global' 文档查询 + 现有 Anthropic 用法对照(0.5d)

Day 2-3

  • 收第一批 cache hit rate 数据
  • 启动发现 C 调研:useGeminiStream 的 React 渲染路径
  • 根据 Checkpoint 2 决定要不要做 scope: 'global' 改造

Week 1 末

  • Checkpoint 1 数据决策(看分布)
  • 决定要不要做 tool_choice='none' / 关 thinking(根据 hit rate 数据)

Week 2-3

  • Checkpoint 4 二次基线测量
  • 决定是否启动 D1(最大的非浮油项,3-4s/终态轮)

始终不做:D2 / D4 / system prompt 稳定化。

7.8 prompts.ts 动态内容审计(2026-05-27)

§7.1 给出 “system prompt 已稳态” 的结论时只做了粗略 grep。本节是对 packages/core/src/core/prompts.ts(1169 行)的系统性审计,列清单作为后续 cache 命中率分析与浮油决策的依据。

审计方法:枚举所有 ${...} 插值表达式、IIFE、process.* / new Date / Date.now / Math.random / fs.* 调用,对每一处判断”在同一 session 内是否会变化”。

完全没有(常被怀疑的硬伤)

候选代码事实
Date.now() / new Date()全文 零次出现rg 全无匹配)
Math.random()零次出现
process.cwd() 值写入 prompt仅 L366 if (isGitRepository(process.cwd())) { ... }值不写入字符串,只作开关
git status / git branch 子进程调用零次,git 段是静态指导文本
当前文件列表 / 项目结构注入零次
LSP 状态 / 错误数零次
用户输入历史零次(history 走 messages,不在 system)

启动时一次,session 内不变

位置内容何时可能变
L190process.env['QWEN_SYSTEM_MD'] 决定 basePrompt 来源(默认 vs 用户 system.md)进程内不变
L342-343process.env['SANDBOX'] 决定 sandbox 段选哪一版(Seatbelt / Sandbox / Outside)进程内不变
L366isGitRepository(process.cwd()) 决定 git 段是否插入cwd 同 session 内通常不变
L871process.env['QWEN_CODE_TOOL_CALL_STYLE'] 决定 tool call 风格(qwen-coder / qwen-vl / general)进程内不变

事件触发(低频)

参数触发条件频率估计
userMemorygetCoreSystemPrompt 第 1 参)save_memory 工具 / /memory refresh / 扩展加载0-3 次/session
model 名(影响 getToolCallExamples 选哪一支)/model 切换罕见
appendInstruction配置项,session 内基本不变几乎从不
deferredToolsbuildDeferredToolsSectionMCP 工具动态加载session 启动期居多

一个隐蔽的小坑

L207-209:若设置了 QWEN_SYSTEM_MD env,每次 getCoreSystemPrompt 都会 fs.readFileSync(systemMdPath)

const basePrompt = systemMdEnabled ? fs.readFileSync(systemMdPath, 'utf8') : `...`;
  • 文件不变时内容稳定 → cache 命中不受影响
  • 但每轮 LLM 调用都有一次同步 IO(默认 .qwen/system.md,网络挂载文件会更慢)
  • 不影响本节”cache 友好性”结论,仅作为已知性能小坑记录

连带结论

  1. system prompt 在稳态 session 内每次产出 byte-for-byte 一致 → DashScope ephemeral cache key(基于内容 hash)整段稳定 → system 段 cache 命中率几乎 100%
  2. 唯一打 cache 的事件是 save_memory——核心功能,不能为 cache 让路
  3. 浮油 #1(简洁回复指令)的代价分析:把指令加到 Final Reminder 段(L389-390)→ system prompt 内容改变一次 → 首次请求 cache miss(一次性预热成本),之后所有请求继续命中
  4. §7 的 “system prompt 稳定化” 已废弃判断得到正式证据支持——不仅没必要做,连”理论上做了能进一步降低 cache miss 率”都不成立,因为本来就 ≈ 0
  5. 本审计可作为后续相关讨论的引用基线,避免重复 grep;若 prompts.ts 有大改动,本节需要同步更新
Last updated on