|
|
|
|
@@ -1,199 +0,0 @@
|
|
|
|
|
# Лимит шагов AI-агента (8 → 20) и принудительный финальный ответ
|
|
|
|
|
|
|
|
|
|
Контекст (симптом из реального чата): на узкий поисковый вопрос («Какой
|
|
|
|
|
процессор в первой версии Яндекс.Колонки?») агент сделал подряд ~8 вызовов
|
|
|
|
|
`Search_tavily_search` / `Search_tavily_extract` и **остановился без текстового
|
|
|
|
|
ответа** — ход завершился пустым. Пользователь отправил «?», что стартовало
|
|
|
|
|
новый ход с новым бюджетом, и агент продолжил. Причина — жёсткий потолок в
|
|
|
|
|
8 шагов на один ход агента: бюджет был израсходован на инструменты раньше, чем
|
|
|
|
|
модель дошла до шага с финальным текстом.
|
|
|
|
|
|
|
|
|
|
Хотим две вещи:
|
|
|
|
|
1. поднять лимит шагов с 8 до 20;
|
|
|
|
|
2. гарантировать непустой ответ — на последнем шаге принудительно запрещать
|
|
|
|
|
инструменты, чтобы модель синтезировала лучший ответ из уже собранного.
|
|
|
|
|
|
|
|
|
|
## Как сейчас устроен лимит (цепочка)
|
|
|
|
|
|
|
|
|
|
Единственная точка ограничения — `stopWhen` в вызове `streamText`:
|
|
|
|
|
|
|
|
|
|
- Импорт условия: `apps/server/src/core/ai-chat/ai-chat.service.ts:7`
|
|
|
|
|
(`stepCountIs` из `ai`).
|
|
|
|
|
- Потолок: `apps/server/src/core/ai-chat/ai-chat.service.ts:247`
|
|
|
|
|
— `stopWhen: stepCountIs(8)` внутри `streamText({...})` (вызов начинается на
|
|
|
|
|
`:237`).
|
|
|
|
|
- Системный промпт, который уходит в `streamText({ system, ... })`, собирается
|
|
|
|
|
заранее в локальной переменной `system`:
|
|
|
|
|
`apps/server/src/core/ai-chat/ai-chat.service.ts:146-150`
|
|
|
|
|
(`buildSystemPrompt({...})`). Эта переменная в области видимости рядом с
|
|
|
|
|
вызовом `streamText` — её можно переиспользовать в `prepareStep`.
|
|
|
|
|
- Терминальные колбэки `onFinish` / `onError` / `onAbort`
|
|
|
|
|
(`ai-chat.service.ts:249-301`) сохраняют ответ ассистента через
|
|
|
|
|
`persistAssistant` (`:210-230`). При пустом ходе `onFinish` приходит с
|
|
|
|
|
`text === ''`, и в историю пишется пустое сообщение — это и видит пользователь
|
|
|
|
|
как «агент ничего не ответил».
|
|
|
|
|
|
|
|
|
|
### Что такое «шаг» (семантика AI SDK v6)
|
|
|
|
|
|
|
|
|
|
Один шаг = одна генерация модели. Если в шаге есть вызовы инструментов, они
|
|
|
|
|
выполняются, результат возвращается модели, и запускается следующий шаг.
|
|
|
|
|
`stopWhen: stepCountIs(N)` останавливает цикл, как только число завершённых
|
|
|
|
|
шагов достигает `N`. Цикл также завершается естественно, если модель сделала шаг
|
|
|
|
|
**без** вызова инструментов (выдала финальный текст).
|
|
|
|
|
|
|
|
|
|
Важно: `stepNumber` в `prepareStep` нумеруется с нуля; последний из `N` шагов —
|
|
|
|
|
это `stepNumber === N - 1`. Один шаг может содержать несколько параллельных
|
|
|
|
|
вызовов инструментов, поэтому `N` шагов ≠ всегда ровно `N` вызовов (в инциденте
|
|
|
|
|
они шли последовательно — получилось ровно 8).
|
|
|
|
|
|
|
|
|
|
## Решение (точечное, только сервер)
|
|
|
|
|
|
|
|
|
|
Файл: `apps/server/src/core/ai-chat/ai-chat.service.ts`.
|
|
|
|
|
|
|
|
|
|
1. Завести модульную константу вместо «магической» восьмёрки:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// Max agent steps per turn. One step = one model generation; a step that calls
|
|
|
|
|
// tools is followed by another step carrying the tool results. Raised from 8 so
|
|
|
|
|
// multi-search research questions are not cut off mid-investigation.
|
|
|
|
|
const MAX_AGENT_STEPS = 20;
|
|
|
|
|
|
|
|
|
|
// System-prompt addendum injected ONLY on the final step (see prepareStep). It
|
|
|
|
|
// forbids further tool calls and tells the model to synthesize the best answer
|
|
|
|
|
// it can from what it already gathered, so a tool-heavy turn never ends empty.
|
|
|
|
|
const FINAL_STEP_INSTRUCTION =
|
|
|
|
|
'You have reached the maximum number of tool-use steps for this turn. ' +
|
|
|
|
|
'Do NOT call any more tools. Using only the information already gathered, ' +
|
|
|
|
|
"write the most complete, useful final answer you can now, in the user's " +
|
|
|
|
|
'language. If the information is incomplete, say so explicitly: summarize ' +
|
|
|
|
|
'what you found, what is still missing, and give your best partial conclusion.';
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
2. Поднять потолок:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
stopWhen: stepCountIs(MAX_AGENT_STEPS),
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
3. Добавить `prepareStep` в опции `streamText({...})` (рядом со `stopWhen`,
|
|
|
|
|
перед `abortSignal`). На последнем разрешённом шаге запрещаем инструменты
|
|
|
|
|
(`toolChoice: 'none'` → модель обязана выдать текст) и дополняем системный
|
|
|
|
|
промпт инструкцией синтеза. На остальных шагах ничего не возвращаем →
|
|
|
|
|
действуют дефолтные настройки:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// Forced finalization: reserve the LAST allowed step for a text-only answer.
|
|
|
|
|
// Without this, a turn that spends all its steps on tool calls ends with no
|
|
|
|
|
// assistant text (an empty turn). On the final step we forbid further tool
|
|
|
|
|
// calls and append a synthesis instruction. `system` is the prompt built above
|
|
|
|
|
// (in scope here); we CONCATENATE so the original persona/context is preserved
|
|
|
|
|
// — a bare `system` override would REPLACE the whole system prompt for the step.
|
|
|
|
|
prepareStep: ({ stepNumber }) => {
|
|
|
|
|
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
|
|
|
|
return {
|
|
|
|
|
toolChoice: 'none',
|
|
|
|
|
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return undefined; // default settings for all earlier steps
|
|
|
|
|
},
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Итог: до 19 шагов модель свободно работает с инструментами, 20-й (последний)
|
|
|
|
|
шаг гарантированно текстовый. Если модель завершилась раньше естественным
|
|
|
|
|
образом — `prepareStep` для ранних шагов возвращает `undefined`, поведение не
|
|
|
|
|
меняется.
|
|
|
|
|
|
|
|
|
|
## Подтверждённые факты по API (установлено: `ai@6.0.207`)
|
|
|
|
|
|
|
|
|
|
Проверено по `node_modules/ai/dist/index.d.ts`:
|
|
|
|
|
|
|
|
|
|
- `prepareStep({ stepNumber, steps, model, messages }) => PrepareStepResult |
|
|
|
|
|
void` — колбэк опции `streamText`.
|
|
|
|
|
- `PrepareStepResult` (строки ~990-1019) содержит поля:
|
|
|
|
|
`model?`, `toolChoice?`, `activeTools?`, `system?`, `messages?` и др.
|
|
|
|
|
- `toolChoice?: ToolChoice<TOOLS>`, где
|
|
|
|
|
`ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }`
|
|
|
|
|
(строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель
|
|
|
|
|
отвечать текстом.
|
|
|
|
|
- `system?: string | SystemModelMessage | Array<SystemModelMessage>` — override
|
|
|
|
|
системного сообщения **для шага**; это полная замена, поэтому конкатенируем с
|
|
|
|
|
исходным `system`, а не пишем голую инструкцию.
|
|
|
|
|
- `stepNumber` нумеруется с нуля (док. пример: `if (stepNumber === 0) {...}`).
|
|
|
|
|
|
|
|
|
|
> ⚠️ При апгрейде до AI SDK v7 поле `system` в `prepareStep` переименовано в
|
|
|
|
|
> `instructions` (см. migration guide 7.0). На v6 (`^6.0.134`, фактически
|
|
|
|
|
> 6.0.207) корректно именно `system`. Учесть при будущем bump.
|
|
|
|
|
|
|
|
|
|
## Тонкие моменты / edge cases
|
|
|
|
|
|
|
|
|
|
- **Резерв ровно одного шага** — на 20-м шаге модель не сможет сделать ещё один
|
|
|
|
|
«дозапрос». Это осознанный компромисс: гарантированный ответ важнее одного
|
|
|
|
|
лишнего инструмента. Если захочется буфера — форсить на `stepNumber >=
|
|
|
|
|
MAX_AGENT_STEPS - 2` (зарезервировать 2 шага), но это режет полезную работу.
|
|
|
|
|
- **Естественное завершение** до последнего шага — не затрагивается: override
|
|
|
|
|
применяется только при `stepNumber >= MAX_AGENT_STEPS - 1`.
|
|
|
|
|
- **finishReason** последнего шага: при `toolChoice:'none'` модель выдаёт текст
|
|
|
|
|
без tool-calls → цикл завершается как `stop` (а не «оборвался на лимите»).
|
|
|
|
|
Пустых ходов больше не будет; `onFinish` получит непустой `text`.
|
|
|
|
|
- **Замена system** override-ом — единственная ловушка: НЕ потерять исходный
|
|
|
|
|
промпт. Переменная `system` (`ai-chat.service.ts:146`) в замыкании — берём её.
|
|
|
|
|
- **maxOutputTokens** на агенте намеренно не задан (коммент `:242-246`) — это
|
|
|
|
|
изменение его не трогает; токенов на финальный текстовый шаг достаточно.
|
|
|
|
|
- **Клиент не меняется**: рендер шагов и текста уже есть в
|
|
|
|
|
`apps/client/src/features/ai-chat/components/message-list.tsx`. Раньше пустой
|
|
|
|
|
ход показывался как ход без текста — после фикса будет нормальный ответ.
|
|
|
|
|
- **Внешние MCP-клиенты** (tavily и пр.) закрываются в терминальных колбэках
|
|
|
|
|
(`closeExternalClients`) — путь завершения не меняется, ликов не добавляем.
|
|
|
|
|
|
|
|
|
|
## Тестирование
|
|
|
|
|
|
|
|
|
|
- Цикл `streamText` целиком юнит-тестировать дорого. Рекомендуется вынести
|
|
|
|
|
логику выбора шага в чистую экспортируемую функцию (по образцу
|
|
|
|
|
`compactToolOutput`, который уже тестируется в `ai-chat.service.spec.ts`):
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
|
|
|
|
// steps, and forces a text-only synthesis on the final step.
|
|
|
|
|
export function prepareAgentStep(
|
|
|
|
|
stepNumber: number,
|
|
|
|
|
system: string,
|
|
|
|
|
): { toolChoice: 'none'; system: string } | undefined {
|
|
|
|
|
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
|
|
|
|
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Тогда `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`,
|
|
|
|
|
а тест проверяет: для `stepNumber < 19` → `undefined`; для `19` → объект с
|
|
|
|
|
`toolChoice === 'none'` и `system`, начинающимся с исходного промпта и
|
|
|
|
|
содержащим `FINAL_STEP_INSTRUCTION`.
|
|
|
|
|
|
|
|
|
|
## Альтернативы / возможные расширения (вне базового объёма)
|
|
|
|
|
|
|
|
|
|
- **Конфигурируемый лимит** — вынести `MAX_AGENT_STEPS` в настройку воркспейса
|
|
|
|
|
(admin → AI), как системный промпт (`AiSettingsService.resolve`). Сейчас же —
|
|
|
|
|
просто константа в коде.
|
|
|
|
|
- **UI-метка «ответ по неполным данным»** — если последний шаг был принудительным,
|
|
|
|
|
можно прокинуть флажок в metadata и показать бейдж в `message-list.tsx`. Не
|
|
|
|
|
обязательно для базовой фичи.
|
|
|
|
|
|
|
|
|
|
## Открытые вопросы (согласовать перед реализацией)
|
|
|
|
|
|
|
|
|
|
- [ ] Значение лимита: 20 — ок? (компромисс «глубина исследования» vs стоимость
|
|
|
|
|
токенов на ход.)
|
|
|
|
|
- [ ] Текст `FINAL_STEP_INSTRUCTION` — устраивает формулировка? Язык ответа
|
|
|
|
|
модель выбирает сама по контексту; инструкция на английском как и весь
|
|
|
|
|
системный промпт.
|
|
|
|
|
- [ ] Выносить ли логику шага в чистую функцию ради юнит-теста (рекомендуется),
|
|
|
|
|
или оставить инлайн в `prepareStep` без отдельного теста.
|
|
|
|
|
|
|
|
|
|
## Процесс
|
|
|
|
|
|
|
|
|
|
- Сейчас это только план; код НЕ менялся.
|
|
|
|
|
- Реализация — режим делегирования (по умолчанию): изменение логическое
|
|
|
|
|
(новый `prepareStep` + константы, >5 строк) → general-purpose кодеру, затем
|
|
|
|
|
обязательный прогон `review`.
|
|
|
|
|
- Не коммитить; в конце предложить сообщение коммита.
|