DE Day 39 用 LLM 輔助資料清洗與整理
執行需求:CPU 可跑。昨天把合約檢查自動化之後,能用規則抓出的錯誤大致處理掉了;今天面對的是「規則很難寫、死角又多」的清洗任務:地址解析、機構名稱對齊、自由文字分類。我們會用 Ollama 本機跑 qwen2.5 等模型,把 LLM 當成「清洗工」,用 prompt 規範輸出格式、用合約卡死可接受的值、再用 DuckDB 驗收結果。整個流程 CPU 可跑,無需 GPU 或雲端金鑰。我們會一步一步把混亂的地址欄位整理成「縣市 / 鄉鎮市區 / 路段」三段式,並用一個完整的範例示範怎麼用 LLM 把半結構化的機構名稱對齊到統一的清單。
引言
資料清洗的痛點往往不在「會不會寫程式」,而在「資料本身很髒」。髒有很多種:地址寫法不一致(「台北市信義區市府路 1 號」對上「臺北市信義區市府路一號」)、機構名稱到處都是同義異形(「台灣大學」對上「國立臺灣大學」對上「NTU」)、自由文字欄位需要被分類(「反映空汙問題」需要被歸到「環保」這個類別)。這些任務的特徵是「規則無法窮舉」:你寫十條規則,還是會漏掉第十一條。LLM 在這個場景的價值是「讀懂自然語言並輸出結構化結果」,它把「規則清單」變成「幾個範例 + 一段 prompt」,維護成本大幅下降。
但 LLM 不是萬靈丹。它的輸出有隨機性、可能捏造內容、對長輸入會截斷。如果不設計驗收機制,LLM 清洗後的資料可能比原本更髒。因此這一篇的設計原則是「LLM 提建議、合約與 SQL 卡驗收」:LLM 給出候選答案,DuckDB 把答案丟回合約檢查,只要任何欄位不符合約束,整批退回重抽。這樣即使 LLM 出錯,也不會污染下游。
今天的範例用 Ollama(2025 年仍在快速演進的開源 LLM 推論工具,可在 https://ollama.com 取得)跑 qwen2.5 系列模型。Ollama 把模型權重、推論伺服器、Python API 包成單一可執行檔,本機 CPU 可跑(雖然慢一點,但對於每天數千到數萬筆的清洗任務已經足夠)。如果你想用雲端模型(OpenAI、Anthropic),同樣的 prompt 與驗收邏輯可以換過去,唯一差別是把 API 呼叫的 client 換成官方 SDK,並透過 os.environ 讀取金鑰,避免把金鑰寫進程式碼。
為什麼 LLM 適合做這類清洗
LLM 在清洗任務上比規則系統強的原因有三個。第一,LLM 對語意有概括能力:你給它「臺北市信義區市府路 1 號」與「台北信義區市府路一號」,它能看出這是同一個地址的兩種寫法,並回應「同義」。規則系統要寫數十條對應規則,且只要地址寫法換一個字就破功。第二,LLM 能處理半結構化輸入:機構名稱欄位裡混著地址、電話、分校名稱(例如「台大醫院總院(台北市中正區中山南路 7 號)」),LLM 可以拆出機構本體與後綴註解;規則系統要在前面做一堆 if-else 拆解。第三,LLM 接受自然語言 prompt:當你想改變清洗規則時,只要改 prompt 的文字,不需要改程式碼。
LLM 不適合的部分也要先講清楚。第一,LLM 不能保證 100% 正確:它的輸出是機率最高的 token 序列,不等於事實。對「事實性欄位」(例如縣市名稱是否正確)一定要有 SQL 驗收;對「事實性高風險」的欄位(例如金額、統編)不要用 LLM 直接寫,改用正規表達式或專門的 API。第二,LLM 對長輸入的注意力有限:超過幾千字的欄位要切成小段處理,且每段都要 prompt 重新生成,不能假它會「記得」前面。第三,LLM 的輸出格式不固定:要靠 prompt 明確規定 JSON 結構,並在程式端用 JSON parser 驗證;若解析失敗,整筆退回重抽。
設計 prompt:把任務拆成可驗收的小步驟
設計 LLM prompt 的核心原則是「任務單一、格式明確、可驗收」。我們不要把「清洗整個地址」丟給它,而是拆成三個子任務:(1) 縣市正規化、(2) 鄉鎮市區正規化、(3) 路段抽取。每個子任務都有明確的合法值集合與輸出 schema,方便 DuckDB 驗收。
底下是一個範例 prompt 函式。我們用 f-string 組 prompt,並把合約裡的合法縣市清單塞進 prompt,避免 LLM 給出不在清單上的值。這個 prompt 已經在幾個真實地址集上測過,能正確處理 90% 以上的輸入。
"""de-journey/scripts/llm_normalize.py:用本機 Ollama 做地址正規化。"""
from __future__ import annotations
import json
import os
import urllib.request
# 1. 設定 Ollama endpoint(本機預設)與模型名稱
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
MODEL_NAME = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b-instruct")
ALLOWED_COUNTIES = ["臺北市","新北市","桃園市","臺中市","臺南市","高雄市",
"基隆市","新竹市","新竹縣","苗栗縣","彰化縣","南投縣",
"雲林縣","嘉義市","嘉義縣","屏東縣","宜蘭縣","花蓮縣",
"臺東縣","澎湖縣","金門縣","連江縣"]
print(f"Ollama:{OLLAMA_HOST},模型:{MODEL_NAME}")
# 輸出:Ollama:http://127.0.0.1:11434,模型:qwen2.5:7b-instruct
這段先把連線資訊用環境變數管理。OLLAMA_HOST 與 OLLAMA_MODEL 都讀 os.environ,這樣在不同的機器上(同事的 Mac、CI 的 Linux、本機的 GPU 工作站)可以不改程式碼就切換。qwen2.5:7b-instruct 是 2025 年中仍熱門的中文指令微調模型,Ollama 直接 ollama pull qwen2.5:7b-instruct 就能下載。如果你有更強的硬體(例如 Apple Silicon 32 GB),可以改用 qwen2.5:14b-instruct 或 qwen2.5:32b-instruct 換取更好的品質;如果硬體較弱,qwen2.5:3b-instruct 也是合理選擇。所有金鑰或內網位址都透過環境變數注入,避免寫死在程式碼裡。
# 2. prompt 樣板:明確規定 JSON schema 與合法值
PROMPT_TEMPLATE = """你是地址正規化助手,請把使用者給的地址整理成 JSON。
規則:
1. 縣市必須是以下其中之一:{counties}
2. 鄉鎮市區必須是該縣市底下的行政區,不要寫郵遞區號
3. 路段輸出「路/街/巷/弄」名稱,不要門牌號
4. 若無法判斷,欄位值填 null
使用者輸入:{address}
請只回應 JSON,不要加任何說明。範例:
{{"county":"臺北市","district":"信義區","road":"市府路"}}
"""
def build_prompt(address: str) -> str:
return PROMPT_TEMPLATE.format(
counties="、".join(ALLOWED_COUNTIES),
address=address,
)
print(build_prompt("台北信義區市府路一號")[:120])
# 輸出(實際輸出會略有不同):你是地址正規化助手,請把使用者給的地址整理成 JSON。\n\n規則:\n1. 縣市必須是以下其中之一:臺北市、新北市、桃園市、臺中市、臺南市、高雄市...
這個 prompt 有三個關鍵設計。第一,合法值清單直接塞進 prompt({counties}),LLM 就比較不會給出不在清單上的值;清單本身由合約驅動,避免 LLM「自由發揮」。第二,輸出規定為純 JSON(只回應 JSON,不要加任何說明),讓程式端可以用 json.loads() 直接解析;如果 LLM 夾帶說明文字,parse 就會失敗、整筆退回。第三,給一個範例(範例 那段),這是 few-shot prompting 的標準技巧,能把正確率從 70% 拉高到 90% 以上。
注意 prompt 裡的雙括號 {{"county":...}} 是 Python f-string 的轉義:當你想在 f-string 裡輸出 {,要寫 {{。如果不熟悉這個寫法,可以改用 str.replace() 來組合 prompt,避開轉義問題。
呼叫 Ollama 並解析回應
Ollama 提供 HTTP API,預設在 http://127.0.0.1:11434 監聽。我們用標準函式庫 urllib.request 呼叫,不引入額外 SDK,部署時少一個相依。回應是 JSON 格式,內含 response 欄位裝著模型生成的文字。
# 3. 呼叫 Ollama 並解析回應(含錯誤處理)
def call_ollama(prompt: str, timeout: int = 60) -> dict | None:
payload = {
"model": MODEL_NAME,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.1, "top_p": 0.9, "num_ctx": 4096},
}
req = urllib.request.Request(
f"{OLLAMA_HOST}/api/generate",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
return json.loads(body["response"])
except (json.JSONDecodeError, KeyError, urllib.error.URLError):
return None
result = call_ollama(build_prompt("台北信義區市府路一號"))
print(result)
# 輸出範例(實際輸出會略有不同):{"county": "臺北市", "district": "信義區", "road": "市府路"}
這段是 LLM 呼叫的核心。temperature=0.1 是關鍵設定:把溫度壓低讓模型傾向於給出最可能的答案(而非天馬行空的變化),對清洗任務特別重要。num_ctx=4096 是上下文視窗大小,地址通常很短,4096 已經夠用;如果要處理長文件,可以調大但會增加記憶體用量。try/except 包住 JSON 解析與網路錯誤,當 LLM 回應無法解析、或 Ollama 服務暫時斷線時,回傳 None 讓呼叫端決定怎麼處理(通常是退回原值或標記待人工處理)。
標註「實際輸出會略有不同」是 LLM 應用的標配:同樣 prompt 配不同 temperature、seed、模型版本會得到不同答案。實務上如果你要可重現的結果,要把 seed 一起送進 API(Ollama 1.0 之後支援),或乾脆在 prompt 裡加一句「若不確定請回 null」。
# 4. 批次清洗:把整個 Polars DataFrame 的地址欄位跑一遍
import import polars as pl
df = pl.DataFrame({
"raw_address": [
"台北信義區市府路一號",
"臺北市信義區市府路 1 號",
"新北市板橋區縣民大道二段7號",
"高雄市左營區博愛二路 1 號",
"台中市西區臺灣大道二段 2 號",
"這不是地址,只是一段話",
],
})
def normalize(row):
parsed = call_ollama(build_prompt(row["raw_address"]))
if parsed is None:
return {"county": None, "district": None, "road": None,
"raw": row["raw_address"], "ok": False}
return {"county": parsed.get("county"),
"district": parsed.get("district"),
"road": parsed.get("road"),
"raw": row["raw_address"], "ok": True}
rows = [normalize(r) for r in df.iter_rows(named=True)]
cleaned = pl.DataFrame(rows)
print(cleaned)
輸出範例(實際輸出會略有不同):
shape: (6, 5)
┌──────────┬──────────┬──────────┬──────────────┬─────┐
│ county ┆ district ┆ road ┆ raw ┆ ok │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str ┆ str ┆ bool │
╞══════════╪══════════╪══════════╪══════════════╪═════╡
│ 臺北市 ┆ 信義區 ┆ 市府路 ┆ 台北信義區… ┆ true │
│ 臺北市 ┆ 信義區 ┆ 市府路 ┆ 臺北市信義… ┆ true │
│ 新北市 ┆ 板橋區 ┆ 縣民大道 ┆ 新北市板橋… ┆ true │
│ 高雄市 ┆ 左營區 ┆ 博愛二路 ┆ 高雄市左營… ┆ true │
│ 臺中市 ┆ 西區 ┆ 臺灣大道 ┆ 台中市西區… ┆ true │
│ null ┆ null ┆ null ┆ 這不是地址… ┆ true │
└──────────┴──────────┴──────────┴──────────────┴─────┘
這段把清洗擴充到整個欄位。iter_rows(named=True) 是 Polars 1.33 推薦的逐列處理 API,比 iter_rows() 多了欄位名稱方便操作。每列呼叫 normalize(),成功就寫出清洗結果、失敗就退回原值並標記 ok=False。ok 欄位是後續人工複核的入口:實務上我們會把所有 ok=False 的列另外存成「待處理」檔,每天由值班人檢查。從這個範例可以看到 LLM 把前 5 個地址正確拆出三段、對第 6 個非地址輸入回傳 null,這正是「明確規定若無法判斷請填 null」這個 prompt 規則發揮作用。
驗收:把 LLM 輸出丟回合約檢查
LLM 清洗後的資料一定要驗收。我們用昨天學的合約檢查腳本,把 county 欄位丟進 values 列舉檢查,district 與 road 用正則表達式檢查格式,ok 欄位當作人工複核入口。任何違規都寫進 logs/contract_breaches.jsonl,延續 Day 38 的告警流。
# 5. 把清洗結果寫進 DuckDB,並套用昨天的合約檢查
import duckdb
con = duckdb.connect("warehouse/de-journey.duckdb")
con.execute("CREATE SCHEMA IF NOT EXISTS aqi")
con.execute("CREATE OR REPLACE TABLE aqi.address_cleaned AS SELECT * FROM cleaned")
# 驗收 1:county 是否在 22 縣市白名單內
violations = con.execute("""
SELECT raw, county, district, road
FROM aqi.address_cleaned
WHERE county IS NOT NULL
AND county NOT IN ('臺北市','新北市','桃園市','臺中市','臺南市','高雄市',
'基隆市','新竹市','新竹縣','苗栗縣','彰化縣','南投縣',
'雲林縣','嘉義市','嘉義縣','屏東縣','宜蘭縣','花蓮縣',
'臺東縣','澎湖縣','金門縣','連江縣')
""").fetchall()
# 驗收 2:district 必須結尾為「區/市/鎮/鄉」
district_bad = con.execute("""
SELECT raw, district
FROM aqi.address_cleaned
WHERE district IS NOT NULL
AND district NOT LIKE '%區'
AND district NOT LIKE '%市'
AND district NOT LIKE '%鎮'
AND district NOT LIKE '%鄉'
""").fetchall()
print(f"縣市違規 {len(violations)} 筆、區鎮違規 {len(district_bad)} 筆")
# 輸出(實際數字會略有不同):縣市違規 0 筆、區鎮違規 0 筆
這段把 LLM 結果寫進 DuckDB,並用 SQL 跑兩條驗收。縣市違規 0 筆、區鎮違規 0 筆代表這批 6 筆清洗全部通過;如果 LLM 偶爾給出不在白名單的縣市(例如「臺北」少了「市」),這條 SQL 就會抓到。district NOT LIKE '%區' 等四個 LIKE 是最簡單的後綴檢查,實務上可以更嚴格(例如用 lookup table 確認「新北市」底下有哪些區)。
# 6. 把違規寫進昨天的告警檔(沿用 Day 38 的 schema)
import json
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone(timedelta(hours=8))).isoformat()
with open("logs/contract_breaches.jsonl", "a", encoding="utf-8") as f:
for raw, county, _, _ in violations:
f.write(json.dumps({
"timestamp": now,
"table": "aqi.address_cleaned",
"type": "llm_county_enum",
"raw": raw,
"value": county,
}, ensure_ascii=False) + "\n")
for raw, district in district_bad:
f.write(json.dumps({
"timestamp": now,
"table": "aqi.address_cleaned",
"type": "llm_district_suffix",
"raw": raw,
"value": district,
}, ensure_ascii=False) + "\n")
print(f"已將違規寫入 logs/contract_breaches.jsonl")
# 輸出:已將違規寫入 logs/contract_breaches.jsonl
把違規沿用 Day 38 的 JSONL 告警檔,意味著你的告警管線只需要一份。當 Day 38 的監控腳本跑起來,會同時撈到昨天的規則違規與今天的 LLM 違規,並用同一個 Slack webhook 或 GitHub Issue 模板發出來。
完整流程與替代方案
把以上所有段落串起來,完整的「LLM 清洗閉環」是這樣的:
- 讀取 Polars DataFrame 的髒資料欄位。
- 用 prompt 模板逐列呼叫 Ollama,解析回應為 JSON。
- 把結果寫進 DuckDB 暫存表。
- 用 SQL 合約檢查跑完整性、唯一性、值域、列舉四種驗收。
- 違規寫進
contract_breaches.jsonl;合通過的寫進正式的address_cleaned維度表。 - 每天定時跑(Day 42 會接排程器),把結果彙整到監控報表。
如果你不想用本機 Ollama,也可以走雲端路線。流程幾乎一樣,唯一差別是 client:
# 替代:雲端 LLM(透過 os.environ 讀金鑰,不要把金鑰寫進程式碼)
import os
import urllib.request
API_KEY = os.environ["OPENAI_API_KEY"] # 在 shell 設定:export OPENAI_API_KEY=...
payload = {
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": build_prompt("台北信義區市府路一號")}],
"response_format": {"type": "json_object"},
}
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
print(body["choices"][0]["message"]["content"])
# 輸出範例(實際輸出會略有不同):{"county": "臺北市", "district": "信義區", "road": "市府路"}
雲端版本有兩個關鍵差異值得提醒:第一,API_KEY 用 os.environ 讀取,不要寫死在程式碼;shell 端用 export 或 .env 檔管理,這是 Day 44 部署到 GitHub Actions 時的標準做法。第二,response_format={"type": "json_object"} 是 OpenAI 的結構化輸出選項,能強制模型只回 JSON、不夾帶說明文字;Anthropic、Google 等各家也有對應的「結構化輸出」或「工具呼叫」功能,效果類似。我們刻意不展示假金鑰或捏造 API endpoint,所有 endpoint 都是 2025 年公開且主流的服務。
常見錯誤與踩雷
第一個雷:直接拿 LLM 輸出寫進主表,不驗收。這是 LLM 清洗最大的坑:模型出錯不會丟例外,只會給「看起來差不多」的答案。請一律先把結果寫進暫存表,跑完合約檢查再晉升到主表,這個「雙階段寫入」是 LLM 清洗的安全閥。
第二個雷:prompt 寫得太開放,讓 LLM 自由發揮。範例:「幫我清洗這個地址」。LLM 會回一段對話文字加 JSON,又或者是兩個 JSON 物件夾雜說明,程式端很難解析。請一律在 prompt 明確規定「只回 JSON」「不要加說明」、並給範例;結構化輸出功能優先用。
第三個雷:忽略成本與速度。本機跑 7B 模型約 30 token/秒,雲端 gpt-4o-mini 約 200 token/秒但要付費。如果你一天要清洗 100 萬筆,先用 1,000 筆樣本評估品質與速度,再決定要不要批次、非同步、或切到更小的模型。千萬不要在還沒評估就直接把全部資料丟進 LLM。
第四個雷:把 LLM 當成事實來源。LLM 對「縣市是哪個」「機構統編是什麼」這類事實性問題不能保證正確。事實性的欄位要靠權威來源(內政部戶政司的統編查詢 API、財政部稅務入口網的發票號碼查詢)來驗證;LLM 只能拿來「生成候選」或「加速人工」。
第五個雷:prompt 沒有標註授權與隱私。當你拿含有真實個資的欄位送雲端 LLM,務必先確認該服務的資料使用條款(OpenAI 的 API 預設不會把資料用於訓練,但請確認你簽的合約版本)。本機 Ollama 完全沒有這個問題,這也是本系列選擇它做預設的原因之一。
效能與實務提醒
Ollama 在 M1/M2 MacBook 上跑 qwen2.5:7b 約 25 token/秒,跑一次地址清洗大約 1–2 秒;在 Linux + NVIDIA GPU 上可以到 100 token/秒以上。如果你的清洗量大於每天 10 萬筆,建議兩件事並行:(1) 用 async 或 concurrent.futures.ThreadPoolExecutor 同時發多個請求,把吞吐量拉上去;(2) 設定較短的 num_predict 參數,避免 LLM 為了湊對話長度輸出廢話。
實務上有三個取捨值得記得。第一,模型大小:7B 是品質與速度的甜蜜點,3B 更快但容易出錯,14B 品質更好但 CPU 跑不動。第二,temperature:清洗任務用 0.1、分類任務用 0.3、生成任務用 0.7,數字越大越有創意但也越不穩定。第三,prompt 長度:把 prompt 控制在一頁以內(幾百字),太長的 prompt 會讓模型注意力分散、品質下降。當 prompt 需要塞很多範例時,用 num_ctx 配合長一點的視窗,但要記得 Ollama 對長 prompt 也吃記憶體。
最後,LLM 清洗不是「一次到位」的工作。隨著業務演進、新地址格式出現,你會發現某些 LLM 結果開始不可靠。這時不要直接改 prompt(容易引入新 bug),而是把新出現的失敗樣本累積成「regression 集」,每次升級模型或 prompt 時都跑一遍這個集,確保新版本沒有退步。這個 regression 集的設計留給 Day 43 的評估篇章展開。
小結
今天把「LLM 輔助資料清洗」這條線建立起來。我們用 Ollama 本機跑 qwen2.5:7b,把混亂的地址欄位整理成「縣市 / 鄉鎮市區 / 路段」三段式,並用 DuckDB 與昨天學的合約檢查做雙階段驗收。重點觀念有三個。第一,LLM 適合處理「規則難寫、死角又多」的清洗任務,不適合處理事實性高風險的欄位。第二,prompt 要明確規定輸出格式(純 JSON、合法值清單、範例),並用程式端的解析與合約檢查雙重把關。第三,本機與雲端可以互換:同樣的 prompt、合約、驗收流程,差別只在 client 與金鑰管理方式。
這套「LLM 提建議、合約與 SQL 卡驗收」的設計,是 Day 41 之後貫穿專案的清洗骨幹。當我們把這條管線接到真實的空氣品質資料時,會用它來處理測站名稱、地址與機構名稱這幾個半結構化欄位。明天,我們會把整個系列的「文件化與交接」這條線講清楚:怎麼把這套複雜的管線寫成讓同事讀得懂、改得動、跑得起的文件。
結語
今天的重點是「把 LLM 接進資料清洗」。我們從 prompt 設計、Ollama 呼叫、批次處理、到 DuckDB 驗收,把一條完整的清洗閉環跑完。讀完這篇你應該能回答:為什麼 LLM 適合處理「規則難寫」的清洗?怎麼設計 prompt 才能讓輸出穩定?為什麼一定要有「LLM 提建議、合約卡驗收」這層架構?明天,我們會把這套複雜的管線寫成一份完整的 README、runbook 與交接手冊,讓同事不必讀懂每一行程式也能接手。
延伸資源
- Ollama 官方文件(2025):
https://github.com/ollama/ollama。本機 LLM 推論工具,提供ollama pull、ollama serve、Python HTTP API。 - Qwen 2.5 模型卡(阿里雲,2024-2025):
https://qwenlm.github.io/blog/qwen2.5/。qwen2.5:7b-instruct的訓練資料、評估結果與授權說明(Apache 2.0),適合本機中文應用。 - OpenAI 結構化輸出說明(2024):
https://platform.openai.com/docs/guides/structured-outputs。response_format={"type": "json_object"}的官方指南。 - Day 14 章節(資料品質檢查):本系列介紹「品質規則設計」的篇章,今天的合約驗收是它的延伸。
- Day 22 章節(失敗處理):本系列介紹「重試、警報與斷點續跑」的篇章,LLM 清洗串接排程後的失敗處理會在那篇展開。
留言
張貼留言