本日核心價值 (Core Focus): LINE webhook 驗簽後,用 Intent + Function Calling 呼叫白名單內的 ERP 唯讀 API(訂單、庫存),再組官方回覆 JSON;模型不准發明並執行 SQL,且每個 user-id 都要限流與綁定授權。
概念說明與實戰情境 (Overview)
企業要的不是「會聊天的客服」,而是員工在 LINE 問「SO-20260817-001 出了沒」「料號 A-100 庫存」能拿到 ERP 真資料。架構應固定為:LINE webhook(C# 或 Python Flask)→ 驗簽 → 辨識意圖 → Function Calling 只打 allowlist 內的唯讀 API → 把工具結果填進回覆。模型負責選工具與整理文句,不負責組 SQL、不負責寫入 ERP。Webhook 必須驗證 X-Line-Signature;查詢必須綁 LINE userId 到員工帳號;同一 user 要做 rate limit,避免把 ERP 查詢接口打爆。
關鍵操作與範例 (Implementation & Example)
把 Bot 當 ERP 的只讀投影,而不是第二套後台。訂單狀態、庫存數字只能來自 tool 回傳;模型沒打工具就作答,視為實作缺陷。Channel secret、ERP token 只存在伺服器環境變數。Webhook URL 用 HTTPS,且只接受 LINE 平台的 POST。以下用 Flask 示範驗簽與限流,C# 給對等 HMAC;工具分派兩邊相同。
實作順序建議:先接通驗簽與「未授權」回覆,再接 ERP GET,最後才接 LLM。沒有前兩段就上模型,會把授權漏洞藏在自然語言裡,之後很難測。
資料流如下:
LINE Platform
-> HTTPS POST /webhook (raw body + X-Line-Signature)
-> 驗簽、辨識 userId、rate limit
-> LLM + tools(僅 query_order / query_inventory)
-> ERP read API(訂單、庫存)
-> LINE Reply API JSON
工具白名單只放唯讀查詢。名稱、參數、後端實作都由你定義;模型只能填參數,不能新增工具、不能把 sql 當參數。
| Tool | 允許參數 | 後端實際呼叫 | 禁止 |
|---|---|---|---|
query_order |
order_id |
GET /erp/orders/{id} |
任意 SQL、更新狀態 |
query_inventory |
sku |
GET /erp/inventory/{sku} |
匯出全表、改庫存 |
Python Flask 驗簽與 webhook 骨架(務必用 原始 bytes 算 HMAC,不可先 request.json):
import base64
import hashlib
import hmac
import os
import time
from collections import defaultdict, deque
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]
WINDOW_SEC = 60
MAX_CALLS = 8
_hits: dict[str, deque[float]] = defaultdict(deque)
# Map LINE userId -> ERP employee id. Missing => deny.
EMPLOYEES = {"Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": "EMP-0042"}
def verify_signature(body: bytes, signature: str) -> bool:
digest = hmac.new(
CHANNEL_SECRET.encode("utf-8"),
body,
hashlib.sha256,
).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, signature or "")
def rate_limit(user_id: str) -> bool:
now = time.monotonic()
q = _hits[user_id]
while q and now - q[0] > WINDOW_SEC:
q.popleft()
if len(q) >= MAX_CALLS:
return False
q.append(now)
return True
@app.post("/webhook")
def webhook():
body = request.get_data()
if not verify_signature(body, request.headers.get("X-Line-Signature", "")):
abort(400)
payload = request.get_json(force=True, silent=True) or {}
events = payload.get("events") or []
# Handle message events: bind userId, call agent, then reply.
return jsonify({"ok": True, "events": len(events)})
C# 對等驗簽(ASP.NET Minimal API 同樣先讀 raw body):
static bool VerifyLineSignature(byte[] body, string? signature, string channelSecret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret));
var expected = Convert.ToBase64String(hmac.ComputeHash(body));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature ?? string.Empty));
}
Function Calling 的 tool schema 不要出現 sql 欄位。參數用嚴格型別,後端再用自己的 HTTP client 打 ERP:
TOOLS = [
{
"type": "function",
"function": {
"name": "query_order",
"description": "Read one sales order by id. Read-only.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": r"^SO-[0-9]{8}-[0-9]{3}$"}
},
"required": ["order_id"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "query_inventory",
"description": "Read on-hand qty for one SKU. Read-only.",
"parameters": {
"type": "object",
"properties": {
"sku": {"type": "string", "pattern": r"^[A-Z0-9-]{3,32}$"}
},
"required": ["sku"],
"additionalProperties": False,
},
},
},
]
ALLOWED = {"query_order", "query_inventory"}
def dispatch(name: str, args: dict, employee_id: str) -> dict:
if name not in ALLOWED:
return {"error": "tool_not_allowed"}
# Call ERP HTTP APIs with service credentials; never exec model-written SQL.
if name == "query_order":
return erp_get(f"/orders/{args['order_id']}", employee_id)
return erp_get(f"/inventory/{args['sku']}", employee_id)
模型若回傳不在 ALLOWED 的函式名稱,直接拒絕。ERP 端用員工代號做資料範圍(業務只能看自己的訂單),不要把 LINE userId 當資料庫主鍵散落到每張表。
LINE 回覆必須走 Reply API 的 JSON,而不是自己印文字到 webhook response。範例:
{
"replyToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"messages": [
{
"type": "text",
"text": "訂單 SO-20260817-001:已出貨。料號 A-100 可用庫存 42。"
}
]
}
Intent 不要做成「自由聊天再看要不要查 ERP」。使用者一則文字進來後,先做授權與限流,再讓模型只能在兩個 tool 裡選一個或拒絕。查不到就說查不到,不要用訓練資料補訂單狀態。replyToken 時效短,工具呼叫與 ERP HTTP 都要設 timeout(建議各 3–5 秒),逾時回「查詢逾時,請稍後再試」,避免 webhook 重試把同一張訂單查很多次。群組訊息的 source.userId 與 1:1 不同,授權表必須用真正的發送者,而不是 groupId。ERP 服務帳號放在後端設定,不進 LINE 訊息、不進 Prompt。
上線前用簽名單元測試擋回歸:正確 X-Line-Signature 必須進業務流程,錯誤簽名必須 400。授權表建議放資料庫或 IdP,範例裡的 EMPLOYEES 字典只適合本機。查詢結果要做資料分類:訂單編號可以回,客戶電話與成本單價不要回。模型整理文句時只能用 tool 回傳的欄位,缺欄就說「系統未提供」。rate limit 除了每 userId 滑動視窗,也要對整個 Bot 做全局限流,避免群組洗版打掛 ERP。寫 log 時記錄 employee_id 與 tool 名稱即可,不要把完整聊天內容當訂單副本存下來。
System prompt 寫成政策,而不是「盡量幫忙」:
You are an ERP query assistant.
You may only call query_order and query_inventory.
Never invent SQL. Never guess order ids or stock qty.
If the tool returns not_found, say so. If the user asks to update ERP, refuse.
注意事項與常見失敗 (Pitfalls)
request.json 再序列化去驗簽:JSON 空白不同就會驗簽失敗或被繞過。修法:HMAC 只打 raw body,比對 X-Line-Signature 用 compare_digest。execute():一次 Prompt Injection 就能讀走整庫。修法:只有 HTTP GET 白名單;SQL 留在 ERP 服務內部。userId:任何人加 Bot 就能查訂單。修法:EMPLOYEES 對照表(或 IdP);未註冊一律回「未授權」。userId 滑動視窗(上例 60 秒 8 次),超限回「請稍後再查」。replyToken:使用者看不到答案,還以為系統當掉。修法:先驗簽與限流,再在 token 有效時間內呼叫 Reply API。not_found / forbidden / timeout / ok,再讓模型組使用者看懂的句子。userId 對照表決定,HTTP client 帶對應的服務憑證,模型看不到憑證內容。本日總結 (Takeaways)
query_order、query_inventory 這類唯讀查詢;禁止模型發明並執行 SQL。userId 必須對到員工帳號,查詢範圍由 ERP 授權決定,不是由 Prompt 決定。明日預告 (Next)
明天把「唯讀查詢」換成批次資料移動:用 Python / Codex 做大規模 ETL 與 Data Migration,重點是分塊、Pydantic 驗證、冪等鍵,以及沒有 checksum 就不 load。