Кратко
Teel — это Python-среда выполнения с открытым исходным кодом, которая распараллеливает независимые IO-bound вызовы в коде, написанном вами последовательно. Никаких async, await, futures или gather. Пометьте ресурсоемкие функции декоратором @cacheable, а оркестрацию — @jit, и магический теневой интерпретатор будет просматривать ваш байт-код наперед, находить вызовы, аргументы которых уже известны, и запускать их в фоновых потоках до того, как до них дойдет основной поток. На примере Princeton’s Tree of Thoughts без изменений кода: время выполнения сократилось в среднем на 83%, а на самом длинном прогоне — в 8,07 раза. В среднем на 19% быстрее, чем сгенерированный LLM асинхронный JavaScript внутри агента Deep Agents. Ознакомьтесь с репозиторием: github.com/AI21Labs/teel
Почему это важно для агентов
Качество работы агентов сегодня в основном зависит от увеличения вычислительных мощностей при инференсе. Запуск ансамбля. Генерация пяти вариантов патчей вместо одного. Split a task across N subagents and merge what they come back with. Разные методы, но одна структура выполнения: запуск множества независимых IO-bound задач с последующим их объединением.
Наши недавние результаты на BrowseComp-Plus, SWE-rebench и DeepResearch Bench II были получены с помощью трех разных методов, но показали одну и ту же картину на уровне вычислений: множество независимых запусков, обращений, вызовов инструментов или целых агентских сессий, которые перекрываются, а затем объединяются.
И все чаще агент сам пишет эту оркестрацию. В LangChain Deep Agents модель пишет короткий скрипт, который запускает ее собственные субагенты, вместо того чтобы вызывать их по одному инструменту за раз. Кто бы это ни писал, за все это распараллеливание приходится платить. Агентские рои доводят это до предела: Kimi Agent Swarm от Moonshot запускает до 300 субагентов параллельно и сообщает о завершении задач примерно в 4,5 раза быстрее, чем один агент.
Проблема
Когда мы профилировали работу наших агентов, большая часть времени уходила на ожидание ответа от сокета: вызовы моделей, обращения к данным, вызовы инструментов, выполнение команд в песочнице. Оптимизация основного кода агента почти не влияла на общий результат. Основной потенциал скрыт в ожиданиях, которые не зависят друг от друга.
В Python распараллеливание таких ожиданий означает предварительную переписку кода. Чтобы запустить два вызова одновременно, вы делаете их асинхронными. Затем каждая функция, которая их вызывает, тоже должна стать асинхронной, как и каждая функция, вызывающая их. Изменение, которое вы хотели внести в одном месте, распространяется по всей цепочке вызовов, пока логика оркестрации и инфраструктура конкурентности не становятся одними и теми же строками кода. Каждое новое распараллеливание требует повторения этого процесса. А когда этот код пишет модель, она должна сделать все правильно при каждом запуске.
Вот небольшой агент для проверки кода в трех вариантах: как есть, переписанный с помощью asyncio и как есть плюс декораторы Teel. Он запрашивает у модели информацию о самой серьезной ошибке в каждом измененном файле, спрашивает, должны ли эти находки блокировать слияние, а затем либо запрашивает изменения, либо одобряет их.
Версии 2 и 3 завершаются примерно за одно и то же время: 3,0 с и 2,9 с против 7,1 с при последовательном выполнении. Обе достигают этого одинаково: они запускают оба возможных варианта развития событий до того, как будет вынесен вердикт, и сохраняют тот, который будет выбран. Teel делает это самостоятельно, проходя по обеим веткам if, пока вердикт еще не вынесен. Версия на asyncio делает это вручную, с тремя задачами, двумя отменами, блоком finally и асинхронным двойником каждой вызываемой функции. Разница не в конкурентности. Разница в том, кому приходится ее находить и управлять ею.
Вот почему мы создали Teel. Конкурентность не должна быть частью кода, который пишет разработчик или агент. Это может быть задачей среды выполнения. Вы пишете последовательную версию. Teel считывает байт-код вашей функции перед текущей выполняемой строкой, находит вызовы, которые ничего не ждут, и запускает их заранее в фоновых потоках. К тому моменту, когда ваш код доходит до них, результаты уже готовы или находятся в процессе получения.
Результаты
Tree of Thoughts, без изменений. Мы запустили Princeton’s tree-of-thoughts-llm на примерах Game of 24 с использованием GPT-4 без редактирования репозитория: @jit, @cacheable и @inline были применены извне. Это классический пример распараллеливания, написанный как классический последовательный код (генерация кандидатов, оценка, выбор лучшего, рекурсия), и оригинал обрабатывает независимые ветки по одной. Среднее сокращение времени выполнения составило 83%: со 148 с до 24 с в нижней границе и с 307 с до 38 с в верхней.
Код, который модель пишет во время выполнения. LangChain Deep Agents поставляется с агентом dcode и инструментом js_eval: модель решает задачу, создавая фрагмент кода, который выполняется в песочнице QuickJS. Фрагмент может вызывать tools.web_search() и порождать субагентов через task(), поэтому модель создает распараллеливание «на лету». Но она создает его на асинхронном JavaScript, который передает модель конкурентности самой модели, а модели непоследовательны в использовании Promise.all.
Мы создали форк Deep Agents и заменили язык фрагментов на синхронный Python с использованием Teel, ожидая получить более простой сгенерированный код ценой некоторой потери скорости. Вместо этого он оказался быстрее: на десяти смешанных запросах (веб-поиск и LLM) среднее время составило 58 с против 71 с для асинхронного JS и 150 с для того же кода на Python при последовательном выполнении. Teel распараллеливает независимые вызовы, которые модель забыла объединить, и спекулятивно выполняет резервные ветки до того, как будет принято решение, требующее их. (Один и тот же запрос каждый раз выдает разный код, поэтому здесь сравниваются распределения сгенерированных программ, а не две реализации одной.) Наш форк доступен по ссылке github.com/AI21Labs/deepagents.
API
Есть три декоратора и один контекстный менеджер. Teel в настоящее время требует строго Python 3.12.13; в разделе «Известные ограничения» объясняется почему.
@cacheable помечает тяжелые функции, которые стоит запускать заранее. Результаты мемоизируются по ключу, построенному из json.dumps аргументов, поэтому следите за тем, чтобы эти аргументы были сериализуемыми в JSON и простыми.
@jit помечает функции, внутрь которых Teel разрешено заглядывать. Это ваш уровень оркестрации.
@inline является необязательным. Он помечает простые вспомогательные функции (форматирование строк, работа со словарями), которые Teel может фактически выполнить во время предварительного просмотра, что обычно позволяет обнаружить больше вызовов @cacheable для запуска заранее.
with Teel(): включает библиотеку. Вне этого контекста каждая декорированная функция работает как обычный Python, без изменений.
Как это работает
Почему байт-код? Реализация Teel настолько необычна, что путь к ней стоит 30 секунд, так как каждый шаг объясняет следующий. Каждый шаг сохранял одну и ту же идею выполнения — независимые вызовы, работающие параллельно, — и менял только интерфейс, в котором вы писали оркестрацию.
Первая версия запускала оркестрацию на Celery. Сигнатуры Canvas дали нам настоящий DAG, который кластер мог планировать: утилизация ресурсов выросла, запуски стали возобновляемыми, оркестрация наконец отделилась от вычислений. Но писать это было мучительно, потому что оркестрация — это бизнес-логика, а Celery заставляет писать бизнес-логику на диалекте каналов и сигнатур, который не совсем является Python.
Поэтому мы вернули его обратно в Python, а точнее, в ограниченное подмножество Python. Мы распарсили это подмножество с помощью ast, скомпилировали его в граф выполнения и написали собственный исполнитель. Граф оправдал себя сам по себе: независимые функции выполнялись параллельно, в тела операторов if можно было входить спекулятивно до разрешения условия, а вызов вроде func(x, y) мог переходить к своим подвызовам, пока x и y всё ещё находились в ожидании. Всё это было доступно только для подмножества языка, которое мы согласились разбирать.
Отказ от этого ограничения означал отказ от ast. Байт-код — это уровень, на котором каждая программа на Python выглядит одинаково.
Как среда выполнения узнаёт, что вы собираетесь вызвать? Когда выполняется функция с декоратором @jit, Teel дублирует состояние выполнения в ограниченный теневой интерпретатор и выполняет по шагам байт-код, до которого ваш главный поток ещё не дошёл. Он работает со своей собственной копией, поэтому состояние вашей программы остаётся нетронутым.
Строит ли Teel граф зависимостей? Нет. Версия с ast строила. Teel никогда не требуется весь граф, а лишь один ответ на каждый вызов: готовы ли уже аргументы этого вызова? Любое значение, которое он пока не может вычислить, например результат вызова @cacheable, всё ещё находящегося в процессе выполнения, помечается как ожидающее (pending), и всё, что вычисляется из ожидающего значения, также становится ожидающим:
Когда упреждающее чтение достигает вызова @cacheable без ожидающих аргументов, Teel отправляет его в ThreadPoolExecutor и мемоизирует результат. Когда ваш код доходит до этой строки по-настоящему, ответ уже готов или находится на пути к этому. Два вызова, не использующие результаты друг друга, никогда не ждут друг друга, именно поэтому веерная рассылка (fan-out) не требует специальной поддержки: [answer(q) for q in sub_questions] отправляет каждый answer(q), вопрос для которого уже известен. Параллелизм работает по умолчанию, а отказаться от него можно только путем создания реальной зависимости по данным.
Как он получает доступ к состоянию вашей программы? Teel захватывает стек кадров с помощью sys._getframe(), использует небольшое C-расширение (frameinspect), чтобы извлечь полный стек значений каждого кадра из CPython, и считывает f_lasti, чтобы найти последнюю инструкцию, которая была фактически выполнена. Именно с этого места теневой интерпретатор начинает шагать вперёд.
Получение этого состояния — весьма специфическая операция. Чтение стека значений активного кадра — это не то, что CPython предоставляет «из коробки», именно поэтому frameinspect компилируется с использованием внутренних заголовков CPython, а вся система привязана к одной конкретной версии интерпретатора. Полный разбор, код за кодом, находится в разделе «Under the hood» в README.
Контракт
Что обещаете вы. Teel делает три вещи, которые обычный интерпретатор никогда не делает: он выполняет функцию раньше, чем это сделал бы ваш код, иногда выполняет ту, до которой ваш код никогда бы не дошёл, и возвращает сохранённый результат вместо повторного запуска. Каждое из этих действий допустимо только в том случае, если функции всё равно, когда и как часто она запускается. Представьте себе функцию @cacheable, которая отправляет письмо по электронной почте или списывает деньги с карты: Teel может выполнить её для ветки, по которой ваша программа никогда не пойдёт, и никто не отменит отправку письма. В этом и заключается контракт @cacheable, и ответственность лежит на вас: никаких записей в базу данных, никакого чтения системных часов, никакого скрытого состояния и JSON-сериализуемые аргументы, поскольку результат json.dumps для них является ключом мемоизации. @jit ничего не обещает. Это просто разрешение на чтение вашего байт-кода, а не утверждение о том, что делает функция.
Что обещает Teel. Один и тот же вызов выполняется единожды: ключом для ожидающих и завершённых задач является функция вместе с аргументами, поэтому повторный просмотр вперёд, обнаруживший тот же fetch(url), подключается к первому вызову вместо отправки ещё одного запроса. Завершившиеся с ошибкой вызовы отбрасываются, а не кэшируются, из предположения, что сбой был временным.
А ошибочное предположение стоит времени и токенов, но не корректности. Теневой интерпретатор никогда не формирует итоговый ответ. CPython по-прежнему выполняет вашу функцию до конца, инструкция за инструкцией, ровно так же, как и без Teel, и единственное отличие заключается в том, что некоторые вызовы находят свой результат уже в кэше. Ошиблись в предположении — и вы потратили фоновый вызов, а также его токены, если это был вызов LLM. Ничего не угадали — платите полную цену последовательного выполнения. Однако если вы нарушите контракт чистоты функций, последствия будут на вашей совести. Это единственный сбой, от которого Teel не сможет вас защитить.
Наблюдаемость: куда на самом деле ушло астрономическое время
Когда среда выполнения решает, что именно запускать заранее, вам нужен способ увидеть, что именно она решила. Укажите контексту путь, и Teel запишет прогон:
Выходные данные представляют собой файл trace-event Chrome, поэтому Perfetto отображает их в виде временной шкалы: по одной полосе на каждый вызов @cacheable в потоке, который его выполнял, с временем начала и длительностью, а перекрывающаяся работа отображается в виде параллельных полос.
Детализация на уровне отдельных вызовов становится важной, как только вы начинаете варьировать объем вычислений во время тестирования. Результат в сложном агентном бенчмарке редко выражается одним числом: наша работа над BrowseComp-Plus представила границу точности в зависимости от стоимости и задержки, чтобы команда могла выбрать качество, которое она может себе позволить. Нельзя построить график границы задержек, если измерять процесс только от начала до конца.
Чтобы измерить ускорение, запустите один и тот же код дважды. Функция @cacheable выполняется в неизменном виде без активного контекста, поэтому базовым уровнем служит не отдельная последовательная реализация, а та же самая программа.
Куда это ведёт
Параллелизм и наблюдаемость вытекают из одного решения: прогон — это набор вызовов, ключами для которых служат их аргументы. Сделайте ещё один шаг вперед, и прогон агента перестанет быть одноразовым процессом, превратившись в повторно используемые данные. Сохраните результаты, и последующий запуск сможет использовать их: неизменённые вызовы дадут попадания в кэш, а любой вызов, куда попадают изменённые входные данные, получит новый ключ и выполнится заново. Это даёт вам возобновление после сбоев, отладку на зафиксированных выходных данных и итеративную разработку оркестрации без повторной оплаты каждого вызова инструментов. Это также обеспечивает поддержку участия человека в процессе (human-in-the-loop): агент замораживает своё состояние в точке принятия решения, человек проводит проверку, а прогон возобновляется через несколько часов на другом хосте.
Известные ограничения
Это доказательство концепции с двумя подводными камнями. Система жестко привязана именно к Python 3.12.13, так как frameinspect компилируется с использованием внутренних заголовков CPython. И GIL по-прежнему действует: фоновые вызовы запускаются в ThreadPoolExecutor, поэтому операции ввода-вывода, сетевые запросы и задачи без использования GIL (например, NumPy) отлично перекрываются по времени, но для задач, ограниченных процессором и GIL, этот подход не сработает.
Склонируйте репозиторий, украсьте декораторами свои функции, оберните точку входа и начните с README:










