AI大模型時代下運維開發探索第二篇:基于大模型(LLM)的數據倉庫

在SREWorks社區聚集了很多進行運維數倉建設的同學,大家都會遇到類似的挑戰和問題:

  • 數倉中存儲大量數據消耗成本,但很多存儲的數據卻并沒有消費。
  • 進數倉的ETL學習成本高、管理成本高,相關同學配合度低,以及上游結構改動后ETL卻遲遲無人調整。
  • 數倉中數據的時效性、準確性問題,導致很多場景無法完全依賴數倉展開。

上面的種種讓推廣數倉的同學很犯難:明明花了大力氣構建了統一數倉,但卻又受限于各種問題,無法讓其價值得到完全的落地。本文旨在闡述一種基于LLM的數倉構建方案,從架構層面解決上述的三個問題。

一、方案設計

從需求出發,再次思考一下我們進行運維數倉構建的初衷:用一句SQL可以查詢或統計到所有我們關注的運維對象的情況。雖然有很多方案能做,但總結一下,就是這樣兩種抽象模型:Push 或 Pull。

  • Push的方式是我們去主動管理數據的ETL鏈路,比如使用Flink、MaxCompute等大數據方案將數據進行加工放到數倉中。在需要查詢的時候,直接SELECT數倉就能出結果。這類方案的問題在于:1. ETL管理維護成本高。2. 數據準確性較數據源有所下降。
  • Pull的方式是我們不去主動拉所有的數據,在執行時候再去各個數據源找數據,比較具有代表性的就是Presto。這種方案的優點就是不用進行ETL管理以及數據準確性較好,畢竟是實時拉的。但問題就在于這種方案把復雜性都后置到了查詢那一刻,查詢速度過慢就成了問題。

那么是否有一種方案能將這兩種模型結合起來,取其中的優點呢?經過這段時間對于大模型熟悉,我認為這個方案是可行的,于是嘗試設計了一下流程圖:

1703817156624_5B0E163E-294B-4a61-8298-5B10D6B8D03D.png

二、基于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數智運維社區溝通交流。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容