Pattern 4: Backend Relay
你的後端作為前端與 Edge Server 之間的中繼層, 完整掌控 Auth、History、Memory、Audit、Payload 注入。最進階、最有彈性的整合方案。
適用場景
這個模式適合以下情境:
- 🏢 企業級 / Enterprise 整合: 需要 per-user 身份驗證、細粒度權限控管、稽核紀錄
- 🔐 API Key 必須藏在後端: 不能讓使用者拿到 Bot Provider API Key
- 📚 需要跨裝置同步的對話歷史: 同一使用者切換瀏覽器、手機 App 都能看到完整歷史
- 🧠 需要從後端動態組裝 Memory / User Config / 業務上下文 注入 Workflow
- 📋 需要完整 Audit Log: 每則訊息、每個工具呼叫、每筆扣點都要可追溯
- 💼 複雜業務邏輯: 訊息分流、Human Handoff、CRM 同步、Webhook 觸發等
- 🎯 需要 Server-side push: 訂單狀態變更、排程訊息等「不是使用者主動觸發」的訊息推送
如果以上你都不需要, 簡單一點的 Pattern(尤其 Direct Connect)已經夠用。
架構圖
此模式下, 4 個 Plane 全部開放: 畫面層、瀏覽器層、伺服器層、Agent 內部層, 程式化控制權最為完整。不熟悉 Plane / 執行層的概念? → 架構深入解析
Endpoint 介面
前端 SDK 會根據 botProviderEndpoint 自動拼接子路徑發出請求到你的後端, 而你的後端必須實作並提供以下的對應路由:
| Endpoint | 必要性 | 說明 |
|---|---|---|
POST {base}/message/sse | 必須實作 | 接收訊息、中繼到 Edge Server、以 SSE 串流回傳 AI 回應 |
POST {base}/blob | 可選 | 支援對話框上傳檔案 |
{base} 就是你在前端填入的 botProviderEndpoint。 舉例, 若路由設計為 POST /api/chat/message/sse, 則前端應設定 botProviderEndpoint: 'https://your-backend.com/api/chat'。
能力邊界
✅ 你能做的
- 完整的 per-user 身份驗證(整合你的 Session / JWT / OAuth / SSO 系統)
- 細粒度 ACL(per-resource、per-action)
- Bot Provider API Key 完全藏在後端, 不暴露給前端
- 跨裝置同步對話歷史(寫入你的 DB)
- 完整 Audit Log(攔截所有 SSE 事件)
- 從你的 DB 動態注入 Memory、User Config、業務上下文進 Payload
- 配額 / Cost 追蹤、計費對帳
- 訊息分級審核、Human Handoff、外部系統整合(CRM、Webhook、Email、Push)
- Server-side push(透過後端主動發送訊息給 Bot, 例如「您的訂單已出貨」)
- 統一錯誤處理、重試策略、降級邏輯
⚠️ 需要承擔的成本
| 成本項 | 說明 |
|---|---|
| 需要建立後端服務 | 你需要部署、維運一個能處理 SSE 串流的後端 |
| 需要管理 Bot Provider API Key | 安全地存放於後端 secrets, 不能 commit 到程式碼 |
| 需要設計 DB schema | history、memory、user_config 等需要設計儲存結構 |
| 需要實作 SSE 雙向轉發 | 後端要同時是 SSE Client(對 Edge Server)和 SSE Server(對前端) |
想完整理解這個模式為何能開放所有 Plane 的能力? → 架構深入解析
後端的 4 個角色
理解這 4 個角色, 就掌握了 Backend Relay 的全部:
角色 ① — 驗證身份
收到前端請求時, 用你自己的 Auth 機制驗證使用者:
- 從 Cookie /
Authorizationheader 取出 Session / JWT - 查詢你的使用者資料表確認身份與權限
角色 ② — 注入 Payload
驗證通過後, 從你的 DB 撈取個人化資料, 塞入訊息 Payload。Workflow 在執行階段可透過 prevPayload.* 讀取:
- 使用者偏好(
user_config): 語言、語氣、回應風格 - 使用者記憶(
memories): 歷史互動學到的個人事實 - 業務上下文: 當前帳號餘額、目前查看的訂單、所屬部門等
- 動態權限資訊: 可使用的工具集、可查的資料表
角色 ③ — 轉發到 Edge Server
正因為 Backend Relay 需要後端主動對 Edge Server 發起通訊, Asgard 提供了專用的後端 SDK 來處理這段連線——包括建立 SSE 串流、傳送訊息、接收回應事件等。詳細 API 見 後端 SDK 使用指南。
用後端 SDK 對 Edge Server 建立 SSE 串流。Bot Provider API Key 由後端持有, 從不外洩:
bpClient := client.NewBotProviderClient(
"https://api.asgard-ai.com",
"your-namespace",
"your-bot",
botProviderApiKey, // 從你的 secrets 讀取
)
stream, err := bpClient.NewStreamer(ctx, message, nil)
角色 ④ — 攔截事件流做 side effects
SSE 事件回流時, 後端逐一攔截處理,同時也轉發給前端:
asgard.run.init→ 把使用者剛送出的訊息寫入 history 表asgard.message.complete→ 把 AI 完成的訊息寫入 history 表asgard.tool_call.complete→ 處理工具呼叫(例如: Bot 用 tool 申請「更新對話標題」, 後端真的去改 DB)asgard.completion_model.usage→ 計算 LLM 用量稽核asgard.run.error→ 集中記錄錯誤、觸發告警
這個攔截能力是 Backend Relay 的核心威力 — 你能在 Asgard 不感知的情況下, 圍繞 AI 對話建構你自己的業務系統。
前端的角色
前端需要做兩件事才能讓整個模式運作:
-
把請求導向你的後端: 在前端 SDK 的
botProviderEndpoint填入你自己的後端 base path(路由規格見上方 Endpoint 介面), 而非 Asgard 的 Edge Server。SDK 行為不變, 只是流量改走你的後端。 -
帶上 Auth Credential: 後端要能識別「這個請求是誰發的」, 前端必須把 credential 一起送出:
- Session Cookie: 瀏覽器預設行為, 同 origin 的請求會自動帶上, 通常不需要額外處理
- JWT / Token: 如果你的 Auth 用 Token 而非 Cookie, 需用
onBeforeSendMessage把 token 放進 payload, 或透過自訂 header 傳遞
快速起步
這個模式有兩個部分:(A) 後端中繼層、(B) 前端改為打你的後端。
A. 後端中繼層實作
對應上方 Endpoint 介面 表格的兩條路由, 以下逐一實作。完整 API 參考見 後端 SDK 使用指南。
A-1. 訊息串流端點(必須實作): POST {base}/message/sse
這個 handler 就是上面 後端的 4 個角色 ①②③④ 的具體實作 — 在同一個 request 生命週期裡完成驗證、注入、轉發、攔截。
- Go
- Node.js
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"go.asgard-ai.com/asgard-sdk-go/pkg/client"
"go.asgard-ai.com/asgard-sdk-go/pkg/models"
)
// 從環境變數讀取,請勿 hardcode
var (
edgeServerHost = getEnvOrDefault("EDGE_SERVER_HOST", "https://api.asgard-ai.com")
namespace = os.Getenv("NAMESPACE")
botProviderName = os.Getenv("BOT_PROVIDER_NAME")
botProviderApiKey = os.Getenv("BOT_PROVIDER_API_KEY")
)
func getEnvOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// POST https://your-backend.com/api/chat/message/sse
// 接收使用者訊息,中繼到 Edge Server,以 SSE 串流回傳 AI 回應
func SendMessage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 角色 ①:驗證身份 — 從 Authorization header 取出 token
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
userID, err := verifyToken(token)
if err != nil {
http.Error(w, "Unauthorized", 401)
return
}
// 直接使用 SDK 的 GenericBotMessage 作為 request 模型
var req models.GenericBotMessage
json.NewDecoder(r.Body).Decode(&req)
// 角色 ②:注入 Payload(從 DB 撈個人化資料)
userConfig, _ := repo.GetUserConfig(ctx, userID)
memories, _ := repo.ListMemories(ctx, userID)
req.Payload = map[string]interface{}{
"user_id": userID,
"user_config": userConfig,
"memories": memories,
}
// 角色 ③:轉發到 Edge Server
bpClient := client.NewBotProviderClientWithConfig(&client.BotProviderConfig{
EdgeServerHost: edgeServerHost,
Namespace: namespace,
BotProviderName: botProviderName,
BotProviderApiKey: botProviderApiKey,
})
stream, err := bpClient.NewStreamer(ctx, &req, nil)
if err != nil {
http.Error(w, "Failed to connect", 500)
return
}
defer stream.Close()
// 設定 SSE 回應 headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher := w.(http.Flusher)
// 角色 ④:攔截事件並轉發
for stream.Next() {
event := stream.Current()
// 根據業務需求自行決定 side effects
switch event.EventType {
case models.SseEventTypeRunInit:
repo.SaveUserMessage(ctx, userID, req)
case models.SseEventTypeMessageComplete:
repo.SaveAssistantMessage(ctx, userID, event.Fact.MessageComplete)
case models.SseEventTypeToolCallComplete:
handleToolCall(ctx, userID, event.Fact.ToolCallComplete)
case models.SseEventTypeCompletionModelUsage:
billing.DeductCredits(ctx, userID, event.Fact.CompletionModelUsage)
}
// 轉發 SSE 給前端
fmt.Fprintf(w, "event: %s\n", event.EventType)
fmt.Fprintf(w, "data: %s\n\n", toJSON(event))
flusher.Flush()
}
if err := stream.Err(); err != nil {
// 集中錯誤處理
}
}
// 以 Express 示意,Fastify / Next.js Route Handler / Hono 行為類似
import express, { type Request, type Response } from 'express';
import {
AsgardError,
BotProviderClient,
SseEventTypeRunInit,
SseEventTypeMessageComplete,
SseEventTypeToolCallComplete,
SseEventTypeCompletionModelUsage,
type GenericBotMessage,
} from '@asgard-js/nodejs';
// 從環境變數讀取,請勿 hardcode
const edgeServerHost = process.env.EDGE_SERVER_HOST ?? 'https://api.asgard-ai.com';
const namespace = process.env.NAMESPACE ?? '';
const botProviderName = process.env.BOT_PROVIDER_NAME ?? '';
const botProviderApiKey = process.env.BOT_PROVIDER_API_KEY ?? '';
// 整個後端共用同一份 client
const bpClient = new BotProviderClient({
edgeServerHost,
namespace,
botProviderName,
botProviderApiKey,
});
const app = express();
app.use(express.json());
// POST https://your-backend.com/api/chat/message/sse
// 接收使用者訊息,中繼到 Edge Server,以 SSE 串流回傳 AI 回應
app.post('/api/chat/message/sse', async (req: Request, res: Response) => {
// 角色 ①:驗證身份 — 從 Authorization header 取出 token
const token = (req.header('Authorization') ?? '').replace(/^Bearer\s+/, '');
let userId: string;
try {
userId = await verifyToken(token);
} catch {
res.status(401).send('Unauthorized');
return;
}
// 直接使用 SDK 的 GenericBotMessage 作為 request 模型
const reqBody = req.body as GenericBotMessage;
// 角色 ②:注入 Payload(從 DB 撈個人化資料)
const userConfig = await repo.getUserConfig(userId);
const memories = await repo.listMemories(userId);
reqBody.payload = {
user_id: userId,
user_config: userConfig,
memories,
};
// 角色 ③:轉發到 Edge Server
let streamer;
try {
streamer = await bpClient.newStreamer(reqBody);
} catch {
res.status(500).send('Failed to connect');
return;
}
// 設定 SSE 回應 headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 客戶端斷線時關閉上游 SSE,避免 LLM 持續燒 token
req.on('close', () => streamer.close());
// 角色 ④:攔截事件並轉發
try {
for await (const event of streamer) {
// 根據業務需求自行決定 side effects
switch (event.eventType) {
case SseEventTypeRunInit:
await repo.saveUserMessage(userId, reqBody);
break;
case SseEventTypeMessageComplete:
await repo.saveAssistantMessage(userId, event.fact?.messageComplete);
break;
case SseEventTypeToolCallComplete:
await handleToolCall(userId, event.fact?.toolCallComplete);
break;
case SseEventTypeCompletionModelUsage:
await billing.deductCredits(userId, event.fact?.completionModelUsage);
break;
}
// 轉發 SSE 給前端
res.write(`event: ${event.eventType}\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
} catch (e) {
if (e instanceof AsgardError) {
// 集中錯誤處理
}
} finally {
streamer.close();
res.end();
}
});
A-2. 檔案上傳端點(選擇性): POST {base}/blob
只有當你要支援對話框上傳檔案時才需要實作; 若不需要, 可跳過這一節。為了讓每個 snippet 都能獨立閱讀, 以下會重複 A-1 已出現過的 import 與 client 初始化 — 實際整合到同一個 server 時合併即可。
- Go
- Node.js
import (
"bufio"
"encoding/json"
"net/http"
"os"
"go.asgard-ai.com/asgard-sdk-go/pkg/client"
)
// 從環境變數讀取,請勿 hardcode
var (
edgeServerHost = getEnvOrDefault("EDGE_SERVER_HOST", "https://api.asgard-ai.com")
namespace = os.Getenv("NAMESPACE")
botProviderName = os.Getenv("BOT_PROVIDER_NAME")
botProviderApiKey = os.Getenv("BOT_PROVIDER_API_KEY")
)
func getEnvOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// POST https://your-backend.com/api/chat/blob
// 接收上傳檔案,轉發到 Edge Server
func UploadBlob(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
channelId := r.PathValue("channel_id")
bpClient := client.NewBotProviderClientWithConfig(&client.BotProviderConfig{
EdgeServerHost: edgeServerHost,
Namespace: namespace,
BotProviderName: botProviderName,
BotProviderApiKey: botProviderApiKey,
})
if err := r.ParseMultipartForm(128 << 20); err != nil { // 128 MiB
http.Error(w, "Invalid form", 400)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file field required", 400)
return
}
defer file.Close()
mime := header.Header.Get("Content-Type")
blob, err := bpClient.UploadBlob(ctx, channelId, bufio.NewReader(file), header.Filename, &mime)
if err != nil {
http.Error(w, "Upload failed", 500)
return
}
json.NewEncoder(w).Encode(blob)
}
import express, { type Request, type Response } from 'express';
import multer from 'multer';
import { BotProviderClient } from '@asgard-js/nodejs';
import { Readable } from 'node:stream';
// 從環境變數讀取,請勿 hardcode
const edgeServerHost = process.env.EDGE_SERVER_HOST ?? 'https://api.asgard-ai.com';
const namespace = process.env.NAMESPACE ?? '';
const botProviderName = process.env.BOT_PROVIDER_NAME ?? '';
const botProviderApiKey = process.env.BOT_PROVIDER_API_KEY ?? '';
const bpClient = new BotProviderClient({
edgeServerHost,
namespace,
botProviderName,
botProviderApiKey,
});
const app = express();
// POST https://your-backend.com/api/chat/blob
// 接收上傳檔案,轉發到 Edge Server
const upload = multer();
app.post(
'/api/chat/channels/:channel_id/blob',
upload.single('file'),
async (req: Request, res: Response) => {
const channelId = req.params.channel_id;
const f = req.file;
if (!f) {
res.status(400).send('file field required');
return;
}
try {
const blob = await bpClient.uploadBlob(channelId, {
stream: Readable.from(f.buffer),
filename: f.originalname,
mime: f.mimetype,
});
res.json(blob);
} catch {
res.status(500).send('Upload failed');
}
},
);
B. 前端: 把 endpoint 改為你的後端
前端仍可使用 @asgard-js/react, 只是 botProviderEndpoint 改為指向你的後端, 而非 Asgard。透過 customHeaders 帶上 Authorization header, 後端即可驗證身份:
import { Chatbot } from '@asgard-js/react';
import '@asgard-js/react/style';
export default function App() {
const accessToken = getAccessToken(); // 從你的 Auth 系統取得
return (
<Chatbot
title="我的 AI 助理"
config={{
botProviderEndpoint: 'https://your-backend.com/api/chat',
// 不需要 apiKey — 已藏在你的後端
}}
customChannelId={channelId}
customHeaders={{
Authorization: `Bearer ${accessToken}`,
}}
/>
);
}
Authorizationheader 只是示例。請根據你後端的驗證機制調整:若後端驗的是其他 header,改對應的 key;若使用 Cookie-based Session,瀏覽器同源請求會自動帶上 Cookie,customHeaders可直接省略。
後端須實作的路由規格見 Endpoint 介面。完整 SDK API 見 後端 SDK 使用指南。
Case Study: 虛構的「AI 個人筆記助理」
為了具體說明 4 個角色如何協同, 以下用一個虛構產品「AI 個人筆記助理」走一遍完整流程。這個產品的功能是:
「使用者跟 AI 對話時, AI 會自動記住使用者的偏好(例如喜好的解釋深度、慣用語言、領域興趣), 並在後續對話中參考。對話內容跨裝置同步, 公司需要稽核紀錄。」
資料庫設計(簡化)
流程拆解
當使用者送出「我最近在學 Rust, 推薦一些進階主題」時:
下次使用者開啟對話時, 後端會把「使用者目前在學 Rust」這條 memory 注入 Payload, Workflow 的 Prompt 自然會帶入這個上下文 — 使用者感覺 AI「記得」他。
同樣的架構能擴展到哪些功能?
這個架構非常有彈性, 以下功能都是同一個模式的變體:
- Server-side push(訂單狀態變更時主動推訊息給使用者): 後端用 SDK 主動建立 SSE 連線, 把訊息推進 channel
- Human Handoff(複雜問題轉真人客服): 後端攔截
asgard.tool_call.complete事件, 當 LLM 呼叫request_human工具時, 觸發你的客服系統 - CRM 同步: 攔截
asgard.message.complete事件時把對話內容同步進 Salesforce - 配額管理:
asgard.completion_model.usage累積到使用者本月用量, 超量時拒絕新請求 - 共享對話: 在你的 DB 加
channel_members表, 讓多位使用者看到同一 channel
限制與注意事項
-
後端必須能處理 SSE 雙向: 你的後端需要同時作為 SSE Client(對 Edge Server)與 SSE Server(對前端)。常見錯誤是使用了不支援 streaming 的中介層(例如某些 Lambda 設定、某些 API Gateway 設定), 導致回應被緩衝。
-
客戶端斷線時務必清理上游 SSE 連線: 當使用者關閉頁面、切換頁籤, 你的 SSE Server 應該偵測到並關閉對 Edge Server 的上游連線, 避免資源浪費。
-
長連線基礎設施: SSE 是長連線, 要評估 Load Balancer 連線數上限、Idle Timeout 設定、Worker 並發模型。Idle Timeout 建議至少設為 5 分鐘, 避免 LLM 長回應在生成途中被 Load Balancer 中途切斷。
-
事件處理的 idempotency: 攔截事件做 side effect 時, 要考慮重連或重試導致的重複事件, 避免重複扣點、重複寫入 history。
-
Bot Provider API Key 安全保管: Key 應放在 secrets manager, 不可寫入程式碼或環境變數明文。
延伸閱讀
- 完整 SSE Event Type 列表:SSE 事件類型參考
- 後端 SDK 完整使用指南:後端 SDK 使用指南
- Auth 跨模式比較:Authentication & Access Control
- History 持久化深入:Conversation History
- Memory / Payload 注入完整指南:Memory & Payload 注入
- Audit Log 實作模式:Audit Logging
- 想理解這個模式為何最有彈性:架構深入解析