# 第四堂:實務 AI 商業場景開發
## AI 客服 (資料庫版本)
* 由 [week4 版本調整](https://github.com/hexschool/node-training-postgresql)
### NPM 安裝
```=json
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"openai": "^4.9.2",
"pg": "^8.11.3",
"typeorm": "^0.3.17"
},
```
### db.js
```=JavaScript
// db.js
const { DataSource, EntitySchema } = require("typeorm");
// 新增對話實體
const Conversation = new EntitySchema({
name: "Conversation",
tableName: "CONVERSATION",
columns: {
id: {
primary: true,
type: "uuid",
generated: "uuid",
nullable: false,
},
userId: {
type: "varchar",
length: 100,
nullable: false,
name: "user_id",
},
lastActivity: {
type: "timestamp",
name: "last_activity",
nullable: false,
},
createdAt: {
type: "timestamp",
createDate: true,
name: "created_at",
nullable: false,
},
},
});
// 新增消息實體
const Message = new EntitySchema({
name: "Message",
tableName: "MESSAGE",
columns: {
id: {
primary: true,
type: "uuid",
generated: "uuid",
nullable: false,
},
role: {
type: "varchar",
length: 20,
nullable: false,
},
content: {
type: "text",
nullable: false,
},
timestamp: {
type: "timestamp",
nullable: false,
},
},
relations: {
conversation: {
type: "many-to-one",
target: "Conversation",
joinColumn: {
name: "conversation_id",
},
onDelete: "CASCADE",
},
},
});
const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST || "localhost",
port: process.env.DB_PORT || 5432,
username: process.env.DB_USERNAME || "root",
password: process.env.DB_PASSWORD || "test",
database: process.env.DB_DATABASE || "test",
entities: [Conversation, Message],
synchronize: true,
});
module.exports = AppDataSource;
```
### server.js
```=javascript
const express = require("express");
const OpenAI = require("openai");
const cors = require("cors");
require("dotenv").config();
const AppDataSource = require("./db");
const app = express();
const PORT = process.env.PORT || 3000;
// 中間件
app.use(cors());
app.use(express.json());
app.use(express.static("public"));
// 初始化 OpenAI
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// 初始化資料庫連結
let dbInitialized = false;
AppDataSource.initialize()
.then(() => {
console.log("資料庫連結成功");
dbInitialized = true;
})
.catch((error) => {
console.error("資料庫連結失敗:", error);
});
// 處理客服查詢
app.post("/api/chat", async (req, res) => {
try {
if (!dbInitialized) {
return res.status(503).json({ error: "資料庫尚未初始化,請稍後再試" });
}
const { message, userId } = req.body;
if (!message) {
return res.status(400).json({ error: "缺少必要參數" });
}
// 生成隨機用戶ID(如果沒有提供)
const clientId = userId || `user_${Date.now()}`;
// 取得對話實體
const conversationRepo = AppDataSource.getRepository("Conversation");
const messageRepo = AppDataSource.getRepository("Message");
// 尋找或建立對話
let conversation = await conversationRepo.findOne({
where: { userId: clientId },
});
if (!conversation) {
conversation = conversationRepo.create({
userId: clientId,
lastActivity: new Date(),
});
await conversationRepo.save(conversation);
} else {
// 更新最後活動時間
conversation.lastActivity = new Date();
await conversationRepo.save(conversation);
}
// 新增用戶資訊到資料庫
await messageRepo.save(
messageRepo.create({
conversation: conversation,
role: "user",
content: message,
timestamp: new Date(),
})
);
// 取得對話紀錄
const messageHistory = await messageRepo.find({
where: { conversation: { id: conversation.id } },
order: { timestamp: "ASC" },
});
// 將對話紀錄轉換為字串
const conversationHistory = messageHistory
.map((msg) => `${msg.role === "user" ? "用戶" : "客服"}: ${msg.content}`)
.join("\n");
// 構建系統提示
const stockInfo = `- 智慧型手機 X1:庫存 50
- 筆記型電腦 Pro:庫存 30
- 無線耳機:庫存 100
- 智慧型手錶:庫存 45
- 平板電腦:庫存 25`;
const systemPrompt = `你是一個專業的客服機器人,專門負責回答商品庫存查詢。
目前商品庫存情況:
${stockInfo}
當用戶詢問商品庫存時,請提供上述最新資訊。如果用戶詢問其他商品,請告知我們目前沒有該商品的資訊。
如果用戶詢問非庫存相關問題,請禮貌地引導他們詢問商品庫存情況。`;
// 呼叫 OpenAI Responses API 與 Function Calling
const response = await openai.responses.create({
model: "gpt-4-turbo",
instructions: systemPrompt,
input: conversationHistory,
tools: [
{
type: "function",
name: "provide_stock_info",
description: "提供商品庫存資訊給用戶",
parameters: {
type: "object",
properties: {
response: {
type: "string",
description: "對用戶問題的回應",
},
},
required: ["response"],
},
},
],
tool_choice: { type: "function", name: "provide_stock_info" },
});
console.log(response);
// 取得回應內容
const toolCall = response.output.find(
(item) => item.type === "function_call"
);
const responseData = JSON.parse(toolCall.arguments);
// 增加 AI 客服回應到資料庫
await messageRepo.save(
messageRepo.create({
conversation: conversation,
role: "assistant",
content: responseData.response,
timestamp: new Date(),
})
);
// 回傳結果
res.json({
response: responseData.response,
userId: clientId,
});
} catch (error) {
console.error("處理客服查詢時出錯:", error);
res.status(500).json({
error: "處理您的請求時發生錯誤",
details: error.message,
});
}
});
// 啟動伺服器
app.listen(PORT, () => {
console.log(`伺服器運行在 http://localhost:${PORT}`);
});
```
### public/html
```=html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客服機器人</title>
<style>
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
color: #2c3e50;
}
.chat-container {
height: 400px;
overflow-y: auto;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 15px;
}
.message {
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
max-width: 80%;
}
.user-message {
background-color: #dcf8c6;
margin-left: auto;
}
.bot-message {
background-color: #f1f0f0;
}
.input-container {
display: flex;
gap: 10px;
}
input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}
button {
padding: 10px 15px;
background-color: #2c3e50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: #1a252f;
}
.product-list {
margin-top: 30px;
}
.product-list h2 {
color: #2c3e50;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
}
.products {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.product-card {
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
background-color: #fff;
}
.product-name {
font-weight: bold;
margin-bottom: 5px;
}
.product-price {
color: #e74c3c;
font-weight: bold;
}
.product-stock {
color: #27ae60;
margin-top: 5px;
}
.loading {
text-align: center;
margin: 20px 0;
color: #7f8c8d;
}
.stock-control {
margin-top: 10px;
display: flex;
gap: 5px;
}
.stock-control input {
width: 60px;
}
</style>
</head>
<body>
<div class="container">
<h1>智能客服系統</h1>
<div class="chat-container" id="chatContainer">
<div class="message bot-message">
您好!我是您的客服助手。請問有什麼可以幫助您的嗎?您可以詢問商品庫存問題。
</div>
</div>
<div class="input-container">
<input type="text" id="userInput" placeholder="請輸入您的問題..." />
<button id="sendBtn">發送</button>
<button id="clearBtn">清除對話</button>
</div>
</div>
<script>
let userId = localStorage.getItem('userId') || null;
// 發送訊息到客服系統
async function sendMessage() {
const userInput = document.getElementById('userInput');
const message = userInput.value.trim();
if (!message) return;
// 加入用戶資訊到聊天介面
addMessage(message, 'user');
userInput.value = '';
// 顯示載入中
const loadingId = addMessage('正在思考...', 'bot');
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
userId,
}),
});
const data = await response.json();
// 移除載入訊息
removeMessage(loadingId);
if (data.error) {
addMessage(`發生錯誤: ${data.error}`, 'bot');
} else {
// 保存用戶ID
if (data.userId) {
userId = data.userId;
localStorage.setItem('userId', userId);
}
// 添加機器人回應
addMessage(data.response, 'bot');
}
} catch (error) {
console.error('發送訊息時出錯:', error);
removeMessage(loadingId);
addMessage('發送訊息時發生錯誤,請稍後再試。', 'bot');
}
}
// 加入訊息到聊天介面
function addMessage(text, sender) {
const chatContainer = document.getElementById('chatContainer');
const messageElement = document.createElement('div');
messageElement.className = `message ${sender}-message`;
messageElement.textContent = text;
const messageId = Date.now();
messageElement.id = `msg-${messageId}`;
chatContainer.appendChild(messageElement);
chatContainer.scrollTop = chatContainer.scrollHeight;
return messageId;
}
// 事件監聽器
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('clearBtn').addEventListener('click', clearConversation);
document.getElementById('userInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
```
### fine-tuneing 流程
* [文件連結](https://platform.openai.com/docs/guides/fine-tuning)
### 步驟一:準備資料
### data.jsonl 範例檔案
```=jsonl
{"messages":[{"role":"user","content":"如何使用智慧型手錶 X1?"},{"role":"assistant","content":"您可以先為手錶充電,並在手機上安裝專屬 App,打開藍牙進行配對後,即可開始量測步數、心率及睡眠品質。若需更多功能,請至設定頁面進行個人化調整。"}]}
{"messages":[{"role":"user","content":"這款真皮皮包的保養方式是什麼?"},{"role":"assistant","content":"建議使用專業的皮革清潔劑及柔軟布料輕擦,切勿使用含酒精或刺激性成分的化學劑,以免破壞皮質。定期上油保養可延長皮革壽命,儲存時請放在乾燥通風處。"}]}
{"messages":[{"role":"user","content":"請問該如何清潔筆電鍵盤?"},{"role":"assistant","content":"可先關機並斷電,再以微吸力吸塵器或壓縮空氣清理縫隙內的灰塵,切勿用水直接擦拭。若有油漬或指紋,請使用不含酒精的電子產品專用清潔濕巾。"}]}
{"messages":[{"role":"user","content":"你們的運費怎麼計算?"},{"role":"assistant","content":"本店採用宅配到府,每筆訂單收取固定運費 80 元;若訂單金額滿 2000 元則享免運優惠。偏遠地區及離島運費依照物流公司規定另計。"}]}
{"messages":[{"role":"user","content":"如果商品寄到後發現瑕疵,該怎麼辦?"},{"role":"assistant","content":"若您收到的商品有任何瑕疵或損壞,請先拍照留存並於 7 天內聯繫客服,客服人員會協助您申請退換貨並負擔回寄運費。"}]}
{"messages":[{"role":"user","content":"要如何辦理退貨?"},{"role":"assistant","content":"在商品簽收後 7 天內未拆封、配件完整且保持原包裝的情況下,可申請退貨。請先聯繫客服取得退貨編號,再依照指示將商品寄回。我們會在收到並確認商品狀況後,於 5-7 個工作天內辦理退款。"}]}
{"messages":[{"role":"user","content":"你們提供哪些付款方式?"},{"role":"assistant","content":"我們支援多種付款方式,包括信用卡、ATM 轉帳、行動支付(如 Apple Pay)和貨到付款。請在結帳頁面選擇最方便的方式即可。"}]}
{"messages":[{"role":"user","content":"這家品牌怎麼創立的?"},{"role":"assistant","content":"本品牌於 2010 年由熱愛科技與創新設計的兩位大學生創立,初衷是將高品質產品與人性化介面結合,讓消費者能以平易近人的價格享受優質生活。經過多年發展,我們持續追求技術創新與品質提升。"}]}
{"messages":[{"role":"user","content":"為什麼你們的商品要強調環保?"},{"role":"assistant","content":"我們相信永續是企業的社會責任,因此在原料選擇與生產過程中盡量採用環保材質,並透過再生能源與減少塑料包裝等實際行動,為環境盡一份力。"}]}
{"messages":[{"role":"user","content":"若我的智慧型手錶 X1 壞掉了該怎麼辦?"},{"role":"assistant","content":"我們提供一年保固服務,若非人為因素造成的損壞可免費維修。請先聯繫客服並提供購買憑證、問題描述及照片,經檢測後若符合保固條件,會協助安排維修或更換新品。"}]}
{"messages":[{"role":"user","content":"你們有提供海外運送嗎?"},{"role":"assistant","content":"目前我們只提供國內配送,尚未開放海外運送服務。若您有特殊需求,請直接聯繫客服,我們會視情況協助處理。"}]}
{"messages":[{"role":"user","content":"購買後要怎麼保養無線耳機?"},{"role":"assistant","content":"建議在每次使用後,用柔軟的乾布或棉花棒擦拭耳機表面及充電盒;若接觸到汗水或水分,務必保持乾燥再進行充電。定期清潔耳塞部分可以維持音質與衛生。"}]}
{"messages":[{"role":"user","content":"關於保固服務有什麼需要注意的地方?"},{"role":"assistant","content":"保固只適用於原廠硬體故障,人為損壞(如摔落、進水)或私自拆解則不在保固範圍。請於購買後保留發票或訂單證明,以便後續維修或更換。"}]}
{"messages":[{"role":"user","content":"你們家產品最特別的地方是什麼?"},{"role":"assistant","content":"我們主打『質感生活、平易價格』,通過精心設計、嚴格品質控管與合理定價策略,讓更多人能以親民的價格享受高品質科技產品。"}]}
{"messages":[{"role":"user","content":"運送大概需要多久時間?"},{"role":"assistant","content":"一般狀況下,週一至週五下午三點前完成訂單,當日即可出貨;出貨後約 1-3 個工作天送達。若遇連假或天候因素,運送時間可能會延誤。"}]}
```
> **注意**
> - 每行是一個獨立的 JSON 物件。
> - 不要外加 `[]` 或在最後一行多逗號。
> - 每個物件裡都有 `"messages"` 屬性,且是一個陣列,陣列內包含至少一個 `{"role":"user"}` 與 `{"role":"assistant"}`。
> - 此格式才符合 GPT-4 最新 chat-based fine-tuning 的要求。
### 步驟二:上傳檔案 (Files API)
1. **準備資料**(以 chat-based 格式)
2. **上傳資料檔案 (Files API)**
3. **啟動 Fine-tuning 工作 (Fine-tuning Jobs API)**
4. **檢查 Fine-tuning 狀態並使用自訂模型**
在 **Postman** 中:
1. 建立 **POST** 請求,URL 設為:
```
https://api.openai.com/v1/files
```
2. **Headers** 設置:
```
Authorization: Bearer <YOUR_OPENAI_API_KEY>
```
> 另外可加:
> - `Content-Type: multipart/form-data`
> - 或直接在 Body 選擇 `form-data` 後,Postman 會自動帶入。
3. **Body** 選擇 `form-data`,新增兩個欄位:
- `purpose`: `fine-tune`
- `file`: (Type 選擇 File),上傳 jsonl 檔案
上傳成功後,會回傳一段 JSON,最重要的是 `id` 這個檔案編號:
```json
{
"id": "file-ABCDE12345",
"object": "file",
"bytes": 4567,
"created_at": 1692190013,
"filename": "training_data.jsonl",
"purpose": "fine-tune",
"status": "uploaded",
"status_details": null
}
```
---
## 3. 建立 Fine-tuning 工作 (Fine-tuning Jobs API)
1. 接著在 **Postman** 新增 **POST** 請求,URL:
```
https://api.openai.com/v1/fine_tuning/jobs
```
2. **Headers**:
```
Authorization: Bearer <YOUR_OPENAI_API_KEY>
Content-Type: application/json
```
3. **Body** (raw JSON):
```json
{
"training_file": "file-ABCDE12345",
"model": "gpt-4",
"n_epochs": 3
}
```
- `training_file`:填入上一步回傳的 `id`。
- `model`: 可觀看此 [model 列表](https://platform.openai.com/docs/guides/fine-tuning#token-limits)
- `n_epochs`: 訓練的迭代次數,可自行調整。
送出後會回傳類似:
```json
{
"id": "ftjob-abcdef123456",
"object": "fine_tuning.job",
"model": "gpt-4",
"created_at": 1692190113,
"fine_tuned_model": null,
"status": "running",
"training_file": "file-ABCDE12345",
...
}
```
其中 `id` 就是這次 Fine-tuning 工作的隨機編號(例如 `ftjob-abcdef123456`)。
---
## 4. 檢查 Fine-tuning 狀態
1. 建立 **GET** 請求:
```
https://api.openai.com/v1/fine_tuning/jobs/ftjob-abcdef123456
```
2. **Headers**:
```
Authorization: Bearer <YOUR_OPENAI_API_KEY>
```
3. 送出後,若 `status` 為 `succeeded`,代表微調成功。
- 這時會看到一個 `fine_tuned_model` 屬性,例如:
```
"fine_tuned_model": "ft:gpt-4:org-1234:my-custom-model-2025-05-12-12-00-00"
```
- 這 ID 要記住,後續就能用來呼叫你專屬的模型。
---
## 總結
1. **準備 chat-based JSONL**。
2. **上傳檔案**到 OpenAI Files API(purpose=fine-tune)。
3. **啟動 Fine-tuning** 工作(指定 `training_file`、`model`、`n_epochs` 等)。
4. **查詢狀態**直到 `status = succeeded`,並取得 `fine_tuned_model`。
5. **使用自訂模型**呼叫 ,將 `model` 指向微調後的 ID。
### code
```=javascript
const express = require("express");
const OpenAI = require("openai");
const cors = require("cors");
require("dotenv").config();
const AppDataSource = require("./db");
const app = express();
const PORT = process.env.PORT || 3000;
// 中間件
app.use(cors());
app.use(express.json());
app.use(express.static("public"));
// 初始化 OpenAI
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// 初始化資料庫連結
let dbInitialized = false;
AppDataSource.initialize()
.then(() => {
console.log("資料庫連結成功");
dbInitialized = true;
})
.catch((error) => {
console.error("資料庫連結失敗:", error);
});
// 處理客服查詢
app.post("/api/chat", async (req, res) => {
try {
if (!dbInitialized) {
return res.status(503).json({ error: "資料庫尚未初始化,請稍後再試" });
}
const { message, userId } = req.body;
if (!message) {
return res.status(400).json({ error: "缺少必要參數" });
}
// 生成隨機用戶ID(如果沒有提供)
const clientId = userId || `user_${Date.now()}`;
// 取得對話實體
const conversationRepo = AppDataSource.getRepository("Conversation");
const messageRepo = AppDataSource.getRepository("Message");
// 尋找或建立對話
let conversation = await conversationRepo.findOne({
where: { userId: clientId },
});
if (!conversation) {
conversation = conversationRepo.create({
userId: clientId,
lastActivity: new Date(),
});
await conversationRepo.save(conversation);
} else {
// 更新最後活動時間
conversation.lastActivity = new Date();
await conversationRepo.save(conversation);
}
// 新增用戶資訊到資料庫
await messageRepo.save(
messageRepo.create({
conversation: conversation,
role: "user",
content: message,
timestamp: new Date(),
})
);
// 取得對話紀錄
const messageHistory = await messageRepo.find({
where: { conversation: { id: conversation.id } },
order: { timestamp: "ASC" },
});
// 將對話紀錄轉換為字串
const conversationHistory = messageHistory
.map((msg) => `${msg.role === "user" ? "用戶" : "客服"}: ${msg.content}`)
.join("\n");
// 構建系統提示
const stockInfo = `- 智慧型手機 X1:庫存 50
- 筆記型電腦 Pro:庫存 30
- 無線耳機:庫存 100
- 智慧型手錶:庫存 45
- 平板電腦:庫存 25`;
const systemPrompt = `你是一個專業的客服機器人,專門回答各種問題`;
// 呼叫 OpenAI Responses API
const response = await openai.responses.create({
model: "請填入自己的 model ID",
instructions: systemPrompt,
input: conversationHistory,
});
// 取得回應內容
const responseText = response.output_text;
// 增加 AI 客服回應到資料庫
await messageRepo.save(
messageRepo.create({
conversation: conversation,
role: "assistant",
content: responseText,
timestamp: new Date(),
})
);
// 回傳結果
res.json({
response: responseText,
userId: clientId,
});
} catch (error) {
console.error("處理客服查詢時出錯:", error);
res.status(500).json({
error: "處理您的請求時發生錯誤",
details: error.message,
});
}
});
// 啟動伺服器
app.listen(PORT, () => {
console.log(`伺服器運行在 http://localhost:${PORT}`);
});
```