[feature][ai-chat] Реалтайм-счётчик токенов (включая токены размышления), как в Claude Code #151

Closed
opened 2026-06-24 05:15:34 +03:00 by Ghost · 0 comments

Цель

Счётчик токенов, который тикает в реальном времени во время генерации и отдельно показывает токены размышления (reasoning/thinking) — как в Claude Code: строка вида Thinking… · 60 tokens рядом с индикатором размышления, а не только итог после ответа.

Сейчас токены учитываются только пост-фактум: в onFinish пишутся usage/contextTokens, а бейдж в шапке окна обновляется лишь при открытии/переключении чата и мид-стрим не тикает (помечено как ограничение v1). Reasoning не запрашивается и не отображается.

Что уже есть (опора для фичи)

Чат на Vercel AI SDK v6 (сервер и клиент).

  • Сервер: streamText(...)result.pipeUIMessageStreamToResponse(...) в apps/server/src/core/ai-chat/ai-chat.service.ts. Колбэк messageMetadata уже задействован (вешает chatId на часть start, см. chatStreamStartMetadata) — готовая точка для проброса usage в реальном времени.
  • Токены берутся только в onFinish (totalUsage/usage), пишутся в metadata строки (usage, contextTokens).
  • Клиент: useChat (chat-thread.tsx) на каждую streamed-дельту подменяет объект сообщения → компоненты, читающие message.parts, перерисовываются на каждый тик. Это и есть готовый «реалтайм-движок».
  • TypingIndicator (typing-indicator.tsx) висит именно в фазе размышления (reasoning-парты сейчас не считаются «видимым контентом» — message-content.ts, и игнорируются в message-item.tsx). Идеальное место для Thinking… · N tokens.
  • Готовая инфраструктура: formatTokens() (1.2M/3.4k/950), бейдж контекста в шапке, contextTokens из метаданных (ai-chat-window.tsx).
  • Типы метаданных (ai-chat.types.ts): есть usage.inputTokens/outputTokens/totalTokens, contextTokens; нет reasoningTokens.

Ключевой факт про источники данных (AI SDK v6)

Три канала, разные по точности/гранулярности:

Источник Что даёт Гранулярность Зависит от
reasoning-парты в стриме (part.type==='reasoning') текст размышления дельтами по дельте (плавно) провайдер должен стримить текст размышления
messageMetadata на finish-steppart.usage авторитетный usage за шаг, вкл. reasoningTokens по шагу провайдер возвращает usage
onFinishtotalUsage/usage итог за весь ход один раз в конце всегда

Следствия:

  • Текст размышления ст��имят не все (OpenRouter/Gemini/DeepSeek-R1/Anthropic-через-шлюз/z.ai — да; OpenAI o-серия через Chat Completions, а проект использует .chat(), — только число reasoningTokens в конце шага, без текста).
  • Точного per-token usage в реальном времени API не отдаёт никто. Поэтому «плавная» цифра — это всегда оценка на клиенте, сверяемая с авторитетной на границах шага и в конце (ровно как в Claude Code).

Предлагаемая архитектура — гибрид «живая оценка + авторитетная сверка»

СЕРВЕР: streamText --reasoning/text deltas--> pipeUIMessageStreamToResponse
  messageMetadata({part}):
    'start'       -> { chatId }              (как сейчас)
    'finish-step' -> { usage: part.usage }   (НОВОЕ: авторитетно, c reasoningTokens)
    'finish'      -> { usage: totalUsage }   (НОВОЕ)
        │ SSE (UI message stream)
        ▼
КЛИЕНТ (useChat, перерисовка на каждой дельте):
  • LIVE-оценка: длина reasoning+text партов tail-сообщения / ~4   (дёшево, плавно)
  • пришёл message.metadata.usage -> показываем АВТОРИТЕТНУЮ цифру вместо оценки
  Отображение:
    • фаза размышления -> TypingIndicator: "Thinking… · N tokens"
    • шапка окна -> живой бейдж токенов хода (in+out+reasoning), тикает мид-стрим

