[bug][ai-chat] Счётчик токенов в шапке не тикает в реальном времени между шагами агента (регрессия #151) #163

Closed
opened 2026-06-24 14:44:29 +03:00 by Ghost · 0 comments

Симптом

Счётчик токенов в шапке окна AI-чата (бейдж «Tokens generated this turn», напр. 571) не тикает в реальном времени во время многошагового ответа агента. Он замирает и прыгает скачками, а не растёт плавно. Та же проблема замораживает нижнюю строку Thinking… · N tokens, как только у хода появилась авторитетная статистика usage.

Регрессия/недоделка фичи #151 (реалтайм-счётчик токенов).

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

  1. Открыть AI-чат, задать вопрос, требующий нескольких шагов агента (несколько tool-вызовов: поиск, crawl), затем размышление и ответ.
  2. Смотреть на бейдж токенов в шапке во время стрима.

Ожидается: счётчик плавно растёт по мере генерации (как Thinking… · N tokens в Claude Code).
По факту: счётчик «висит» на значении границы предыдущего шага и подскакивает скачком только на границе следующего шага.

Причина

Вся арифметика — в apps/client/src/features/ai-chat/utils/count-stream-tokens.ts, функция liveTurnTokens():

const usage = metadataUsage(message);
if (usage) {
  // АВТОРИТЕТНАЯ ветка: вернуть точные числа сервера ВЕРБАТИМ
  const reasoning = usage.reasoningTokens ?? 0;
  const output = Math.max(0, (usage.outputTokens ?? 0) - reasoning);
  return { reasoning, output, authoritative: true };  // оценку текущего шага ИГНОРИРУЕТ
}
// ниже — ветка оценки по тексту (chars/4), которая как раз тикает

Сервер шлёт точные usage не на каждый токен, а только на границах шагов агента — messageMetadata на finish-step/finish (apps/server/src/core/ai-chat/ai-chat.service.ts, ~539-547). Клиентский useChat мёржит метаданные заменой.

Поэтому в многошаговом ходе:

  1. Шаг 1 (tool-вызовы): usage ещё нет → работает ветка оценки → счётчик тикает.
  2. finish-step шага 1 → message.metadata.usage заполнено → функция уходит в авторитетную ветку и возвращает значение как есть.
  3. Шаг 2 (размышление + ответ): usage всё ещё равно значению после шага 1, новый finish-step будет только в конце шага → функция возвращает замороженное число, оценку живого незавершённого шага не прибавляет.

Итог: счётчик прыгает скачками на границах шагов и «висит» между ними.

При этом намерение в комментариях кода правильно�� — «estimate while streaming, authoritative once a step reports usage, accumulate across steps» — но авторитетное и оценочное так и не объединены.

Предлагаемое решение

Объединить оба источника покомпонентно через max: авторитетная сумма по завершённым шагам подпирается оценкой текущего шага, берётся бо́льшее.

export function liveTurnTokens(message) {
  if (!message) return { reasoning: 0, output: 0, authoritative: false };

  // Бегущая ОЦЕНКА по всем reasoning/text-частям — растёт на каждом дельта-апдейте.
  let estReasoning = 0, estOutput = 0;
  for (const part of message.parts ?? []) {
    if (part.type === "reasoning") estReasoning += estimateTokens(part.text ?? "");
    else if (part.type === "text") estOutput += estimateTokens(part.text ?? "");
  }

  const usage = metadataUsage(message);
  if (!usage) {
    return { reasoning: estReasoning, output: estOutput, authoritative: false };
  }
  // Авторитетная сумма по ЗАВЕРШЁННЫМ шагам; токены текущего шага в ней ещё не учтены.
  // max по каждому компоненту: между границами тикает оценка, на границе подскок к
  // точному значению, и НИКОГДА не падает вниз.
  const authReasoning = usage.reasoningTokens ?? 0;
  const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
  return {
    reasoning: Math.max(authReasoning, estReasoning),
    output: Math.max(authOutput, estOutput),
    authoritative: true,
  };
}

Свойства решения:

  • Реалтайм. reasoning-компонент тикает через оценку, output держится на авторитетной базе → сумма в бейдже плавно растёт между шагами; строка Thinking… снова тикает.
  • Монотонно. max + сервер шлёт накопительное значение → счётчик никогда не прыгает вниз (это явная цель в комментарии сервера).
  • В итоге точно. На finish-step авторитетное подскакивает к настоящему; на finish ход заканчивается, бейдж штатно возвращается к contextTokens.
  • Не теряет фичу #151. Для провайдеров без стрима текста размышлений, но с reasoningTokens, авторитетное число по-прежнему показывается (max(authoritative, 0)).
  • Обратная совместимость по тестам. Во всех существующих тестах (count-stream-tokens.test.ts, tail-thinking-tokens.test.ts) оценка меньше авторитетного значения, поэтому max возвращает то же самое — они продолжат проходить. Нужно добавить новые тесты на кейс «оценка текущего шага превышает авторитетную базу → значение растёт».

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

  • apps/client/src/features/ai-chat/utils/count-stream-tokens.ts — правка liveTurnTokens() (+ комментарии).
  • apps/client/src/features/ai-chat/utils/count-stream-tokens.test.ts — новые тесты на комбинацию авторитетного и оценки.

Файлы chat-thread.tsx, ai-chat-window.tsx, message-list.tsx менять не нужно — они уже используют liveTurnTokens корректно.

## Симптом Счётчик токенов в шапке окна AI-чата (бейдж «Tokens generated this turn», напр. `571`) **не тикает в реальном времени** во время многошагового ответа агента. Он замирает и прыгает скачками, а не растёт плавно. Та же проблема замораживает нижнюю строку `Thinking… · N tokens`, как только у хода появилась авторитетная статистика usage. Регрессия/недоделка фичи #151 (реалтайм-счётчик токенов). ## Воспроизведение 1. Открыть AI-чат, задать вопрос, требующий нескольких шагов агента (несколько tool-вызовов: поиск, crawl), затем размышление и ответ. 2. Смотреть на бейдж токенов в шапке во время стрима. **Ожидается:** счётчик плавно растёт по мере генерации (как `Thinking… · N tokens` в Claude Code). **По факту:** счётчик «висит» на значении границы предыдущего шага и подскакивает скачком только на границе следующего шага. ## Причина Вся арифметика — в `apps/client/src/features/ai-chat/utils/count-stream-tokens.ts`, функция `liveTurnTokens()`: ```ts const usage = metadataUsage(message); if (usage) { // АВТОРИТЕТНАЯ ветка: вернуть точные числа сервера ВЕРБАТИМ const reasoning = usage.reasoningTokens ?? 0; const output = Math.max(0, (usage.outputTokens ?? 0) - reasoning); return { reasoning, output, authoritative: true }; // оценку текущего шага ИГНОРИРУЕТ } // ниже — ветка оценки по тексту (chars/4), которая как раз тикает ``` Сервер шлёт точные `usage` не на каждый токен, а **только на границах шагов** агента — `messageMetadata` на `finish-step`/`finish` (`apps/server/src/core/ai-chat/ai-chat.service.ts`, ~539-547). Клиентский `useChat` мёржит метаданные заменой. Поэтому в многошаговом ходе: 1. Шаг 1 (tool-вызовы): `usage` ещё нет → работает ветка оценки → счётчик тикает. 2. `finish-step` шага 1 → `message.metadata.usage` заполнено → функция уходит в авторитетную ветку и возвращает значение **как есть**. 3. Шаг 2 (размышление + ответ): `usage` всё ещё равно значению после шага 1, новый `finish-step` будет только в конце шага → функция возвращает замороженное число, **оценку живого незавершённого шага не прибавляет**. Итог: счётчик прыгает скачками на границах шагов и «висит» между ними. При этом намерение в комментариях кода правильно�� — *«estimate while streaming, authoritative once a step reports usage, accumulate across steps»* — но авторитетное и оценочное так и не объединены. ## Предлагаемое решение Объединить оба источника **покомпонентно через `max`**: авторитетная сумма по завершённым шагам подпирается оценкой текущего шага, берётся бо́льшее. ```ts export function liveTurnTokens(message) { if (!message) return { reasoning: 0, output: 0, authoritative: false }; // Бегущая ОЦЕНКА по всем reasoning/text-частям — растёт на каждом дельта-апдейте. let estReasoning = 0, estOutput = 0; for (const part of message.parts ?? []) { if (part.type === "reasoning") estReasoning += estimateTokens(part.text ?? ""); else if (part.type === "text") estOutput += estimateTokens(part.text ?? ""); } const usage = metadataUsage(message); if (!usage) { return { reasoning: estReasoning, output: estOutput, authoritative: false }; } // Авторитетная сумма по ЗАВЕРШЁННЫМ шагам; токены текущего шага в ней ещё не учтены. // max по каждому компоненту: между границами тикает оценка, на границе подскок к // точному значению, и НИКОГДА не падает вниз. const authReasoning = usage.reasoningTokens ?? 0; const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning); return { reasoning: Math.max(authReasoning, estReasoning), output: Math.max(authOutput, estOutput), authoritative: true, }; } ``` Свойства решения: - **Реалтайм.** `reasoning`-компонент тикает через оценку, `output` держится на авторитетной базе → сумма в бейдже плавно растёт между шагами; строка `Thinking…` снова тикает. - **Монотонно.** `max` + сервер шлёт накопительное значение → счётчик никогда не прыгает вниз (это явная цель в комментарии сервера). - **В итоге точно.** На `finish-step` авторитетное подскакивает к настоящему; на `finish` ход заканчивается, бейдж штатно возвращается к `contextTokens`. - **Не теряет фичу #151.** Для провайдеров без стрима текста размышлений, но с `reasoningTokens`, авторитетное число по-прежнему показывается (`max(authoritative, 0)`). - **Обратная совместимость по тестам.** Во всех существующих тестах (`count-stream-tokens.test.ts`, `tail-thinking-tokens.test.ts`) оценка меньше авторитетного значения, поэтому `max` возвращает то же самое — они продолжат проходить. Нужно добавить новые тесты на кейс «оценка текущего шага превышает авторитетную базу → значение растёт». ## Затронутые файлы - `apps/client/src/features/ai-chat/utils/count-stream-tokens.ts` — правка `liveTurnTokens()` (+ комментарии). - `apps/client/src/features/ai-chat/utils/count-stream-tokens.test.ts` — новые тесты на комбинацию авторитетного и оценки. Файлы `chat-thread.tsx`, `ai-chat-window.tsx`, `message-list.tsx` менять не нужно — они уже используют `liveTurnTokens` корректно.
Ghost added the bug label 2026-06-24 14:44:29 +03:00
Ghost closed this issue 2026-06-25 12:49:15 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#163