在SREWorks社區聚集了很多進行運維數倉建設的同學,大家都會遇到類似的挑戰和問題:
- 數倉中存儲大量數據消耗成本,但很多存儲的數據卻并沒有消費。
- 進數倉的ETL學習成本高、管理成本高,相關同學配合度低,以及上游結構改動后ETL卻遲遲無人調整。
- 數倉中數據的時效性、準確性問題,導致很多場景無法完全依賴數倉展開。
上面的種種讓推廣數倉的同學很犯難:明明花了大力氣構建了統一數倉,但卻又受限于各種問題,無法讓其價值得到完全的落地。本文旨在闡述一種基于LLM的數倉構建方案,從架構層面解決上述的三個問題。
一、方案設計
從需求出發,再次思考一下我們進行運維數倉構建的初衷:用一句SQL可以查詢或統計到所有我們關注的運維對象的情況。雖然有很多方案能做,但總結一下,就是這樣兩種抽象模型:Push 或 Pull。
- Push的方式是我們去主動管理數據的ETL鏈路,比如使用Flink、MaxCompute等大數據方案將數據進行加工放到數倉中。在需要查詢的時候,直接SELECT數倉就能出結果。這類方案的問題在于:1. ETL管理維護成本高。2. 數據準確性較數據源有所下降。
- Pull的方式是我們不去主動拉所有的數據,在執行時候再去各個數據源找數據,比較具有代表性的就是Presto。這種方案的優點就是不用進行ETL管理以及數據準確性較好,畢竟是實時拉的。但問題就在于這種方案把復雜性都后置到了查詢那一刻,查詢速度過慢就成了問題。
那么是否有一種方案能將這兩種模型結合起來,取其中的優點呢?經過這段時間對于大模型熟悉,我認為這個方案是可行的,于是嘗試設計了一下流程圖:
二、基于LLM的SQL預查詢
相信大家在使用了類似Presto的聯邦查詢(Federated Query),都會對此印象深刻,原本要好多個for循環的代碼,放到里面只要一個select-join就能解決。但Presto本身的定位就是為分析型的負載設計,我們無法把它置于一些高頻查詢的關鍵鏈路上。
聯邦查詢的SQL和for循環的代碼,看起來似乎只隔了一層紗,現在大模型的出現就直接把這層紗給捅破了。我們的思路也非常簡單:既然大模型可以非常方便地把用戶需求轉換成SQL,那么把用戶SQL轉換成代碼似乎也不是一件難事。
import os
import sys
from openai import OpenAI
import traceback
from io import StringIO
from contextlib import redirect_stdout, redirect_stderr
client = OpenAI()
def get_script(content):
return content.split("```python")[1].split("```")[0]
def execute_python(code_str: str):
stdout = StringIO()
stderr = StringIO()
return_head = 1000
context = {}
try:
# 重定向stdout和stderr,執行代碼
with redirect_stdout(stdout), redirect_stderr(stderr):
exec(code_str, context)
except Exception:
stderr.write(traceback.format_exc())
# 獲取執行后的stdout, stderr和context
stdout_value = stdout.getvalue()[0:return_head]
stderr_value = stderr.getvalue()[0:return_head]
return {"stdout": stdout_value.strip(), "stderr": stderr_value.strip()}
prompt = """
你是一個數據庫專家,我會給你一段SQL,請你轉換成可執行的Python代碼。
當前有2個數據庫的連接信息,分別是:
1. 數據庫名稱 processes 連接串 mysql://root@test-db1.com:3306/processes
下面是這個數據庫的表結構
```
CREATE TABLE `process_table` (
`process_name` varchar(100) DEFAULT NULL,
`start_time` datetime DEFAULT NULL,
`end_time` datetime DEFAULT NULL,
`server_name` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
```
2. 數據庫名稱 servers 連接串 mysql://root@test-db2.com:3306/servers
下面是這個數據庫的表結構
···
CREATE TABLE `server_table` (
`server_name` varchar(100) DEFAULT NULL,
`ip` varchar(100) DEFAULT NULL,
`zone` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
···
在編寫Python代碼的時候,不要把所有的數據庫的信息都傳入,請根據SQL的內容按需傳入。
返回結果中只有Python代碼,如要描述信息全部放在注釋中。Python的執行結果需要是JSON格式的數據。
下面用戶會給出你需要轉換的SQL:
"""
query_sql = "select * from processes.process_table a join servers.server_table b on a.server_name = b.server_name where b.zone = 'ZoneA';"
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": query_sql}
]
res = client.chat.completions.create(messages=messages, model="gpt-4")
print(res.choices[0].message.content)
exec_result = execute_python(get_script(res.choices[0].message.content))
print("result:")
print(exec_result)
if exec_result["stderr"] == "" and exec_result["stdout"] != "":
print(exec_result["stdout"])
sys.exit(0)
這個例子,確實相當簡單直接,連langchain都不需要,我們基于OpenAI的接口直接實現聯邦查詢的能力:
- 事先將連接串和數據結構準備好,輸入給LLM。
- 將SQL語句輸入給LLM,確保SQL中的表名和數據結構中的表名一致,避免LLM混淆。
- 將LLM給出的代碼直接執行,便獲得了查詢結果。
下面我們來看看運行的結果
```python
# Import required libraries
import pandas as pd
from sqlalchemy import create_engine
# Create connection engines
engine1 = create_engine('mysql://root@test-db1.com:3306/processes')
engine2 = create_engine('mysql://root@test-db2.com:3306/servers')
# Query data from process_table in processes database
df1 = pd.read_sql('SELECT * FROM `process_table`', engine1)
# Query data from server_table in servers database
df2 = pd.read_sql('SELECT * FROM `server_table` WHERE `zone` = "ZoneA"', engine2)
# Merge both dataframes on server_name
result = pd.merge(df1, df2, how='inner', on='server_name')
# Convert dataframe to JSON
out_json = result.to_json(orient='records')
print(out_json)
```
關于此代碼:
我們首先導入了必要的庫,然后使用提供的憑據創建了兩個數據庫連接。然后,我們分別從兩個數據庫中查詢數據。接著,我們根據"server_name"合并這兩個數據框,最后我們將結果轉換為JSON格式。
result:
{'stdout': '[{"process_name":"Process1","start_time":1703259365000,"end_time":1703266565000,"server_name":"Server1","zone":"ZoneA"},{"process_name":"Process2","start_time":1703262965000,"end_time":1703270165000,"server_name":"Server2","zone":"ZoneA"}]', 'stderr': ''}
[{"process_name":"Process1","start_time":1703259365000,"end_time":1703266565000,"server_name":"Server1","zone":"ZoneA"},{"process_name":"Process2","start_time":1703262965000,"end_time":1703270165000,"server_name":"Server2","zone":"ZoneA"}]
真實運行起來,確實LLM給的代碼比較隨機,一會兒使用pandas處理數據,一會兒使用pymysql處理數據,存在非常大的不確定性,但是結果是確定的。多試幾次之后,我們發現這個結果還是不穩定,有時候會寫一些存在瑕疵的代碼,導致報錯。基于我們在上一篇已經講清楚的思維鏈的模型,我們可以給它加上一個報錯反饋鏈路,讓它自行修改問題代碼。
for i in range(3):
print("第", i + 1, "次重試")
messages = [
{"role": "system", "content": prompt + "\n" + query_sql},
]
if exec_result["stderr"] != "":
messages.append({"role": "user", "content": res.choices[0].message.content + "\n\n" + exec_result["stderr"] + "\n執行報錯,請根據報錯修正,再次生成代碼"})
else:
messages.append({"role": "user", "content": res.choices[0].message.content + "\n\n" + "執行沒有任何返回,請再次生成代碼"})
res = client.chat.completions.create(messages=messages, model="gpt-4")
print(res.choices[0].message.content)
exec_result = execute_python(get_script(res.choices[0].message.content))
print("result:")
print(exec_result)
if exec_result["stderr"] == "" and exec_result["stdout"] != "":
print(exec_result["stdout"])
sys.exit(0)
print("查詢失敗")
有了這層錯誤反饋之后,我們會發現這個查詢就非常穩定了,雖然有些時候LLM產生的代碼會出錯,但是通過報錯信息自行修改優化之后,能夠保持產出結果穩定(不過自動修改報錯的查詢,時延明顯會比較長一些)。
總計 | 一次正確 | 二次正確 | 三次正確 | 失敗 | |||||
---|---|---|---|---|---|---|---|---|---|
次數 | 20 | 7 | 35% | 9 | 45% | 0 | 0 | 4 | 20% |
平均耗時 | 43.0s | 13.2s | 45.3s | N/A | 91.2s |
從20次的測試中,可以看出一次查詢的成功率在30%左右,通過報錯反饋優化,成功率就能達到80%。 通過觀察每個查詢語句,基本可以發現使用pandas的代碼編寫準確率高很多,后續如果需要優化prompt,可以在再增加一些使用依賴庫上的指引,編寫成功率會更高。同時我們也發現,如果有些代碼一開始方向就錯誤的話,通過報錯反饋優化也救不回來,三次成功率為零就是一個很好的說明。當前測試用的LLM推理速度較慢,如果本地化部署LLM理論上推理速度還能更快不少。
當前,基于LLM的查詢表現上可以和Presto已經比較近似了,但有些地方會比Presto要更強:
- 數據源擴展:presto需要進行適配器的開發才能對接其他數據源,而LLM的方案你只要教會LLM怎么查詢特定數據源就行了,事實上可能都不用教,因為它有幾乎所有的編程知識。
- 白盒化以及復雜查詢優化:針對復雜場景如果存在一些查詢準確性問題,需要去preso引擎中排查原因并不簡單。但LLM的方案是按照人可閱讀的代碼來了,你可以要求它按照你熟悉的編程語言編寫,你甚至可以要求它寫的代碼每行都加上注釋。
當然,和Presto一樣,基于LLM的查詢方案,只能被放到預查詢中,在生產鏈路中并不能每次都讓LLM去生成查詢代碼,這太慢了。那么有沒有辦法讓它的查詢提速呢?可以的。還記得我們在文章開頭提過的Push和Pull的模式嗎?聯邦查詢是基于Pull的模式展開的,而流式ETL是基于Push模式展開的,我們如果把查詢語句直接翻譯成流式ETL的語句,預先將需要的數據處理到一個數據庫中,那是不是就完全可以規避掉性能問題了呢?
三、基于LLM的流計算處理
和分析型的查詢相比,流計算的數據同步邏輯顯然簡單很多,只要分析SQL,按需求字段進行同步即可。這里就不貼完整的代碼了,就把相關部分的prompt貼出來。
當前有2個數據庫的連接信息,分別是:
1. 數據庫名稱 processes 連接串 mysql://root@test-db1.com:3306/processes
下面是這個數據庫的表結構
```
CREATE TABLE `process_table` (
`process_name` varchar(100) DEFAULT NULL,
`start_time` datetime DEFAULT NULL,
`end_time` datetime DEFAULT NULL,
`server_name` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
```
2. 數據庫名稱 servers 連接串 mysql://root@test-db2.com:3306/servers
下面是這個數據庫的表結構
···
CREATE TABLE `server_table` (
`server_name` varchar(100) DEFAULT NULL,
`ip` varchar(100) DEFAULT NULL,
`zone` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
···
你是一個Flink專家,我會給你一段需求SQL,請你根據需求SQL,分析出哪些字段被需要,編寫合適的Flink SQL預先處理到clickhouse中。
原有的表在clickhouse中依然保持原有的表名,保持一對一的映射。
Flink SQL處理的目標是使得用戶能在clickhouse中在完全不改動需求SQL的情況下正確執行。
在Flink SQL中只進行數據同步,不要加數據處理邏輯。
請在結果中直接給出一份完整的Flink SQL代碼,不要任何文字說明,如果有說明的地方在代碼中用注釋標出。
下面用戶會給需求SQL:
select a.process_name,b.server_name from processes.process_table a join servers.server_table b on a.server_name = b.server_name where b.zone = 'ZoneA';
我們可以看到這份prompt和上一章生成查詢代碼的prompt幾乎沒有太大差別,只是對于其生成的目標做了修改,明確告訴它要生成Flink SQL。下面就是LLM生成的Flink SQL:
-- 創建Flink SQL連接到數據庫processes的process_table
CREATE TABLE process_table (
process_name STRING,
server_name STRING
) WITH (
'connector' = 'mysql-cdc', -- 使用 MySQL CDC 連接器
'hostname' = 'test-db1.com', -- 源數據庫主機名
'port' = '3306', -- 源數據庫端口
'username' = 'root', -- 源數據庫用戶名
'database-name' = 'processes', -- 源數據庫名
'table-name' = 'process_table' -- 源數據庫表名
);
-- 創建Flink SQL連接到數據庫servers的server_table
CREATE TABLE server_table (
server_name STRING,
zone STRING
) WITH (
'connector' = 'mysql-cdc', -- 使用 MySQL CDC 連接器
'hostname' = 'test-db2.com', -- 源數據庫主機名
'port' = '3306', -- 源數據庫端口
'username' = 'root', -- 源數據庫用戶名
'database-name' = 'servers', -- 源數據庫名
'table-name' = 'server_table' -- 源數據庫表名
);
-- 創建到ClickHouse的連接,保持和源數據庫對應關系
CREATE TABLE clickhouse_process_table (
process_name STRING,
server_name STRING
) WITH (
'connector' = 'clickhouse',
'url' = 'clickhouse://localhost:8123',
'table-name' = 'process_table'
);
CREATE TABLE clickhouse_server_table (
server_name STRING,
zone STRING
) WITH (
'connector' = 'clickhouse',
'url' = 'clickhouse://localhost:8123',
'table-name' = 'server_table'
);
-- Flink SQL同步數據
INSERT INTO clickhouse_process_table
SELECT process_name, server_name
FROM process_table;
INSERT INTO clickhouse_server_table
SELECT server_name, zone
FROM server_table;
如我們所預期的,LLM給出了準確的Flink SQL語句,并且還根據查詢需求的SQL,它只同步了會查到的字段,用不到的字段就沒有同步(比如server中的ip字段沒有用到)。在這樣一條鏈路中,我們同樣可以類似第三章使用的報錯反饋自優化的方式,提高生成代碼的穩定性,使得其生成的代碼可以直接在生產中部署運行,在這里我們就不做過多展開了。
四、總結
一份需求查詢SQL,利用LLM生成兩份代碼,一份用于Pull:直接查詢返回結果,預查詢調試用;一份用于Push:構建消費鏈路進實時數倉。基于LLM,實現真正意義上從需求出發的ETL生產鏈路構建,大概包含如下優點:
- 避免ETL過程的過度加工:按需加字段,不會加工太多用不到字段浪費計算、浪費存儲。
- 降低使用者維護ETL加工過程成本:雖然Flink SQL的可維護性已經很好了,但是面向計算過程的SQL編寫方式還是讓很多用戶不適應。如果直接用查詢SQL來進行自動生成,就大大降低了維護的門檻。
- 統一數據鏈路: 以查詢為驅動的數據模型,可以使得使用者始終面向數據源表進行需求思考。ETL實時計算產生的數據會更像一個物化視圖,這樣在做實時數據準確性校驗時也更加方便。
如果您當前還在為數倉的構建所困擾,可以嘗試一下這個基于LLM的方案,歡迎大家在SREWorks數智運維社區溝通交流。