Почему так:

  • Реалтайм-движок уже есть (перерисовка useChat).
  • Настоящий BPE-токенайзер на каждой дельте нельзя: O(n²) за ход, тяжёлый bundle, неверно для Gemini/Ollama. Поэтому живая цифра — дешёвая эвристика (символы/≈4), точность даёт сервер на границах шагов.

Точки изменений

Сервер (apps/server/src/core/ai-chat/ai-chat.service.ts):

  • Расширить chatStreamStartMetadata (или ввести chatStreamMetadata): на finish-step и finish возвращать { usage: part.usage } (включает reasoningTokens). Точка вызова — опции pipeUIMessageStreamToResponse. Чистая функция → юнит-тест.
  • Явно зафиксировать sendReasoning: true (в v6 по умолчанию true).
  • (Опц.) Точечные providerOptions для включения reasoning у поддерживающих провайдеров (gemini thinkingConfig.includeThoughts, openai-совместимые шлюзы) — аккуратно/конфигурируемо.
  • В onFinish добавить reasoningTokens в сохраняемый metadata.usage.

Клиент:

  • Новый util count-stream-tokens.ts: чистые estimateTokens(text) и liveTurnTokens(message){ reasoning, output, authoritative? } (отдаёт авторитет, если пришёл metadata.usage, иначе оценку). Юнит-тестируемо.
  • typing-indicator.tsx: проп thinkingTokens? → дорисовать · {{count}} tokens; считать в message-list.tsx из tail-сообщения.
  • message-item.tsx / message-content.ts (слой 2): рендер reasoning как сворачиваемого блока «Размышление» с его счётчиком.
  • ai-chat-window.tsx: живой бейдж токенов хода мид-стрим (throttle ~5–10 Гц, чтобы не ререндерить окно на каждую дельту); по завершении — обратно к persisted contextTokens.
  • ai-chat.types.ts: добавить reasoningTokens?: number в usage; синхронно — экспорт в Markdown (chat-markdown.ts).
  • i18n: строка "Thinking… · {{count}} tokens".

План (поэтапно)

  • Этап A (MVP «как на скриншоте»): только живая оценка на клиенте + Thinking… · N tokens в индикаторе. Сервер не трогаем. Быстро закрывает референс.
  • Этап B (поверх A): сервер пробрасывает usage на finish-step/finish; клиент сверяет оценку с фактом; живой бейдж в шапке; блок «Размышление»; reasoningTokens в типах/persist/экспорте.

Util liveTurnTokens спроектирован так, что B встраивается без переделки A.

Краевые случаи

  • Провайдер не стримит текст размышления → показываем reasoningTokens из usage по приходу шага (B); на A — только output-оценку.
  • Расхождение оценки и факта → цифра «допрыгивает» до точной при приходе usage (как в Claude Code).
  • Производительность: эвристика на горячем пути, throttle бейджа.
  • Мультишаговый ход (до MAX_AGENT_STEPS=20): копим reasoning/output по всем шагам.
  • Abort/error: партиал уже сохраняется; живая цифра замораживается на последнем значении.
  • Разные токенайзеры провайдеров: лайв-оценка провайдер-независима, точность — из usage самого провайдера.
  • Совместимость со старой историей: новых обязательных полей нет, reasoningTokens опционально.

Definition of Done

  • Во время генерации индикатор показывает Thinking… · N tokens, число тикает в реальном времени.
  • Reasoning-токены учитываются отдельно (текстом, если провайдер стримит; иначе по usage.reasoningTokens).
  • Живой счётчик токенов хода в шапке окна обновляется мид-стрим.
  • По завершении хода оценка заменяется авторитетными числами из usage провайдера.
  • reasoningTokens сохраняется в метаданных и попадает в Markdown-экспорт.
  • Юнит-тесты на чистые функции (liveTurnTokens/estimateTokens, расширенный chatStreamMetadata).
