IT 維運 AI Agent 自動化系統 — 架構設計
Arova Nexus — Phase 0 Product Definition | 2026-03-31

版本: 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 任務
PDF 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

三、Multi-Agent Skills 架構

3.1 Supervisor + Specialist Agent

使用者:「查 TH-DCVer1 的 CPU,然後產報告」
         │
    Supervisor(意圖辨識)
         │
    ┌────┴────┐
    ▼         ▼
LibreNMS   Report
 Agent      Agent

3.2 Skill 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 模板產出繁體中文報告。

3.3 Agent 實作

// 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 },
});

3.4 Supervisor Agent

// 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,
  },
});

3.5 Skill 映射對照

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

3.6 動態 Skill 載入

// 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 定義

命名規則: Tool name 使用 snake_case(部分 model provider 不接受其他格式)

4.1 LibreNMS Tools

// 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"));
  },
});

4.2 Graylog Tools

// 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 });
  },
});

4.3 Report Tool

// 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 });
  },
});

五、混合模型策略

5.1 AI 算力主機:ASUS Ascent GX10

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

5.2 可運行的模型

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 種替代組合。

5.3 模型分配(升級版)

有了 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 架構使得推理成本接近小模型,品質接近大模型。

5.4 Ollama 部署在 GX10

// 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 },
// ]

5.5 Fallback 策略

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)時自動嘗試下一個模型,不會因回答品質差而觸發。

5.6 Ollama Tool Calling 限制

限制 影響 因應
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 水準。


六、Chat UI(Vercel AI SDK v6)

6.1 架構

Browser (Next.js)                    Server
┌───────────────────────┐      ┌──────────────────────────┐
│  useChat() hook       │      │  POST /api/chat          │
│  (@ai-sdk/react)      │ SSE  │                          │
│                       │ ←──→ │  mastra.getAgent()       │
│  messages.parts.map() │      │       ↓                  │
│  sendMessage()        │      │  agent.stream(messages)  │
│  status               │      │       ↓                  │
└───────────────────────┘      │  toDataStreamResponse()  │
                               │                          │
                               └──────────────────────────┘

6.2 API Route

// 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 }),
  }),
});

6.3 Chat 前端(AI SDK v6 API)

// 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>
  );
}

6.4 PDF 報告下載

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}"` },
  });
}

七、定時排程

7.1 任務清單

排程 觸發時間 模型 產出
每日資安簡報 每天 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 異常時通知

7.2 實作

// 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 上)

# 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 設定

# 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

9.1 離線模式(Air-Gapped / 外網禁止)

部分客戶環境完全禁止外網存取。系統必須支援 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 savedocker 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 訓練資料更豐富)。


十、開發路線圖

Phase 1:Core Agent + CLI(1 週)

Phase 2:Multi-Agent + Supervisor(0.5 週)

Phase 3:Chat UI(1 週)

Phase 4:排程 + 通知(0.5 週)

Phase 5:GX10 Ollama 整合(0.5 週)

Phase 6:擴展(持續)


十一、專案目錄結構

arova-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