1 674 813 токенов в одном запросе: как я чистил контекст OpenWebUI, а потом вынес агента наружу. llm.. llm. Open source.. llm. Open source. OpenAI Agents SDK.. llm. Open source. OpenAI Agents SDK. openwebui.. llm. Open source. OpenAI Agents SDK. openwebui. python.. llm. Open source. OpenAI Agents SDK. openwebui. python. responses api.. llm. Open source. OpenAI Agents SDK. openwebui. python. responses api. искусственный интеллект.. llm. Open source. OpenAI Agents SDK. openwebui. python. responses api. искусственный интеллект. контекст.

Input tokens exceed 922,000 tokens. Your messages resulted in 1,674,813 tokens — с этой ошибки началась история, которая растянулась на полгода. У нас self-hosted OpenWebUI, на нём внутренний ассистент для небольшой команды: файлы, документы, мелкие инженерные задачи. Сначала я писал фильтр, который вычищает из истории результаты инструментов. Потом понял, что латаю не то место, и вынес исполнение агента в отдельный сервис на OpenAI Agents SDK и Codex SDK. По дороге ещё споткнулся о потоковый Responses API.

Про версии сразу. 21 апреля мы перевели прод с 0.8.12 на OpenWebUI v0.9.1, в тот же день, когда она вышла, и фильтр я писал уже под неё. Всё, что ниже сказано про внутренности OpenWebUI, относится к v0.9.1. Пилот с отдельным агентом в июле запускался на v0.9.5, в конце июля прод переехал на v0.11.0. Порядок вызовов в middleware я потом сверил с текущим main (коммит b0e6098, он же тег v0.13.3, 9 ноября 2026) — одно отличие отмечу по ходу.

Апрель: один read_file на 66 871 символ

Всё упёрлось в Native Function Call. Как только я его включил, каждый результат вызова инструмента стал ездить к модели вместе со всей историей, в каждом следующем запросе. На внутреннем тесте 21 апреля 2026 года, в день того самого обновления, один read_file вернул 66 871 символ. Одиннадцать вызовов инструментов дали в сумме 129 972 символа, грубо 32 000 токенов. Через несколько ходов запрос перевалил за лимит, и пришла та самая ошибка про 1,67 млн токенов.

Хуже было поведение модели. Она раз за разом решала, что надо ещё что-то проверить или поправить, снова звала инструменты, контекст снова рос. Заканчивалось тем, что пользователь отменял задачу.

Где резать

Глядя на интерфейс, легко решить, что резать надо HTML: вызов инструмента там выглядит как свёрнутый блок <details type="tool_calls">, и он действительно лежит в chat_message.content. Я полез в middleware и в базу, и картина оказалась другой. Настоящие данные хранятся в chat_message.output — это структурированный массив результатов. Функция process_messages_with_output ещё до вызова inlet-фильтра разворачивает output в обычный OpenAI-формат: assistant с tool_calls, за ним сообщения role=tool с полным содержимым. Там и сидели те 66 871 символ.

Outlet не подходил. Он срабатывает, когда ответ уже сохранён, и в v0.9.1 записывает обратно в базу только изменённый content. Поле output, из которого и рождаются сообщения role=tool, он не трогает. (В v0.11.0 и в текущем main outlet уже умеет сохранять и изменённый output, но весной у меня этого не было.) Оставался inlet: он получает развёрнутый список messages и может переписать его перед отправкой модели.

Схема: порядок вызовов в OpenWebUI v0.9.1 — process_messages_with_output, inlet, сохранение, outlet

Схема: порядок вызовов в OpenWebUI v0.9.1 — process_messages_with_output, inlet, сохранение, outlet

Минус такого решения: база остаётся прежней, и чистить приходится заново на каждом запросе.

Phase 1: выкинуть старые результаты

Первая версия нашего фильтра получилась короткой. Ищу последнее сообщение role=user — всё, что после него, считается текущим ходом и не трогается, иначе сломаются инструменты, которые выполняются прямо сейчас. Всё, что раньше, чистится: старые role=tool удаляются, у старых ответов assistant обнуляются tool_calls, текст ответа остаётся. Потом обрезка по числу ходов. Ход — это пара user/assistant, сообщения инструментов не считаются, системные я не трогаю.

