luftpause
Среда выполнения под вашей обвязкой агента. Каждый вызов инструмента — это await; движок забывает о запуске, а luftpause сохраняет его состояние.
Вызовы инструментов разбивают работу агента на ходы. Для движка инференса каждый ход — новый запрос от неизвестного отправителя: когда инструмент возвращает результат, продолжение встаёт в очередь после всех пакетных запросов, поступивших за время ожидания, а KV cache может быть уже недоступен.
luftpause располагается между обвязкой и движками и сохраняет состояние запуска:
-
Продолжения обрабатываются первыми. Запуск, приостановленный в ожидании вызова инструмента, возобновляется раньше пакетных задач; пакетные ходы оставляют место для интерактивных запусков, которые вернутся в очередь.
-
Запуск остаётся там, где находится его префикс. Пока запуск генерирует ответ или ждёт инструмент, его ходы направляются только на реплику с соответствующим KV cache; новые ходы движок принимает в собственную очередь.
-
Один отправитель не может лишить другого ресурсов. Ожидающие ходы обрабатываются справедливо для всех отправителей.
-
Любая обвязка — два способа подключения. Укажите luftpause в
base_url(совместимый с OpenAI интерфейс, собственный цикл агента) или подключитесь через MCP и получите субагентовspawn/await, которые переживают перезапуск.
Измерения на ноутбуке с GPU на 8 ГБ при полной загрузке движков (48 пакетных запусков и 4 интерактивных запуска одновременно):
| Пропускная способность | Задержка | Цена | |
|---|---|---|---|
| Агенты с собственным циклом через luftpause по сравнению с прямым подключением к маршрутизатору SGLang | В 2,0 раза выше для интерактивных запусков (12,7 против 6,4 запуска/мин) | Медианная задержка интерактивных запусков — 19 с вместо 36 с | Пакетная пропускная способность ниже на 9 % |
| Субагенты через MCP с luftpause по сравнению с прямым подключением среды выполнения к маршрутизатору | В 2,7 раза выше для интерактивных запусков (8,0 против 3,0 запуска/мин) | Медианная задержка интерактивных запусков — 27 с вместо 58 с | Без изменений для пакетных задач |
| Параллельный запуск 16 субагентов по сравнению с последовательным запуском | В 5,3 раза быстрее (все 16 завершаются за 23 с вместо 120 с) | Медианная задержка для одного субагента — 15–22 с, как и при подключении через маршрутизатор | Никаких: один только маршрутизатор справляется за 21,5 с |
| Claude Code в роли клиента, шесть запросов к Wikipedia | В 2,9 раза быстрее (12,5 с вместо 36 с при последовательной обработке без дополнительного промпта) | Все шесть субагентов на модели 70B выполняются за один цикл, 9,3 с | 0 неудачных вызовов инструментов, 0 повторных запусков |
Попробуйте
docker compose up -d # имитационные движки, GPU не требуется
claude mcp add --transport http --scope local luftpause http://localhost:7000/mcp
Команда docker compose up -d загружает опубликованные образы; добавьте --build, чтобы собрать их из исходного кода (первая сборка занимает несколько минут). По умолчанию загружается latest; зафиксировать версию можно так: LUFTPAUSE_VERSION=v0.1.3 docker compose up -d. Среда выполнения также опубликована в официальном реестре MCP под именем io.github.vmedvedovskiy/luftpause.
Теперь Claude Code поддерживает инструменты spawn, await, run, status, cancel, approve, tools и decide. Имитационные движки отвечают текстом-заполнителем. Единственные инструменты субагентов — lookup и deep_search из fake-mcp: они ждут примерно секунду и тоже возвращают текст-заполнитель. Этого достаточно, чтобы увидеть параллельный запуск. Чтобы получать настоящие ответы от уже запущенного на вашей машине Ollama и использовать Wikipedia в качестве инструмента субагентов вместо имитационных инструментов:
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
API Wikipedia ограничивает частоту запросов от анонимных клиентов, поэтому вызов инструмента может занимать десятки секунд, пока wikipedia-mcp выполняет повторные попытки.
Llama 3.2 1B иногда передаёт схему инструмента вместо аргументов. Среда выполнения отклоняет такой вызов, и субагент может ответить без использования инструмента. В этом случае повторите запрос или загрузите более крупную модель.
Модель необходимо загрузить в Ollama (ollama pull llama3.2:1b или задайте OLLAMA_MODEL).
Кроме того, Ollama должна принимать подключения не только через loopback (OLLAMA_HOST=0.0.0.0), если Docker Desktop уже не может подключиться к ней напрямую. В Windows и macOS Docker Desktop подключается к Ollama с привязкой к loopback по умолчанию. По умолчанию Ollama обрабатывает запросы по одному; увеличьте значение OLLAMA_NUM_PARALLEL, чтобы выполнять несколько запросов одновременно. Поэтому субагенты выстраиваются в очередь внутри Ollama: luftpause не получает от неё метрик очереди и не ограничивает её загрузку. Аналогичным образом работают SGLang на вашем GPU (compose.testbed.yaml) и любая конечная точка, совместимая с OpenAI (пример с размещённым pod vLLM приведён в compose.runpod.yaml).
Отличия
Маршрутизаторы распределяют запросы по пулу GPU, а MCP-серверы поручают локальной модели подзадачи. Ни те ни другие не понимают, что такое запуск. luftpause понимает: через оба способа подключения он видит каждый ход и каждый вызов инструмента каждого запуска и распределяет их по общему пулу, учитывая три обстоятельства, которые движку неизвестны: приостановленный запуск возобновится, задачи бывают разных классов, а запросы поступают от разных отправителей.
Преимущества достигаются за счёт управления допуском, а не маршрутизации. При одной лишь маршрутизации результат такой же, как у маршрутизатора SGLang с учётом кэша (см. ниже); при незагруженных движках показатели всех вариантов одинаковы. Разница проявляется при полной загрузке движков — именно тогда она и важна.
Два способа подключения
-
Прокси, совместимый с OpenAI — агент с собственным циклом указывает luftpause в
base_url. Для каждого запуска он передаётx-luftpause-run-idи, при необходимости,x-luftpause-classиx-luftpause-caller. После этого каждый вызов инструмента в цикле планируется какawait. -
Среда выполнения MCP — агент, например Claude Code, подключается через MCP и получает инструменты
spawn/await. luftpause выполняет цикл каждого субагента через тот же прокси: ходы LLM направляются в пул, вызовы инструментов — на вышестоящие MCP-серверы, в качестве отправителя используется сессия MCP, а журнал позволяет запускам пережить завершение процесса.
В examples/ приведены три агента с собственным циклом, которые задают все три заголовка: пакет openai, OpenAI Agents SDK и LangGraph.
Подключение из
Обвязки, которые не умеют передавать заголовок для каждого запуска, тоже работают через прокси: запуск определяется по tool_call_id, которые она передаёт обратно. Для справедливого распределения достаточно фиксированного значения x-luftpause-caller.
| Инструмент | Подключение через MCP | Подключение через прокси |
|---|---|---|
| Claude Code | claude mcp add --transport http luftpause http://localhost:7000/mcp | Не применимо: используется Anthropic Messages API, а не Chat Completions |
| Codex CLI | ~/.codex/config.toml: [mcp_servers.luftpause] url = "http://localhost:7000/mcp" | Не применимо: wire_api принимает только responses, а прокси предоставляет Chat Completions |
| Gemini CLI | gemini mcp add --transport http luftpause http://localhost:7000/mcp или .gemini/settings.json: {"mcpServers": {"luftpause": {"httpUrl": "http://localhost:7000/mcp"}}} | Не применимо: используется Gemini API (не проверено) |
| Goose | goose session --with-streamable-http-extension "http://localhost:7000/mcp" или goose configure → Add Extension → Remote Extension (Streamable HTTP) | GOOSE_PROVIDER=openai OPENAI_HOST=http://localhost:8080 OPENAI_API_KEY=x OPENAI_CUSTOM_HEADERS="x-luftpause-caller=goose" |
| Cursor | ~/.cursor/mcp.json: {"mcpServers": {"luftpause": {"url": "http://localhost:7000/mcp"}}} | Не применимо: настройка «Переопределить базовый URL OpenAI» задаётся для серверов Cursor, которые не могут подключиться к localhost (не проверено) |
| Zed | settings.json: "context_servers": {"luftpause": {"url": "http://localhost:7000/mcp"}} | settings.json: "language_models": {"openai_compatible": {"luftpause": {"api_url": "http://localhost:8080/v1", "available_models": [{"name": "", "max_tokens": 128000}], "custom_headers": {"x-luftpause-caller": "zed"}}}} |
| OpenHands | Settings → MCP Servers → добавьте http://localhost:7000/mcp | Settings → LLM → Advanced: Custom Model openai/, Base URL http://localhost:8080/v1, любой API key; настройка заголовков отсутствует |
| Cline / Roo Code | Cline cline_mcp_settings.json: {"mcpServers": {"luftpause": {"type": "streamableHttp", "url": "http://localhost:7000/mcp"}}}; Roo .roo/mcp.json: то же самое, но с "type": "streamable-http" | Провайдер «OpenAI Compatible», Base URL http://localhost:8080/v1, любой API key; добавьте x-luftpause-caller в Custom Headers (не проверено) |
Архитектура
%%{init: {"themeVariables": {"fontFamily": "-apple-system, Segoe UI, Helvetica, Arial, sans-serif"}, "flowchart": {"wrappingWidth": 260}}}%%
flowchart LR
mcp["Claude Code<br/>or any MCP client"]
own["Agents with<br/>their own loop"]
subgraph lp["luftpause"]
runtime["<b>runtime</b> · .NET<br/>spawn · await · journal<br/>sub-agent loops"]
dp["<b>dataplane</b> · Rust<br/>OpenAI-compatible proxy"]
sched["<b>scheduler</b> · .NET<br/>decide(state, event)"]
end
tools[("MCP tool<br/>servers")]
subgraph pool["Engine pool"]
direction TB
engines["SGLang · vLLM"]
router["SGLang router"]
hosted["Hosted OpenAI-<br/>compatible APIs"]
end
mcp -- MCP --> runtime
own -- "HTTP / SSE" --> dp
runtime -- "tool calls" --> tools
runtime -- "LLM turns" --> dp
dp -- "turns" --> pool
sched <-- "Admit · Report · run ended" --> dp
sched -. "health · load" .-> pool
-
runtime (
runtime/) — MCP-сервер. Каждый запущенный процесс выполняется как .NET-задача (Task), в которой работает цикл агента; вызовы инструментов направляются на настроенные MCP-серверы. Все процессы записываются в журналruns.jsonlи возобновляются после перезапуска. По завершении процесса dataplane получает уведомление (DELETE /v1/runs/{id}), чтобы планировщик сразу удалил его из памяти. -
dataplane (
dataplane/crates/dataplane) — прокси на базе tokio/axum. Он определяет процесс по заголовку или идентификаторам вызовов инструментов, считывает заголовки класса и вызывающей стороны, запрашивает у планировщика, куда направить ход, передаёт потоковый ответ движка без изменений и сообщает о первом токене, использовании ресурсов и причине завершения. -
scheduler (
scheduler/) — ядро принятия решений состоит из чистых функций, работающих с неизменяемым состоянием (Luftpause.Scheduling); gRPC-хост выполняет соответствующие действия. -
Комплект для тестирования —
fake-engine(OpenAI и/classifyс имитацией задержки),fake-mcp(инструментыlookupи медленныйdeep_search),tools/loadgen(агенты, параллельный запуск субагентов через MCP и тесты пропускной способности с замкнутым циклом).
Что делает
| Возможность | Как работает |
|---|---|
spawn / await через MCP | spawn(task, model?, serviceClass?, system?, maxToolCalls?, tools?, maxTokens?, parallelToolCalls?, parent?, budgetTokens?, budgetToolCalls?, budgetSeconds?, approveTools?) сразу возвращает дескриптор; await(handles, all|any, timeout) собирает результаты с полями toolCalls / failedToolCalls. Также доступны tools, status, cancel и decide(input, labels?, model?): без labels используются метки модели однопроходной классификации; с 2–20 метками выполняется один ход генеративной модели с max_tokens: 1, а результат оценивается по логарифмам вероятностей первого токена. В ответе возвращаются label, probabilities и unscored |
| Сохранение запусков | Каждый запуск записывается в журнал (runs.jsonl в Runtime:StateDir): создание, сообщения каждого раунда вызова инструментов и завершение. После перезапуска среда выполнения воспроизводит журнал, возвращает результаты завершённых запусков при вызове await и возобновляет незавершённые с последнего полного раунда вызова инструментов |
| Дерево запусков | spawn(parent) создаёт дочерний запуск; status показывает parent и children; отмена родительского запуска через cancel отменяет все его выполняющиеся дочерние запуски. Эндпоинт среды выполнения GET /status возвращает дерево запусков: для каждого запуска указаны его собственные показатели и суммарные показатели поддерева (ходы, вызовы инструментов, неудачные вызовы инструментов, токены запроса и ответа, время выполнения ходов и инструментов) |
| Бюджеты | spawn(budgetTokens?, budgetToolCalls?, budgetSeconds?) ограничивает весь бюджет поддерева запуска — самого запуска и всех созданных в нём дочерних запусков, включая будущие. Ограничения задаются по сумме токенов запроса и ответа, числу вызовов инструментов и времени в секундах. Они проверяются на каждом ходу и раунде вызова инструментов во всём поддереве запуска с заданным бюджетом. Для ограничения по времени используется таймер, поэтому лимит сработает, даже если поддерево простаивает. При превышении лимита поддерево отменяется с сообщением budget exceeded: /, а status и await показывают budget с полями limits, used и exceeded |
| Подтверждение вызовов инструментов | Вызов инструмента субагентом ожидает подтверждения от пользователя клиента, если сервер пометил инструмент как destructiveHint: true (и не как readOnlyHint: true) или если он указан в spawn(approveTools). Статус запуска меняется на AwaitingApproval, а в pendingApproval {tool, arguments} указываются ожидающий подтверждения инструмент и его аргументы. Если клиент поддерживает elicitation и его вызов await или run охватывает этот запуск, ему отправляется форма с одним полем (allow): в рамках сессии — как запрос elicitation, а в запросе без сессии по протоколу 2026-07-28 — как результат, требующий ввода. Остальные клиенты могут ответить вызовом approve(handle, allow). При отказе субагент получает сообщение Error: the user declined this tool call. Решения сохраняются в журнале: если ответ получен непосредственно перед перезапуском, повторный запрос не отправляется; если ответа не было — запрос отправляется снова. Клиенты: Claude Code 2.1.218 в терминале показывает запрос во время await, и ответ передаётся запуску (проверено); вкладка Code в настольном приложении Claude отклоняет запрос без отображения — при любом режиме разрешений, поэтому запуск получает отказ в вызове инструмента; клиент Python mcp 2.3 был опрошен обоими способами на тестовом стеке; любой клиент может использовать approve |
| MCP Tasks | run(task, ...) принимает аргументы spawn и ожидает завершения запуска в рамках того же вызова. Клиент, поддерживающий расширение Tasks (io.modelcontextprotocol/tasks, протокол 2026-07-28), вместо этого получает задачу: он опрашивает tasks/get; если запуск ожидает подтверждения, задача получает статус input_required с запросом ввода elicitation, на который клиент отвечает через tasks/update. Задачи записываются в tasks.jsonl рядом с runs.jsonl; задача, остававшаяся открытой при остановке среды выполнения, после перезапуска завершается с ошибкой, а её запуск возобновляется обычным образом. Для остальных клиентов run остаётся одним блокирующим вызовом. Клиенты: клиент SDK C# 2.2 (проверено в процессе); Claude Code 2.1.218 не поддерживает Tasks, поэтому для него run остаётся блокирующим вызовом |
| Статус | Эндпоинт планировщика GET /status на :5001 (HTTP/1; gRPC работает на :5000) возвращает сведения о репликах и выполняемых ходах относительно их ёмкости, удерживаемых ходах по классам и вызывающим сторонам с указанием самого долгого ожидания, а также о запусках по этапам и классам. Эндпоинт GET / на этом же адресе открывает страницу, которая опрашивает его каждые 2 секунды; параметр ?runtime=http://host:7000 дополнительно отображает дерево запусков этой среды выполнения и затраты по поддеревьям |
| Справедливое распределение между вызывающими сторонами | Каждый ход содержит x-luftpause-caller (сессию MCP для субагентов и произвольную строку для агентов с собственным циклом). Удерживаемые ходы сначала передаются классу interactive, затем — вызывающей стороне с наименьшим числом выполняемых ходов. Поэтому всплеск запросов от одной стороны не блокирует остальные |
| Инструменты вне ходов | Запуск, ожидающий ответа инструмента, не занимает слот хода. Вызовы инструментов выполняются в среде выполнения и обращаются к серверам MCP по HTTP или stdio. Если задано spawn(parallelToolCalls: true), модель может запросить несколько инструментов в одном ответе, и они будут запущены параллельно. По умолчанию эта возможность выключена: Llama 3.x выполняет один вызов на ответ |
| Допуск с учётом нагрузки движка | Движок принимает ходы, пока сообщает об отсутствии очереди ожидания (метрики SGLang и vLLM /metrics). Когда очередь появляется, luftpause ограничивает число ходов числом запросов, которые выполняет движок, а остальные удерживает. Движки, не сообщающие о нагрузке, не ограничиваются |
| Строгая привязка запусков | Запуск, который генерирует ответ или приостановлен в ожидании инструмента, допускается только к той реплике, которая обслуживала его последний ход и кэширует его префикс, даже если на другой реплике есть свободные ресурсы. Запуск может перейти на другую реплику, если его текущая реплика неисправна или он простаивает. Результаты измерений приведены ниже: именно эта привязка обеспечивает прирост интерактивной производительности. Оценка объёма памяти KV поверх неё снижала пропускную способность, поэтому от неё отказались |
| Классы обслуживания | Ходы пакетной обработки оставляют на движке ресурсы для каждого интерактивного запуска, приостановленного на этом движке: такой запуск возобновится. Ходы, удерживаемые luftpause, отправляются сначала классу interactive, затем распределяются по справедливости между вызывающими сторонами и после этого — в порядке поступления. Они ожидают, пока не будут размещены или пока клиент не отключится |
| Маршрутизаторы как бэкенды | Реплика может быть маршрутизатором (например, маршрутизатором SGLang), который выбирает движок. Её нагрузка определяется по движкам за ней (LoadFrom) |
| Маршрутизация по модели | Эндпоинты указывают обслуживаемые модели; ход направляется только на реплику, обслуживающую соответствующую модель. Для неизвестной модели сразу возвращается ошибка 404 |
| Размещённые эндпоинты | Любой API, совместимый с OpenAI (vLLM на RunPod, OpenAI, OpenRouter и другие), можно использовать как реплику: ключ API берётся из переменной окружения, путь проверки состояния задаётся для каждого эндпоинта отдельно, поддерживается https |
| Перезапуск планировщика | Плоскость передачи данных хранит сведения о последней реплике, этапе, классе и вызывающей стороне для каждого запуска (простаивающие запуски удаляются через 30 минут), а также сведения обо всех выполняемых ходах. При запуске и после каждого переподключения она передаёт их планировщику (Resync) до возобновления потока событий. Поэтому привязка, резерв ресурсов для приостановленных интерактивных запусков и подсчёт ёмкости сохраняются при перезапуске планировщика. Ходы, удержанные во время сбоя, после восстановления планировщика снова допускаются к выполнению с теми же идентификаторами |
| Проверка состояния | Путь /health (или другой путь, заданный для конкретного эндпоинта) проверяется каждые 2 секунды; неисправные реплики не получают ходы |
| Надёжность вызовов инструментов | Аргументы приводятся к скалярным типам, заданным в схеме, проверяются и при ошибке отклоняются с указанием ожидаемой структуры вместо молчаливого принятия некорректного вызова. Вызовы, переданные в виде текста, восстанавливаются |
| Идентификация запуска | Используется x-luftpause-run-id или значения tool_call_id, которые агент возвращает в ответе |
| Однопроходные движки | POST /v1/decide (формат vLLM /classify) выполняется на отдельных репликах вне состояния запусков. Для вызова decide с заданными вызывающей стороной labels вторая модель не нужна: запрос выполняется как один интерактивный ход на генеративной реплике |
| Трассировка | Если задана переменная OTEL_EXPORTER_OTLP_ENDPOINT, каждый сервис передаёт трассировочные интервалы OpenTelemetry по OTLP (соглашения GenAI): среда выполнения создаёт интервал invoke_agent для каждого запуска (дочерний интервал родительского запуска или traceparent MCP-запроса, вызвавшего spawn), интервал chat для каждого хода модели с числом токенов и причиной завершения, а также интервал execute_tool для каждого вызова инструмента; плоскость передачи данных создаёт интервал для каждого проксируемого хода; планировщик создаёт интервал admit с атрибутами luftpause.replica и luftpause.held_ms. traceparent по стандарту W3C передаётся плоскости передачи данных, планировщику, движкам и HTTP-серверам инструментов, а события ходов содержат идентификатор трассировки. Если переменная не задана, данные не передаются |
| Аутентификация | Если задана переменная LUFTPAUSE_TOKEN, для всех маршрутов /v1 плоскости передачи данных, а также для /mcp и GET /status среды выполнения требуется заголовок Authorization: Bearer (сравнение выполняется за постоянное время); в противном случае возвращается ошибка 401 в формате JSON. Среда выполнения отправляет тот же токен Bearer плоскости передачи данных; заголовок Authorization клиента не передаётся дальше, а движки получают настроенный для них ключ. Если переменная не задана, оба сервиса выводят предупреждение в журнал и принимают запросы от всех клиентов |
Семантика ошибок
Что происходит с запросом, проходящим через dataplane (/v1/chat/completions, /v1/decide),
если что-то идёт не так. Тела ошибок имеют формат {"error": {"message": "..."}}; во всех ответах, кроме случая недоступности планировщика, присутствует заголовок x-luftpause-run-id. При сбое ход сразу освобождает свой слот, а запуск переходит в состояние ожидания, а не остаётся приостановленным на вызове инструмента. Поэтому повторная попытка допускается наравне с любым следующим ходом этого запуска. Повторный запрос с тем же x-luftpause-run-id сохраняет привязку запуска и его положение в очереди справедливого распределения ресурсов.
| Случай | Что видит клиент | Что делает luftpause | Что следует делать агенту с собственным циклом |
|---|---|---|---|
| Клиент отключается, пока его ход ожидает выполнения | ничего (клиент отключился) | отменяет вызов планировщика; ход покидает очередь ожидания (TurnAbandoned), запрос к движку не отправляется. Если в этот момент ход уже направлялся к движку, он помечается как неудачный, а его слот освобождается | если клиент прервал ожидание из-за тайм-аута на своей стороне, отправить ход повторно с тем же ID запуска: он ещё не выполнялся |
| Клиент отключается во время потоковой передачи | ничего (клиент отключился) | закрывает запрос к движку, останавливая генерацию, и помечает ход как неудачный | отправить ход повторно с тем же ID запуска; частичный ответ нигде не сохраняется |
| Движок недоступен | 502, engine : ; сразу при отклонении соединения или по истечении системного тайм-аута подключения, если узел не отвечает | сообщает о начале и неудачном завершении хода; реплика остаётся в ротации, пока проверка состояния не обнаружит сбой (период — 2 с) | немного подождать и повторить запрос с тем же ID запуска; до тех пор, пока проверка не пометит реплику как недоступную, повторный запрос может попасть на неё же |
| Сбой движка во время потоковой передачи | 200, уже отправленные фрагменты, затем закрытие соединения без последнего фрагмента или [DONE] | сообщает о неудачном завершении хода | считать ответ потерянным, отбросить его и повторить весь ход с тем же ID запуска |
| Движок сразу возвращает ошибку (429, 5xx) | статус и тело ответа движка без изменений, включая его заголовок Retry-After | сообщает о начале и неудачном завершении хода | при 429 подождать время, указанное в Retry-After (если заголовка нет — около 1 с), и повторить запрос с тем же ID запуска; при 5xx повторить несколько раз с увеличивающейся задержкой, затем прекратить попытки |
| Неизвестная модель | 404, no Generative replica serves model '' | отклоняет запрос до постановки в очередь; запрос к движку не отправляется, ход не регистрируется | прекратить попытки и исправить имя модели или конфигурацию конечной точки; повторный запрос вернёт тот же ответ |
| Планировщик недоступен или перезапускается | ничего: ход остаётся в ожидании, как если бы он был поставлен в очередь, пока планировщик не ответит или клиент не отключится | каждую секунду повторяет попытку допуска с тем же ID хода; после восстановления соединения синхронизирует запуски и выполняющиеся ходы, прежде чем снова допускать запросы; до этого запросы к движку не отправляются | ничего не делать; если тайм-аут на стороне клиента короче периода сбоя, ситуация аналогична любому ожидающему ходу: повторить запрос с тем же ID запуска |
Константы
В luftpause используется немного фиксированных значений. Ограничений на время ожидания в очереди и на число ходов нет: ожидающий ход ждёт так же, как в собственной очереди движка, а запуск завершается, когда исчерпан бюджет вызовов инструментов. Остальные параметры:
| Константа | Значение | Назначение | Где задана |
|---|---|---|---|
| Длина ответа | 256 токенов на ход; задаётся для каждого запуска через spawn(maxTokens), значение 0 передаёт ограничение на усмотрение движка | SGLang резервирует память для самого длинного ответа, который может выдать запрос. Без ограничения движок обрабатывал в батче в семь раз меньше запросов (6 против 42 запусков в минуту на тестовом стенде), а слишком длинные ответы нарушали формат Llama «один вызов на ответ» | runtime/…/LuftpauseTools.cs |
| Бюджет вызовов инструментов | 8 на запуск; задаётся для каждого запуска через spawn(maxToolCalls) | После исчерпания бюджета модель больше не получает инструменты и должна сформировать ответ. Это также ограничивает число ходов запуска значением «бюджет + 1» | runtime/…/LuftpauseTools.cs |
Тайм-аут await | 60 с по умолчанию, максимум 600 с | Время ожидания одного вызова await до возврата текущего состояния запусков. Значение 60 с не превышает собственный лимит клиентов MCP на один запрос (у Claude Code по HTTP он составляет 60 с) | runtime/…/LuftpauseTools.cs |
| Тайм-аут хода | 10 мин | Если завершение хода так и не было зарегистрировано (например, dataplane аварийно завершил работу в середине хода), ход удаляется, а его слот освобождается | scheduler/…/Expiry.cs |
| Тайм-аут бездействия запуска | 30 мин | Если в течение этого времени у запуска не было ходов, он удаляется; следующий ход запускается без привязки. Runtime сообщает о завершении своих субагентов. Агенты с собственным циклом сами не сообщают о завершении, поэтому для их состояния требуется сигнал окончания | scheduler/…/Expiry.cs |
| Период проверки | 2 с | Состояние и загрузка движка (/metrics) проверяются с этой периодичностью; допуск запросов учитывает загрузку движка с такой задержкой. Это также тайм-аут проверки | scheduler/…/Probing.cs |
| Интервал системного таймера | 1 с | Запускает два описанных выше механизма удаления по тайм-ауту | scheduler/…/Clock.cs |
| Тайм-аут от runtime до dataplane | 10 мин | Максимальное время выполнения одного хода, включая ожидание в очереди, до сбоя запуска; совпадает со значением по умолчанию для клиентов OpenAI и Anthropic | runtime/…/Program.cs |
| Задержка повтора при 429 | Значение из ответа Retry-After, иначе 1 с | Runtime повторяет ход, отклонённый движком с кодом 429 (ограничение частоты запросов в hosted API), пока запуск не будет отменён | runtime/…/LlmClient.cs |
| Тайм-аут подключения к движку | 2 с | По истечении этого времени dataplane прекращает попытки подключиться к движку | dataplane/…/main.rs |
| Задержка переподключения для отправки событий | 1 с | После этой паузы dataplane заново подключается к потоку событий планировщика | dataplane/…/report.rs |
| Память ID вызовов инструментов | 30 мин; очистка каждые 60 с | Время, в течение которого dataplane может распознать запуск по значениям tool_call_id, которые агент возвращает в ответах (для агентов, не передающих заголовок запуска) | dataplane/…/run_identity.rs |
| Журнал запусков | Runtime:StateDir, по умолчанию state, в контейнере — /var/lib/luftpause | Расположение файла runs.jsonl; это единственное состояние, которое runtime сохраняет между перезапусками | runtime/…/Program.cs |
| Заголовок вызывающей стороны | x-luftpause-caller, ограничен 128 байтами | Идентифицирует вызывающую сторону для справедливого распределения ресурсов; если заголовок отсутствует, все запросы считаются запросами одного анонимного клиента | dataplane/…/proxy.rs |
Запуск
docker compose up -d --build # scheduler, dataplane, runtime, fake engines, fake MCP tools
dotnet run --project tools/loadgen -- --spawn 10 # fan 10 sub-agents out over MCP and await them
dotnet run --project tools/loadgen -- --agents 8 --duration-s 60 --class batch --caller me # 8 own-loop agents through the proxy, closed loop
tools/check.sh # both .NET test projects, loadgen, cargo test (when cargo is installed), image builds
Выпуск версии: tools/release.sh vX.Y.Z собирает, помечает тегами и отправляет три образа в ghcr.io/luftpause (переменная LUFTPAUSE_REGISTRY позволяет изменить реестр), а также создаёт тег git. При DRY_RUN=1 команда только выводит команды, не выполняя их.
Для запуска с токеном: LUFTPAUSE_TOKEN= docker compose up -d, затем claude mcp add --transport http luftpause http://localhost:7000/mcp --header "Authorization: Bearer ".
Ollama на хосте в качестве единственной реплики (модель задаётся через OLLAMA_MODEL; по умолчанию — llama3.2:1b):
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
Две реплики SGLang с Llama-3.2-1B FP8 на одной GPU (конфигурация тестового стенда, использованного ниже):
docker compose -f compose.yaml -f compose.testbed.yaml up -d --build scheduler dataplane runtime fake-mcp sglang-a sglang-b
Чтобы просматривать трейсы в Jaeger, добавьте compose.otel.yaml. Этот файл запускает jaegertracing/all-in-one и направляет OTEL_EXPORTER_OTLP_ENDPOINT трёх сервисов на Jaeger. Если тестовый стенд передаёт traceparent в запросах MCP, в том же трейсе будут видны его субагенты, их ходы и решение планировщика о допуске запросов. Интерфейс доступен по адресу http://localhost:16686.
docker compose -f compose.yaml -f compose.otel.yaml up -d --build
Hosted pod с vLLM в качестве дополнительной реплики (ключ и ID pod задаются через переменные окружения):
docker compose -f compose.yaml -f compose.testbed.yaml -f compose.runpod.yaml up -d scheduler runtime">
RUNPOD_POD_ID=<pod-id> RUNPOD_KEY=<key> docker compose -f compose.yaml -f compose.testbed.yaml -f compose.runpod.yaml up -d scheduler runtime
Замеры
Один ноутбук с RTX 4060 Laptop 8 GB и двумя репликами SGLang на базе Llama-3.2-1B-Instruct FP8,
если не указано иное. Число повторов указано для каждой таблицы; различия в пределах разброса считаются шумом. Данные для таблиц с высокой нагрузкой получены с помощью tools/bench/flood.sh (субагенты через MCP) и tools/bench/proxy-flood.sh (агенты с собственным циклом). Каждый скрипт принимает ревизии git для сравнения и собирает их в отдельные образы с соответствующими тегами.
Параллельный запуск: spawn × 16 + await all и запуск по одному субагенту (3 повтора)
16 субагентов, задачи объёмом около 2,8 тыс. токенов, длительность вызова инструмента lookup фиксирована на уровне 2 с, не более 3 вызовов для каждого субагента.
| Время для всех 16 | По сравнению с последовательным запуском | |
|---|---|---|
последовательно (spawn, await, следующий запуск) | 120.5 с | — |
| параллельный запуск в luftpause | 22.9 с | 5.3× |
| параллельный запуск напрямую через маршрутизатор SGLang | 21.5 с | 5.6× |
Для каждого субагента параллельный запуск в luftpause и через маршрутизатор занимает одинаковое время (p50 — 15–22 с); разница в 1,4 с укладывается в разброс между запусками.
Интерактивные запуски при высокой пакетной нагрузке (2 повтора, результаты обоих)
48 пакетных запусков создают нагрузку на реплики, параллельно выполняются 4 интерактивных запуска, окно — 3 минуты, контекст — до 8000 символов, не более 3 вызовов инструментов на запуск, замкнутый цикл (завершённый запуск немедленно заменяется новым). В варианте без планировщика оба движка ставили запросы в очередь, поэтому пул был насыщен. Во всех строках SGLang запускается с параметром --enable-priority-scheduling; приоритеты не передаются.
Агенты с собственным циклом, base_url указывает на luftpause (tools/loadgen --agents):
| Интерактивных запусков/мин | Время интерактивного запуска p50 | Пакетных запусков/мин | |
|---|---|---|---|
| напрямую к маршрутизатору SGLang, без учёта класса запросов | 6.0 / 6.7 | 38.5 с / 33.6 с | 83.7 / 82.3 |
| luftpause, допуск по загрузке движка, запуски могут перемещаться (0afb28a) | 8.7 / 9.0 | 23.2 с / 25.9 с | 79.3 / 79.0 |
| luftpause, допуск по загрузке движка со строгой привязкой запусков к репликам | 12.0 / 13.3 | 18.5 с / 19.8 с | 75.7 / 76.0 |
| luftpause, дополнительно учитывается оценка использования памяти KV (удалено) | 13.0 / 10.7 | 17.8 с / 20.8 с | 76.3 / 76.0 |
Субагенты через MCP (tools/loadgen --spawn):
| Интерактивных запусков/мин | Время интерактивного запуска p50 | Пакетных запусков/мин | |
|---|---|---|---|
| среда выполнения напрямую обращается к маршрутизатору SGLang | 3.0 / 3.0 | 56.6 с / 59.5 с | 39.2 / 36.5 |
| luftpause, допуск по загрузке движка, запуски могут перемещаться (0afb28a) | 4.0 / 5.0, 4.3 / 5.6 | 44.4 с / 36.7 с | 35.5 / 36.6 |
| luftpause, допуск по загрузке движка со строгой привязкой запусков к репликам | 8.0 / 8.0 (а в тестовой ветке — 8.0 / 8.0, 7.3 / 9.3) | 29.2 с / 25.2 с | 36.2 / 38.5 |
| luftpause, дополнительно учитывается оценка использования памяти KV (удалено) | 6.3 / 6.6, 6.0 / 7.3 | 29.7 с / 35.4 с | 35.5 / 35.5 |
Через прокси пропускная способность интерактивных запусков в 2 раза выше, чем у маршрутизатора без учёта класса запросов, а при работе через MCP — в 2,7 раза выше. При этом число пакетных запусков через прокси уменьшается на 9 %, а через MCP не меняется. Между строками различается только логика допуска. Если разрешить запускам перемещаться между репликами, 39 из 48 пакетных запусков меняли реплику во время выполнения и теряли кэш префикса. Весь прирост даёт закрепление генерирующего или приостановленного запуска за его репликой. Дополнительный учёт оценки использования памяти KV для каждого шага, сверенной с данными о пуле, которые сообщает движок, снижал пропускную способность интерактивных запусков через MCP примерно на одну пятую и никак не влиял на неё через прокси, поэтому эту оценку убрали. При небольшой нагрузке (контексты на 2000 символов, 24 пакетных запуска) движки не ставят запросы в очередь, и все варианты показывают одинаковый результат — около 14 интерактивных запусков в минуту.
Claude Code в роли клиента
Claude Code попросили параллельно выполнить шесть поисков по рекам. Он запустил шесть задач и дождался их завершения одним вызовом: весь поиск занял 12,5 с с учётом собственных обменов Claude Code, тогда как последовательное выполнение тех же задач заняло бы 36 с.
В новой сессии ему дали только задачу «исследовать эти шесть рек с помощью локальных субагентов». Claude Code вывел список инструментов субагентов, ограничил запуски инструментами Wikipedia, запустил шесть задач, дождался их завершения одним вызовом и повторно запустил задачи, ответы на которые оказались недостаточно точными.
Инструменты: wikipedia-mcp через stdio.
| Субагенты | Повторные запуски Claude | Отклонённые вызовы инструментов | Результат |
|---|---|---|---|
| локальная модель 1B, до приведения аргументов | 4 | 38 из 46 | ответы в основном основаны на знаниях модели |
| локальная модель 3B, с приведением аргументов | 2 | 0 из 20 | длины и устья указаны верно, списки стран ненадёжны |
| Llama 3.3 70B AWQ на поде RunPod vLLM | 0 | 0 из 6 | все шесть ответов получены из Wikipedia за один цикл (9,3 с) |
Под на 70B находился в одном пуле с локальной репликой 3B: шаги с запросом к llama-3.3-70b отправлялись на под через https с его API key, а все остальные запросы оставались локальными. Поле failedToolCalls в await позволяет вызывающей стороне определить, что ответ был получен не с помощью инструментов.
Маршрутизация (N = 8 одновременно выполняющихся субагентов, 4 повтора)
| Запусков/мин | Токены запроса, полученные из кэша | |
|---|---|---|
| привязка luftpause | 30.1 ± 1.7 | 44.6 % |
маршрутизатор SGLang, cache_aware | 30.1 ± 0.5 | 44.6 % |
маршрутизатор SGLang, round_robin | 28.2 ± 0.5 | 25.7 % |
Привязка даёт такой же результат, как маршрутизация SGLang с учётом кэша, и превосходит round_robin на 7–15 % при умеренной нагрузке. Когда одновременно выполняется 16–24 запуска, кэши постоянно обновляются, и выбор политики перестаёт влиять на результат.
Однопроходная обработка (классификатор vLLM, RoBERTa-base)
luftpause обслуживает два типа моделей в одном пуле. Генеративный движок (SGLang, vLLM, размещённый API) выполняет шаги агента: он генерирует токены один за другим, хранит KV-кэш для каждого запуска, а шаг может завершиться вызовом инструментов и приостановкой запуска. Однопроходный движок отвечает за один прямой проход: энкодер или классификатор в стиле RoBERTa, работающий через /classify в vLLM, возвращает типизированные метки с вероятностями, без генерации, потока данных и сохранения состояния между вызовами. Агенты используют такой движок для задач, которым не нужен сгенерированный ответ: например, для маршрутизации запроса, оценки результата работы инструмента или выбора следующего шага. Это занимает несколько миллисекунд вместо генеративного шага. Субагенты обращаются к нему через инструмент MCP decide, а агенты с собственным циклом — через POST /v1/decide. Однопроходные шаги направляются на отдельные реплики и не входят в состояние запусков, поэтому не занимают генеративные слоты и не ждут их освобождения. Если набор меток не входит в обучающие данные классификатора, вызов decide(labels: [...]) оценивает метки по логарифмическим вероятностям первых токенов генеративной реплики. Для этого требуется один шаг генерации длиной в один токен.
На тестовом стенде с моделью 1B система относит фразу «сервер трижды вернул ошибку 500, а дежурный телефон звонит» к метке urgent с вероятностью 0.89, а вопрос о населении — к lookup с вероятностью 0.99. Но фразы «2 + 2» и «спасибо, на этом всё» она тоже относит к lookup: механизм работает, но модель 1B склонна выбирать первую метку. Используйте более крупную модель или калибруйте оценки.
| Нагрузка | Задержка на уровне плоскости данных |
|---|---|
| один вызов за раз | p50 20.6 мс |
| 20 одновременно работающих агентов | p50 85 мс, p99 180 мс |
Лицензия
Business Source License 1.1, переходящая в Apache License 2.0 через четыре года после публикации каждой версии. На практике это означает, что проект можно бесплатно использовать, изменять, включать в свои продукты и использовать в своей компании, а также распространять — без требований copyleft к собственному коду. До перехода лицензии запрещено только одно: предоставлять сам luftpause третьим лицам в виде размещённого или управляемого сервиса. Таким образом, исходный код доступен, но проект не является открытым ПО по определению OSI, и мы прямо об этом говорим. Причина типична для инфраструктурных проектов: небольшая команда не может конкурировать с облачным провайдером, который оборачивает проект в консоль и продаёт его как сервис. Такой исход приведёт к закрытию проекта, а не к его развитию. Дата перехода отвечает на вопрос «станет ли проект когда-нибудь открытым ПО»: да, для каждой версии это произойдёт в дату, указанную в лицензии. Если до этого срока ограничение мешает вам что-либо сделать, создайте issue — разрешение для отдельного случая получить несложно. Вклады принимаются на тех же условиях (см. CONTRIBUTING.md).