## Цель Счётчик токенов, который тикает **в реальном времени во время генерации** и отдельно показывает **токены размышления** (reasoning/thinking) — как в Claude Code: строка вида `Thinking… · 60 tokens` рядом с индикатором размышления, а не только итог после ответа. Сейчас токены учитываются только **пост-фактум**: в `onFinish` пишутся `usage`/`contextTokens`, а бейдж в шапке окна обновляется лишь при открытии/переключении чата и **мид-стрим не тикает** (помечено как ограничение v1). Reasoning не запрашивается и не отображается. ## Что уже есть (опора для фичи) Чат на **Vercel AI SDK v6** (сервер и клиент). - Сервер: `streamText(...)` → `result.pipeUIMessageStreamToResponse(...)` в `apps/server/src/core/ai-chat/ai-chat.service.ts`. Колбэк `messageMetadata` уже задействован (вешает `chatId` на часть `start`, см. `chatStreamStartMetadata`) — **готовая точка** для проброса usage в реальном времени. - Токены берутся только в `onFinish` (`totalUsage`/`usage`), пишутся в `metadata` строки (`usage`, `contextTokens`). - Клиент: `useChat` (`chat-thread.tsx`) на каждую streamed-дельту подменяет объект сообщения → компоненты, читающие `message.parts`, перерисовываются на каждый тик. **Это и есть готовый «реалтайм-движок».** - `TypingIndicator` (`typing-indicator.tsx`) висит именно в фазе размышления (reasoning-парты сейчас не считаются «видимым контентом» — `message-content.ts`, и игнорируются в `message-item.tsx`). **Идеальное место для `Thinking… · N tokens`.** - Готовая инфраструктура: `formatTokens()` (`1.2M/3.4k/950`), бейдж контекста в шапке, `contextTokens` из метаданных (`ai-chat-window.tsx`). - Типы метаданных (`ai-chat.types.ts`): есть `usage.inputTokens/outputTokens/totalTokens`, `contextTokens`; **нет `reasoningTokens`**. ## Ключевой факт про источники данных (AI SDK v6) Три канала, разные по точности/гранулярности: | Источник | Что даёт | Гранулярность | Зависит от | |---|---|---|---| | `reasoning`-парты в стриме (`part.type==='reasoning'`) | текст размышления дельтами | по дельте (плавно) | провайдер должен **стримить текст** размышления | | `messageMetadata` на `finish-step` → `part.usage` | авторитетный usage за шаг, вкл. `reasoningTokens` | по шагу | провайдер возвращает usage | | `onFinish` → `totalUsage`/`usage` | итог за весь ход | один раз в конце | всегда | Следствия: - Текст размышления ст��имят не все (OpenRouter/Gemini/DeepSeek-R1/Anthropic-через-шлюз/z.ai — да; OpenAI o-серия через Chat Completions, а проект использует `.chat()`, — только число `reasoningTokens` в конце шага, без текста). - **Точного per-token usage в реальном времени API не отдаёт никто.** Поэтому «плавная» цифра — это всегда **оценка на клиенте**, сверяемая с авторитетной на границах шага и в конце (ровно как в Claude Code). ## Предлагаемая архитектура — гибрид «живая оценка + авторитетная сверка» ``` СЕРВЕР: streamText --reasoning/text deltas--> pipeUIMessageStreamToResponse messageMetadata({part}): 'start' -> { chatId } (как сейчас) 'finish-step' -> { usage: part.usage } (НОВОЕ: авторитетно, c reasoningTokens) 'finish' -> { usage: totalUsage } (НОВОЕ) │ SSE (UI message stream) ▼ КЛИЕНТ (useChat, перерисовка на каждой дельте): • LIVE-оценка: длина reasoning+text партов tail-сообщения / ~4 (дёшево, плавно) • пришёл message.metadata.usage -> показываем АВТОРИТЕТНУЮ цифру вместо оценки Отображение: • фаза размышления -> TypingIndicator: "Thinking… · N tokens" • шапка окна -> живой бейдж токенов хода (in+out+reasoning), тикает мид-стрим ``` Почему так: - Реалтайм-движок уже есть (перерисовка `useChat`). - Настоящий BPE-токенайзер на каждой дельте нельзя: O(n²) за ход, тяжёлый bundle, неверно для Gemini/Ollama. Поэтому **живая цифра — дешёвая эвристика** (символы/≈4), **точность даёт сервер** на границах шагов. ## Точки изменений **Сервер** (`apps/server/src/core/ai-chat/ai-chat.service.ts`): - Расширить `chatStreamStartMetadata` (или ввести `chatStreamMetadata`): на `finish-step` и `finish` возвращать `{ usage: part.usage }` (включает `reasoningTokens`). Точка вызова — опции `pipeUIMessageStreamToResponse`. Чистая функция → юнит-тест. - Явно зафиксировать `sendReasoning: true` (в v6 по умолчанию true). - (Опц.) Точечные `providerOptions` для включения reasoning у поддерживающих провайдеров (gemini `thinkingConfig.includeThoughts`, openai-совместимые шлюзы) — аккуратно/конфигурируемо. - В `onFinish` добавить `reasoningTokens` в сохраняемый `metadata.usage`. **Клиент**: - Новый util `count-stream-tokens.ts`: чистые `estimateTokens(text)` и `liveTurnTokens(message)` → `{ reasoning, output, authoritative? }` (отдаёт авторитет, если пришёл `metadata.usage`, иначе оценку). Юнит-тестируемо. - `typing-indicator.tsx`: проп `thinkingTokens?` → дорисовать ` · {{count}} tokens`; считать в `message-list.tsx` из tail-сообщения. - `message-item.tsx` / `message-content.ts` (слой 2): рендер `reasoning` как сворачиваемого блока «Размышление» с его счётчиком. - `ai-chat-window.tsx`: живой бейдж токенов хода мид-стрим (throttle ~5–10 Гц, чтобы не ререндерить окно на каждую дельту); по завершении — обратно к persisted `contextTokens`. - `ai-chat.types.ts`: добавить `reasoningTokens?: number` в `usage`; синхронно — экспорт в Markdown (`chat-markdown.ts`). - i18n: строка `"Thinking… · {{count}} tokens"`. ## План (поэтапно) - **Этап A (MVP «как на скриншоте»)**: только живая оценка на клиенте + `Thinking… · N tokens` в индикаторе. Сервер не трогаем. Быстро закрывает референс. - **Этап B (поверх A)**: сервер пробрасывает `usage` на `finish-step`/`finish`; клиент сверяет оценку с фактом; живой бейдж в шапке; блок «Размышление»; `reasoningTokens` в типах/persist/экспорте. Util `liveTurnTokens` спроектирован так, что B встраивается без переделки A. ## Краевые случаи - Провайдер не стримит текст размышления → показываем `reasoningTokens` из usage по приходу шага (B); на A — только output-оценку. - Расхождение оценки и факта → цифра «допрыгивает» до точной при приходе `usage` (как в Claude Code). - Производительность: эвристика на горячем пути, throttle бейджа. - Мультишаговый ход (до `MAX_AGENT_STEPS=20`): копим reasoning/output по всем шагам. - Abort/error: партиал уже сохраняется; живая цифра замораживается на последнем значении. - Разные токенайзеры провайдеров: лайв-оценка провайдер-независима, точность — из usage самого провайдера. - Совместимость со старой историей: новых обязательных полей нет, `reasoningTokens` опционально. ## Definition of Done - [ ] Во время генерации индикатор показывает `Thinking… · N tokens`, число тикает в реальном времени. - [ ] Reasoning-токены учитываются отдельно (текстом, если провайдер стримит; иначе по `usage.reasoningTokens`). - [ ] Живой счётчик токенов хода в шапке окна обновляется мид-стрим. - [ ] По завершении хода оценка заменяется авторитетными числами из usage провайдера. - [ ] `reasoningTokens` сохраняется в метаданных и попадает в Markdown-экспорт. - [ ] Юнит-тесты на чистые функции (`liveTurnTokens`/`estimateTokens`, расширенный `chatStreamMetadata`).
Ghost added the feature label 2026-06-24 05:15:34 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#151