TL;DR: Исполняемые UDF (пользовательские функции) стали общедоступными (GA) в ClickHouse Cloud на AWS, GCP и Azure. Они поддерживают среду выполнения Native для скомпилированного кода на Rust/Go/C++/JavaScript, сетевой доступ, лимиты памяти, флаг детерминированности, поддержку Cloud API + Terraform, а также метрики UDF для каждого запроса в версии 26.6+. Ниже мы используем их для подсчета и оценки стоимости токенов LLM, а также для проверки ответов внутри ClickHouse.
TL;DR: Исполняемые UDF (пользовательские функции) стали общедоступными (GA) в ClickHouse Cloud на AWS, GCP и Azure. Они поддерживают среду выполнения Native для скомпилированного кода на Rust/Go/C++/JavaScript, сетевой доступ, лимиты памяти, флаг детерминированности, поддержку Cloud API + Terraform, а также метрики UDF для каждого запроса в версии 26.6+. Ниже мы используем их для подсчета и оценки стоимости токенов LLM, а также для проверки ответов внутри ClickHouse.
Примечание: сопутствующий код доступен здесь — github.com/ClickHouse/llm-token-udf.
Четыре месяца назад мы перевели исполняемые UDF в стадию публичного бета-тестирования в ClickHouse Cloud: вы пишете функцию на Python, загружаете её в виде zip-архива и вызываете из SQL так же, как любую встроенную функцию. Запросы, которые мы получали во время бета-тестирования, были довольно однотипными. Пользователи хотели загружать скомпилированный код вместо Python, хотели использовать UDF в Azure, требовали сетевого доступа без необходимости открывать тикет в поддержку и хотели видеть, как их UDF влияют на работу кластера.
Сегодня мы рады объявить, что исполняемые UDF стали общедоступными в ClickHouse Cloud на AWS, GCP и Azure. С момента анонса бета-версии мы добавили:
- Среда выполнения Native — загружайте предварительно скомпилированный, статически связанный бинарный файл вместо скрипта на Python. На сегодняшний день поддерживаются Rust, Go, C++ и JavaScript (скомпилированный с помощью Bun).
- Сетевой доступ — функция из закрытого бета-тестирования теперь доступна в каждой организации. UDF могут выполнять исходящие вызовы к публичным эндпоинтам.
- Azure — теперь UDF работают во всех трех облаках.
- Лимит памяти на процесс и флаг детерминированности, который позволяет кэшу запросов обслуживать запросы, вызывающие вашу UDF.
- 12 эндпоинтов UDF в Cloud API и ресурсы clickhouse_udf / clickhouse_udf_attachment в провайдере Terraform (3.24.0+) с привязкой версий для каждого сервиса.
- 8 счетчиков ProfileEvents и 2 асинхронные метрики в ClickHouse 26.6+, благодаря чему стоимость UDF для запроса (время выполнения, ожидание в пуле, CPU, память, байты в канале) отображается в system.query_log наряду с остальными данными.
UDF не тарифицируются отдельно. Они работают внутри подов вашего сервиса и потребляют те же ресурсы CPU и памяти, что и ваши запросы.
В посте о бета-версии мы обрабатывали ~6 миллиардов биржевых сделок с помощью автокодировщика PyTorch. В этот раз мы создали нечто более близкое к тому, что большинство из вас делает с помощью UDF, а именно — учет расходов. В данном случае мы вычисляем затраты на LLM, используя промпты, которые вы уже логируете в ClickHouse. Полный исходный код для UDF, SQL и Terraform доступен по ссылке .
Если вы запускаете что-либо поверх LLM, вы где-то логируете вызовы: промпт, ответ (completion), модель, задержку, возможно, trace ID. Все чаще этим «где-то» становится ClickHouse через ClickStack, Langfuse или коллектор OpenTelemetry, использующий семантические соглашения GenAI.
Чего у вас обычно нет, так это надежного подсчета токенов. Провайдеры возвращают данные об использовании в большинстве ответов, но не в потоковых (некоторые возвращают, некоторые нет, некоторые только при наличии флага), не через каждый прокси и не для self-hosted моделей. В синтетическом наборе данных ниже ~30% спанов приходят вообще без данных об использовании, что примерно соответствует тому, что вы увидели бы при потоковой передаче ответов пользователям. А когда данные об использовании присутствуют, это общая сумма. Она не скажет вам, что 41% затрат на ваш чат-бот приходится на системный промпт, который не менялся с марта, или что ваш слой поиска (retrieval layer) начал возвращать в два раза больше чанков в прошлый вторник.
Подсчет токенов — это вызов библиотеки (tiktoken в Python, tiktoken-rs в Rust), поэтому проблема не в самом подсчете. Проблема в том, что библиотека живет в коде, а промпты — в таблице. Вы можете экспортировать промпты, посчитать их в ноутбуке и загрузить результаты обратно, но это медленно, данные устаревают, и это еще один конвейер, за которым нужно следить. Вы можете реализовать byte-pair encoding для словаря из 200 тысяч элементов с помощью arrayFold (пожалуйста, не надо). Или вы можете поместить библиотеку рядом с данными.
Две UDF и немного SQL:
- count_tokens(model, text) -> UInt32 — бинарный файл Rust в среде выполнения Native. Детерминированная и CPU-интенсивная функция, вызываемая четыре раза на каждый спан во время вставки данных с помощью материализованного представления.
- judge_response(prompt, completion) -> Tuple(score, verdict, reason) — UDF на Python с сетевым доступом, которая просит Claude оценить выборку ответов, с выводом, ограниченным через tool-use. Вызывается каждые 10 минут обновляемым материализованным представлением.
Все остальное — обычный ClickHouse: словарь цен на модели (получаемый из публичного прайс-листа с помощью url(), так как получение JSON-файла — не задача для UDF), два материализованных представления и запросы.
Среда выполнения Native (не путать с форматом Native в ClickHouse, который является одной из опций формата ввода-вывода для любой UDF) принимает zip-архив с двумя папками, amd64/ и arm64/, каждая из которых содержит статически скомпилированный Linux-исполняемый файл с именем main и любые файлы данных, которые вам нужны во время выполнения. Оба архитектурных типа обязательны, так как ClickHouse Cloud работает на обоих. Бинарный файл запускается без аргументов, и ничего не устанавливается автоматически, поэтому все необходимое должно быть скомпилировано внутри.
count_tokens — это около 100 строк кода на Rust вокруг tiktoken-rs. Большая часть кода — это протокол передачи данных, который такой же, как и у UDF на Python, только в бинарном виде:
Мы использовали RowBinary вместо формата TabSeparated из демо-версии бета-тестирования. TSV подходил для 14 числовых признаков, но промпты полны табуляций, символов новой строки и обратных слэшей, которые нужно экранировать с обеих сторон канала, тогда как RowBinary является бинарно-безопасным, и ClickHouse не нужно форматировать текст при отправке или парсить его при получении. По нашим тестам, переход UDF с TabSeparatedRaw на RowBinary дает прирост производительности около 20%.
Заголовок чанка — это вторая половина протокола. При включенном send_chunk_header ClickHouse записывает количество строк перед каждым чанком, поэтому процесс считывает ровно столько строк, обрабатывает их и выполняет сброс (flush) один раз. Без этого UDF, которая буферизует вывод, не имеет возможности узнать, когда заканчивается блок. Во время бета-тестирования мы отлаживали UDF, которая выполняла сброс каждые 128 строк, и поэтому зависала на каждом блоке, размер которого не был кратен 128 (т.е. на последнем блоке почти каждого запроса), до тех пор, пока не срабатывал таймаут чтения. Если ваша UDF не выполняет сброс после каждой строки, включите эту опцию.
Сопоставление моделей и кодировок (gpt-4o → o200k_base, gpt-4 → cl100k_base и так далее) находится в файле models.json рядом с бинарным файлом, поэтому при выходе новой модели мы редактируем текстовый файл и загружаем новую версию вместо перекомпиляции. Одна деталь, которая стоила нам цикла проверки: файлы данных развертываются рядом с бинарным файлом, но процесс запускается не в этой директории (пакет находится в /scripts, а рабочая директория — /), поэтому бинарный файл разрешает путь к models.json относительно собственного пути, а не рабочей директории, и завершается с ошибкой, если файла там нет, вместо того чтобы тихо продолжить работу без него. Модели, которые мы не распознаем, возвращаются к o200k_base. Это приближение для токенизаторов не от OpenAI, но его достаточно для распределения затрат, а собственные данные об использовании от провайдера имеют приоритет, если они присутствуют (подробнее об этом ниже).
Сборка обеих архитектур с ноутбука выполняется двумя командами cargo с целями musl:
Бинарные файлы весят около 7 МБ каждый, а zip-архив — 6,6 МБ.
Поверхность развертывания — тот же экран загрузки, что и в бета-версии, с несколькими новыми полями:
Два из них появились после бета-версии. 'Deterministic' сообщает ClickHouse, что функция возвращает одинаковый результат для одинаковых входных данных, что необходимо знать кэшу запросов перед сохранением результата. Во время бета-тестирования каждая Cloud UDF считалась недетерминированной, поэтому use_query_cache = 1 в запросе, вызывающем такую функцию, приводил к ошибке QUERY_CACHE_USED_WITH_NONDETERMINISTIC_FUNCTIONS. С установленным флагом второй запуск этого запроса дашборда возвращается из кэша за 0 мс с нулевым количеством вызовов UDF:
'Memory limit' ограничивает память, доступную каждому процессу песочницы; значение по умолчанию — 4 ГиБ. Лимит применяется к виртуальному адресному пространству, а не к резидентной памяти, что важно, поскольку некоторые среды выполнения резервируют гораздо больше адресного пространства, чем используют. Наш бинарный файл на Rust занимает 43 МиБ виртуальной / 39 МиБ резидентной памяти на процесс, версия на Python — 103 / 93 МиБ, а версия на Go резервирует ~1,2 ГиБ адресного пространства, используя при этом 29 МиБ. Другими словами, устанавливайте лимит исходя из среды выполнения, а не рабочей нагрузки, и давайте Go достаточный запас.
Мы будем вызывать count_tokens из материализованного представления, поэтому токенизатор запускается ровно один раз для каждого span, во время вставки:
Каждый INSERT INTO llm_spans запускает это представление, подсчитывает четыре компонента для каждого span и записывает результат в llm_span_tokens. Каждый последующий запрос — это агрегация по целым числам.
Для понимания пропускной способности: 100 000 синтетических span (351 МиБ текста промптов, 69 млн токенов) проходят через это представление за 5,2 секунды на машине с 4 vCPU и пулом из 4, то есть ~13 млн токенов/сек, или ~19 тыс. span/сек.
Количество токенов превращается в доллары с помощью цены за токен. LiteLLM поддерживает публичный прайс-лист в виде JSON-файла, и поскольку получение файла — это задача для url(), а не для UDF, мы обернем получение в обновляемое материализованное представление, которое ежедневно перезагружает его и наполняет словарь:
Это дает нам 3683 модели с ценами, обновляемые раз в день и доступные через dictGet. Запрос стоимости использует данные об использовании от провайдера, если они есть, и наш подсчет, если их нет:
Там, где провайдер предоставил данные об использовании, мы можем проверить свою работу. Разница между provider_input_tokens и нашими тремя подсчетами компонентов — это накладные расходы шаблона чата (маркеры ролей и обрамление сообщений), которые для конкретной модели должны составлять стабильные 10-12 токенов. Если значение отклоняется, значит, либо провайдер изменил шаблон, либо наш models.json неверен для этой модели, и в любом случае это проверяется одним квантильным запросом.
Системный промпт — это статический текст, отправляемый при каждом вызове, и провайдеры теперь тарифицируют кэшированные префиксные токены со скидкой 50-90% от прайса в зависимости от провайдера (это столбец cache_read_input_token_cost выше). Поэтому вопрос 'какая часть моих затрат на ввод приходится на системный промпт?' — это то же самое, что 'сколько сэкономит мне кэширование промптов?':
Цифры в долларах небольшие, потому что набор данных мал (100 тыс. span за две недели); умножьте на свой объем. Суть в том, что этот запрос вообще нельзя написать, основываясь только на итоговых данных об использовании. Нужны компоненты, а компоненты приходят из токенизатора.
Стоимость — это одна сторона учета; были ли ответы качественными — другая, и обычный подход заключается в том, чтобы вторая модель оценила выборку. Это сетевой вызов API LLM изнутри базы данных, для чего и нужны сетевые UDF.
judge_response — это Python UDF (~120 строк), которая отправляет промпт и ответ в Claude со схемой использования инструментов, где поле вердикта — это перечисление pass, partial и fail, а оценка — целое число от 1 до 5. Модель обязана вызвать инструмент, поэтому ответ всегда можно распарсить, а вердикт всегда является одним из трех значений:
Этот вариант использует JSONEachRow вместо RowBinary, поскольку пропускная способность не важна, когда каждая строка — это round trip по HTTPS, а JSON упрощает возврат кортежа. Каждый процесс в пуле держит открытой requests.Session на протяжении всего времени жизни, кэширует результаты для пары (промпт, ответ), чтобы повторная оценка была бесплатной, и повторяет попытки при 429/529 с экспоненциальной задержкой. Он также выполняет 'мягкий' отказ — неудачный вызов API возвращает (0, 'error', reason) вместо завершения запроса, — потому что работает без присмотра.
Об API-ключе: менеджера секретов для UDF пока нет (см. ниже), поэтому ключ поставляется в config.json внутри zip-архива, а не появляется где-либо в SQL. У песочницы нет собственной облачной идентификации (нет роли инстанса, нет эндпоинта метаданных, нет унаследованного окружения), поэтому единственные учетные данные, которые есть у UDF, — это те, что вы ей предоставили.
Сам цикл оценки — это обновляемое материализованное представление. Каждые 10 минут он берет детерминированную 2% выборку span за последние 10 минут, оценивает их и добавляет результаты:
В этой схеме нет планировщика или сервиса оценки, только представление с интервалом обновления. Та же функция GROUP BY, модель, которую вы написали бы для расчета стоимости, дает вам показатели отказов по функциям и моделям, а поскольку llm_evals и llm_span_tokens имеют общий span_id, 'какие дорогие промпты также приводят к ошибкам' — это join.
Самым большим пробелом в бета-версии была наблюдаемость. UDF работает в отдельном процессе, поэтому system.query_log показывал CPU и память самого запроса, но ничего о дочерних процессах, и когда клиент спрашивал, что работает медленно — UDF или запрос, никто из нас не мог ответить, используя системные таблицы.
ClickHouse 26.6 добавил восемь ProfileEvents для исполняемых UDF, и теперь они появляются в system.query_log, system.events и system.metric_log на каждом облачном сервисе:
Две асинхронные метрики, ExecutableUserDefinedFunctionProcesses и ExecutableUserDefinedFunctionMemoryResidentBytes, сообщают, сколько процессов UDF запущено на сервере прямо сейчас и сколько резидентной памяти они занимают (суммарно по процессам, так что это верхняя граница).
С их появлением вопрос о том, на каком языке писать UDF, превращается в запрос к query_log. Мы написали count_tokens трижды — на Rust с использованием Native runtime, на Python с tiktoken и на Go с чистым токенизатором на Go — и прогнали через каждый рабочую нагрузку материализованного представления (100 тыс. span, по 4 подсчета на каждый, 351 МиБ текста):
Rust здесь в 1,4 раза быстрее Python и в 2,9 раза быстрее Go, что совсем не тот порядок, которого мы ожидали. Python держится близко, потому что ядро tiktoken написано на Rust; разница в 1,4 раза обусловлена циклом интерпретатора и обработкой каналов (pipe). Go работает медленно, потому что библиотека токенизатора на чистом Go работает медленно, а нативная среда выполнения (Native runtime) никак не ускоряет медленную библиотеку. Проще говоря, среда выполнения убирает интерпретатор и установку зависимостей, позволяя использовать уже имеющуюся у вас библиотеку, но сама библиотека при этом должна быть быстрой. (Для Python, ограниченного производительностью CPU и не имеющего под собой нативного ядра, картина выглядит иначе: один из клиентов-партнеров по разработке зафиксировал прирост примерно в 25 раз при переносе пользовательской функции (UDF) для сопоставления строк с Python на Go.)
Результаты подсчета, если кому интересно, оказались абсолютно идентичными во всех трех реализациях и совпали с самим tiktoken на 1200 выборочно проверенных значениях.
Все вышеперечисленное было настроено через консоль. Для всего, что отправляется в продакшн, вам, вероятно, захочется использовать код, и провайдер Terraform (версии 3.24.0+) теперь предоставляет для этого два ресурса: clickhouse_udf публикует новую версию при каждом изменении хеша ZIP-архива и ожидает сборки, а clickhouse_udf_attachment привязывает конкретную версию к определенному сервису.
Это зеркально отражает работу версий в консоли. Версии неизменяемы, новый ZIP-архив — это новая версия, а версии привязываются к каждому сервису в отдельности, поэтому вы можете запустить v7 на среде разработки (dev), в то время как продакшн остается на v6, и откатить один сервис, не затронув остальные. Сервис может одновременно использовать только одну версию функции. Для UDF с пулом исполняемых процессов (executable_pool) долгоживущие процессы пула продолжают обслуживать старую версию до тех пор, пока пул не будет перезапущен, поэтому в консоли рядом с каждым привязанным сервисом есть кнопка «Перезагрузить UDF» (Reload UDF), которая автоматически выполняет команду SYSTEM RELOAD FUNCTION. Две относительно новые настройки — детерминированность и лимит памяти — пока еще не включены в схему провайдера, поэтому в настоящее время они задаются в консоли или через API.
Аналогичный жизненный цикл доступен и в Cloud API: создайте URL для загрузки, отправьте ZIP-архив, создайте функцию или новую версию, привяжите ее к сервису.
Четыре месяца обсуждений и поддержки бета-версии сводятся к короткому списку.
Используйте executable_pool. В случае с обычным executable ClickHouse запускает новый изолированный процесс (sandbox) для каждого блока данных; с executable_pool пул долгоживущих процессов переиспользуется между блоками и запросами, что работает быстрее, сохраняет вашу модель или словарь разогретыми в памяти и держит нагрузку так, как не может запуск процессов под каждый блок. Мы еще не встречали облачной рабочей нагрузки, где обычный executable был бы правильным выбором.
Меньше крупных чанков. Каждый вызов имеет фиксированные накладные расходы на настройку с обеих сторон канала. Тот же запрос на 5000 строк выполнялся 165 мс как один чанк и 1544 мс при max_block_size = 1, то есть за 5000 вызовов. Если счетчик ExecutableUserDefinedFunctionInvocations для одного запроса исчисляется сотнями тысяч, увеличьте preferred_block_size_bytes (мы использовали 100000000) и проверьте счетчик снова.
Размер пула указывается для каждого репликата, а max_threads является верхним пределом. Запрос использует не более max_threads процессов пула одновременно, поэтому пул из 64 процессов для запроса с 16 потоками фактически будет равен 16. Начните с 4 и увеличивайте это значение, когда счетчик ExecutableUserDefinedFunctionPoolWaitMicroseconds укажет на необходимость этого, помня о том, что каждый процесс хранит собственную копию всего, что вы загружаете при запуске.
Выполняйте сброс (flush) для каждого чанка, а не каждые N строк. Включите send_chunk_header и считывайте ровно указанное количество строк. Об этом уже говорилось выше, но именно этот пункт породил больше всего обращений в поддержку бета-версии.
Падайте с явной ошибкой. UDF, которая не может найти встроенный файл, не может разобрать конфигурационный файл или получает непонятную строку, должна записать одну строку в stderr и завершиться с ненулевым кодом возврата. ClickHouse выводит содержимое stderr в сообщении об ошибке запроса, поэтому проблема будет заметна уже при выполнении первого запроса, а не в виде некорректного числа на каком-нибудь дашборде три недели спустя. Наша первая функция count_tokens обрабатывала отсутствие файла models.json как «отсутствие правил» и токенизировала каждую модель как o200k_base; результаты выглядели правдоподобно, и никто бы ничего не заметил, пока счет за gpt-4 не перестал бы сходиться.
Понимайте, когда UDF — это неподходящий инструмент. Каждая строка пересекает границу процессов через канал и сериализуется в процессе передачи, и от этих накладных расходов невозможно избавиться с помощью оптимизации. Для тривиальных операций над каждой строкой эти расходы доминируют: UDF без операций (no-op) на миллиардах строк будет работать в несколько раз медленнее эквивалентной встроенной функции на любом языке. UDF оправдывают себя при реализации логики, которую не может выразить SQL (токенизатор, модель, парсер, вызов API), а не той, которую он может.
Первое добавление UDF к сервису перезапускает его. Привязка вашей первой UDF добавляет вспомогательный контейнер в поды сервиса, что означает скользящий перезапуск (rolling restart); при добавлении второй и последующих UDF этого не происходит, а удаление последней UDF снова вызывает перезапуск. Относитесь к первой привязке как к окну обслуживания.
- Секреты — самый частый запрос: переменные окружения или ссылки на секреты для UDF, чтобы ключи API не передавались внутри ZIP-архива. У нас уже есть проект архитектуры, который связывает входные данные UDF с именованными коллекциями с надлежащим разделением доступа, и это следующий пункт в нашем списке.
- Конфигурация среды выполнения без пересборки — размер пула, таймауты и лимиты памяти в настоящее время жестко привязаны к версии. Мы разделяем их, чтобы ресурс clickhouse_udf_attachment мог переопределять их для каждого сервиса по отдельности без публикации новой версии.
- Сетевой доступ для нативных UDF (Native UDF) — на данный момент нативная среда выполнения поддерживает только вычисления, а исходящий сетевой доступ доступен только в Python.
- Другие среды выполнения — UDF на WebAssembly существуют в открытом исходном коде ClickHouse как экспериментальная функция и пока не доступны в Cloud; мы рассматриваем их, а также специализированные среды выполнения для каждого языка в качестве долгосрочной перспективы.
Исполняемые UDF (Executable UDF) теперь общедоступны во всех организациях ClickHouse Cloud на AWS, GCP и Azure без необходимости что-либо включать. Откройте пункт «Пользовательские функции» (User-defined functions) в меню вашей организации в консоли или начните с документации, справочника по Cloud API или ресурсов Terraform.
Полный проект доступен по адресу :
Если вы перенесете Python UDF на нативную среду выполнения и измерите разницу или создадите что-то новое, о чем мы даже не подумали, обязательно напишите нам!
Начните работу с ClickHouse Cloud сегодня и получите 300 долларов на баланс. По окончании 30-дневного пробного периода вы можете продолжить работу по тарифному плану с оплатой по мере использования (pay-as-you-go) или связаться с нами, чтобы узнать больше о скидках за объем. Посетите нашу страницу цен для получения подробной информации.










