ClickHouse Cloud 可执行 UDF 正式 GA
DataHot 速览
ClickHouse 宣布可执行 UDF 在 ClickHouse Cloud 的 AWS、GCP 和 Azure 上正式 GA。新增原生运行时,支持 Rust、Go、C++ 和经 Bun 编译的 JavaScript,并加入网络访问、每进程内存限制和 deterministic 标志。Cloud API 提供 12 个 UDF 端点,Terraform provider 3.24.0+ 支持相关资源;ClickHouse 26.6+ 提供 8 个 ProfileEvents 计数器和 2 个异步指标查看 UDF 成本。UDF 不单独计费,运行在服务 Pod 内消耗与查询相同的 CPU 和内存;文章示例用其在 ClickHouse 内统计和计价 LLM token 并评估补全。
为什么值得关注:ClickHouse Cloud 将可执行 UDF 推向 GA,把自定义代码与 LLM 计费等逻辑下推数据库,并补齐可观测性、网络与 IaC 管理。数据平台从业者可关注其对扩展性、成本治理和生产运营的影响。
本文目录 14 节
译文
AI 逐段翻译简而言之,可执行 UDF 已在 ClickHouse Cloud 上正式发布(GA),覆盖 AWS、GCP 和 Azure,提供用于编译 Rust/Go/C++/JavaScript 的 Native 运行时、网络访问、内存限制、一个 确定性标志、Cloud API + Terraform 支持,以及在 26.6+ 中的按查询 UDF 指标。下面,我们用它们在 ClickHouse 内部统计和定价 LLM token 并对补全进行评分。注意:配套代码在此 - github.com/ClickHouse/llm-token-udf。
四个月前,我们在 ClickHouse Cloud 上推出了可执行 UDF 的公测版:用 Python 编写一个函数,将其作为 zip 上传,就可以像调用任何内置函数一样从 SQL 中调用它。公测期间我们收到的请求相当一致。人们希望上传编译后的代码而不是 Python,希望在 Azure 上使用 UDF,希望无需提交支持工单就能获得网络访问,并且希望看到他们的 UDF 对集群产生了什么影响。
今天,我们很高兴地宣布 可执行 UDF 已在 ClickHouse Cloud 上正式发布,覆盖 AWS、GCP 和 Azure。自公测公告以来,我们新增了:
- Native 运行时 - 上传预编译的、静态链接的二进制文件,而不是 Python 脚本。目前支持 Rust、Go、C++ 和 JavaScript(使用 Bun 编译)。
- 网络访问 - 公测文章中的私有测试版功能现已面向所有组织开放。UDF 可以对外部公共端点发起出站调用。
- Azure - UDF 现已在全部三个云上运行。
- 一个按进程的内存限制,以及一个
确定性标志,允许查询缓存为调用你的 UDF 的查询提供服务。 - Cloud API 中的 12 个 UDF 端点,以及 Terraform provider(3.24.0+)中的
clickhouse_udf/clickhouse_udf_attachment资源,支持按服务固定版本。 - ClickHouse 26.6+ 中的 8 个
ProfileEvents计数器和 2 个异步指标,因此查询的 UDF 成本(墙钟时间、池等待、CPU、内存、通过管道的字节数)会与其他所有内容一起显示在system.query_log中。
UDF 不单独计费。它们在你的服务的 pod 内运行,消耗的 CPU 和内存与你的查询相同。
公测文章用 PyTorch 自编码器对约 60 亿笔股票交易进行了评分。这次我们构建了更接近你们大多数人告诉我们在用 UDF 做的事情——会计——具体来说,就是利用你已经记录到 ClickHouse 中的提示词,弄清楚 LLM 账单花在了哪里。UDF、SQL 和 Terraform 的完整源代码在 github.com/ClickHouse/llm-token-udf。
为什么是 token?
如果你在 LLM 之上运行任何东西,你会把调用记录到某个地方:提示词、补全、模型、延迟,可能还有一个 trace ID。这个地方越来越多地是 ClickHouse,通过 ClickStack、Langfuse,或使用 GenAI 语义约定的 OpenTelemetry collector。
你不能可靠拥有的是 token 计数。提供商会在大多数响应中返回 usage,但流式响应不会(有些会,有些不会,有些仅在带标志时才会),并非通过每个代理都会返回,自托管模型也不会返回。在下面的合成数据集中,约 30% 的 span 到达时完全没有 usage,这大致就是你在向用户流式返回响应时会看到的情况。而且当 usage 存在时,它只是一个总数。它无法告诉你,你的支持机器人输入支出的 41% 是自三月份以来就未改变的系统提示词,也无法告诉你你的检索层上周二开始返回两倍数量的分块。
统计 token 只是一个库调用(Python 中的 tiktoken,Rust 中的 tiktoken-rs),所以问题不在于计数。问题在于库在代码里,而提示词在表里。你可以导出提示词,在 notebook 中计数,再把计数加载回去,这很慢、容易过时,又多了一条要照看的流水线。你可以用 arrayFold 在 20 万条词表上实现字节对编码(请不要这么做)。或者,你可以把库放到数据旁边。
我们构建了什么
两个 UDF 和一些 SQL:
count_tokens(model, text) -> UInt32- Native 运行时上的 Rust 二进制文件。确定性且受 CPU 限制,在插入时由物化视图对每个 span 调用四次。judge_response(prompt, completion) -> Tuple(score, verdict, reason)- 一个具有网络访问权限的 Python UDF,请求 Claude 对补全样本进行评分,输出通过 tool-use 进行约束。由可刷新物化视图每 10 分钟调用一次。
其他一切都是普通的 ClickHouse:模型价格字典(通过 url() 从公开价格列表拉取,因为获取 JSON 文件不是 UDF 的工作)、两个物化视图,以及查询。
1┌───────────────────────────┐2│ llm_spans │ ← prompts, completions, model, usage (often NULL)3└──────────────┬────────────┘4 │ INSERT5 ▼6┌───────────────────────────┐7│ llm_span_tokens_mv │ ← fires on every INSERT8│ (calls count_tokens ×4) │9└──────────────┬────────────┘10 │11 ▼12┌───────────────────────────┐ ┌──────────────────────────┐13│ llm_span_tokens │ ⟵─┤ model_prices (dict) │ ← url() + refreshable MV, daily14│ system / context / user /│ └──────────────────────────┘15│ completion token counts │16└───────────────────────────┘17 ▲18 │ every 10 min, 2% sample19┌──────────────┴────────────┐20│ llm_evals_mv │ ← refreshable MV, APPEND21│ (calls judge_response) │ over the network22└───────────────────────────┘分词器,作为一个 Native UDF
Native 运行时(不要与 ClickHouse 的 Native 格式混淆,后者是任何 UDF 的 I/O 格式选项之一)接收一个包含两个文件夹的 zip,即 amd64/ 和 arm64/,每个文件夹中包含一个名为 main 的静态链接 Linux 可执行文件,以及你希望在运行时随它一起使用的任何数据文件。两个架构都是必需的,因为 ClickHouse Cloud 在两者上运行。该二进制文件在无参数的情况下运行,不会为你安装任何东西,因此它需要的一切都必须编译进去。
count_tokens 是围绕 tiktoken-rs 的大约 100 行 Rust 代码。其中大部分是线协议,也就是 Python UDF 所说的同一个协议,只是以二进制形式:
1// Deploy as: executable_pool, runtime = Native, format = RowBinary,2// send_chunk_header = true, deterministic = true.3// Arguments: (model String, text String) -> UInt324fnmain() {5letmut tok = Tokenizers::load(); // reads models.json from the binary's directory6letmut stdin = BufReader::with_capacity(1 << 20, io::stdin().lock());7letmut stdout = BufWriter::with_capacity(1 << 20, io::stdout().lock());8let (mut header, mut model, mut text) = (String::new(), Vec::new(), Vec::new());910loop {11// 1. chunk header: row count as decimal text + '\n' (send_chunk_header)12 header.clear();13match stdin.read_line(&mut header) {14Ok(0) => return, // pipe closed; pool process exits cleanly15Ok(_) => {}16Err(e) => die(&format!("reading chunk header: {e}")),17 }18letrows: usize = header.trim().parse().unwrap_or_else(|_| die("bad chunk header"));1920// 2. N RowBinary rows: each String is a LEB128 length + raw bytes21foriin0..rows {22read_string(&mut stdin, &mut model)23 .and_then(|_| read_string(&mut stdin, &mut text))24 .unwrap_or_else(|e| die(&format!("row {i}: {e}")));25letn = tok.count(&String::from_utf8_lossy(&model), &String::from_utf8_lossy(&text));26 stdout.write_all(&n.to_le_bytes()).unwrap_or_else(|_| process::exit(1));27 }28// 3. N UInt32 answers, flushed once per chunk29 stdout.flush().unwrap_or_else(|_| process::exit(1));30 }31}我们使用了 RowBinary,而不是公测演示中的 TabSeparated 格式。TSV 对于 14 个数值特征来说没问题,但提示词中充满了制表符、换行符和反斜杠,需要在管道两端进行转义,而 RowBinary 是二进制安全的,ClickHouse 不必在输出时格式化文本或在输入时解析文本。在我们的测试中,将 UDF 从 TabSeparatedRaw 迁移到 RowBinary 本身就值约 20%。
块头是协议的另一半。启用 send_chunk_header 后,ClickHouse 会在每个块之前写入行数,因此进程会精确读取那么多行,处理它们,并刷新一次。没有它,一个缓冲输出的 UDF 就无法知道块何时结束。在公测期间,我们调试过一个 UDF,它每 128 行刷新一次,因此在每个大小不是 128 的倍数的块上都会挂起(即几乎每个查询的最后一个块),直到读取超时触发。如果你的 UDF 不按行刷新,请打开这个。
模型到编码的映射(gpt-4o → o200k_base,gpt-4 → cl100k_base,等等)都放在二进制文件旁边的 models.json 中,所以当有新模型发布时,我们只需编辑一个文本文件并上传新版本,而无需重新编译。有一个细节让我们多花了一个评审周期:数据文件部署在二进制文件旁边,但进程并不是从那个目录启动的(打包文件位于 /scripts 下,工作目录是 /),所以二进制文件是相对于其自身路径而不是相对于工作目录来解析 models.json 的,并且如果该文件不存在,就会以错误退出,而不是悄无声息地继续运行。我们无法识别的模型会回退到 o200k_base。对于非 OpenAI 的分词器来说这是一种近似,但用于成本归因已经足够好,而且只要存在,提供商自己的 usage 就优先(详见下文)。
在一台笔记本电脑上构建两种架构只需两条带 musl 目标的 cargo 命令:
1cargo build --release --target x86_64-unknown-linux-musl2cargo build --release --target aarch64-unknown-linux-musl3# -> count_tokens.zip: amd64/main, amd64/models.json, arm64/main, arm64/models.json二进制文件每个约 7MB,zip 包为 6.6MB。
部署它
部署界面与 beta 版相同的上传屏幕,只是多了几个新字段:
| 字段 | 值 |
|---|---|
| 名称 | count_tokens |
| 类型 | executable_pool |
| 运行时类型 | Native |
| 格式 | RowBinary |
| 发送分块头 | true |
| 确定性 | true |
| 内存限制 | 512 MiB |
| 池大小 | 4 |
| 参数 | model String,text String |
| 返回类型 | UInt32 |
其中有两点是 beta 版之后新增的。'确定性'告诉 ClickHouse 该函数对相同输入返回相同输出,这是查询缓存存储结果之前需要知道的信息。在 beta 版期间,每个 Cloud UDF 都被视为非确定性,因此对调用此类 UDF 的查询设置 use_query_cache = 1 会失败并报错 QUERY_CACHE_USED_WITH_NONDETERMINISTIC_FUNCTIONS。设置该标志后,这个仪表板查询的第二次运行会在 0 毫秒内从缓存返回,且 UDF 调用次数为零:
1SELECT feature, sum(count_tokens(model, system_prompt)) AS system_tokens2FROM llm_spans3WHERE toDate(ts) ='2026-10-01'-- a literal on purpose: today() is itself non-deterministic4GROUPBY feature5SETTINGS use_query_cache =1;1┌─query_duration_ms─┬─QueryCacheHits─┬─QueryCacheMisses─┬─udf_invocations─┐2│ 209 │ 0 │ 1 │ 14 │ ← first run3│ 0 │ 1 │ 0 │ 0 │ ← second run4└───────────────────┴────────────────┴──────────────────┴─────────────────┘'内存限制'限制了每个沙箱进程可用的内存;默认值为 4 GiB。该限制作用于虚拟地址空间而非常驻内存,这一点很重要,因为某些运行时会保留远超其实际触碰量的地址空间。我们的 Rust 二进制文件每个进程占用 43 MiB 虚拟内存 / 39 MiB 常驻内存,Python 版本为 103 / 93 MiB,而 Go 版本保留了约 1.2 GiB 的地址空间,同时只使用了 29 MiB。换句话说,要按运行时来设定限制,而不是按工作负载,并且要给 Go 留出充足的余量。
将其接入数据摄取
我们将从物化视图调用 count_tokens,这样分词器在插入时对每个 span 只运行一次:
1CREATE MATERIALIZED VIEW llm_span_tokens_mv TO llm_span_tokens AS2SELECT3 ts, trace_id, span_id, service, feature, customer_id, model,4 count_tokens(model, system_prompt) AS system_tokens,5 count_tokens(model, retrieved_context) AS context_tokens,6 count_tokens(model, user_prompt) AS user_tokens,7 count_tokens(model, completion) AS completion_tokens,8 provider_input_tokens,9 provider_output_tokens,10 sipHash64(system_prompt) AS system_prompt_hash11FROM llm_spans;每次 INSERT INTO llm_spans 都会触发这个视图,对每个 span 计数四个组成部分,并将结果写入 llm_span_tokens。之后每次查询都是对整数进行聚合。
为了感受一下吞吐量:100,000 个合成 span(351 MiB 的提示文本,6900 万个 token)在一台 4 vCPU 的机器上、池大小为 4 的情况下,经过这个视图需要 5.2 秒,即约 1300 万 token/秒,或约 1.9 万个 span/秒。
为其定价
token 计数通过每个 token 的价格转换为美元。LiteLLM 维护着一份 JSON 格式的公开价格列表,由于获取文件是 url() 的职责而非 UDF 的职责,我们将把获取操作包装在一个可刷新的物化视图中,每天重新拉取一次并供给一个字典:
1CREATE MATERIALIZED VIEW model_prices_mv2REFRESH EVERY1DAY3TO model_prices4AS5SELECT6 kv.1AS model,7 JSONExtractString(kv.2, 'litellm_provider') AS provider,8 JSONExtractFloat(kv.2, 'input_cost_per_token') AS input_cost_per_token,9 JSONExtractFloat(kv.2, 'output_cost_per_token') AS output_cost_per_token,10 JSONExtractFloat(kv.2, 'cache_read_input_token_cost') AS cache_read_input_token_cost,11 now() AS updated_at12FROM13(14SELECT arrayJoin(JSONExtractKeysAndValuesRaw(json)) AS kv15FROM url('https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json',16 JSONAsString, 'json String')17)18WHERE JSONHas(kv.2, 'input_cost_per_token');这样我们就得到了 3,683 个已定价模型,每天刷新一次,并通过 dictGet 进行查找。成本查询在提供商提供 usage 的地方使用它,在未提供的地方使用我们的计数:
1SELECT2 toDate(ts) ASday,3 feature,4 round(sum(coalesce(provider_input_tokens, system_tokens + context_tokens + user_tokens)5* dictGet('model_prices_dict', 'input_cost_per_token', model)6+coalesce(provider_output_tokens, completion_tokens)7* dictGet('model_prices_dict', 'output_cost_per_token', model)), 2) AS est_cost_usd,8 countIf(provider_input_tokens ISNULL) AS spans_filled_by_udf,9count() AS spans10FROM llm_span_tokens11GROUPBYday, feature12ORDERBYdayDESC, est_cost_usd DESC;1┌────────day─┬─feature───────┬─est_cost_usd─┬─spans_filled_by_udf─┬─spans─┐2│ 2026-09-30 │ sql_assistant │ 3.25 │ 558 │ 1781 │3│ 2026-09-30 │ support_bot │ 3.24 │ 539 │ 1759 │4│ 2026-09-30 │ docs_search │ 3.06 │ 535 │ 1796 │5│ 2026-09-30 │ summarizer │ 2.93 │ 527 │ 1818 │6└────────────┴───────────────┴──────────────┴─────────────────────┴───────┘在提供商确实报告了 usage 的地方,我们可以用它来核对自己的工作。 provider_input_tokens 与我们三个组成部分计数之间的差异是聊天模板开销(角色标记和消息框架),对于给定模型来说应该稳定在十几个 token 左右。如果它发生漂移,要么是提供商更改了它的模板,要么是我们的 models.json 对该模型来说是错的,无论是哪种情况,都只需一行 quantile 查询就能查明。
各组成部分能告诉你什么
系统提示词是每次调用都会发送的静态文本,提供商现在根据提供商的不同,对缓存的 prefix token 按标价打 5 折到 1 折计费(就是上面那个 cache_read_input_token_cost 列)。所以'我的输入花费中有多少是系统提示词?'这个问题等同于'提示词缓存能为我节省多少?':
1SELECT2 feature,3sum(system_tokens) AS sys_tokens,4sum(system_tokens + context_tokens + user_tokens) AS input_tokens,5 round(sys_tokens / input_tokens *100, 1) AS system_pct,6 round(sum(system_tokens7* (dictGet('model_prices_dict', 'input_cost_per_token', model)8- dictGet('model_prices_dict', 'cache_read_input_token_cost', model))), 2)9AS usd_saved_if_cached10FROM llm_span_tokens11WHERE ts >= now() -INTERVAL7DAY12GROUPBY feature13ORDERBY usd_saved_if_cached DESC;1┌─feature───────┬─sys_tokens─┬─input_tokens─┬─system_pct─┬─usd_saved_if_cached─┐2│ support_bot │ 3483276 │ 8461730 │ 41.2 │ 4.04 │3│ sql_assistant │ 3237735 │ 8382894 │ 38.6 │ 3.78 │4│ docs_search │ 2213208 │ 7287850 │ 30.4 │ 2.61 │5│ summarizer │ 1552014 │ 6999309 │ 22.2 │ 1.81 │6└───────────────┴────────────┴──────────────┴────────────┴─────────────────────┘美元金额很小是因为数据集很小(两周内 10 万个 span);乘以你自己的数据量即可。关键在于这个查询根本无法从 usage 汇总数据写出来。它需要各个组成部分,而组成部分来自分词器。
抽样评判,通过网络
成本是核算的一半;补全结果是否好是另一半,通常的做法是让第二个模型对样本进行评分。这是从数据库内部向 LLM API 发起的网络调用,而这正是启用网络的 UDF 的用途。
judge_response 是一个 Python UDF(约 120 行),它将提示词和补全结果发送给 Claude,并带有一个工具使用 schema,其 verdict 字段是一个枚举,取值为 pass、 partial 和 fail,其 score 字段是 1 到 5 的整数。模型被要求必须调用该工具,因此响应始终可解析,verdict 始终是这三个值之一:
1# abridged - full file in the repo2JUDGE_TOOL = {3"name": "report_verdict",4"description": "Grade how well the completion answers the prompt.",5"input_schema": {6"type": "object",7"properties": {8"score": {"type": "integer", "minimum": 1, "maximum": 5},9"verdict": {"type": "string", "enum": ["pass", "partial", "fail"]},10"reason": {"type": "string", "maxLength": 200},11 },12"required": ["score", "verdict", "reason"],13 },14}1516defmain() -> None:17for line in sys.stdin: # JSONEachRow in ...18ifnot line.strip():19continue20 row = json.loads(line)21 score, verdict, reason = judge(row["prompt"], row["completion"])22 sys.stdout.write(json.dumps({"result": [score, verdict, reason]}) + "\n") # ... JSONEachRow out23 sys.stdout.flush()这个使用 JSONEachRow 而非 RowBinary,因为当每一行都是一次 HTTPS 往返时吞吐量无关紧要,而且 JSON 让元组返回类型易于输出。每个池进程在其生命周期内保持一个 requests.Session 打开,按 (prompt, completion) 缓存结果,因此重新评判是免费的,并在 429/529 时进行退避重试。它还会软失败——一次失败的 API 调用返回 (0, 'error', reason) 而不是终止查询——因为它是无人值守运行的。
关于 API 密钥:UDF 目前还没有密钥管理器(见下文),所以密钥随 zip 包内的 config.json 一起发布,而不是出现在 SQL 中的任何地方。沙箱本身没有云身份(没有实例角色、没有元数据端点、没有继承的环境变量),所以 UDF 拥有的唯一凭据就是你所提供给它的。
| 字段 | 值 |
|---|---|
| 名称 | judge_response |
| 类型 | executable_pool |
| 运行时类型 | python3.11 |
| 格式 | JSONEachRow |
| 网络访问 | enabled |
| 确定性 | false |
| 最大命令执行时间(秒) | 30 |
| 池大小 | 4 |
| 参数 | prompt String,completion String |
| 返回类型 | Tuple(UInt8, String, String) |
评估循环本身就是一个可刷新的物化视图。每 10 分钟,它对最近 10 分钟的 span 取一个确定性的 2% 样本,对其进行评判,并追加结果:
1CREATE MATERIALIZED VIEW llm_evals_mv2REFRESH EVERY10MINUTE3APPEND TO llm_evals4AS5WITH judge_response(6 concat(system_prompt, '\n\n', retrieved_context, '\n\nUser: ', user_prompt),7 completion8 ) AS j9SELECT10 ts, trace_id, span_id, feature, model,11 j.1AS score,12 j.2AS verdict,13 j.3AS reason,14 now() AS judged_at15FROM llm_spans16WHERE ts >= now() -INTERVAL10MINUTE17AND cityHash64(trace_id) %100<2;在这幅图中没有调度器或评估服务,只有一个带刷新间隔的视图。你为成本而写的同一个 GROUP BY feature, model 就能给你按 feature 和 model 划分的失败率,而且由于 llm_evals 和 llm_span_tokens 共享 span_id,'哪些昂贵的提示词也在失败' 就是一个 join。
运行它的成本
beta 版中最大的缺口是可观测性。UDF 在单独的进程中运行,所以 system.query_log 只显示查询自身的 CPU 和内存,完全不显示子进程的信息,当客户询问是他们的 UDF 慢还是他们的查询慢时,我们俩都无法从系统表中回答。
ClickHouse 26.6 为可执行 UDF 新增了八个 ProfileEvents,它们现在出现在每个 Cloud 服务的 system.query_log、system.events 和 system.metric_log 中:
| ProfileEvent | 它衡量什么 |
|---|---|
ExecutableUserDefinedFunctionInvocations | UDF 调用次数(每个 chunk 一次) |
ExecutableUserDefinedFunctionElapsedMicroseconds | 在 UDF 调用中花费的墙钟时间 |
ExecutableUserDefinedFunctionPoolWaitMicroseconds | 等待空闲池进程所花费的时间 |
ExecutableUserDefinedFunctionUserTimeMicroseconds | 子进程消耗的用户态 CPU |
ExecutableUserDefinedFunctionSystemTimeMicroseconds | 子进程消耗的内核态 CPU |
ExecutableUserDefinedFunctionPeakMemoryByteSeconds | 按进程的峰值内存对墙钟时间的积分 |
ExecutableUserDefinedFunctionInputBytes | 写入子进程 stdin 的字节数 |
ExecutableUserDefinedFunctionOutputBytes | 从子进程 stdout 读取的字节数 |
两个异步指标,ExecutableUserDefinedFunctionProcesses 和 ExecutableUserDefinedFunctionMemoryResidentBytes,报告服务器当前有多少个 UDF 进程存活,以及它们占用多少常驻内存(按进程求和,因此是一个上界)。
有了这些,用哪种语言编写 UDF 的问题就变成了一个 query_log 查询。我们把 count_tokens 写了三遍——在 Native 运行时上用 Rust,用 tiktoken 的 Python,以及用纯 Go 分词器的 Go——并让每个实现跑物化视图的工作负载(100k 个 span,每个 4 次计数,共 351 MiB 文本):
1SELECT2extract(query, 'AS (\\w+)_tokens') AS impl,3 query_duration_ms,4 ProfileEvents['ExecutableUserDefinedFunctionInvocations'] AS invocations,5 round(ProfileEvents['ExecutableUserDefinedFunctionElapsedMicroseconds'] /1e6, 2) AS udf_wall_s,6 round((ProfileEvents['ExecutableUserDefinedFunctionUserTimeMicroseconds']7+ ProfileEvents['ExecutableUserDefinedFunctionSystemTimeMicroseconds']) /1e6, 2) AS udf_cpu_s,8 formatReadableSize(ProfileEvents['ExecutableUserDefinedFunctionInputBytes']) AS udf_in9FROM system.query_log10WHERE type ='QueryFinish'AND ProfileEvents['ExecutableUserDefinedFunctionInvocations'] >011ORDERBY event_time_microseconds;1┌─impl─┬─query_duration_ms─┬─invocations─┬─udf_wall_s─┬─udf_cpu_s─┬─udf_in─────┐2│ rs │ 5161 │ 144 │ 7.05 │ 6.94 │ 355.09 MiB │3│ py │ 7207 │ 144 │ 10.05 │ 9.94 │ 355.09 MiB │4│ go │ 15028 │ 144 │ 20.97 │ 26.14 │ 355.09 MiB │5└──────┴───────────────────┴─────────────┴────────────┴───────────┴────────────┘Rust 在这里比 Python 快 1.4 倍,比 Go 快 2.9 倍,这不是我们一开始预期的顺序。Python 接近是因为 tiktoken 的核心是 Rust;那 1.4 倍来自解释器循环和管道处理。Go 慢是因为纯 Go 分词器库慢,而 Native 运行时对慢的库无能为力。简而言之,该运行时去掉了解释器和依赖安装,让你能发布已有的库,但库本身仍然必须够快。(对于底层没有原生核心的 CPU 密集型 Python,情况不同:一位设计合作伙伴客户测得,将一个字符串匹配 UDF 从 Python 迁移到 Go 大约快了 25 倍。)
顺便一提,三种实现的计数完全相同,并且在 1,200 个抽查值上与 tiktoken 本身一致。
Terraform
以上所有内容都是在控制台中设置的。对于任何要投入生产的内容,你大概会希望它用代码管理,Terraform provider(3.24.0+)现在为此提供了两个资源:clickhouse_udf 在 zip 的哈希变化时发布新版本并等待构建完成,而 clickhouse_udf_attachment 将一个版本绑定到一个服务。
1resource "clickhouse_udf" "count_tokens" {2 function_name = "count_tokens"3 runtime = "native"4 type = "executable_pool"5 format = "RowBinary"6 return_type = "UInt32"7 arguments = [{ name = "model", type = "String" }, { name = "text", type = "String" }]89 pool_size = 410 send_chunk_header = true11 max_command_execution_time = 101213 source_archive_path = "${path.module}/../udf/count_tokens_rs/count_tokens.zip"14 source_archive_hash = filebase64sha256("${path.module}/../udf/count_tokens_rs/count_tokens.zip")15}1617# Dev follows every successful build.18resource "clickhouse_udf_attachment" "dev" {19 function_name = clickhouse_udf.count_tokens.function_name20 service_id = var.dev_service_id21 version = clickhouse_udf.count_tokens.version22}2324# Prod stays where you pinned it.25resource "clickhouse_udf_attachment" "prod" {26 function_name = clickhouse_udf.count_tokens.function_name27 service_id = var.prod_service_id28 version = var.count_tokens_prod_version29}这反映了控制台中版本的工作方式。版本不可变,新的 zip 就是新版本,且版本按服务附加,因此你可以在 dev 上运行 v7,而 prod 保持在 v6,并回滚单个服务而不影响其他服务。一个服务一次持有一个函数的一个版本。对于 executable_pool UDF,长寿命的池进程会继续提供旧版本,直到池被刷新,因此控制台在每个已附加服务旁边有一个 'Reload UDF' 操作,它会替你运行 SYSTEM RELOAD FUNCTION。两个较新的设置,deterministic 和内存限制,尚未出现在 provider schema 中,所以目前这些是在控制台或通过 API 设置的。
同样的生命周期在 Cloud API 中也可用:创建一个上传 URL,推送 zip,创建函数或新版本,将其附加到服务。
我们在 beta 中学到了什么
四个月的 beta 支持线程可以归结为一份简短的清单。
使用 executable_pool。使用普通的 executable 时,ClickHouse 会为每个数据块启动一个新的沙箱进程;使用 executable_pool 时,一组长寿命进程会在块和查询之间被复用,这更快,让你的模型或词表在内存中保持热状态,并且能以一种每个块都生成进程所无法做到的方式承受负载。我们还没有见过哪个 Cloud 工作负载中 executable 是正确选择。
更少、更大的 chunk。每次调用在管道的两侧都有固定的设置成本。同一个 5,000 行查询作为一个 chunk 耗时 165 ms,而在 max_block_size = 1 时为 1,544 ms,即 5,000 次调用。如果单个查询的 ExecutableUserDefinedFunctionInvocations 达到数十万,就提高 preferred_block_size_bytes(我们用过 100000000)并再次检查计数器。
池大小是按副本计算的,而 max_threads 是上限。一个查询最多同时使用 max_threads 个池进程,所以在 16 线程查询上使用 64 的池实际就是 16 的池。从 4 开始,当 ExecutableUserDefinedFunctionPoolWaitMicroseconds 表明需要时再提高,同时要记住每个进程都持有你在启动时加载的任何内容的自己的一份副本。
按 chunk 刷新,而不是按 N 行刷新。开启 send_chunk_header 并读取正好那么多行。上面已经讲过,但它导致的 beta 支持线程比其他任何内容都多。
大声失败。一个 UDF 如果找不到捆绑文件、无法解析配置,或遇到它不理解的行,就应该向 stderr 写一行并以非零状态退出。ClickHouse 会在查询错误中显示 stderr,因此问题在第一次查询中就可见,而不是在三个仪表盘之后才表现为一个错误的数字。我们第一个 count_tokens 把一个缺失的 models.json将其视为“无规则”,并把每个模型都分词为o200k_base;这些计数看起来合理,直到一张gpt-4账单对不上,才不会有人注意到。
要知道 UDF 在什么时候是错误的选择。每一行都会通过管道跨越进程边界,并在传输过程中被序列化,而这个成本是无法通过优化消除的。对于简单的逐行工作,它占据主导——在任何语言中,对数以十亿计的行执行无操作 UDF 都会比等效的内置函数慢好几倍。UDF 的价值在于 SQL 无法表达的逻 辑(分词器、模型、解析器、API 调用),而不在于 SQL 能表达的逻 辑。
服务上的第一个 UDF 会重启服务。附加第一个 UDF 会向服务的 Pod 中添加一个辅助容器,这意味着一次滚动重启;从第二个 UDF 起则不会,而移除最后一个 UDF 会再次重启。请像对待维护窗口一样对待第一次附加。
我们接下来要构建的内容
- 密钥——最首要的需求:为 UDF 提供环境变量或密钥引用,这样 API 密钥就不会被打包进 zip 里。我们有一个设计,将 UDF 输入与命名集合关联起来,并实现适当的访问隔离,这是列表上的下一项。
- 无需重新构建的运行时配置——池大小、超时和内存限制目前都与某个版本绑定。我们正在将它们解耦,这样
clickhouse_udf_attachment就可以按服务覆盖它们,而无需发布新版本。 - Native UDF 的网络访问——目前 Native 运行时仅支持计算,出站网络仅限 Python。
- 更多运行时——WebAssembly UDF 在开源 ClickHouse 中作为实验性功能存在,尚未进入 Cloud;我们正在研究这一点,以及按语言精选的运行时,作为更长期的形态。
亲自尝试
可执行 UDF 现已在 AWS、GCP 和 Azure 上的每个 ClickHouse Cloud 组织中正式可用,无需启用任何功能。在控制台中从组织菜单打开“用户定义函数”,或从 文档、Cloud API 参考或 Terraform 资源开始。
完整项目位于 github.com/ClickHouse/llm-token-udf:
1llm-token-udf/2├── udf/3│ ├── count_tokens_rs/ # the Native UDF (Rust): source, build script, zip layout4│ ├── count_tokens/ # the same UDF in Go5│ ├── count_tokens_py/ # the same UDF in Python6│ └── judge_response/ # the network UDF (Python, Anthropic tool-use)7├── sql/ # schema, MVs, prices, evals, and every query in this post8├── terraform/ # clickhouse_udf + dev/prod attachments9└── local/ # XML config for running the same UDFs against open-source ClickHouse如果你将 Python UDF 移植到 Native 运行时并测量了差异,或者构建了我们没想到的东西,请给我们留言!
立即开始
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