Технический план оптимизации 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% |
| Выполнение Skill | 1 мс | <1% |
| LLM Round 2 (решение вызвать shell) | 3,0 с | 22% |
| Выполнение Shell | 2,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» |
| Результаты безусловно передаются обратно в LLM | useGeminiStream.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.modelOverride | client.ts:142 → 1598 → turn.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. Принципы проектирования
- Универсальность: План не привязан к конкретному инструменту/skill
- Обратная совместимость: Существующие инструменты продолжают работать без изменений
- Постепенное внедрение + явные сигналы: Стратегия по умолчанию консервативна; авторы инструментов через явные поля opt-in к оптимизации
- Возможность отката: Все оптимизации контролируются feature-флагами; на уровне пользователя можно принудительно отключить
- Честный баланс: Чётко обозначены риски по качеству, стоимости и границы применимости
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)
| Инструмент | skipLlmRound | resultIsTerminal | Примечание |
|---|---|---|---|
read_file | В сочетании со сценарием query-only | true | Содержимое файла и есть ответ |
cat (через shell) | В зависимости от сценария | true | Аналогично read_file |
grep / glob / ls | false | false (по умолчанию) | Результаты часто требуют фильтрации/сортировки/обобщения моделью; на уровне skill явно true в известных сценариях «чистого запроса» |
git status / git log (через shell) | false | true | Вывод уже отформатирован |
| Инструменты 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) — точка вызова
sendMessageStreamL1841 - 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 нарушает состояние чата:
geminiChat.ts:1428при запуске stream сразу добавляетuserContentв history; повторный вызов добавит его снова, что приведёт к дублированиюfunction_responseв историиsendPromiselock (geminiChat.ts:1392, 1398) — после abort нужно гарантировать вызовstreamDoneResolverpendingPartialStateи другие маркеры-инварианты из PR #4176 нужно корректно очистить- Атрибут модели в 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 - Измерить частоту срабатывания
tryCompressP_compact, проверить, что чистая выгода RT =(1 - P_compact) × ΔRT − P_compact × compression_RT > 0 - Включать только если fast P50 ≤ primary P50 × 0,5 и P95 ≤ primary P95 × 0,6
- Запустить 100 summary-раундов (вход с
- Fast-модель и primary-модель должны быть из одного семейства (чтобы избежать различий в кодировании function_response); кросс-семейный выбор должен отклоняться на уровне
getFastModel() - Совместимость
thinkingConfig:- Fast-модель должна совпадать с primary по поддержке
thinkingConfig.includeThoughts; или - Путь fast принудительно устанавливает
includeThoughts: false(как вsideQuery.ts:118-122) - Проверка: fast-модель корректно обрабатывает историю с thought-частей (не выдаёт ошибку, не воспринимает thought как пользовательский ввод)
- Fast-модель должна совпадать с primary по поддержке
Риски и смягчение
| Риск | Серьёзность | Смягчение |
|---|---|---|
| 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):
- Поиск регистрации инструмента
- Построение invocation
- Выполнение
shouldConfirmExecute(кэширование результата) - При
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 / Execute | ❌ | MUTATOR_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 Быстрая маршрутизация summary | 2-3 с/виток summary (требуется замер) | Средняя-высокая (9 д) | Средняя-высокая | Эвристика D2 + эксперимент в главном чате + синхронизация ACP | P1 |
| D3 Разделение отображения | 3-4 с улучшение восприятия (зависит от поведения пользователя) | Средняя (3-5 д, включая исправление инвариантов) | Средний | Исправление инварианта истории из D1 | P1 |
| 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 + discardPendingAssistant | 1,5 д |
| Переработка синхронизации сессии ACP (acp-integration/session/Session.ts) | 1 д |
Исправление спанов телеметрии (разделение requested / actual) | 0,5 д |
Интеграция пользовательской настройки summaryTierStrategy + JSON schema + /config | 0,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 1 | Phase 3 |
|---|---|---|---|
| Сквозной RT P50 (3 витка loop) | 13,4 с | <10 с | <8 с (требуется замер) |
| Сквозной RT P95 | - | <13 с | <12 с (макс. путь fallback) |
| Воспринимаемое время первого результата P50 | 13,4 с | <10 с | <5 с (с D3) |
| Воспринимаемое время первого результата P95 | - | <13 с | <8 с |
| Количество вызовов LLM (пропускаемые сценарии) | 3 | 2 | 2 (быстрее) |
Примечание: Базовый уровень — однократная выборка, перед внедрением нужно дополнить ≥3 сценариями.
5.2 Показатели качества
| Метрика | Базовый уровень | Допустимая деградация |
|---|---|---|
| Точность вызова инструментов (виток summary быстрой модели) | 100% | ≥98% |
| Частота ошибочного использования skipLlmRound (пользователь просит “подробнее”) | - | <1% |
| Частота fallback_triggered быстрой модели | - | <10% (>20% автоматически выключает флаг) |
| Попадание неполного assistant в history во время Summarizing | 0 | 0 (жёстко) |
5.3 Показатели стоимости
| Метрика | Базовый уровень | Цель Phase 3 |
|---|---|---|
| Стоимость токенов на тысячу сессий (виток summary) | 100% | <70% |
| Доля потраченных впустую токенов из-за fallback | 0 | <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: dogfood | Release N | false | Внутренние пользователи включают 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 Известные ограничения
- Базовые данные скудны: однократная выборка не охватывает все шаблоны задач, перед внедрением требуется дополнить сценарии
- Предпосылка быстрой модели: если не существует значительно более быстрой модели с достаточным качеством вызова инструментов в том же семействе → D2 не включается
skipLlmRound— это компромисс качества на скорость: пропуск LLM = отказ от понимания и исправления моделью, применим только для высокоопределённых сценариев- D2 — это компромисс качества+стоимости на скорость: качество быстрой модели ниже, чем primary; путь fallback, наоборот, дороже — необходимо измерить чистую выгоду по журналам решений
- Срабатывание
tryCompressможет ухудшить ситуацию: у быстрой модели меньше контекст, сам compression потребляет вызов LLM — шлюзwouldTriggerCompressionявляется обязательной защитой - Разделение отображения меняет модель взаимодействия: новая модель требует адаптации пользователя; фактическое восприятие выгоды зависит от поведения пользователя
- Сетевая задержка неконтролируема: данное решение уменьшает количество вызовов, а не оптимизирует отдельный вызов
- Прямое подключение Anthropic не покрыто: текущая толерантность чередования зависит от API стиля Qwen / OpenAI
- fastModel-streaming на главном чате впервые внедряется: нет производственных прецедентов, требуется независимый проверочный эксперимент
- Локальный CLI не имеет развёртывания во время выполнения: стратегия выпуска может быть только поэтапным продвижением релизов, не поддерживает быстрое канареечное регулирование
- D2 действует только на интерактивный путь: Subagent / Cron / Notification не получают выгоды, это намеренно
- Долгосрочное влияние смешанной модели на историю неизвестно: после включения D2 витки внутри сессии переключаются между fast/primary, возобновление длинных сессий и связность контекста требуют наблюдения
- Снижение выгоды D4: после исключения Edit из allowlist prevalidate покрывает только инструменты чисто чтения (выгода 50-100 ms); выгода 200ms с Edit требует механизма проверки mtime/hash по варианту B
5.7 Ключевые места в коде
| Файл | Ключевые символы | Расположение |
|---|---|---|
packages/core/src/tools/tools.ts | Интерфейс ToolResult | L422 |
packages/core/src/tools/tools.ts | Перечисление Kind + MUTATOR_KINDS + CONCURRENCY_SAFE_KINDS | L793, L806, L818 |
packages/core/src/tools/tools.ts | DeclarativeTool.kind: Kind (каждый экземпляр Tool содержит) | L165 |
packages/core/src/core/client.ts | SendMessageOptions.modelOverride | L142 |
packages/core/src/core/client.ts | sendMessageStream | L1216 |
packages/core/src/core/client.ts | modelOverride ?? getModel() | L1305, L1598 |
packages/core/src/core/client.ts | turn.run(model, …) | L1707 |
packages/core/src/core/geminiChat.ts | sendMessageStream(model, …) | L1387 |
packages/core/src/core/geminiChat.ts | history.push(userContent) | L1428 |
packages/core/src/core/geminiChat.ts | блокировка sendPromise | L1392 |
packages/cli/src/ui/hooks/useGeminiStream.ts | modelOverrideRef (выбор модели для skill) | L376, L2225 |
packages/cli/src/ui/hooks/useGeminiStream.ts | processGeminiStreamEvents | L1365 |
packages/cli/src/ui/hooks/useGeminiStream.ts | точка вызова sendMessageStream | L1841 |
packages/cli/src/ui/hooks/useGeminiStream.ts | handleCompletedTools | L2038 |
packages/cli/src/ui/hooks/useGeminiStream.ts | submitQuery(ToolResult, …) | L2355 |
packages/core/src/services/toolUseSummary.ts | быстрый side-запрос быстрой модели (непотоковый прецедент) | L108 |
packages/core/src/followup/speculation.ts | потоковая передача быстрой модели (прецедент с forked chat) | L224 |
packages/core/src/config/config.ts | fastModel + getFastModel + setFastModel | L684, L1987, L2021 |
packages/core/src/core/coreToolScheduler.ts | attemptExecutionOfScheduledCalls | L2436 |
packages/core/src/core/coreToolScheduler.ts | runConcurrently + partitionToolCalls | L2473 |
packages/cli/src/acp-integration/session/Session.ts | точки вызова sendMessageStream (путь ACP / IDE) | L705, L965, L1182, L1423 |
packages/core/src/agents/runtime/agent-core.ts | sendMessageStream 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 документа “перед abortstream.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 实际 TTL | 5 分钟滑动窗口(命中后重置) | 百炼文档明确说明 |
| 长 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% 折扣) | 百炼定价文档 |
| 最小可缓存 prompt | 1024 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 重核) |
几条副发现的连带意义:
- TTL 滑动窗口 对 agent loop 是好消息——loop 内连续调用间隔通常 < 30s,cache 永远新鲜,不会 5min 失效
- 隐式 cache 20% 折扣 是免费红利——即使没标
cache_control也能拿;但精细控制需要显式 —— 更正(2026-05-26):经重核,qwen3.6-plus 确实在显式 cache 列表里,享受 90% 折扣。前一轮报告此处错误,已于本节首张表更正qwen3.6-plus未在显式列表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 工具后置指令 | P0 | P0 保留,但等浮油完成后再评估 | ROI 仍然好,但不再”立刻就做”——先把更便宜的浮油拿掉 |
| D2 summary fast 路由 | P1 | Defer / Won’t Fix | 被 DashScope cache 抵消,14-16d 投入换接近 0 收益 |
| D3 展示解耦 | P1 | 保留为可选,看 Checkpoint 4 数据 | 感知改善确定,但绝对 RT 不变,依赖用户行为 |
| D4 流式提前调度 | P2 | Defer | 收益归因错,真实 ~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 内不变
| 位置 | 内容 | 何时可能变 |
|---|---|---|
| L190 | process.env['QWEN_SYSTEM_MD'] 决定 basePrompt 来源(默认 vs 用户 system.md) | 进程内不变 |
| L342-343 | process.env['SANDBOX'] 决定 sandbox 段选哪一版(Seatbelt / Sandbox / Outside) | 进程内不变 |
| L366 | isGitRepository(process.cwd()) 决定 git 段是否插入 | cwd 同 session 内通常不变 |
| L871 | process.env['QWEN_CODE_TOOL_CALL_STYLE'] 决定 tool call 风格(qwen-coder / qwen-vl / general) | 进程内不变 |
事件触发(低频)
| 参数 | 触发条件 | 频率估计 |
|---|---|---|
userMemory(getCoreSystemPrompt 第 1 参) | save_memory 工具 / /memory refresh / 扩展加载 | 0-3 次/session |
model 名(影响 getToolCallExamples 选哪一支) | /model 切换 | 罕见 |
appendInstruction | 配置项,session 内基本不变 | 几乎从不 |
deferredTools(buildDeferredToolsSection) | MCP 工具动态加载 | 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 友好性”结论,仅作为已知性能小坑记录
连带结论
- system prompt 在稳态 session 内每次产出 byte-for-byte 一致 → DashScope ephemeral cache key(基于内容 hash)整段稳定 → system 段 cache 命中率几乎 100%
- 唯一打 cache 的事件是
save_memory——核心功能,不能为 cache 让路 - 浮油 #1(简洁回复指令)的代价分析:把指令加到 Final Reminder 段(L389-390)→ system prompt 内容改变一次 → 首次请求 cache miss(一次性预热成本),之后所有请求继续命中
- §7 的 “system prompt 稳定化” 已废弃判断得到正式证据支持——不仅没必要做,连”理论上做了能进一步降低 cache miss 率”都不成立,因为本来就 ≈ 0
- 本审计可作为后续相关讨论的引用基线,避免重复 grep;若 prompts.ts 有大改动,本节需要同步更新