← 全部文章
PRODUCT發布於 · 2026年4月15日

多租戶 LINE Agent 架構決策:BeauBot 的 5 個踩坑

每家店一個 channel、prompt 隔離、context 共享 — 把 ReAct Agent 變成多租戶 SaaS 路上學到的事。

4 分鐘閱讀

BeauBot 是給美業店家用的 LINE AI 客服 SaaS。一開始只服務一家美甲店,code 寫得很爽:寫死 LINE Channel ID、提示詞 hardcode、營業時間放 config。要擴成多家店時,5 個結構性問題炸出來。記下這些踩坑,給後來人。

踩坑 1:每家店一個 Channel,token 怎麼存

LINE Messaging API 的 Channel Access Token 是長期有效(可選 30 天 / 不過期),每家店的 token 必須加密存 DB。我的選擇:

internal/store/tenant.go
// Channel token 用 PG 的 pgcrypto extension 加密
type Tenant struct {
    ID                  string `db:"id"`
    Name                string `db:"name"`
    LineChannelID       string `db:"line_channel_id"`
    // 不存原始 token,存 pgcrypto encrypt 後 base64
    LineChannelTokenEnc string `db:"line_channel_token_enc"`
    OperatingHours      string `db:"operating_hours"` // JSON
}
 
// 寫入時:
// INSERT INTO tenants (..., line_channel_token_enc)
// VALUES (..., pgp_sym_encrypt($1, $masterKey))
//
// 讀取時:
// SELECT pgp_sym_decrypt(line_channel_token_enc::bytea, $masterKey) FROM tenants

$masterKey 從 env 讀(AES_MASTER_KEY),絕不寫入 DB。

踩坑 2:Prompt 隔離 vs 共用模板

每家店的客服語氣不同:年輕美甲店要活潑,傳統美髮店要正式。但 prompt 結構大致相同(角色設定 + tool list + 範例)。

最初我給每家店一份完整 prompt,結果改 base 邏輯時要 update 所有 tenant — 災難。

改成 layered prompt

prompt 結構
[base layer — 共用]
你是一個美業店家的 LINE AI 客服。可用工具:
- query_services: 查服務項目
- check_availability: 查可預約時段
- create_booking: 建立預約
...
 
[tenant layer — 每家店覆蓋]
店家名稱:{{store_name}}
語氣:{{tone}}  # casual | formal | friendly
特殊規則:{{custom_rules}}
範例對話:{{examples}}

base layer 動,所有店一起得到改進;tenant layer 動,只影響該店。

踩坑 3:tools 的 tenant context

ReAct Agent 的工具呼叫要知道「現在是誰的店」。如果 tool function 簽名是 func queryServices(category string),就無法知道要查哪家店的 services。

解法:在 tool registration 時 partial apply tenant ID:

internal/agent/tools.go
func buildTools(tenantID string, db *sql.DB) []Tool {
    return []Tool{
        {
            Name: "query_services",
            Description: "查詢本店所有服務項目",
            // 把 tenantID closure 進去,LLM 看不到它
            Run: func(args map[string]any) (string, error) {
                rows, err := db.Query(
                    "SELECT name, price FROM services WHERE tenant_id = $1",
                    tenantID,
                )
                // ...
            },
        },
    }
}

LLM 只負責決定「呼叫 query_services」,租戶隔離永遠在程式層強制執行,不交給 LLM 自由 reasoning。

踩坑 4:rate limit per-tenant

OpenAI API 有全帳號級的 RPM / TPM 限制。一家店突然大量訊息(活動爆量),不能 throttle 到其他店。

加 per-tenant token bucket:

internal/middleware/rate_limit.go
import "golang.org/x/time/rate"
 
var tenantLimiters sync.Map // map[tenantID]*rate.Limiter
 
func getLimiter(tenantID string) *rate.Limiter {
    if l, ok := tenantLimiters.Load(tenantID); ok {
        return l.(*rate.Limiter)
    }
    // 預設每秒 5 次、桶子 10 個
    l := rate.NewLimiter(5, 10)
    tenantLimiters.Store(tenantID, l)
    return l
}
 
func RateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tid := r.Context().Value("tenant_id").(string)
        if !getLimiter(tid).Allow() {
            http.Error(w, "rate limit exceeded", 429)
            return
        }
        next.ServeHTTP(w, r)
    })
}

踩坑 5:模型成本控制

GPT-4o 一次 reasoning 約 NT$ 0.5。一家店一天 300 則訊息 × 100 家店 = 30000 次 = NT$ 15000/day。撐不住。

意圖分類兩階段

1. nano 階段(GPT-4o-mini)
   判斷意圖類型:trivia / booking / complaint / other

2. 視類型決定模型
   trivia → 直接 nano 回(每次 NT$ 0.01)
   booking → ReAct Agent 用 4o
   complaint → 升級給真人客服

成本下降 60%,使用者感受沒差。

學到什麼

多租戶不是把 schema 加 tenant_id 就好。 Prompt、tools、rate limit、成本控制每一層都要 per-tenant 設計。否則一家店壞了會拖垮所有人。

如果你也在做多租戶 AI SaaS,先把這 5 個問題列清楚再開工,會省掉至少 2 個月重構時間。

覺得有用?

分享給可能用得上的人。