22 апреля прошли восемь юнит-тестов: сохранение текущего хода, очистка исторических tool_calls, подсчёт ходов, возврат исходного body при внутренней ошибке. Dry-run на подготовленной истории с длинными результатами выдал:

[ContextFilter] INFO | tool_strip | before_tokens=~3637 | after_tokens=~103 | deleted_tool_msgs=2

История была синтетическая, и позже это аукнулось.

Ещё на одну мелочь ушло время: фильтр какое-то время просто не работал. Код обновлён, к модели привязан, а сама Function выключена. С тех пор, если в логах нет строк trigger и tool_strip, я первым делом проверяю, активна ли Function.

Phase 2: сжатие, Valves и дедлок на одном воркере

Просто выбрасывать историю жалко — теряется смысл разговора. Во второй фазе я добавил сжатие: последние ходы остаются как есть, более ранние уходят в модель-суммаризатор и превращаются в одну историческую сводку. При следующем сжатии старая сводка идёт в материал и заменяется новой, так что сводки не копятся стопкой. Ходы после сжатия считаются заново, только по оставшимся парам user/assistant, иначе свёрнутая история продолжала бы раздувать счётчик.

Настройки вынесены в Valves. По умолчанию max_recent_turns=20, compress_trigger_turns=10, token_budget=30000, freeze_turns=5. Сжатие запускается, когда исторических ходов больше порога или примерная оценка токенов выше бюджета. Подключение и модель для сжатия по умолчанию не заданы, и тогда работает только Phase 1. На freeze_turns >= max_recent_turns фильтр пишет warning и тоже откатывается на Phase 1. Для Claude через LiteLLM к сводке добавляется cache_control с type=ephemeral.

Первая версия сжатия ходила за суммаризацией в собственный /api/chat/completions OpenWebUI прямо из inlet. В логах появилось compress_failed | TimeoutError: timed out. Причина нашлась в параметрах запуска: uvicorn ... --workers 1. Текущий запрос держит единственный воркер, внутренний запрос на сжатие ждёт, пока тот освободится, и через 30 секунд отваливается по таймауту. Я убрал этот путь совсем. Теперь фильтр читает конфигурацию подключений OpenWebUI, берёт оттуда адрес и ключ и идёт напрямую в OpenAI-совместимый шлюз. Нет подключения, оно выключено или настроено не полностью — пишет cache_skip и откатывается на Phase 1.

Тестов стало 25 вместо 8. Добавились условия запуска, неполные конфиги, падения HTTP, поведение cache_control и проверка, что при Native FC фильтр не переписывает tools и metadata.

Что показал прод через неделю

28 апреля я посмотрел логи за сутки: trigger=128, tool_strip=128. Фильтр срабатывал на каждом запросе. А вот реально удалённых сообщений role=tool за эти 128 срабатываний набралось два. Основной объём шёл другим путём. OpenWebUI сохранял в текст ответов assistant те самые блоки <details type="tool_calls"> и строки статуса выполнения, и они путешествовали по истории как обычный текст. На синтетике я этого увидеть не мог. Пришлось дописать чистку встроенных блоков в старых ответах (текущий ход опять не трогаю) и три теста к ней.

Там же нашлись четыре compress_failed. У модели сжатия, gpt-4o-mini, лимит 128 000 токенов, а запросы на сжатие доходили до 161 590 – 169 575. Сводку пытались собрать из того, что само уже не влезало. Я переключил сжатие на qwen2.5-14b-instruct-1m с контекстом на миллион токенов, остальные Valves оставил.

Июль: перестать латать и вынести агента

К июлю стало понятно, что проблема шире истории сообщений. Не хватало инженерного цикла: прочитать файл, поправить, запустить команду, проверить, отдать отчёт. Встроенный агент OpenWebUI больше про подключение инструментов к чату. Большие файлы всё равно попадали в обычный контекст и раздували его или подвешивали фронтенд, а фильтр мог только подрезать хвост.

