Tuần này trên Dev.to có một project khá hay: Kharcha, dùng model 4B chạy local để đọc SMS ngân hàng ở Ấn Độ, dữ liệu tiền bạc không rời khỏi laptop. Trên HN thì antirez (tác giả Redis) cũng đang làm chuyện chạy LLM local. Mình thấy ý tưởng này hợp với dev Việt Nam. Ngày nào mình cũng nhận cả chục tin báo biến động số dư từ Vietcombank, Techcombank, MB... Muốn tự làm app quản lý chi tiêu thì cách nhanh nhất là gửi hết lên OpenAI. Nhưng gửi số tài khoản, số dư và nội dung chuyển khoản lên server bên thứ ba thì mình không yên tâm.
Bài này mình chia sẻ pipeline đang dùng thật: Ollama + model 3-4B + JSON Schema structured output + Pydantic validation + regex fallback. Chạy được trên MacBook M1 8GB hoặc laptop có GPU tầm trung.
Tại sao dùng model nhỏ chạy local cho bài toán này
Nhiều người nghĩ LLM local thì "ngu", không dùng được việc gì. Với bài toán extraction có cấu trúc thì khác. Mình không cần model biết làm thơ. Mình chỉ cần nó đọc một đoạn text ngắn rồi trả về vài field: số tiền, loại giao dịch (cộng/trừ), số dư, nội dung, thời gian.
Bài toán kiểu này có mấy đặc điểm:
- Input ngắn (dưới 300 token), nên model 3B chạy chỉ vài trăm ms.
- Output có schema cố định, ràng buộc được bằng grammar.
- Format tin nhắn mỗi ngân hàng mỗi kiểu. Regex thuần sẽ vỡ khi ngân hàng đổi template, còn LLM thì linh hoạt hơn.
- Dữ liệu nhạy cảm, nên chạy local là lựa chọn hợp lý nhất.
Các model mình đã thử: qwen2.5:3b, llama3.2:3b, gemma3:4b. Với tin nhắn tiếng Việt, Qwen2.5 3B cho kết quả ổn định nhất trong nhóm này. Gemma3 4B cũng tốt nhưng chậm hơn một chút.
flowchart LR
A[SMS / Push notification] --> B[Pre-filter: có phải tin ngân hàng?]
B -->|Không| X[Bỏ qua]
B -->|Có| C[Ollama local + JSON Schema]
C --> D[Pydantic validate]
D -->|OK| E[Cross-check với regex]
D -->|Fail| F[Regex fallback]
E --> G[(SQLite local)]
F --> G
Setup Ollama và chọn model
Từ Ollama 0.5 trở đi, tham số format nhận thẳng một JSON Schema, không chỉ chuỗi "json" như trước. Bên dưới Ollama dùng grammar-constrained decoding của llama.cpp, nên model không thể sinh ra JSON sai cú pháp. Đây là tính năng quan trọng nhất của cả pipeline này.
# macOS
brew install ollama
ollama --version # cần >= 0.5.0
# Chạy server (mặc định bind 127.0.0.1:11434 - đừng đổi sang 0.0.0.0)
ollama serve &
# Pull model, bản quantize Q4_K_M mặc định ~1.9GB
ollama pull qwen2.5:3b
# Test nhanh
curl -s http://127.0.0.1:11434/api/generate -d '{
"model": "qwen2.5:3b",
"prompt": "Trả lời bằng 1 từ: thủ đô Việt Nam?",
"stream": false
}' | jq -r .response
Mấy lưu ý từ kinh nghiệm thực tế:
- Đặt
OLLAMA_KEEP_ALIVE=30mđể model không bị unload sau 5 phút. Không thì request đầu tiên mỗi lần sẽ mất 2-3 giây để load lại. - Đặt
temperature: 0cho extraction. Bạn cần kết quả deterministic chứ không cần sáng tạo. -
num_ctx: 2048là đủ. Context mặc định lớn hơn chỉ tốn RAM vô ích.
Code extraction với Pydantic + JSON Schema
Đây là phần chính. Mình định nghĩa schema bằng Pydantic v2, dùng model_json_schema() để sinh JSON Schema đưa cho Ollama, sau đó validate lại output bằng chính model đó. Một nguồn sự thật cho cả hai đầu.
# pip install ollama==0.4.* pydantic==2.*
from datetime import datetime
from decimal import Decimal
from typing import Literal, Optional
import ollama
from pydantic import BaseModel, Field, ValidationError
class BankTxn(BaseModel):
bank: str = Field(description="Tên ngân hàng, ví dụ VCB, TCB, MB")
direction: Literal["credit", "debit"]
amount: int = Field(ge=0, description="Số tiền VND, số nguyên, không dấu chấm")
balance: Optional[int] = Field(default=None, ge=0)
description: str = Field(description="Nội dung chuyển khoản")
occurred_at: Optional[str] = Field(default=None, description="dd/mm/yyyy HH:MM")
SYSTEM = (
"Bạn trích xuất thông tin giao dịch từ tin nhắn ngân hàng Việt Nam. "
"Dấu '+' là credit, '-' là debit. Số tiền VND dùng dấu chấm hoặc phẩy "
"phân cách hàng nghìn, hãy chuyển thành số nguyên. Không bịa thông tin."
)
def extract(sms: str) -> BankTxn | None:
resp = ollama.chat(
model="qwen2.5:3b",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": sms},
],
format=BankTxn.model_json_schema(),
options={"temperature": 0, "num_ctx": 2048},
)
try:
return BankTxn.model_validate_json(resp.message.content)
except ValidationError as e:
print(f"[warn] validation failed: {e.error_count()} errors")
return None
if __name__ == "__main__":
sms = (
"SD TK 0071xxxx123 -1.250.000VND luc 02-10-2026 19:42. "
"SD 8.430.500VND. Ref MBVCB.123456.THANH TOAN TIEN DIEN T9"
)
print(extract(sms))
Trên M1 8GB, mỗi tin mất khoảng 400-700ms. Để xử lý vài chục tin một ngày thì quá đủ.
Có một điểm nhiều người bỏ qua: grammar chỉ đảm bảo đúng cú pháp, không đảm bảo đúng nghĩa. Model vẫn có thể trả amount: 125000 thay vì 1250000 vì đếm nhầm dấu chấm. Đó là lý do cần thêm bước tiếp theo.
Regex làm safety net: đừng tin LLM 100%
Mình không dùng regex để thay LLM. Mình dùng nó để kiểm tra chéo các field quan trọng nhất, tức là số tiền. Nếu regex tìm được số tiền và nó khác số LLM trả về, mình đánh dấu giao dịch là needs_review thay vì lưu thẳng.
import re
AMOUNT_RE = re.compile(r"([+-])\s?([\d.,]{4,})\s?(?:VND|đ|d)", re.IGNORECASE)
def regex_amount(sms: str) -> tuple[str, int] | None:
m = AMOUNT_RE.search(sms)
if not m:
return None
sign, raw = m.groups()
value = int(re.sub(r"[.,]", "", raw))
return ("credit" if sign == "+" else "debit", value)
def process(sms: str) -> dict:
txn = extract(sms)
rx = regex_amount(sms)
if txn is None and rx is None:
return {"status": "failed", "raw": sms}
if txn is None: # LLM fail, dùng regex tối thiểu
return {"status": "partial", "direction": rx[0], "amount": rx[1], "raw": sms}
status = "ok"
if rx and (rx[0] != txn.direction or rx[1] != txn.amount):
status = "needs_review"
return {"status": status, **txn.model_dump(), "raw": sms}
Sau khoảng 2 tháng chạy với ~1.500 tin nhắn từ 4 ngân hàng, tỷ lệ needs_review của mình còn khoảng 1-2%. Phần lớn là tin có hai số tiền (phí giao dịch + số tiền chính). Những case này mình review tay trong 30 giây mỗi tuần.
sequenceDiagram
participant App
participant Ollama as Ollama (127.0.0.1)
participant Rx as Regex checker
participant DB as SQLite
App->>Ollama: chat(sms, format=JSON Schema)
Ollama-->>App: JSON hợp lệ cú pháp
App->>App: Pydantic validate
App->>Rx: regex_amount(sms)
Rx-->>App: (direction, amount)
App->>DB: INSERT status=ok / needs_review
Mấy bẫy mình đã dính
-
Đừng expose Ollama ra mạng. Nếu
OLLAMA_HOST=0.0.0.0thì ai trong cùng wifi cũng gọi được API, và Ollama không có auth. Muốn dùng từ điện thoại thì đi qua Tailscale hoặc SSH tunnel. -
Log cũng là leak. Bạn đã chạy local mà lại
printcả tin nhắn ra file log rồi sync lên cloud thì cũng như không. Mask số tài khoản trước khi log. -
Few-shot giúp nhiều hơn đổi model. Thêm 2-3 ví dụ mẫu của chính ngân hàng bạn dùng vào
messagesgiúp độ chính xác tăng rõ rệt, hiệu quả hơn nâng từ 3B lên 7B. -
Pin version model.
qwen2.5:3bcó thể được cập nhật khi pull lại. Dùng tag cụ thể hoặc ghi lại digest bằngollama show qwen2.5:3bđể kết quả có thể tái lập.
Kết luận
Chạy LLM local không phải để cạnh tranh với GPT trên mọi bài toán. Nó phát huy tác dụng ở những việc hẹp, có schema rõ ràng và dữ liệu nhạy cảm. Đọc tin nhắn ngân hàng là ví dụ điển hình.
Bạn có thể áp dụng ngay theo các bước sau:
- Cài Ollama >= 0.5, pull
qwen2.5:3bhoặcgemma3:4b, đặttemperature: 0. - Định nghĩa schema bằng Pydantic rồi truyền
model_json_schema()vào tham sốformat. Không parse JSON bằng tay. - Luôn validate output, vì grammar chỉ đảm bảo cú pháp chứ không đảm bảo nghĩa.
- Thêm regex kiểm tra chéo cho field quan trọng nhất (số tiền), có mismatch thì đưa vào
needs_review. - Giữ mọi thứ ở
127.0.0.1và mask dữ liệu trong log.
Pattern này dùng được cho nhiều việc khác: đọc hóa đơn điện tử, parse email đặt vé, trích xuất thông tin từ CV. Lần tới khi định gửi dữ liệu nhạy cảm lên một API bên ngoài, bạn nên thử model 3B chạy local trước.
Top comments (0)