# LLM 多店客服系統:從零開始操作流程(完整實務指南) claude ai 3.7版本
本文提供使用 RTX GPU 在 Windows 11 + WSL2 環境下,建置並部署多租戶繁體中文客服 LLM 系統的完整操作流程,適合新手學習參考。文檔包含每個步驟的具體操作指令、原理解釋以及實用建議。
## 目錄
1. [基礎環境建置](#一基礎環境建置)
2. [安裝依賴套件與模型準備](#二安裝依賴套件與模型準備)
3. [撰寫 RAG 多店 API 架構](#三撰寫-rag-多店-api-架構)
4. [模型微調(LoRA)](#四模型微調lora)
5. [部署為 GGUF 格式](#五部署為-gguf-格式int4-壓縮)
6. [多租戶佈署與 Docker 化](#六多租戶佈署與-docker-化)
7. [LINE Webhook 整合](#七line-webhook-整合)
8. [系統監控與優化](#八系統監控與優化)
9. [常見問題與排解](#九常見問題與排解)
---
## 一、基礎環境建置
### 為什麼這一步很重要?
LLM 模型開發通常是在 Linux 環境下進行,但許多使用者習慣 Windows 作業系統。透過 WSL2(Windows Subsystem for Linux)在 Windows 系統中安裝 Ubuntu 環境,讓您可以在熟悉的 Windows 環境中使用 Linux 強大的開發工具。
### 1. 啟用 WSL2 + 安裝 Ubuntu 22.04
在 Windows PowerShell(以系統管理員身份運行)中執行:
```bash
wsl --install -d Ubuntu-22.04
```
執行完後系統會重新啟動並安裝 Ubuntu,安裝後打開「Ubuntu」終端機即可進入 Linux 環境。
### 2. 設定 WSL2 的 GPU 訪問與記憶體配置
確保 WSL2 能正確使用 GPU:
```bash
# 在 Windows PowerShell 中更新 WSL
wsl --update
# 在 Ubuntu 終端機中確認 GPU 連接
nvidia-smi
```
如果 `nvidia-smi` 指令無法執行,您需要安裝 NVIDIA CUDA 驅動。
為 WSL2 配置足夠的系統資源,在 Windows 中建立 `.wslconfig` 檔案:
```
# 檔案位置: C:\Users\<您的使用者名稱>\.wslconfig
[wsl2]
memory=24GB # 依據您系統可用記憶體調整
processors=8 # 依據您的 CPU 核心數調整
gpuSupport=true # 啟用 GPU 支援
```
### 3. 安裝 Conda 與 Python 開發環境
Conda 是 Python 的虛擬環境管理工具,可以隔離開發環境,避免套件版本衝突:
```bash
# 在 Ubuntu 終端機中執行
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
bash Miniconda3-latest-Linux-x86_64.sh -b
echo 'export PATH="$HOME/miniconda3/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
建立專用的虛擬環境:
```bash
conda create -n llm python=3.10 -y
conda activate llm
```
---
## 二、安裝依賴套件與模型準備
### 為什麼要這些套件?
以下套件是操作 LLM 模型、建立向量資料庫、開發 API 介面所必須的核心工具:
* `torch`: 深度學習框架,提供 GPU 加速計算
* `transformers`: Hugging Face 的模型庫,用於載入與使用各類預訓練模型
* `faiss`: Meta 開發的高效向量搜尋引擎,RAG 系統的核心組件
* `fastapi`: 高效能的 Python Web 框架,用於建立 API 服務
* `bitsandbytes`: 提供模型量化功能,支援低精度(int8/4)推論
* `sentence-transformers`: 將文本轉換為向量的工具,RAG 系統的基礎
### 1. 安裝核心套件
```bash
# 在已啟用的 llm 環境中安裝
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install transformers sentence-transformers faiss-cpu fastapi uvicorn peft bitsandbytes
pip install accelerate safetensors einops redis line-bot-sdk
```
### 2. 模型選擇與下載
本系統提供兩種模型方案供選擇:
#### 方案一:使用 Meta 的 LLaMA-3 模型
```python
# 需要訪問權限,請參考 Meta 官方網站申請
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B-Instruct",
device_map="auto",
load_in_8bit=True
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3-8B-Instruct")
```
#### 方案二:使用台灣本地化模型(推薦)
```python
# 繁體中文最佳化選項,無需申請即可使用
model = AutoModelForCausalLM.from_pretrained(
"yentinglin/Taiwan-LLM-7B-v2.1-chat",
device_map="auto",
load_in_8bit=True
)
tokenizer = AutoTokenizer.from_pretrained("yentinglin/Taiwan-LLM-7B-v2.1-chat")
# 或使用 TAIDE 模型
model = AutoModelForCausalLM.from_pretrained(
"cytseng/TAIDE-7B-chat",
device_map="auto",
load_in_8bit=True
)
tokenizer = AutoTokenizer.from_pretrained("cytseng/TAIDE-7B-chat")
```
### 3. 模型測試
快速測試模型是否正常運作:
```python
# 保存為 test_model.py
from transformers import AutoModelForCausalLM, AutoTokenizer
# 載入模型
model_name = "yentinglin/Taiwan-LLM-7B-v2.1-chat" # 依您選擇的模型調整
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map="auto",
load_in_8bit=True
)
# 測試對話
prompt = "你好,請問你是誰?"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
repetition_penalty=1.1
)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
print("模型回應:", response)
```
執行測試:
```bash
python test_model.py
```
---
## 三、撰寫 RAG 多店 API 架構
### 什麼是 RAG?
RAG(Retrieval Augmented Generation)結合了檢索與生成能力,讓 LLM 能夠參考特定資料庫中的內容來生成回答。這項技術有效解決了 LLM「幻覺」問題,使模型能夠基於最新、準確的資訊提供回答。
### 1. 建立多店知識庫結構
每間店鋪擁有獨立的知識庫與向量索引:
```
/stores/
├── store_001/
│ ├── faq.json # 常見問答資料
│ └── vector.index # 向量資料庫(檢索用)
├── store_002/
│ ├── faq.json
│ └── vector.index
└── ...
```
### 2. 建立 FAQ 資料格式
FAQ 檔案格式範例(JSON):
```json
[
{
"id": 1,
"question": "營業時間是幾點到幾點?",
"answer": "我們週一至週五營業時間為早上 10 點至晚上 8 點,週六日為早上 11 點至晚上 9 點。"
},
{
"id": 2,
"question": "是否提供外送服務?",
"answer": "是的,我們提供外送服務,但僅限方圓 5 公里內,外送費用為 NT$50。"
}
]
```
### 3. 建立向量資料庫生成工具
```python
# 保存為 create_vector_db.py
import os
import json
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
import argparse
def create_store_vector_db(store_id, model_name="paraphrase-multilingual-MiniLM-L12-v2"):
"""為指定商店創建向量資料庫"""
store_path = f"./stores/{store_id}"
faq_path = f"{store_path}/faq.json"
vector_path = f"{store_path}/vector.index"
# 確保目錄存在
os.makedirs(store_path, exist_ok=True)
# 檢查 FAQ 檔案是否存在
if not os.path.exists(faq_path):
print(f"錯誤: {faq_path} 不存在")
return False
# 載入 FAQ 資料
with open(faq_path, "r", encoding="utf-8") as f:
faq_data = json.load(f)
# 準備問題列表
questions = [item["question"] for item in faq_data]
# 載入向量模型
print(f"載入 {model_name} 模型...")
encoder = SentenceTransformer(model_name)
# 將問題轉換為向量
print("向量化問題...")
question_vectors = encoder.encode(questions, convert_to_tensor=True, show_progress_bar=True)
question_vectors = question_vectors.detach().cpu().numpy()
# 建立 FAISS 索引(使用 L2 距離)
vector_dim = question_vectors.shape[1]
index = faiss.IndexFlatL2(vector_dim)
# 將向量加入索引
index.add(question_vectors.astype(np.float32))
# 保存索引
print(f"保存向量索引到 {vector_path}")
faiss.write_index(index, vector_path)
print(f"成功建立商店 {store_id} 的向量資料庫")
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="建立店家向量資料庫")
parser.add_argument("--store_id", type=str, required=True, help="店家 ID")
args = parser.parse_args()
create_store_vector_db(args.store_id)
```
使用方法:
```bash
python create_vector_db.py --store_id store_001
```
### 4. 實現 FastAPI 多店客服 API
```python
# 保存為 app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
import faiss
import json
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import redis
import uuid
from typing import List, Optional
app = FastAPI(title="多店 LLM 客服系統")
# 載入配置
MODEL_NAME = "yentinglin/Taiwan-LLM-7B-v2.1-chat" # 或您選擇的其他模型
VECTOR_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
STORES_PATH = "./stores"
SIMILARITY_THRESHOLD = 30.0 # L2 距離閾值,越小表示越相似
# 建立 Redis 連接
redis_client = redis.Redis(host='localhost', port=6379, db=0)
# 載入向量模型(全局共用)
encoder = SentenceTransformer(VECTOR_MODEL)
# 店家資料與模型快取
store_data = {}
model = None
tokenizer = None
# 定義請求和回應模型
class ChatRequest(BaseModel):
message: str
session_id: Optional[str] = None
class ChatResponse(BaseModel):
response: str
source: str
session_id: str
# 初始化模型(延遲載入)
def load_model():
global model, tokenizer
if model is None:
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
device_map="auto",
load_in_8bit=True
)
return model, tokenizer
# 載入店家資料
def load_store_data(store_id):
store_path = f"{STORES_PATH}/{store_id}"
if not os.path.exists(store_path):
raise HTTPException(status_code=404, detail=f"店家 {store_id} 不存在")
# 檢查向量索引
vector_path = f"{store_path}/vector.index"
if not os.path.exists(vector_path):
raise HTTPException(status_code=500, detail=f"店家 {store_id} 的向量索引不存在")
# 載入 FAQ 資料
faq_path = f"{store_path}/faq.json"
with open(faq_path, "r", encoding="utf-8") as f:
faq_data = json.load(f)
# 載入向量索引
vector_index = faiss.read_index(vector_path)
return {"faq": faq_data, "vector_index": vector_index}
# 保存對話歷史
def save_chat_history(session_id, store_id, role, content):
key = f"chat:{store_id}:{session_id}"
message = json.dumps({"role": role, "content": content})
redis_client.rpush(key, message)
redis_client.expire(key, 86400) # 設置 24 小時過期
# 獲取對話歷史
def get_chat_history(session_id, store_id, limit=10):
key = f"chat:{store_id}:{session_id}"
history_data = redis_client.lrange(key, -limit, -1)
messages = []
for item in history_data:
messages.append(json.loads(item))
return messages
# 模型推論
def generate_response(prompt, history=None):
model, tokenizer = load_model()
# 構建完整提示
if history:
full_prompt = ""
for msg in history:
if msg["role"] == "user":
full_prompt += f"### 用戶: {msg['content']}\n"
else:
full_prompt += f"### 助理: {msg['content']}\n"
full_prompt += f"### 用戶: {prompt}\n### 助理:"
else:
full_prompt = f"### 用戶: {prompt}\n### 助理:"
# 編碼輸入
inputs = tokenizer(full_prompt, return_tensors="pt").to(model.device)
# 生成回應
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
repetition_penalty=1.1,
do_sample=True
)
# 解碼輸出
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
# 提取助理的回應部分
if "### 助理:" in response:
response = response.split("### 助理:")[-1].strip()
return response
@app.post("/chat/{store_id}", response_model=ChatResponse)
async def chat(store_id: str, request: ChatRequest):
# 確保已載入店家資料
if store_id not in store_data:
try:
store_data[store_id] = load_store_data(store_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 處理會話 ID
session_id = request.session_id or str(uuid.uuid4())
# 獲取用戶輸入
user_input = request.message.strip()
# 獲取對話歷史
history = get_chat_history(session_id, store_id)
# 將用戶輸入向量化
query_vector = encoder.encode([user_input])[0].astype('float32').reshape(1, -1)
# 搜尋最相似的 FAQ
store_index = store_data[store_id]["vector_index"]
distances, indices = store_index.search(query_vector, 3) # 取前 3 個最相似結果
# 檢查相似度是否超過閾值
if distances[0][0] < SIMILARITY_THRESHOLD:
# 找到相似問題
faq_data = store_data[store_id]["faq"]
similar_questions = [faq_data[int(idx)] for idx in indices[0] if int(idx) < len(faq_data)]
# 構建提示,加入相關 FAQ 作為上下文
context = "\n\n".join([
f"Q: {qa['question']}\nA: {qa['answer']}" for qa in similar_questions
])
prompt = f"""你是一位專業的客服助理。請使用以下資訊回答用戶的問題。
如果問題與提供的資訊無關,請婉轉告知你無法回答,並建議用戶聯繫人工客服。
參考資訊:
{context}
用戶問題: {user_input}
回答:"""
# 生成回應
response = generate_response(prompt, history)
source = "faq"
else:
# 找不到相似問題,使用一般模式回答
prompt = f"""你是一位專業的客服助理。請回答用戶的問題。
如果你不確定答案,請婉轉告知你無法回答,並建議用戶聯繫人工客服。
用戶問題: {user_input}
回答:"""
# 生成回應
response = generate_response(prompt, history)
source = "model"
# 保存對話歷史
save_chat_history(session_id, store_id, "user", user_input)
save_chat_history(session_id, store_id, "assistant", response)
return {
"response": response,
"source": source,
"session_id": session_id
}
@app.get("/")
async def root():
return {"status": "online", "service": "多店 LLM 客服系統"}
# 啟動服務
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
```
啟動服務:
```bash
python app.py
```
或使用 uvicorn:
```bash
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
```
---
## 四、模型微調(LoRA)
### 為什麼要微調?
即使是強大的預訓練模型,也無法了解您特定業務的細節。通過 LoRA(Low-Rank Adaptation)微調,我們可以使用少量資料高效地調整模型,讓它更好地理解您業務的專業術語、服務細節和回答風格。
### 1. 準備訓練資料
建立訓練資料集(JSON Lines 格式):
```json
{"instruction": "請問你們的營業時間是?", "output": "我們的營業時間是週一至週五上午 10 點至晚上 8 點,週末及國定假日是上午 11 點至晚上 9 點。"}
{"instruction": "你們有素食選擇嗎?", "output": "是的,我們提供多種素食選擇,包括全素沙拉、蔬菜義大利麵和素食漢堡。您可以在點餐時告知服務人員您的飲食需求。"}
{"instruction": "可以預約今晚 7 點的位子嗎?", "output": "您好,如果是 4 人以下的訂位,今晚 7 點還有空位。請提供您的姓名、電話和人數,我們將為您安排。"}
```
將這些範例保存為 `training_data.jsonl`。
### 2. 安裝微調所需套件
```bash
pip install peft accelerate bitsandbytes
```
### 3. 建立微調腳本
```python
# 保存為 train_lora.py
import os
import torch
import argparse
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForLanguageModeling
)
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
TaskType
)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--model_name", type=str, default="yentinglin/Taiwan-LLM-7B-v2.1-chat")
parser.add_argument("--dataset_path", type=str, required=True)
parser.add_argument("--output_dir", type=str, default="./lora_out")
parser.add_argument("--lora_r", type=int, default=8)
parser.add_argument("--lora_alpha", type=int, default=16)
parser.add_argument("--lora_dropout", type=float, default=0.05)
parser.add_argument("--learning_rate", type=float, default=3e-4)
parser.add_argument("--batch_size", type=int, default=4)
parser.add_argument("--epochs", type=int, default=3)
return parser.parse_args()
def main():
args = parse_args()
# 確保輸出目錄存在
os.makedirs(args.output_dir, exist_ok=True)
# 載入模型和 tokenizer
print(f"載入模型 {args.model_name}...")
tokenizer = AutoTokenizer.from_pretrained(args.model_name)
model = AutoModelForCausalLM.from_pretrained(
args.model_name,
load_in_8bit=True,
device_map="auto",
torch_dtype=torch.float16,
)
# 確保 tokenizer 有 pad_token
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# 準備模型進行 LoRA 微調
model = prepare_model_for_kbit_training(model)
# 定義 LoRA 配置
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
lora_dropout=args.lora_dropout,
bias="none",
task_type=TaskType.CAUSAL_LM,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]
)
# 應用 LoRA 到模型
model = get_peft_model(model, lora_config)
# 載入數據集
print(f"載入數據集 {args.dataset_path}...")
dataset = load_dataset("json", data_files=args.dataset_path)
# 定義數據預處理函數
def preprocess_function(examples):
# 為每個樣本創建提示
prompts = []
for instruction in examples["instruction"]:
prompt = f"### 用戶: {instruction}\n### 助理:"
prompts.append(prompt)
# 目標輸出
targets = [f"{output}" for output in examples["output"]]
# 編碼提示
tokenized_prompts = tokenizer(
prompts,
padding="max_length",
truncation=True,
max_length=512,
return_tensors="pt"
)
# 編碼目標
tokenized_targets = tokenizer(
targets,
padding="max_length",
truncation=True,
max_length=512,
return_tensors="pt"
)
input_ids = []
attention_mask = []
labels = []
# 構建訓練樣本
for i in range(len(prompts)):
prompt_len = tokenized_prompts["input_ids"][i].ne(tokenizer.pad_token_id).sum().item()
# 合併提示和目標的 input_ids
sample_input_ids = torch.cat([
tokenized_prompts["input_ids"][i][:prompt_len],
tokenized_targets["input_ids"][i]
])
# 合併注意力掩碼
sample_attention_mask = torch.cat([
tokenized_prompts["attention_mask"][i][:prompt_len],
tokenized_targets["attention_mask"][i]
])
# 創建標籤,提示部分用 -100 標記(不計算損失)
sample_labels = torch.cat([
torch.full_like(tokenized_prompts["input_ids"][i][:prompt_len], -100),
tokenized_targets["input_ids"][i]
])
input_ids.append(sample_input_ids)
attention_mask.append(sample_attention_mask)
labels.append(sample_labels)
# 構建批次
max_length = max(len(ids) for ids in input_ids)
for i in range(len(input_ids)):
padding_length = max_length - len(input_ids[i])
if padding_length > 0:
input_ids[i] = torch.cat([input_ids[i], torch.full((padding_length,), tokenizer.pad_token_id, dtype=torch.long)])
attention_mask[i] = torch.cat([attention_mask[i], torch.zeros(padding_length, dtype=torch.long)])
labels[i] = torch.cat([labels[i], torch.full((padding_length,), -100, dtype=torch.long)])
batch = {
"input_ids": torch.stack(input_ids),
"attention_mask": torch.stack(attention_mask),
"labels": torch.stack(labels)
}
return batch
# 處理數據集
processed_dataset = dataset["train"].map(
preprocess_function,
batched=True,
remove_columns=dataset["train"].column_names,
)
# 定義訓練參數
training_args = TrainingArguments(
output_dir=args.output_dir,
learning_rate=args.learning_rate,
per_device_train_batch_size=args.batch_size,
gradient_accumulation_steps=4,
num_train_epochs=args.epochs,
weight_decay=0.01,
save_total_limit=3,
logging_steps=10,
save_steps=100,
fp16=True,
)
# 初始化 Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=processed_dataset,
data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False),
)
# 開始訓練
print("開始訓練...")
trainer.train()
# 保存模型
print(f"保存模型到 {args.output_dir}")
model.save_pretrained(args.output_dir)
tokenizer.save_pretrained(args.output_dir)
print("訓練完成!")
if __name__ == "__main__":
main()
```
### 4. 執行微調
```bash
python train_lora.py --dataset_path training_data.jsonl --output_dir ./lora_model
```
### 5. 合併 LoRA 權重(將微調參數與原始模型合併)
```python
# 保存為 merge_lora.py
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
def merge_lora_with_base_model(base_model_name, lora_model_path, output_dir):
"""將 LoRA 模型合併回基礎模型"""
# 建立輸出目錄
os.makedirs(output_dir, exist_ok=True)
# 載入基礎模型
print(f"載入基礎模型 {base_model_name}...")
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype=torch.float16,
device_map="auto"
)
# 載入 tokenizer
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
# 載入 LoRA 模型
print(f"載入 LoRA 模型 {lora_model_path}...")
model = PeftModel.from_pretrained(base_model, lora_model_path)
# 合併 LoRA 權重到基礎模型
print("合併模型...")
merged_model = model.merge_and_unload()
# 保存合併後的模型
print(f"保存合併後的模型到 {output_dir}")
merged_model.save_pretrained(output_dir)
tokenizer.save_pretrained(output_dir)
print("模型合併完成!")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--base_model", type=str, default="yentinglin/Taiwan-LLM-7B-v2.1-chat")
parser.add_argument("--lora_model", type=str, required=True)
parser.add_argument("--output_dir", type=str, default="./merged_model")
args = parser.parse_args()
merge_lora_with_base_model(args.base_model, args.lora_model, args.output_dir)
```
執行合併:
```bash
python merge_lora.py --lora_model ./lora_model --output_dir ./merged_model
```
---
## 五、部署為 GGUF 格式(int4 壓縮)
### 為什麼要壓縮?
GGUF(GPT-Generated Unified Format)是一種針對 LLM 優化的格式,將模型壓縮為 int4 格式能夠顯著降低記憶體需求,讓您在有限的硬體資源上同時運行多個模型。該格式與 llama.cpp 和 Ollama 等輕量級推論引擎完全相容。
### 1. 安裝 llama.cpp
```bash
# 克隆 llama.cpp 專案
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
# 編譯(啟用 CUDA 支援)
make LLAMA_CUBLAS=1
# 返回主目錄
cd ..
```
### 2. 將 Hugging Face 模型轉換為 GGUF 格式
```python
# 保存為 convert_to_gguf.py
import os
import argparse
from transformers import AutoTokenizer
def convert_to_gguf(model_path, output_path, bits=4):
"""將 Hugging Face 模型轉換為 GGUF 格式"""
# 確認 tokenizer 存在
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 構建轉換命令
convert_cmd = f"python llama.cpp/convert.py {model_path} --outfile {output_path} --outtype q{bits}_K_M"
# 執行轉換
print(f"執行轉換: {convert_cmd}")
os.system(convert_cmd)
print(f"轉換完成!模型已保存至: {output_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model_path", type=str, required=True, help="Hugging Face 格式模型路徑")
parser.add_argument("--output_path", type=str, required=True, help="輸出 GGUF 檔案路徑")
parser.add_argument("--bits", type=int, default=4, choices=[2, 3, 4, 5, 6, 8], help="量化位元數(2-8)")
args = parser.parse_args()
convert_to_gguf(args.model_path, args.output_path, args.bits)
```
執行轉換:
```bash
python convert_to_gguf.py --model_path ./merged_model --output_path ./store_model_q4.gguf --bits 4
```
### 3. 使用 llama.cpp 測試 GGUF 模型
```bash
./llama.cpp/main -m ./store_model_q4.gguf -n 512 -p "### 用戶: 請問你們今天有營業嗎?
### 助理:"
```
### 4. 使用 Ollama 部署模型(可選)
如果您希望使用 Ollama 作為推論引擎,可以按照以下步驟操作:
```bash
# 安裝 Ollama
curl -fsSL https://ollama.com/install.sh | sh
# 建立自訂模型
ollama create store001 --file ./store_model_q4.gguf
# 執行模型
ollama run store001
```
---
## 六、多租戶佈署與 Docker 化
### 為什麼要用 Docker?
Docker 容器化技術提供了一致的運行環境,能夠簡化部署流程,提高系統穩定性和可擴展性。透過 Docker,您可以輕鬆管理多個店家的模型實例,進行橫向擴展,並簡化版本控制。
### 1. 建立應用程式 Dockerfile
```dockerfile
# 檔案名稱: Dockerfile
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
WORKDIR /app
# 安裝基本套件
RUN apt-get update && apt-get install -y \
python3 python3-pip \
git wget curl \
&& rm -rf /var/lib/apt/lists/*
# 安裝 Python 依賴
COPY requirements.txt .
RUN pip3 install -r requirements.txt
# 複製應用程式代碼
COPY app/ /app/
COPY models/ /models/
COPY stores/ /stores/
# 設定環境變數
ENV MODEL_PATH="/models/store_model_q4.gguf"
ENV STORES_PATH="/stores"
ENV REDIS_HOST="redis"
ENV REDIS_PORT="6379"
# 暴露API端口
EXPOSE 8000
# 啟動服務
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
```
### 2. 建立 requirements.txt
```
# 檔案名稱: requirements.txt
fastapi==0.100.0
uvicorn==0.23.2
transformers==4.33.1
sentence-transformers==2.2.2
faiss-cpu==1.7.4
redis==4.6.0
pydantic==2.1.1
python-multipart==0.0.6
line-bot-sdk==3.1.0
llama-cpp-python==0.2.6
```
### 3. 建立 Docker Compose 配置
```yaml
# 檔案名稱: docker-compose.yml
version: '3'
services:
redis:
image: redis:7.0
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: always
nginx:
image: nginx:1.25
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- store001
- store002
restart: always
store001:
build:
context: .
dockerfile: Dockerfile
environment:
- STORE_ID=store001
- MODEL_PATH=/models/store001_model_q4.gguf
volumes:
- ./models:/models
- ./stores:/stores
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: always
store002:
build:
context: .
dockerfile: Dockerfile
environment:
- STORE_ID=store002
- MODEL_PATH=/models/store002_model_q4.gguf
volumes:
- ./models:/models
- ./stores:/stores
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: always
volumes:
redis-data:
```
### 4. 建立 Nginx 設定檔
```nginx
# 檔案名稱: nginx.conf
server {
listen 80;
server_name localhost;
location /store001 {
proxy_pass http://store001:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
rewrite ^/store001(.*)$ $1 break;
}
location /store002 {
proxy_pass http://store002:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
rewrite ^/store002(.*)$ $1 break;
}
location /webhook {
proxy_pass http://webhook-service:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 5. 啟動 Docker 服務
```bash
# 建立並啟動所有服務
docker-compose up -d
# 檢查服務狀態
docker-compose ps
# 查看日誌
docker-compose logs -f
```
---
## 七、LINE Webhook 整合
### 為什麼整合 LINE?
LINE 是台灣地區使用率極高的通訊平台,整合 LINE 官方帳號能讓您的客服系統直接觸達客戶,提供即時自動化回覆,大幅提升客戶體驗。
### 1. 建立 LINE Bot 服務
```python
# 保存為 line_webhook.py
from fastapi import FastAPI, Request, HTTPException, Depends
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage
import httpx
import os
import redis
import json
from typing import Dict
app = FastAPI()
# 載入環境變數
CHANNEL_ACCESS_TOKEN = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
CHANNEL_SECRET = os.getenv("LINE_CHANNEL_SECRET")
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost")
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
# 初始化 LINE Bot API
line_bot_api = LineBotApi(CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)
# Redis 連接
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
# 存儲用戶與店家的對應關係
def get_store_id(user_id: str) -> str:
"""根據 LINE 用戶 ID 獲取對應的店家 ID"""
store_id = redis_client.get(f"user:{user_id}:store")
if store_id:
return store_id.decode('utf-8')
return None
def set_store_id(user_id: str, store_id: str):
"""設置 LINE 用戶對應的店家 ID"""
redis_client.set(f"user:{user_id}:store", store_id)
# 路由映射表
STORE_ROUTES = {
"store001": "/store001/chat/store_001",
"store002": "/store002/chat/store_002",
# 更多店家...
}
# 獲取會話 ID
def get_session_id(user_id: str) -> str:
"""獲取或建立用戶的會話 ID"""
session_key = f"user:{user_id}:session"
session_id = redis_client.get(session_key)
if not session_id:
# 如果沒有會話 ID,則創建一個新的
import uuid
session_id = str(uuid.uuid4())
redis_client.set(session_key, session_id)
return session_id.decode('utf-8') if isinstance(session_id, bytes) else session_id
@app.post("/webhook/{store_id}")
async def line_webhook(store_id: str, request: Request):
# 獲取 X-Line-Signature 頭部
signature = request.headers.get('X-Line-Signature', '')
# 獲取請求內容
body = await request.body()
body_text = body.decode('utf-8')
try:
# 驗證簽名
handler.handle(body_text, signature)
except InvalidSignatureError:
raise HTTPException(status_code=400, detail="Invalid signature")
return {"status": "OK"}
@handler.add(MessageEvent, message=TextMessage)
def handle_text_message(event):
# 獲取用戶 ID 和文本
user_id = event.source.user_id
text = event.message.text
# 確定對應的店家 ID
store_id = get_store_id(user_id)
# 如果沒有對應的店家,則假設用戶第一次發送訊息是選擇店家
if not store_id:
# 檢查用戶是否發送的是店家代碼
if text in STORE_ROUTES:
set_store_id(user_id, text)
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=f"您已連接到 {text} 的客服系統。有什麼可以幫您的嗎?")
)
return
else:
# 如果不是有效的店家代碼,則提示用戶選擇店家
store_list = ", ".join(STORE_ROUTES.keys())
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=f"請先選擇店家,可用的店家代碼: {store_list}")
)
return
# 獲取會話 ID
session_id = get_session_id(user_id)
# 獲取對應店家的 API 路由
route = STORE_ROUTES.get(store_id)
if not route:
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text="對不起,無法確定您要連接的店家。請聯繫客服人員。")
)
return
# 構建 API URL
api_url = f"{API_BASE_URL}{route}"
# 調用 LLM API
try:
with httpx.Client(timeout=30.0) as client:
response = client.post(
api_url,
json={"message": text, "session_id": session_id}
)
# 檢查響應
if response.status_code != 200:
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text="對不起,系統暫時無法回應。請稍後再試。")
)
return
# 解析回應
result = response.json()
reply_text = result.get("response", "對不起,無法取得回應。")
# 回覆用戶
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=reply_text)
)
except Exception as e:
print(f"API 呼叫錯誤: {str(e)}")
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text="對不起,系統發生錯誤。請稍後再試。")
)
@app.get("/")
def read_root():
return {"status": "LINE Webhook 服務正常運行中"}
# 啟動服務
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
```
### 2. 建立 LINE Webhook 服務的 Dockerfile
```dockerfile
# 檔案名稱: Dockerfile.line
FROM python:3.10-slim
WORKDIR /app
# 安裝依賴
COPY line-requirements.txt .
RUN pip install -r line-requirements.txt
# 複製代碼
COPY line_webhook.py .
# 環境變數
ENV LINE_CHANNEL_ACCESS_TOKEN=""
ENV LINE_CHANNEL_SECRET=""
ENV API_BASE_URL="http://nginx"
ENV REDIS_HOST="redis"
ENV REDIS_PORT="6379"
# 暴露端口
EXPOSE 8001
# 啟動服務
CMD ["uvicorn", "line_webhook:app", "--host", "0.0.0.0", "--port", "8001"]
```
### 3. 新增至 docker-compose.yml
在 `docker-compose.yml` 中添加 LINE Webhook 服務:
```yaml
webhook-service:
build:
context: .
dockerfile: Dockerfile.line
environment:
- LINE_CHANNEL_ACCESS_TOKEN=${LINE_TOKEN}
- LINE_CHANNEL_SECRET=${LINE_SECRET}
- API_BASE_URL=http://nginx
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
- redis
- nginx
restart: always
```
### 4. 建立 LINE 專用的依賴檔案
```
# 檔案名稱: line-requirements.txt
fastapi==0.100.0
uvicorn==0.23.2
line-bot-sdk==3.1.0
httpx==0.24.1
redis==4.6.0
```
### 5. 設定 LINE 開發者帳號
1. 在 [LINE Developers](https://developers.line.biz/) 註冊並創建一個 Provider
2. 建立一個 Messaging API Channel
3. 獲取 Channel Access Token 和 Channel Secret
4. 設定 Webhook URL 為您的服務 URL(例如 `https://your-domain.com/webhook`)
5. 啟用 Webhook 並關閉自動回覆功能
---
## 八、系統監控與優化
### 1. 監控指標設置
建立一個基本的監控系統,追蹤關鍵性能指標:
```python
# 保存為 monitoring.py
from fastapi import FastAPI, Depends, Request
from prometheus_client import Counter, Histogram, generate_latest
from prometheus_client.exposition import CONTENT_TYPE_LATEST
import time
from starlette.responses import Response
import redis
app = FastAPI()
# 建立 Prometheus 指標
REQUEST_COUNT = Counter('api_requests_total', 'Total API requests', ['store_id', 'endpoint'])
RESPONSE_TIME = Histogram('api_response_time_seconds', 'API response time in seconds', ['store_id', 'endpoint'])
QUERY_SUCCESS = Counter('query_success_total', 'Successful queries', ['store_id'])
QUERY_FAILURE = Counter('query_failure_total', 'Failed queries', ['store_id'])
# Redis 連接
redis_client = redis.Redis(host='localhost', port=6379, db=0)
@app.middleware("http")
async def monitor_requests(request: Request, call_next):
# 記錄請求開始時間
start_time = time.time()
# 提取路徑和店家 ID
path = request.url.path
store_id = "unknown"
# 嘗試從路徑中提取店家 ID
parts = path.split("/")
if len(parts) > 1 and parts[1].startswith("store"):
store_id = parts[1]
# 增加請求計數
REQUEST_COUNT.labels(store_id=store_id, endpoint=path).inc()
# 處理請求
try:
response = await call_next(request)
# 如果是 API 回應,記錄響應時間
if path.endswith("/chat"):
RESPONSE_TIME.labels(store_id=store_id, endpoint=path).observe(time.time() - start_time)
# 根據狀態碼記錄成功或失敗
if response.status_code == 200:
QUERY_SUCCESS.labels(store_id=store_id).inc()
else:
QUERY_FAILURE.labels(store_id=store_id).inc()
return response
except Exception as e:
QUERY_FAILURE.labels(store_id=store_id).inc()
raise e
@app.get("/metrics")
def metrics():
"""導出 Prometheus 指標"""
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
@app.get("/health")
def health_check():
"""健康檢查端點"""
try:
# 檢查 Redis 連接
redis_client.ping()
return {"status": "healthy"}
except Exception as e:
return Response(
status_code=500,
content=f"Service unhealthy: {str(e)}",
media_type="text/plain"
)
```
### 2. Grafana 視覺化儀表板設置 (docker-compose.yml 新增)
```yaml
prometheus:
image: prom/prometheus:v2.45.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- "9090:9090"
restart: always
grafana:
image: grafana/grafana:9.5.2
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=secret
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
restart: always
volumes:
prometheus-data:
grafana-data:
```
### 3. 系統優化建議
#### 記憶體用量優化
修改 `app.py` 添加記憶體管理功能:
```python
# 添加到 app.py
import gc
import torch
from fastapi import BackgroundTasks
# 定義清理函數
def cleanup_gpu_memory():
"""在背景清理 GPU 記憶體"""
torch.cuda.empty_cache()
gc.collect()
@app.post("/chat/{store_id}", response_model=ChatResponse)
async def chat(store_id: str, request: ChatRequest, background_tasks: BackgroundTasks):
# 現有代碼...
# 在回應後安排清理任務
background_tasks.add_task(cleanup_gpu_memory)
return {
"response": response,
"source": source,
"session_id": session_id
}
```
#### 模型快取策略
```python
# 模型快取管理類
class ModelCache:
def __init__(self, max_models=3):
self.models = {} # 存儲模型的字典
self.usage_count = {} # 追蹤模型使用次數
self.max_models = max_models # 最大模型數量
def get_model(self, model_path):
"""獲取模型,如果不存在則載入"""
if model_path in self.models:
self.usage_count[model_path] += 1
return self.models[model_path]
# 如果達到最大模型數,則移除最少使用的
if len(self.models) >= self.max_models:
least_used = min(self.usage_count.items(), key=lambda x: x[1])[0]
del self.models[least_used]
del self.usage_count[least_used]
# 載入新模型
from llama_cpp import Llama
model = Llama(
model_path=model_path,
n_ctx=2048, # 上下文長度
n_threads=4 # 使用的線程數
)
self.models[model_path] = model
self.usage_count[model_path] = 1
return model
# 初始化模型快取
model_cache = ModelCache()
```
---
## 九、常見問題與排解
### 1. 顯存不足錯誤
**問題:** CUDA out of memory
**解決方案:**
- 降低模型精度(使用 4bit 或 3bit 量化)
- 減少批次大小(batch size)
- 減少上下文長度(context length)
- 檢查 GPU 是否被其他程序佔用
### 2. 回應速度慢
**問題:** 模型回應時間過長
**解決方案:**
- 使用更高效的推論引擎(如 vLLM、TensorRT-LLM)
- 減少生成的最大 token 數
- 優化向量檢索算法
- 提前載入熱門模型到記憶體
- 使用更小的模型(例如 3B 或 7B 參數模型)
### 3. 向量檢索不精確
**問題:** RAG 系統無法找到相關問題
**解決方案:**
- 調整相似度閾值
- 使用更好的向量模型(如 BERT 多語言模型)
- 增加 FAQ 問題的變體
- 實施混合檢索(結合關鍵詞和向量檢索)
### 4. Docker 容器啟動失敗
**問題:** 容器無法正確啟動或崩潰
**解決方案:**
- 檢查 GPU 驅動和 CUDA 版本
- 確保 `nvidia-container-toolkit` 正確安裝
- 檢查容器日誌尋找錯誤訊息
- 確保配置了足夠的系統資源
### 5. LINE Webhook 未收到回覆
**問題:** LINE 機器人沒有回應
**解決方案:**
- 檢查 Channel Access Token 和 Channel Secret
- 確認 Webhook URL 設定正確
- 檢查 SSL 證書有效性
- 查看 Webhook 服務日誌尋找錯誤
### 6. 系統擴展性問題
**問題:** 系統無法處理高峰期負載
**解決方案:**
- 實施負載平衡(Load Balancing)
- 使用 Kubernetes 自動擴縮容
- 實現請求隊列機制
- 使用快取減少重複請求的處理
---
## 總結
本文檔提供了從零開始建置多租戶繁體中文客服 LLM 系統的完整操作流程,涵蓋了環境設置、模型訓練、API 開發和系統部署的方方面面。透過遵循這些步驟,您可以建立一個高效、穩定且可擴展的智能客服系統,有效提升客戶體驗及運營效率。
隨著您的系統不斷成長,請記得定期更新模型、擴充知識庫,並持續改進系統架構以適應業務需求的變化。
祝您實施順利!