7 июля 2026 года я сел и сравнил варианты. Форкнуть OpenWebUI и заменить его agent loop технически можно, но тогда каждое обновление превращается в мёрж с апстримом, который сам активно развивает Agent, Tools и OpenTerminal. Отдельный сервис со ссылкой из OpenWebUI надёжнее, зато у пользователя два разных интерфейса. Я выбрал середину: в OpenWebUI появляется отдельный вход-«модель» на базе Pipe, а вся работа уходит во внешний Agent Runtime.

Схема: OpenWebUI → Pipe-функция → Agent Runtime → OpenAI Agents SDK / Codex SDK → OpenAI-совместимый шлюз

Схема: OpenWebUI → Pipe-функция → Agent Runtime → OpenAI Agents SDK / Codex SDK → OpenAI-совместимый шлюз

OpenWebUI осталась оболочкой: пользователи, сессии, загрузка файлов, отрисовка ответов и артефактов. Pipe-функция стала тонким адаптером. Историю из базы она не пересобирает: берёт последнее сообщение пользователя, системные правила передаёт отдельным полем, а для вложений отдаёт в Runtime исходный загруженный файл. История в БД OpenWebUI теперь нужна только для отображения и аудита.

Состояние живёт в Runtime. Внешний агент хранит стандартные items Agents SDK в файловой Session, по файлу на разговор. У Codex свой threadId, и следующие сообщения того же разговора продолжаются через resumeThread(threadId). Историю OpenWebUI ни тот, ни другой не копируют, так что вопрос «кто отвечает за контекст» закрылся сам.

Одно правило я принял сразу: в рамках одного входа исполнитель не переключается между встроенным агентом OpenWebUI и Runtime. Иначе два набора состояния — контекст, вызовы инструментов, файлы, повторы, — и при каждом сбое непонятно, чей он. Обычные модели работают как раньше, в «инженерном» входе всё идёт через Runtime.

Внутри Runtime два режима. Первый — маршрутизатор на Agents SDK, который через handoff отдаёт задачу либо агенту общего назначения, либо агенту с Codex SDK для разработки, файлов и команд. Второй, который со временем стал основным, — один SandboxAgent без маршрутизации; оркестратор остался как переключаемый вариант. 10 июля я вынес Runtime из репозитория с доработками OpenWebUI в отдельный проект. Поиск файлов в webui.db специально оставил в Pipe-функции, чтобы Runtime ничего не знал про внутренности OpenWebUI.

Сентябрь: в стриме вызов есть, в итоге — нет

До осени Runtime ходил в шлюз через Chat Completions. 9 сентября я проверял переход на Responses API, в том числе ради compaction. Обычный непотоковый запрос работал. Нативный сценарий Agents SDK с handoff и инструментами падал так:

Max turns (8) exceeded

Агент вызывал инструмент, не видел его и вызывал снова, пока не кончались ходы. Я стал смотреть сырые SSE-события. В response.output_item.done вызов function_call приходил нормально, а в финальном response.completed поле response.output было пустым массивом. Установленная версия Agents SDK (0.13.0) собирала итог хода как раз из response.output финального события. Тот же запрос с stream:false возвращал JSON, где function_call на месте.

С compaction то же самое. Промежуточные события приносили compaction, message, ещё раз compaction, у элементов сжатия есть encrypted_content, а финальный output снова пустой.

Ключевая строка пробника, переформатированная
// raw — тело SSE-ответа целиком
const events = raw.split("n")
  .filter(l => l.startsWith("data:"))
  .flatMap(l => { try { return [JSON.parse(l.slice(5))]; } catch { return []; } });

let obj = events.findLast(e => e.type === "response.completed")?.response;

// финальный output пустой — собираем его из промежуточных событий
if (obj && !obj.output?.length) {
  obj = {
    ...obj,
    output: events
      .filter(e => e.type === "response.output_item.done")
      .map(e => e.item),
  };
}

