版本: v3.0 | 日期: 2026-03-26 | 狀態: 規劃中(已驗證技術堆疊)
技術堆疊: TypeScript + Mastra AI + Vercel AI SDK + Ollama
在整體架構中的位置: 本文件聚焦 L3 Agentic Framework 層的內部設計,五層系統架構的全局圖請見System_Architecture.md。技術選型決策追溯見adr/。
一個支援多 Agent Skills、可定時排程、可對話互動的 IT 維運 AI 系統。透過 Supervisor Agent 路由到專業 Skill Agent(LibreNMS、Graylog、Report 等),自動收集資料、分析異常、產出報告。支援雲端 Claude 和地端 Ollama 混合模型。
┌──────────────────────────────────────────────────────────────────────┐
│ IT Ops AI Agent System │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Scheduler │ │ Chat UI │ │ Webhook │ ← 觸發來源 │
│ │ (BullMQ) │ │ (Next.js) │ │ (事件觸發) │ │
│ └─────┬──────┘ └─────┬──────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────┘ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Supervisor Agent │ ← 意圖辨識 + 路由 │
│ │ (Mastra) │ │
│ └─────┬─────┬─────┬────────┘ │
│ │ │ │ │
│ ┌────────┘ │ └────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐ │
│ │ LibreNMS │ │ Graylog │ │ Report │ │ 更多 │ │
│ │ Agent │ │ Agent │ │ Agent │ │ Skills.. │ │
│ │ 設備/效能 │ │ Log/資安 │ │ HTML/PDF │ │ PM/Arch │ │
│ └─────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────┘ │
│ ▼ ▼ ▼ │
│ LibreNMS API Graylog API Puppeteer │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Model Router │ │
│ │ ┌──────────────┐ ┌───────────────────┐ │ │
│ │ │ Ollama │ │ Claude API │ │ │
│ │ │ qwen2.5:7b │ │ Sonnet / Opus │ │ │
│ │ │ llama3.1:8b │ │ │ │ │
│ │ └──────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
| 層級 | 技術 | 說明 |
|---|---|---|
| Runtime | Node.js + TypeScript | 22 + 5.x |
| Agent 框架 | @mastra/core |
Agent, Tool, Workflow, Model Router |
| Memory | @mastra/memory |
Conversation memory(thread-based) |
| RAG | @mastra/rag |
RAG pipeline(document ingestion + retrieval) |
| MCP | @mastra/mcp |
MCP Client/Server |
| Evals | @mastra/evals |
Agent 評估 |
| Chat UI | Next.js 15 + @ai-sdk/react |
useChat() hook |
| 排程 | BullMQ (Redis-backed) | 定時觸發 Agent 任務 |
| Puppeteer | 複用現有 html-to-pdf.mjs | |
| HTML 模板 | Handlebars | 報告模板引擎 |
| 部署 | Docker Compose | On-Prem First |
npm install @mastra/core @mastra/memory @mastra/rag @mastra/mcp @mastra/evals \
ai @ai-sdk/react \
zod bullmq handlebars puppeteer nodemailer
使用者:「查 TH-DCVer1 的 CPU,然後產報告」
│
Supervisor(意圖辨識)
│
┌────┴────┐
▼ ▼
LibreNMS Report
Agent Agent
每個 Skill 用 YAML 配置,對應一個 Mastra Agent:
# skills/librenms/skill.yaml
name: librenms_expert
description: "設備監控、SNMP、Port 流量、告警規則、網路拓撲、LibreNMS API"
model:
default: ollama/qwen2.5:7b # 地端,日常查詢
complex: anthropic/claude-sonnet-4-6 # 雲端,複雜診斷
tools:
- list_devices
- get_device_health
- get_active_alerts
- get_device_ports
- get_links
prompt: |
你是 LibreNMS 專家,負責透過 REST API 查詢設備狀態、效能指標、告警、網路拓撲。
回覆時:CPU/Memory/Disk 用百分比呈現,Port 流量轉換成 Mbps,異常主動標示。
# skills/graylog/skill.yaml
name: graylog_expert
description: "Log 搜尋、防火牆分析、AD 安全統計、根因分析、Graylog API"
model:
default: anthropic/claude-sonnet-4-6 # log 分析需要強推理
tools:
- search_logs
- get_firewall_stats
- get_ad_login_stats
- get_event_history
prompt: |
你是 Graylog 專家,負責 log 搜尋和資安分析。查詢語法使用 Lucene。
# skills/report/skill.yaml
name: report_generator
description: "產出 HTML/PDF 報告、Email 寄送、Line 通知"
model:
default: ollama/qwen2.5:7b # 模板填充地端夠
tools:
- generate_html
- render_pdf
- send_email
prompt: |
你是報告產生專家。使用 Handlebars 模板產出繁體中文報告。
// src/agents/librenms-agent.ts
import { Agent } from "@mastra/core";
import {
list_devices, get_device_health, get_active_alerts,
get_device_ports, get_links,
} from "@/tools/librenms";
export const librenmsAgent = new Agent({
id: "librenms_expert",
name: "LibreNMS Agent",
model: "ollama/qwen3.5-35b-a3b",
instructions: "你是 LibreNMS 專家,負責透過 REST API 查詢設備狀態、效能指標、告警、網路拓撲。回覆時:CPU/Memory/Disk 用百分比呈現,Port 流量轉換成 Mbps,異常主動標示。",
tools: { list_devices, get_device_health, get_active_alerts, get_device_ports, get_links },
});
// src/agents/graylog-agent.ts
import { Agent } from "@mastra/core";
import { search_logs, get_firewall_stats, get_ad_login_stats, get_event_history } from "@/tools/graylog";
export const graylogAgent = new Agent({
id: "graylog_expert",
name: "Graylog Agent",
model: "anthropic/claude-sonnet-4-6",
instructions: "你是 Graylog 專家,負責 log 搜尋和資安分析。查詢語法使用 Lucene。",
tools: { search_logs, get_firewall_stats, get_ad_login_stats, get_event_history },
});
// src/agents/report-agent.ts
import { Agent } from "@mastra/core";
import { generate_html, render_pdf, send_email } from "@/tools/report";
export const reportAgent = new Agent({
id: "report_generator",
name: "Report Agent",
model: "ollama/qwen3.5-35b-a3b",
instructions: "你是報告產生專家。使用 Handlebars 模板產出繁體中文報告。",
tools: { generate_html, render_pdf, send_email },
});
// src/agents/supervisor.ts
import { Agent } from "@mastra/core";
import {
list_devices, get_device_health, get_active_alerts, get_device_ports, get_links,
} from "@/tools/librenms";
import {
search_logs, get_firewall_stats, get_ad_login_stats, get_event_history,
} from "@/tools/graylog";
import { generate_html, render_pdf, send_email } from "@/tools/report";
// Mastra Supervisor:將所有 tools 集中在一個 Agent,透過 instructions 路由
export const supervisor = new Agent({
id: "supervisor",
name: "Nexus Supervisor",
model: [
{ model: "ollama/qwen3.5-35b-a3b", maxRetries: 2 },
{ model: "anthropic/claude-sonnet-4-6", maxRetries: 1 },
],
instructions: `你是 IT 維運 AI 系統的 Supervisor。根據使用者的問題選擇合適的工具:
- LibreNMS 設備查詢 → 使用 list_devices, get_device_health, get_active_alerts 等工具
- Graylog log 搜尋 → 使用 search_logs, get_firewall_stats 等工具
- 報告產出 → 使用 generate_html, render_pdf 等工具
可以串接多個工具協作。例如:先查設備資料 → 再產報告。`,
tools: {
list_devices, get_device_health, get_active_alerts, get_device_ports, get_links,
search_logs, get_firewall_stats, get_ad_login_stats, get_event_history,
generate_html, render_pdf, send_email,
},
});
| Claude Code Skill | Mastra Agent ID | 模型 |
|---|---|---|
librenms-expert |
librenms_expert |
Ollama (日常) / Claude (複雜) |
(新) graylog-expert |
graylog_expert |
Claude Sonnet |
arova-pm |
pm_agent |
Claude Sonnet |
arova-arch |
arch_agent |
Claude Sonnet |
arova-docs |
docs_agent |
Ollama |
(新) report |
report_generator |
Ollama |
// src/skills/loader.ts
import { readFileSync, readdirSync, existsSync } from "fs";
import { join } from "path";
import { parse } from "yaml";
import { Agent } from "@mastra/core";
import { toolRegistry } from "@/tools/registry";
interface SkillConfig {
name: string;
description: string;
model: { default: string; complex?: string };
tools: string[];
prompt: string;
}
export function loadSkills(skillsDir: string): Record<string, Agent> {
const agents: Record<string, Agent> = {};
readdirSync(skillsDir)
.filter(d => existsSync(join(skillsDir, d, "skill.yaml")))
.forEach(d => {
const config = parse(
readFileSync(join(skillsDir, d, "skill.yaml"), "utf-8")
) as SkillConfig;
const toolMap: Record<string, any> = {};
config.tools.forEach(name => {
toolMap[name] = toolRegistry.get(name)!;
});
agents[config.name] = new Agent({
id: config.name,
name: config.description,
model: config.model.default, // Mastra Model Router 字串格式,如 "ollama/qwen3.5-35b-a3b"
instructions: config.prompt,
tools: toolMap,
});
});
return agents;
}
命名規則: Tool name 使用
snake_case(部分 model provider 不接受其他格式)
// src/tools/librenms.ts
import { createTool } from "@mastra/core";
import { z } from "zod";
const LIBRENMS_URL = process.env.LIBRENMS_URL!;
const LIBRENMS_TOKEN = process.env.LIBRENMS_TOKEN!;
async function lnmsApi(endpoint: string, params?: Record<string, string>) {
const url = new URL(`${LIBRENMS_URL}${endpoint}`);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url, { headers: { "X-Auth-Token": LIBRENMS_TOKEN } });
return res.json();
}
export const list_devices = createTool({
id: "list_devices",
description: "列出 LibreNMS 所有設備。可篩選 active/down/ignored",
inputSchema: z.object({
status: z.enum(["all", "active", "down", "ignored"]).default("all"),
}),
execute: async ({ context }) => {
const params = context.status !== "all" ? { type: context.status } : undefined;
return JSON.stringify(await lnmsApi("/devices", params));
},
});
export const get_device_health = createTool({
id: "get_device_health",
description: "查詢設備的 CPU/Memory/Disk 效能指標",
inputSchema: z.object({
hostname: z.string().describe("設備 hostname 或 IP"),
}),
execute: async ({ context }) => {
const [processors, mempools, storage] = await Promise.all([
lnmsApi(`/devices/${context.hostname}/processors`),
lnmsApi(`/devices/${context.hostname}/mempools`),
lnmsApi(`/devices/${context.hostname}/storage`),
]);
return JSON.stringify({ processors, mempools, storage });
},
});
export const get_active_alerts = createTool({
id: "get_active_alerts",
description: "取得所有活躍的 LibreNMS 告警",
inputSchema: z.object({}),
execute: async () => {
return JSON.stringify(await lnmsApi("/alerts", { state: "1" }));
},
});
export const get_device_ports = createTool({
id: "get_device_ports",
description: "查詢設備 Port 流量和狀態(速率 bytes/sec → Mbps 需 ×8÷1000000)",
inputSchema: z.object({
hostname: z.string(),
}),
execute: async ({ context }) => {
return JSON.stringify(await lnmsApi("/ports", {
columns: "ifName,ifAlias,ifInOctets_rate,ifOutOctets_rate,ifOperStatus,ifSpeed",
}));
},
});
export const get_links = createTool({
id: "get_links",
description: "取得 LLDP/CDP 網路拓撲鄰居資料",
inputSchema: z.object({}),
execute: async () => {
return JSON.stringify(await lnmsApi("/resources/links"));
},
});
// src/tools/graylog.ts
import { createTool } from "@mastra/core";
import { z } from "zod";
const GRAYLOG_URL = process.env.GRAYLOG_URL!;
const GRAYLOG_TOKEN = process.env.GRAYLOG_TOKEN!;
async function graylogApi(endpoint: string, params?: Record<string, string>) {
const url = new URL(`${GRAYLOG_URL}${endpoint}`);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url, {
headers: {
Authorization: `Basic ${Buffer.from(`${GRAYLOG_TOKEN}:token`).toString("base64")}`,
},
});
return res.json();
}
export const search_logs = createTool({
id: "search_logs",
description: "搜尋 Graylog log。query 使用 Lucene 語法(如 action:deny, EventID:4625)",
inputSchema: z.object({
query: z.string().describe("Lucene 查詢語法"),
range_seconds: z.number().default(3600).describe("回溯秒數"),
limit: z.number().default(50).describe("回傳上限"),
}),
execute: async ({ context }) => {
return JSON.stringify(await graylogApi("/search/universal/relative", {
query: context.query, range: String(context.range_seconds), limit: String(context.limit),
}));
},
});
export const get_firewall_stats = createTool({
id: "get_firewall_stats",
description: "取得防火牆 allow/deny 統計、Top 被阻擋 IP、deny 趨勢",
inputSchema: z.object({
range_seconds: z.number().default(86400),
}),
execute: async ({ context }) => {
const [stats, topIPs, histogram] = await Promise.all([
graylogApi("/search/universal/relative/stats", {
field: "action", query: "*", range: String(context.range_seconds),
}),
graylogApi("/search/universal/relative/terms", {
field: "src_ip", query: "action:deny", range: String(context.range_seconds), size: "10",
}),
graylogApi("/search/universal/relative/histogram", {
query: "action:deny", interval: "hour", range: String(context.range_seconds),
}),
]);
return JSON.stringify({ stats, topIPs, histogram });
},
});
export const get_ad_login_stats = createTool({
id: "get_ad_login_stats",
description: "取得 AD 登入成功/失敗統計和 Top 失敗帳戶",
inputSchema: z.object({
range_seconds: z.number().default(86400),
}),
execute: async ({ context }) => {
const [success, failure, topFailed] = await Promise.all([
graylogApi("/search/universal/relative/stats", {
field: "EventID", query: "EventID:4624", range: String(context.range_seconds),
}),
graylogApi("/search/universal/relative/stats", {
field: "EventID", query: "EventID:4625", range: String(context.range_seconds),
}),
graylogApi("/search/universal/relative/terms", {
field: "TargetUserName", query: "EventID:4625", range: String(context.range_seconds), size: "5",
}),
]);
return JSON.stringify({ success, failure, topFailed });
},
});
// src/tools/report.ts
import { createTool } from "@mastra/core";
import { z } from "zod";
import Handlebars from "handlebars";
import { readFileSync, writeFileSync } from "fs";
import puppeteer from "puppeteer-core";
export const generate_report = createTool({
id: "generate_report",
description: "產出 HTML/PDF 報告。回傳 htmlPath 和 pdfPath",
inputSchema: z.object({
title: z.string(),
sections: z.array(z.object({
heading: z.string(),
content: z.string().describe("HTML 內容"),
})),
template: z.string().default("default"),
}),
execute: async ({ context }) => {
const tpl = Handlebars.compile(
readFileSync(`./templates/${context.template}.hbs`, "utf-8")
);
const html = tpl({ title: context.title, sections: context.sections, date: new Date().toISOString().split("T")[0] });
const filename = `${context.title.replace(/\s/g, "_")}_${Date.now()}`;
const htmlPath = `./reports/${filename}.html`;
writeFileSync(htmlPath, html);
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH || "/usr/bin/chromium-browser",
headless: true,
});
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" });
const pdfPath = `./reports/${filename}.pdf`;
await page.pdf({ path: pdfPath, format: "A4", printBackground: true });
await browser.close();
return JSON.stringify({ htmlPath, pdfPath });
},
});
Ollama 部署在獨立的 ASUS Ascent GX10 AI 算力主機(NVIDIA GB10 Grace Blackwell Superchip)。
| 規格 | 值 |
|---|---|
| 晶片 | NVIDIA GB10 Grace Blackwell Superchip(CPU + GPU 整合) |
| CPU | 20 核 ARM v9.2-A(10× Cortex-X925 + 10× Cortex-A725) |
| GPU | Blackwell GPU,5th Gen Tensor Cores |
| 記憶體 | 128 GB LPDDR5x 統一記憶體(CPU/GPU 共享) |
| 預設模型 | Qwen3.5-35B-A3B + Qwen3.5-122B-A10B(詳見 docs/LLM_Model_Selection_Guide.md) |
| 記憶體頻寬 | 273 GB/s |
| NVLink C2C | 600 GB/s(CPU ↔ GPU) |
| FP4 算力 | ~1 PetaFLOP(含 sparsity) |
| 儲存 | 1-4 TB PCIe 5.0 NVMe |
| 功耗 | ~140W TDP(推理時 <100W) |
| 最大模型 | 單機 200B 參數 / 雙機 405B |
128GB 統一記憶體大幅提升可選模型範圍:
| 模型 | 總參數 | 活躍參數 | 架構 | Q4 記憶體 | GX10 適合度 |
|---|---|---|---|---|---|
| Qwen3.5-35B-A3B | 36B | 3B | MoE | ~24 GB | ✅ 主力(快速) |
| Qwen3.5-122B-A10B | 125B | 10B | MoE | ~81 GB | ✅ 主力(深度) |
| Qwen3.5-9B | 10B | 10B | Dense | ~6.6 GB | ✅ 備用 |
| Llama 4 Scout | 109B | 17B | MoE | ~62 GB | ✅ 非中國替代 |
| Llama 3.3 70B | 70B | 70B | Dense | ~40 GB | ✅ 非中國替代 |
| Mistral Large 2 | 123B | 123B | Dense | ~70 GB | ✅ 非中國替代 |
預設配置: Qwen3.5-35B (24GB) + Qwen3.5-122B (81GB) = 105GB,128GB 記憶體仍有 23GB 餘裕。
替代方案: 客戶不能用中國模型時,見docs/LLM_Model_Selection_Guide.md的 4 種替代組合。
有了 GX10 的 128GB 記憶體,地端模型可以承擔更多任務,大幅減少 Claude API 費用:
| 任務 | 模型 | 原因 |
|---|---|---|
| 告警摘要(即時) | Qwen3.5-35B-A3B | MoE 只用 3B 活躍參數,回應極快 |
| 每日報告(定時) | Qwen3.5-35B-A3B | 據報超越上代 Qwen3-235B,品質優秀 |
| 設備巡檢(定時) | Qwen3.5-35B-A3B | 結構化查詢 + 模板填充 |
| Supervisor 路由 | Qwen3.5-35B-A3B | 意圖辨識足夠準確 |
| 對話式查詢 | Qwen3.5-35B-A3B | 繁中自然語言理解最佳 |
| Log 根因分析 | Qwen3.5-122B-A10B | 10B 活躍參數 + 125B 知識,深度推理 |
| 複雜診斷報告 | Qwen3.5-122B-A10B 或 Claude Sonnet | 優先地端,品質不足時 fallback |
| 深度安全事件 | Claude Opus | 最強分析,需要時才用雲端 |
API 費用影響: GX10 + Qwen3.5 MoE 上線後,預估 90-95% 的任務可在地端完成。MoE 架構使得推理成本接近小模型,品質接近大模型。
// models/router.ts — GX10 作為 Ollama 主機
// Mastra Model Router 使用字串格式指定模型
// 預設:Qwen 3.5 MoE 系列
// 替代:客戶不能用中國模型時切換(見 LLM_Model_Selection_Guide.md)
const models = {
fast: "ollama/qwen3.5-35b-a3b", // MoE 3B 活躍,極快
strong: "ollama/qwen3.5-122b-a10b", // MoE 10B 活躍,深度推理
cloud: "anthropic/claude-sonnet-4-6", // 雲端
opus: "anthropic/claude-opus-4-6", // 極端場景
} as const;
// 非中國模型替代(取消註解即可切換)
// const models = {
// fast: "ollama/llama3.3-70b",
// strong: "ollama/mistral-large-123b",
// cloud: "anthropic/claude-sonnet-4-6",
// opus: "anthropic/claude-opus-4-6",
// } as const;
type TaskType = "alert_summary" | "scheduled_report" | "interactive" | "log_analysis" | "deep_diagnosis" | "supervisor";
export function selectModel(task: TaskType) {
const routing: Record<TaskType, keyof typeof models> = {
alert_summary: "fast", // Qwen3.5-35B — MoE 3B active, 極快
scheduled_report: "fast", // Qwen3.5-35B — 品質超越上代 235B
interactive: "fast", // Qwen3.5-35B — 繁中自然語言最佳
log_analysis: "strong", // Qwen3.5-122B — 深度推理
deep_diagnosis: "cloud", // Claude Sonnet — 最強推理
supervisor: "fast", // Qwen3.5-35B — 路由判斷
};
return models[routing[task]];
}
// Fallback chain — Agent 建立時使用
// model: [
// { model: "ollama/qwen3.5-35b-a3b", maxRetries: 2 },
// { model: "anthropic/claude-sonnet-4-6", maxRetries: 1 },
// ]
import { Agent } from "@mastra/core";
// GX10 不可用時 fallback 到 Claude — Mastra Model Router fallback chain
const agent = new Agent({
id: "example-with-fallback",
name: "Example Agent",
model: [
{ model: "ollama/qwen3.5-35b-a3b", maxRetries: 2 },
{ model: "anthropic/claude-sonnet-4-6", maxRetries: 1 },
],
instructions: "...",
tools: {},
});
注意: Mastra Model Router fallback chain 在 primary model 拋出錯誤(GX10 離線、timeout)時自動嘗試下一個模型,不會因回答品質差而觸發。
| 限制 | 影響 | 因應 |
|---|---|---|
Ollama 不支援 tool_choice |
無法強制呼叫特定 tool | Agent instructions 明確指示 |
| 7b 模型 tool calling 品質不穩 | 可能幻覺或漏呼叫 | 用 32b/70b 大幅改善 |
| 部分模型不支援 parallel tool calling | 多 tool 需多輪 | llama3.1 和 qwen3 支援 parallel |
GX10 的優勢: 因為記憶體充足,可以使用 32b/70b 模型,tool calling 品質遠優於 7b,接近 Claude Sonnet 水準。
Browser (Next.js) Server
┌───────────────────────┐ ┌──────────────────────────┐
│ useChat() hook │ │ POST /api/chat │
│ (@ai-sdk/react) │ SSE │ │
│ │ ←──→ │ mastra.getAgent() │
│ messages.parts.map() │ │ ↓ │
│ sendMessage() │ │ agent.stream(messages) │
│ status │ │ ↓ │
└───────────────────────┘ │ toDataStreamResponse() │
│ │
└──────────────────────────┘
// app/api/chat/route.ts
import { mastra } from "@/mastra";
export async function POST(req: Request) {
const { messages, threadId } = await req.json();
const agent = mastra.getAgent("supervisor");
const result = await agent.stream(messages, {
threadId,
resourceId: "user-1",
});
return result.toDataStreamResponse();
}
// src/mastra/index.ts — Mastra instance
import { Mastra } from "@mastra/core";
import { PostgresStore } from "@mastra/pg";
import { Memory } from "@mastra/memory";
import { supervisor } from "@/agents/supervisor";
import { librenmsAgent } from "@/agents/librenms-agent";
import { graylogAgent } from "@/agents/graylog-agent";
import { reportAgent } from "@/agents/report-agent";
export const mastra = new Mastra({
agents: { supervisor, librenmsAgent, graylogAgent, reportAgent },
storage: new PostgresStore({ connectionString: process.env.DATABASE_URL }),
memory: new Memory({
storage: new PostgresStore({ connectionString: process.env.DATABASE_URL }),
}),
});
// app/page.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react"; // v6: 從 @ai-sdk/react 匯入
export default function ChatPage() {
// v6 API: 不再有 input/handleSubmit/isLoading
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState(""); // 手動管理 input state
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input }); // v6: 用 sendMessage 取代 append
setInput("");
};
return (
<div className="flex flex-col h-screen bg-slate-50">
<header className="bg-cyan-700 text-white p-4">
<h1 className="text-lg font-bold">Arova IT Ops Agent</h1>
<p className="text-sm text-cyan-200">LibreNMS + Graylog 智慧監控</p>
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : ""}`}>
<div className={`max-w-[80%] p-3 rounded-lg ${
m.role === "user" ? "bg-cyan-600 text-white" : "bg-white shadow"
}`}>
{/* v6: 用 parts 渲染,不再用 m.content */}
{m.parts.map((part, i) => {
if (part.type === "text") return <span key={i}>{part.text}</span>;
if (part.type === "file" && part.mediaType === "application/pdf") {
return (
<a key={i} href={part.url} download className="block mt-2 text-cyan-600 underline">
📎 下載報告 PDF
</a>
);
}
// Tool 呼叫狀態顯示
if (part.type.startsWith("tool-")) {
if (part.state === "output-available") {
return <div key={i} className="text-xs text-slate-400 mt-1">✓ {part.type}</div>;
}
return <div key={i} className="text-xs text-slate-400 mt-1 animate-pulse">⏳ 執行中...</div>;
}
return null;
})}
</div>
</div>
))}
{status === "streaming" && (
<div className="text-slate-400 animate-pulse">思考中...</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t bg-white">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="問我任何 IT 維運問題..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-cyan-500"
disabled={status === "streaming"}
/>
</form>
</div>
);
}
AI SDK v6 沒有內建的檔案下載機制。Agent 產出 PDF 後,透過 tool result 回傳 URL:
// Tool 回傳報告路徑
return JSON.stringify({ pdfPath: "/reports/xxx.pdf", pdfUrl: "/api/reports/xxx.pdf" });
// Agent 回覆時會包含 URL → 前端渲染為下載連結
// app/api/reports/[filename]/route.ts — 靜態檔案服務
import { readFileSync } from "fs";
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { filename: string } }) {
const file = readFileSync(`./reports/${params.filename}`);
return new Response(file, {
headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${params.filename}"` },
});
}
| 排程 | 觸發時間 | 模型 | 產出 |
|---|---|---|---|
| 每日資安簡報 | 每天 08:00 | Ollama | HTML/PDF + Email |
| 設備健康巡檢 | 每天 08:30 | Ollama | HTML/PDF |
| 每週分析報告 | 每週一 09:00 | Claude Sonnet | HTML/PDF + Email |
| 每月資安報告 | 每月 3 日 09:00 | Claude Sonnet | HTML/PDF + Email |
| 設備同步檢查 | 每小時 | Ollama | 異常時通知 |
| 告警趨勢分析 | 每 4 小時 | Ollama | 異常時通知 |
// src/scheduler.ts
import { Queue, Worker } from "bullmq";
import { supervisor } from "./agents/supervisor";
const connection = { host: process.env.REDIS_HOST || "redis" };
const queue = new Queue("agent-tasks", { connection });
// 定義排程
await queue.add("daily-brief",
{ prompt: "產出過去 24 小時的資安簡報" },
{ repeat: { pattern: "0 8 * * *" } }
);
await queue.add("weekly-report",
{ prompt: "產出上週的完整分析週報" },
{ repeat: { pattern: "0 9 * * 1" } }
);
await queue.add("device-check",
{ prompt: "檢查所有設備健康狀態,標示異常" },
{ repeat: { pattern: "30 8 * * *" } }
);
// Worker
const worker = new Worker("agent-tasks", async (job) => {
const result = await supervisor.invoke({
messages: [{ role: "user", content: job.data.prompt }],
});
// 處理結果:寄 Email、Line 通知
}, { connection });
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ ASUS GX10 │ │ EWAI OMNI │ │ 監控系統 │
│ (AI 算力主機) │ │ (Agent + Chat UI) │ │ │
│ │ │ │ │ LibreNMS .202 │
│ Ollama │ │ Next.js + Agent │ │ Graylog .201 │
│ qwen2.5:32b │◄───►│ BullMQ + Redis │◄───►│ │
│ llama3.1:70b │:11434│ Puppeteer (PDF) │ API │ │
│ qwen2.5:7b │ │ │ │ │
│ │ │ Port 3000 (UI) │ │ │
└─────────────────┘ └──────────────────┘ └──────────────────┘
128GB RAM 4核 / 8GB 獨立主機
Blackwell GPU LAN 10.20.92.x
# docker-compose.yml — 部署在 EWAI OMNI (10.20.92.200)
# Ollama 跑在獨立的 GX10 主機,不在此 compose 中
services:
agent:
build: .
ports:
- "3000:3000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OLLAMA_BASE_URL=http://<gx10-ip>:11434 # GX10 AI 算力主機
- LIBRENMS_URL=http://100.98.129.83/api/v0
- LIBRENMS_TOKEN=${LIBRENMS_TOKEN}
- GRAYLOG_URL=http://100.98.20.188:9000/api
- GRAYLOG_TOKEN=${GRAYLOG_TOKEN}
- REDIS_HOST=redis
- CHROME_PATH=/usr/bin/chromium-browser
volumes:
- ./reports:/app/reports
- ./skills:/app/skills
- ./templates:/app/templates
depends_on:
- redis
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
redis_data:
# GX10 主機上(已預裝 Ollama)
# 預設方案:Qwen 3.5 MoE
ollama pull qwen3.5:35b-a3b # 快速回應 + 日常(~24GB)
ollama pull qwen3.5:122b-a10b # 深度推理(~81GB)
# 合計 ~105GB / 128GB,兩個模型同時載入
# 替代方案:客戶不能用中國模型時
# ollama pull llama3.3:70b # ~40GB
# ollama pull mistral-large:123b # ~70GB
# 或
# ollama pull llama4:scout # ~62GB
# ollama pull gemma3:27b # ~16GB
# 開放網路存取(預設只 listen localhost)
export OLLAMA_HOST=0.0.0.0
ollama serve
| 限制 | 影響 | 因應策略 |
|---|---|---|
| Mastra 無內建 cron 排程 | 需額外排程機制 | 使用 BullMQ + Redis 實作排程 |
| 外網被禁止時 Claude API 不可用 | Fallback 機制失效、深度分析無雲端可用 | 啟用「離線模式」— 見下方 9.1 |
| Ollama 與 Agent 分離部署 | 網路延遲增加(GX10 ↔ EWAI OMNI) | 同網段 LAN 延遲 <1ms,可忽略 |
| Ollama 7b 模型 tool calling 品質不穩 | 可能幻覺或漏呼叫 tool | 關鍵任務 fallback 到 Claude;定時報告用固定流程而非 Agent |
部分客戶環境完全禁止外網存取。系統必須支援 100% 地端運行。
啟用方式: 設定環境變數 OFFLINE_MODE=true
// models/router.ts — 離線模式(Mastra Model Router)
const OFFLINE = process.env.OFFLINE_MODE === "true";
const models = OFFLINE
? {
// 離線模式:全部走 GX10 Ollama,無雲端
fast: "ollama/qwen3.5-35b-a3b",
strong: "ollama/qwen3.5-122b-a10b",
cloud: "ollama/qwen3.5-122b-a10b", // 無雲端,用最強地端替代
opus: "ollama/qwen3.5-122b-a10b", // 同上
}
: {
// 正常模式:地端 + 雲端混合
fast: "ollama/qwen3.5-35b-a3b",
strong: "ollama/qwen3.5-122b-a10b",
cloud: "anthropic/claude-sonnet-4-6",
opus: "anthropic/claude-opus-4-6",
};
// Fallback chain — Agent 建立時使用 Mastra Model Router 陣列格式
const fallbackModel = OFFLINE
? [
{ model: "ollama/qwen3.5-122b-a10b", maxRetries: 2 },
{ model: "ollama/qwen3.5-35b-a3b", maxRetries: 1 }, // 大模型掛了用小模型
]
: [
{ model: "ollama/qwen3.5-35b-a3b", maxRetries: 2 },
{ model: "anthropic/claude-sonnet-4-6", maxRetries: 1 }, // 地端掛了用雲端
];
離線部署 Checklist:
| 項目 | 做法 | 需要先在有網路環境完成 |
|---|---|---|
| Ollama 模型 | 在有網路環境下載後,scp 到 GX10 |
ollama pull → /root/.ollama/models/ 打包搬移 |
| npm 套件 | npm ci 後打包 node_modules/ 一起部署 |
或用 private npm registry |
| Docker images | docker save → docker load |
在有網路環境 docker pull 並 save |
| Google Fonts | 改為本地字型 | 下載 .woff2 放入 assets/fonts/ |
| 通知方式 | 僅用內網 SMTP(禁用 Line Notify) | 設定 NOTIFY_LINE_ENABLED=false |
| 報告模板 | 字型引用改為相對路徑 | 修改 Handlebars 模板 |
字型離線化範例:
/* 原本(需外網) */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC');
/* 離線版 */
@font-face {
font-family: 'Noto Sans TC';
src: url('./fonts/NotoSansTC-Regular.woff2') format('woff2');
font-weight: 400;
}
@font-face {
font-family: 'Noto Sans TC';
src: url('./fonts/NotoSansTC-Bold.woff2') format('woff2');
font-weight: 700;
}
離線模式的能力影響:
| 能力 | 正常模式 | 離線模式 | 差異 |
|---|---|---|---|
| 設備查詢(LibreNMS) | ✅ | ✅ | 無差異(內網) |
| Log 搜尋(Graylog) | ✅ | ✅ | 無差異(內網) |
| 每日/週/月報告 | ✅ | ✅ | 無差異 |
| Dashboard 即時統計 | ✅ | ✅ | 無差異 |
| Chat UI 對話 | ✅ | ✅ | 無差異 |
| 根因分析品質 | Claude Sonnet(★★★★★) | Qwen3.5-122B(★★★★) | 稍降一級 |
| 深度安全事件分析 | Claude Opus(★★★★★) | Qwen3.5-122B(★★★★) | 稍降一級 |
| 繁中回覆品質 | ★★★★★ | ★★★★★ | Qwen 繁中反而更好 |
| Line 通知 | ✅ | ❌ 不可用 | 改用 Email |
| 外部 Email (Gmail) | ✅ | ❌ 不可用 | 改用內網 SMTP |
結論: 離線模式下,系統 95% 功能完全正常。唯一降級的是深度分析從 Claude 降為 Qwen3.5-122B,但 122B MoE 的品質仍然很高。繁體中文品質反而更好(Qwen 的 CJK 訓練資料更豐富)。
new Agent() 建立單一 Mastra Agent(先不分 multi-agent)npx tsx src/cli.ts "查所有設備狀態" → PDF@mastra/core Agent with all tools)@ai-sdk/react + Mastra native streamingagent.stream() + toDataStreamResponse())useChat v6 API + parts 渲染)OLLAMA_HOST=0.0.0.0 開放 EWAI OMNI 連線OFFLINE_MODE=true):Model Router 全走 Ollama、禁用 Claude/Linearova-it-ops-agent/
├── src/
│ ├── agents/
│ │ ├── supervisor.ts # Supervisor(@mastra/core Agent)
│ │ ├── librenms-agent.ts # LibreNMS Specialist
│ │ ├── graylog-agent.ts # Graylog Specialist
│ │ └── report-agent.ts # Report Specialist
│ ├── mastra/
│ │ └── index.ts # Mastra instance(agents, storage, memory)
│ ├── tools/
│ │ ├── registry.ts # Tool 註冊中心
│ │ ├── librenms.ts # LibreNMS API Tools(@mastra/core createTool)
│ │ ├── graylog.ts # Graylog API Tools
│ │ └── report.ts # Report Tool
│ ├── models/
│ │ └── router.ts # Model Router(Mastra string format + Fallback chain)
│ ├── skills/
│ │ └── loader.ts # Skill YAML 動態載入
│ ├── scheduler.ts # BullMQ 排程
│ └── cli.ts # CLI 入口
├── app/ # Next.js Chat UI
│ ├── api/
│ │ ├── chat/route.ts # Chat API(Mastra native streaming)
│ │ └── reports/[filename]/route.ts # PDF 下載
│ ├── page.tsx # Chat 前端(@ai-sdk/react useChat)
│ └── layout.tsx
├── skills/ # Skill YAML 定義
│ ├── librenms/skill.yaml
│ ├── graylog/skill.yaml
│ └── report/skill.yaml
├── templates/ # Handlebars 報告模板
│ ├── daily-brief.hbs
│ ├── weekly-report.hbs
│ └── default.hbs
├── reports/ # 產出的 HTML/PDF
├── docker-compose.yml
├── package.json
└── tsconfig.json