Я написал отдельный пробник, который просто собирал response.output_item.done в памяти, SDK и прод не трогал. С ним результат инструмента возвращался к модели, и модель отвечала значением из инструмента. Compaction тоже заработал: с store:false и context_management=[{type:compaction,compact_threshold:1000}] пришёл зашифрованный элемент сжатия. Дальше я выкинул длинную историю, оставил только последний compaction item и новый вопрос, и модель восстановила все три контрольных факта.

На экономию я рассчитывал зря. Шлюз отчитался input_tokens=28102, на следующем ходу — 28 053. Байты запроса в тот раз я не замерял. Кто теряет output, шлюз или то, что за ним, тоже не выяснил: поток до и после прокси я побайтно не сравнивал.

В тот день прод остался на Chat. Кроме пустого output, висели 400 Upstream request failed у SandboxAgent на Responses и недоделанная связка compaction с Session. Был и подвох в отчёте: часть сценариев формально «прошла», файл сгенерирован. Только в списке запросов там же стоял /v1/chat/completions — Runtime молча откатился на Chat. Такие прогоны я записал в провалы.

Октябрь: переключение

Перед переключением пришлось чинить связь между OpenWebUI и Runtime. Из процесса OpenWebUI, с его реальным окружением, запросы к Runtime стабильно давали URLError / Errno 111 Connection refused, а без прокси проходили с 200. Проверка из обычного шелла тут ничего не доказывала. С тех пор в приёмке есть запрос именно из окружения процесса OpenWebUI и реальный прогон через Pipe.

23 октября прод переключили на Responses вместе с обновлением зависимостей Runtime. Версии «после» взяты из состояния работающего контейнера, «до» — из lock-файла и Dockerfile прежней сборки:

Компонент

Было

Стало

OpenWebUI

v0.11.0

v0.11.0 (не обновлялся)

@openai/agents

0.13.0

0.21.0

Codex SDK

0.143.0

0.163.0

openai (npm)

6.45.0

7.43.0

Node.js

20

22.23.4

Все агенты по умолчанию читают конфигурацию Responses. Старую логику «превысил число ходов — откатись на Chat Completions» я удалил, чтобы откат больше не прятал ошибки. Фронтенд OpenWebUI по-прежнему ходит в свой /api/chat/completions, но это вход оболочки, к протоколу между Runtime и шлюзом он не относится.

Шлюз не поддерживал нативный инструмент apply_patch из Codex. Я выключил его отдельным флагом в конфигурации Runtime, правка файлов через Shell осталась.

Приёмка: потоковый Responses, файлы через Shell, память сессии, handoff и HTML через Codex — 6/6, без отката на другой протокол. Копию тестовой сессии, созданной старым SDK, удалось продолжить на новом — 1/1, реальные разговоры пользователей я не трогал. Проверял через API: страница чата в браузере отдавала ERR_EMPTY_RESPONSE. Вне приёмки остались восстановление внутреннего Codex thread между версиями SDK, генерация изображений и долгая параллельная нагрузка. И да, healthcheck контейнера помечает его unhealthy, но сам не перезапускает.

Что бы я сделал иначе

Фильтр надо было гонять на выгрузке настоящей истории. Красивые ~3637 → ~103 из dry-run проверили только то, что я сам туда положил, а основной объём в проде шёл через текст ответов assistant. Узнал я об этом из суточной статистики: 128 срабатываний, два удалённых сообщения.

Модель сжатия выбирают под худший случай. Сводка нужна как раз тогда, когда история огромная, и gpt-4o-mini с лимитом в 128 000 токенов отказывал на тех разговорах, ради которых всё и затевалось.

Агента стоило выносить из OpenWebUI раньше. Фильтр полезен, он у нас остался. Но цикл «прочитать, поправить, запустить, проверить» требует своего исполнителя со своей сессией, а OpenWebUI хорошо работает как оболочка вокруг него.

С потоковым API правило теперь простое: промежуточные items сохраняю с первого дня и сверяю с финальным объектом. Зелёный тест ничего не значит, пока не видно, по какому протоколу на самом деле ушли запросы.

Автор: 123123213

Источник