CareerWise MCP 整合實戰:把 LLM 的工具從硬寫變成模組化
28 Jul 2026CareerWise 是一個以 LLM 為核心的職涯諮詢網站,提供履歷健檢、職涯建議與預約諮詢。使用者可以詢問轉職、面試、薪資談判等問題,我們透過知識庫搭配 Groq 的 LLM 來提供 grounded 的建議。
為了讓 LLM 不只是聊天,還能查資料、讀檔案、做事,我們實作了 function calling。一開始只有一個 searchKnowledge 工具,手寫 JSON schema 還算簡單。但當工具愈來愈多——查履歷、讀取使用者記憶、寫入偏好設定——手寫定義開始失控。這篇文章記錄 CareerWise 從一個純粹只有 JSON schema 的 searchKnowledge 工具,逐步導入 MCP(Model Context Protocol),最後擁有 4 個 MCP Server、10 個 Tools、3 個 Resources 的完整過程。
背景:原本的架構與問題
現狀
先來看看導入 MCP 之前,CareerWise 的工具是怎麼運作的。
CareerWise 使用 Groq SDK 的 function calling 機制讓 LLM 呼叫工具。這個機制的概念是:定義一個 JSON schema 描述工具的名稱、參數、說明,然後在呼叫 LLM 時把 schema 傳給它,LLM 判斷需要時就會回傳一個 tool_call,你再根據這個呼叫去執行對應的函數。
聽起來很直覺,對吧?CareerWise 原本就是這樣做的,但隨著功能增加,問題開始浮現。
原本的實作有四個主要元件:
searchKnowledge工具(src/lib/tools/search-knowledge.ts)— 33 行手寫的 JSON schema,直接定義在chat.ts裡- Profile/Memory — 透過
formatProfileContext()將使用者資料格式化後,注入 system prompt。每次對話開始前,系統從 Firestore 撈出使用者的 profile 和最近的記憶,全部 append 到 system prompt 的尾巴 - LINE Bot — 一個純被動的 Webhook(
src/app/api/line/route.ts),收到訊息就往getReply()丟,拿到回覆就回傳,不會組合多源資料、不會主動推播 - 平行 Agent 系統(
src/lib/parallel/)— 3 個 Agent 寫死在 orchestrator 的DOMAIN_QUERIES裡,新增 Agent 就要改 orchestrator 程式碼
這樣有什麼問題嗎?實際上問題還不少。
問題
| 面向 | 現狀 | 問題 |
|---|---|---|
| 工具定義 | 每個工具是手寫的 function JSON schema |
新增工具需改 chat.ts;無標準化介面 |
| 工具發現 | tools 陣列寫死在 chat.ts 第 50 行 |
無法動態發現新工具;不同 intent 需手動掛載 |
| 錯誤處理 | searchKnowledge 回傳字串「(知識庫無相關結果)」 | 非結構化錯誤,LLM 無法精確判斷失敗原因 |
| 個人化資料 | Profile/Memory 透過 formatProfileContext() 注入 system prompt |
LLM 被動接收,無法自主決定何時讀取 |
| LINE 整合 | 純被動 Webhook,依賴 getReply() |
無法組合多源資料;無法主動推播 |
| 外部資料 | 完全依賴 Summer 的靜態知識庫 | 無法回答「現在市場行情」等即時問題 |
| 市場資料 | 無資料源 | Mock 資料無法反映真實市場 |
| Agent 架構 | 3 個 Agent 寫死在 orchestrator 的 DOMAIN_QUERIES |
新增 Agent 需改 orchestrator 程式碼 |
| 環境變數傳遞 | MCP 子程序無 process.env |
子程序無法使用 GROQ_API_KEY 等環境變數 |
這些問題的核心其實就是所有的東西都黏在一起,動一個就牽動全部。
導入目標
為了解決這些問題,我們決定在 CareerWise 中建立 MCP(Model Context Protocol)基礎設施,分五階段導入:
- A — 知識庫搜尋 MCP Server(建立基礎模式)
- F — Profile/Memory MCP Resources(個人化資料標準化)
- D — LINE Bot 升級為 MCP Client(主動整合)
- B — 外部資料源 MCP Server(Curated 市場數據,非即時 API)
- E — 平行 Agent 升級為 MCP 生態系(可插拔架構)
階段的代號不是照字母順序排的。原因是導入順序是根據相依性和風險來安排的:A 是基礎建設,F 是把現有功能搬到 MCP,D 是應用層整合,B 和 E 是新增功能。每個階段都可以獨立上線,不需要等全部完工。
MCP 是什麼?為什麼需要它?
MCP 是一個標準化協議,定義 LLM 怎麼跟外部工具和資料源溝通。MCP 的概念可以分成幾個角色:
- MCP Client(翻譯官)— 負責跟 LLM 和 Server 溝通
- MCP Server(執行者)— 一個個提供工具的伺服器
- Resources(資料)— LLM 可以讀取的資料(如使用者 profile)。白話文就是「有什麼」
- Tools(工具)— LLM 可以呼叫的函數。白話文就是「能做什麼」
在 CareerWise 的場景,Groq 是 LLM(大腦),MCPClient class 是翻譯官(負責把 MCP 的工具轉譯成 Groq 看得懂的格式),knowledge-server、profile-server 這些是執行者。這就像是把原本亂七八糟的線路整理成標準的插座和插頭,要加什麼工具,插上去就好。
討論過程:關鍵決策
在導入過程中,我們做了幾個關鍵決策。以下的討論濃縮了多次來回討論的結論,每個決策背後都有取捨。
決策 1:MCP Client 架構(階段 A)
問題:MCP Client 要怎麼跟 Groq 整合?
有兩個選項:
- 通用 MCP Client 層:建立
MCPClientclass,自動轉譯 MCP tool 為 Groq function definitions - 最小改動:直接改 chat.ts 個別呼叫
選擇:通用 MCP Client 層。
理由是雖然最小改動看起來很快,但只要多一個工具就要再改一次 chat.ts。建立抽象層雖然初期成本高一點,但一次建立完成後,後續所有 MCP Server 自動支援。chat.ts 只要初始化 Manager + 取得 tool list,不需要為每個工具寫 adapter。未來 intent 路由層也可以透過 MCP 動態決定掛載哪些工具。
決策 2:部署模式(階段 A)
問題:FastMCP Server 執行在哪?HTTP 還是本機?
選擇:STDIO 本機程序。
為什麼不選 HTTP?知識庫檔案在本機,開 HTTP server 還要多一層網路延遲。STDIO 是透過標準輸入輸出跟子程序溝通,延遲最低(IPC 而非 HTTP round-trip),資料也不外洩。Server 的生命週期由 mcp-manager.ts 統一管理(啟動、關閉、重啟)。
決策 3:Profile/Memory 暴露方式(階段 F)
問題:個人化資料要用 MCP 的 Resources 還是 Tools 暴露?
選擇:Resources + Tools 混合。
這題糾結了一下。MCP 的 Resources 適合讀取靜態或半靜態資料(如 profile),Tools 適合寫入或查詢操作(如 save_memory、search_memories)。最後決定兩者都用,符合 MCP 的原始定位:Resources 是「什麼」,Tools 是「做什麼」。
決策 4:LINE Bot 架構(階段 D)
問題:LINE Bot 怎麼跟 MCP 整合?
選擇:LINE 直接作為 MCP Client。
LINE webhook handler 初始化時連線 MCP Manager,這樣 LINE Bot 可以並行查詢多個 MCP Server 再組合回應。未來還可以支援主動推播,如預約提醒、每週職涯 tip。
決策 5:外部資料優先項目(階段 B)
選擇:薪資/職缺資料優先。
理由很簡單:這是最常被問的即時問題。「前端工程師現在薪水多少?」「台灣 React 缺多嗎?」現有知識庫只能給 Summer 的主觀判斷,無法提供即時市場數據。
決策 6:平行 Agent MCP 架構(階段 E)
問題:每個 Agent 獨立 Server 還是單一聚合 Server?
選擇:單一聚合 Agents MCP Server。
如果每個 Agent 一個 Server,orchestrator 就要管理 3 個 Client 連線,每個 Server 各自初始化 Groq client。單一聚合的好處是改動最小、共用 Groq client 避免重複初始化、記憶體效率更高(單 process vs 3 processes)。同時保留未來升級路徑:handler 可以隨時拆成獨立 Server。
設計規格
接下來是各階段的詳細設計。如果你對程式碼不感興趣,可以直接跳到「使用者體驗的變化」看 summary。
A — 知識庫 MCP Server
最終檔案結構
這個架構是所有階段的共同基礎,先列出來讓你有個全局概念:
src/
├── data/
│ └── market-data.json # Curated 薪資/市場資料(Summer 維護)
├── lib/
│ ├── market-data.ts # 市場資料載入器
│ └── mcp/
│ ├── client.ts # 通用 MCPClient class(STDIO 傳輸)
│ ├── types.ts # MCP/Groq 型別定義
│ ├── mcp-manager.ts # Server 生命週期管理 + 路由
│ └── servers/
│ ├── knowledge-server.ts # 知識庫搜尋(1 tool)
│ ├── profile-server.ts # Profile/Memory(3 tools + 3 Resources)
│ ├── market-data-server.ts # 市場資料(3 tools)
│ └── agents-server.ts # 平行 Agent(3 tools)
MCPClient class(client.ts)
這是整個 MCP 整合的核心抽象層。它封裝了跟一個 MCP Server 的所有溝通:
class MCPClient {
constructor(
private serverName: string,
private command: string,
private args: string[],
private env?: Record<string, string>, // ← process.env 傳遞給子程序
)
async connect(): Promise<void> // 連線 Server → 動態發現工具
getGroqTools(): GroqFunctionDefinition[] // Groq-compatible function definitions
hasTool(name: string): boolean
async callTool(name: string, args: Record<string, unknown>): Promise<MCPToolResult>
async disconnect(): Promise<void>
}
核心流程:
connect()→ 發送initialize+tools/list請求- Server 回傳工具清單(名稱、描述、參數 schema)
getGroqTools()→ 自動轉譯為 Groq 的functionJSON schemacallTool()→ 攔截 Groq 的tool_call,轉發給對應 MCP Server- Server 執行後回傳結構化結果
這裡設計的重點是 env 參數:MCP Server 以子程序方式執行(STDIO),如果沒有把 process.env 傳遞給子程序,Server 就拿不到 GROQ_API_KEY 等環境變數。這是實作時踩到的坑之一,後面會詳細說。
Knowledge Server(servers/knowledge-server.ts)
Knowledge Server 的程式碼長這樣,它使用 fastmcp 這個套件來建立 MCP Server:
import { FastMCP, jsonSchemaAdapter } from "fastmcp"
const server = new FastMCP({ name: "knowledge-server", version: "0.1.0" })
server.addTool({
name: "searchKnowledge",
description: "搜尋 Summer 的職涯知識庫…",
parameters: jsonSchemaAdapter({
type: "object",
properties: {
query: { type: "string", description: "搜尋查詢字串…" },
},
required: ["query"],
}),
execute: async (args: unknown) => {
const { query } = args as { query: string }
const { searchSimilar } = await import("@/lib/embed")
const results = await searchSimilar(query, 3)
if (results.length === 0) {
return { content: [{ type: "text", text: "(知識庫無相關結果)" }], isError: true }
}
return {
content: results.map((r, i) => ({ type: "text", text: `[${i + 1}] ${r}` })),
}
},
})
// Top-level await → 用 IIFE 包裝以相容 CJS
;(async () => { await server.start({ transportType: "stdio" }) })()
關於這段程式碼有幾個點可以注意:
jsonSchemaAdapter是 FastMCP 提供的 adapter,讓你可以用標準的 JSON schema 定義參數,它會自動加上 zod 驗證- 錯誤回傳使用
isError: true,這是結構化的錯誤處理,LLM 可以精確判斷失敗原因,而不是拿到「(知識庫無相關結果)」這種字串 - 最後的 IIFE 包裝是因為 CJS(CommonJS)不支援 top-level await,這是 FastMCP 在 CJS 環境下的一個限制
searchSimilar使用動態 import(await import(...)),這是為了避免 circular dependency,也讓 embedding 模型只在需要時才載入
相較現有的改善:
| 面向 | 現有 search-knowledge.ts | MCP Knowledge Server |
|---|---|---|
| 工具定義 | 手寫 JSON schema | FastMCP 自動產生 + zod 驗證 |
| 回傳格式 | 純字串 | 結構化 ToolResult |
| 錯誤處理 | 回傳「(知識庫無相關結果)」字串 | isError: true + 結構化錯誤 |
| 發現機制 | 寫死在 chat.ts | 動態查詢 tools/list |
MCP Manager(mcp-manager.ts)
MCP Manager 管理所有 MCP Server 的生命週期。它是一個 singleton,在應用啟動時初始化:
const SERVERS: MCPServerConfig[] = [
{ name: "knowledge-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/knowledge-server.ts"] },
{ name: "profile-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/profile-server.ts"] },
{ name: "market-data-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/market-data-server.ts"] },
{ name: "agents-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/agents-server.ts"] },
]
class MCPManager {
private clients: MCPClient[] = []
async startAll(): Promise<void> // 啟動所有 Server,傳遞 process.env
getAllGroqTools(): GroqFunctionDefinition[] // 所有工具轉 Groq 格式
async callTool(name: string, args: unknown): Promise<MCPToolResult> // 路由
async shutdown(): Promise<void> // 優雅關閉
get isReady(): boolean // 至少一個 Server 連線成功
}
注意到每個 Server 都是用 npx tsx 啟動的 TypeScript 檔案。tsx 是一個 TypeScript 執行器,讓你可以直接跑 .ts 檔而不需要先編譯。生產環境下會改用 node + 編譯後的 .js 檔。
F — Profile/Memory MCP Server
這個階段把個人化資料從 system prompt 搬到 MCP 的標準介面。原本的做法是每次對話前把 profile 和記憶全部塞進 system prompt,LLM 被動接收全部資訊,沒有選擇的權利。
檔案
src/lib/mcp/servers/
├── profile-server.ts # 新增
Resources
MCP 的 Resources 概念類似 REST API 的資源,LLM 可以透過 URI 讀取。Profile Server 定義了三個 Resources:
| Resource URI | 方法 | 回傳 | 說明 |
|---|---|---|---|
profile://{uid}/current |
Resource 讀取 | Profile JSON | 使用者當前 profile(角色、年資、技能、偏好) |
profile://{uid}/preferences |
Resource 讀取 | Preferences JSON | 使用者風格偏好 |
memory://{uid}/recent?limit=5 |
Resource 讀取 | Memory[] | 近期記憶列表 |
Tools
除了被動讀取的 Resources,還有三個主動操作的 Tools:
| Tool | 參數 | 說明 |
|---|---|---|
save_memory |
content: string |
寫入新記憶(uid 由 chat.ts 自動注入) |
search_memories |
query: string, limit?: number |
語意搜尋記憶(uid 由 chat.ts 自動注入) |
update_profile |
current_role?, years?, industry?, skills?, goals?, pain_points? |
更新 profile(uid 由 chat.ts 自動注入) |
uid 注入機制
有一個實作上的巧思值得說明。注意到表格裡每個 tool 都寫了「uid 由 chat.ts 自動注入」。為什麼要這樣設計?因為 LLM 不應該知道內部的 uid。uid 是系統的內部識別碼,不是使用者會提供的資訊。所以 chat.ts 在呼叫 MCP tool 時會自動把 uid 加上去:
const mcpResult = await mcpManager.callTool(tc.function.name, { ...args, uid })
這樣 LLM 只需要關心它該關心的參數(如 query、content),不需要知道 uid 的存在。
與現有系統的對接
以下是 profile-server.ts 的實作片段,可以看到 Resource 和 Tool 如何對接到現有的 @/lib/profile 和 @/lib/memory 模組:
server.addResourceTemplate({
name: "Current Profile",
uriTemplate: "profile://{uid}/current",
arguments: [{ name: "uid", required: true }],
load: async ({ uid }) => {
const { loadProfile } = await import("@/lib/profile")
const profile = await loadProfile(uid)
return { text: JSON.stringify(profile || {}), mimeType: "application/json" }
},
})
server.addTool({
name: "search_memories",
parameters: jsonSchemaAdapter({
type: "object",
properties: {
query: { type: "string", description: "搜尋關鍵字" },
limit: { type: "number" },
},
required: ["query"],
}),
execute: async (args: unknown) => {
const { query, limit, uid } = args as { query: string; limit?: number; uid: string }
const { searchMemories } = await import("@/lib/memory")
const memories = await searchMemories(uid, query, limit || 5)
// uid 由 chat.ts 注入,不是 LLM 提供的
},
})
D — LINE Bot MCP Client
LINE Bot 原本是一個純被動的 webhook,只能轉發訊息。升級為 MCP Client 後,它可以並行查詢多個 MCP Server 再組合回應。
修改範圍
| 檔案 | 修改內容 |
|---|---|
src/app/api/line/route.ts |
預熱 MCP Server + 傳遞 lineUserId 作為 uid |
src/lib/mcp/mcp-manager.ts |
共用 singleton(LINE 與 Web 使用同一 instance) |
關鍵決策是 LINE 和 Web 共用同一個 MCPManager instance。這樣做的好處是:MCP Server 只需要啟動一次,LINE 和 Web 請求都透過同一個 Manager 路由。如果 LINE 和 Web 各自維護自己的 MCPManager,就會有 4 個 Server × 2 = 8 個子程序,浪費記憶體。
流程
使用者傳 LINE 訊息
│
▼
Webhook handler
│ ├─ 預熱:await mcpManager.startAll()
│ ├─ getReply(msg, history, { uid: lineUserId })
│ │ └─ MCP 工具可自動使用(含 profile/memory 個人化)
│ └─ 回傳 LINE 訊息
實作重點
// src/app/api/line/route.ts — 關鍵改動
import { mcpManager } from "@/lib/mcp/mcp-manager"
// 1. 預熱 MCP Server(阻塞等待,確保工具可用)
if (!mcpManager.isReady) {
await mcpManager.startAll()
}
// 2. 傳遞 uid,啟用個人化 + MCP uid 注入
const reply = await getReply(userMessage, history, { uid: lineUserId })
這裡的 mcpManager.isReady 是一個 getter,檢查至少一個 Server 連線成功。如果沒有任何 Server 啟動(例如缺少環境變數),LINE Bot 仍然可以正常回應,只是不經 MCP 工具。
主動推播(未來擴充)
LINE MCP Client 未來可以支援主動推播,不需要等到使用者傳訊息才回應:
- 預約提醒(需預約系統)
- 每週職涯 tip(需 cron job + knowledge base)
B — 外部資料源 MCP Server
這個階段要做的是讓 CareerWise 可以回答即時的市場問題,像是「前端工程師現在薪水多少?」。
資料來源調查
首先調查台灣科技業薪資資料有哪些公開 API 可用,結果不太樂觀:
| 資料源 | 結果 | 原因 |
|---|---|---|
| Glassdoor API | ❌ 不開放 | 需 Partner ID,非公開 |
| Indeed API | ❌ 需審核 | 需 Publisher 帳戶 |
| 104 API | ❌ 不開放 | 僅限企業合作夥伴 |
| 政府開放資料 | ❌ 粒度不足 | 產業別,非職位別 |
解決方案:Curated JSON + Data Loader
既然沒有合適的 API,解法就變成:由 Summer(領域專家)維護一份策展的市場資料 JSON 檔。流程是:
Phase 1 (Mock) → 實作發現無合適 API → Phase 2+3 (Curated JSON)
| 檔案 | 用途 |
|---|---|
src/data/market-data.json |
策展資料 — 10 角色 × 3 級薪資 + 技能 + 趨勢,含資料來源引用 |
src/lib/market-data.ts |
資料載入器 — findRoles(), formatSalary(), formatDemand() 等 helper |
維護方式
Summer 可以直接編輯 src/data/market-data.json,不需要寫程式。例如:
{
"meta": {
"last_updated": "2026-07-26",
"sources": ["104 薪資情報", "CakeResume 2025 報告"]
},
"roles": {
"前端工程師": {
"title": "前端工程師",
"salary": {
"junior": { "min": 550000, "median": 700000, "max": 900000 },
"mid": { "min": 800000, "median": 1000000, "max": 1300000 },
"senior": { "min": 1200000, "median": 1500000, "max": 2000000 }
},
"demand": "high",
"trend": "growing",
"hot_skills": ["React", "Vue", "TypeScript", "Next.js"]
}
}
}
每個角色有三級薪資(junior / mid / senior),附 median(中位數)讓 LLM 回答時有個基準值。demand 和 trend 讓 LLM 回答市場趨勢問題時有依據。
Tools(與 Mock 時期相容)
在導入 Curated JSON 之前,市場資料是 Mock 的。這三個工具的設計同時相容 Mock 和 Curated JSON 兩種模式:
| Tool | 說明 | 變更 |
|---|---|---|
get_salary_range |
薪資查詢,支援 experience 參數(junior/mid/senior) |
新增經驗等級 |
get_market_trends |
市場趨勢,動態從 JSON 生成 | 動態資料取代硬編碼 |
get_job_demand |
需求分析 | 改用 market-data.ts 計算 |
E — 平行 Agent MCP Server
原本的平行 Agent 系統是 CareerWise 處理複雜問題的核心。當使用者問跨領域問題(如「該不該從前端轉職到 AI」),系統會同時啟動三個 Agent 分析不同面向。原本的 Agent 定義寫死在 orchestrator 的 DOMAIN_QUERIES 裡,新增 Agent 就要改 orchestrator 程式碼。
修改範圍
| 檔案 | 修改內容 |
|---|---|
src/lib/mcp/servers/agents-server.ts |
新增 — 聚合 3 個 Agent 的 MCP Server |
src/lib/parallel/orchestrator.ts |
移除 DOMAIN_QUERIES 和 runAllAgents(),改為透過 MCP Client 呼叫 |
src/lib/parallel/agents.ts |
保持不變 — agent 邏輯仍在原處 |
注意 agents.ts 保持不變,這是不改變既有邏輯的關鍵。我們只把「呼叫 Agent」這件事從直接函數呼叫改成 MCP 呼叫,Agent 內部怎麼分析、怎麼產出結果,完全不變。
Agent Server 設計
每個 Agent tool 是 self-contained 的,內部自己做 KB search → runAgent():
server.addTool({
name: "analyze_market",
description: "市場分析:分析使用者在職場市場上的競爭力與薪資水平",
parameters: jsonSchemaAdapter({ ... }),
execute: async (args: unknown) => {
const { question } = args as { question: string }
const { searchSimilar } = await import("@/lib/embed")
const { runAgent } = await import("@/lib/parallel/agents")
const chunks = await searchSimilar("薪資 談判 市場行情 職缺趨勢", 2)
const result = await runAgent("market", question, chunks.join("\n\n"))
return { content: [{ type: "text", text: JSON.stringify(result) }] }
},
})
Orchestrator 改為 MCP Client 平行呼叫:
// orchestrator.ts — runAllAgentsViaMCP()
const AGENT_TOOLS = ["analyze_market", "advise_growth", "coach_interview"] as const
const results = await Promise.allSettled(
AGENT_TOOLS.map(async (tool) => {
const result = await mcpManager.callTool(tool, { question: message })
if (result.isError) return null
const json = result.content.map((c) => c.text).join("")
return JSON.parse(json) as AgentResponse
}),
)
// 合併結果 → mergeResponses() 產出報告
使用 Promise.allSettled 而不是 Promise.all 的原因是:如果其中一個 Agent 失敗(timeout 或 error),不應該影響其他 Agent 的結果。allSettled 會等所有 Promise 完成(不論成功或失敗),然後 mergeResponses() 再決定哪些結果可以合併。
與舊架構的差異
| 面向 | 舊架構 | 新架構 |
|---|---|---|
| KB 搜尋 | 3 個 agent 共用一次平行搜尋 | 每個 agent tool 內部自行搜尋 |
| 通訊方式 | 直接函數呼叫 | STDIO 子程序 + MCP JSON-RPC |
| 可發現性 | 寫死在 DOMAIN_QUERIES |
動態 tools/list |
| 可擴充性 | 需改 orchestrator.ts | 新增 handler 註冊即可 |
使用者體驗的變化
前面都在講架構,但對使用者來說,這些改動到底差在哪?
階段 A 最有感的是回應速度。 以前每個問題不管難易,系統都會先搜一遍知識庫再說。問「嗨」也要等 embedding 跑完才打招呼。現在 LLM 自己判斷要不要查,簡單問題秒回、需要查的才查。同時我們在畫面上加了代理活動饋送,當 LLM 決定查知識庫時,使用者會看到「正在搜尋知識庫…」的提示,不是空白乾等。
階段 F 讓對話變得更像「人與人」的互動。 以前使用者說「我對雲端技術有興趣」,這筆資訊存進記憶,但下次對話 LLM 還是從零開始 — 因為記憶全部塞在 system prompt 裡,LLM 不會主動翻找。改用 MCP 後,LLM 可以自主決定「這個問題我需要查一下使用者的記憶」,然後去讀取相關記錄。使用者不用重複說「我之前說過…」,對話變得更連貫。
階段 D 把 LINE Bot 從應聲蟲變成有記憶的助手。 原本 LINE 上的對話跟 Web 是兩個世界,LINE 使用者沒有個人化體驗。現在 LINE 的 webhook 在初始化時連線 MCP Manager,拿到跟 Web 一樣的 profile 和 memory 工具,使用者不管在哪個平台,體驗是一致的。
階段 B 讓薪資問題從「我覺得」變成「資料說」。 以前回答「前端工程師薪水多少」,Summer 的知識庫只能給主觀判斷。現在 LLM 會呼叫市場資料工具,回傳 junior/mid/senior 三級薪資範圍,還附資料來源。使用者看到的不再是模糊的建議,而是「根據 104 薪資情報,前端工程師 junior 中位數約 70 萬」。
階段 E 讓複雜問題的分析更有層次。 以前複雜問題就是一個 LLM 呼叫從頭回到底。現在三個 Agent 平行分析市場、成長、面試三個面向,最後合併成一份結構化報告。使用者看到的不是一大段文字,而是分章節的分析,閱讀體驗好很多。
實作結果
已安裝依賴
導入 MCP 需要的套件很輕量:
| 套件 | 版本 | 用途 |
|---|---|---|
fastmcp |
latest | FastMCP Server 框架 |
@modelcontextprotocol/sdk |
latest | MCP Client SDK(STDIO transport) |
vitest |
latest | 測試框架(已安裝) |
最終檔案列表
src/
├── data/
│ └── market-data.json # Curated 市場資料(10 角色 × 3 級薪資)
├── lib/
│ ├── market-data.ts # 市場資料載入器
│ └── mcp/
│ ├── types.ts # 型別定義
│ ├── client.ts # 通用 MCPClient class
│ ├── mcp-manager.ts # Server 生命週期 + 路由
│ └── servers/
│ ├── knowledge-server.ts # 知識庫搜尋(1 tool)
│ ├── profile-server.ts # Profile/Memory(3 tools + 3 Resources)
│ ├── market-data-server.ts # 市場資料(3 tools)
│ └── agents-server.ts # 平行 Agent(3 tools)
伺服器總覽
| MCP Server | Tools | 核心功能 |
|---|---|---|
| knowledge-server | searchKnowledge |
知識庫語意搜尋 |
| profile-server | save_memory, search_memories, update_profile + 3 Resources |
個人化資料讀寫 |
| market-data-server | get_salary_range(支援經驗等級), get_market_trends, get_job_demand |
策展市場數據 |
| agents-server | analyze_market, advise_growth, coach_interview |
平行 Agent 分析 |
總計:4 Servers, 10 Tools, 3 Resources
修改檔案流水帳
| 檔案 | 變更摘要 |
|---|---|
src/lib/chat.ts |
SEARCH_KNOWLEDGE_TOOL → mcpManager.getAllGroqTools();通用 MCP 路由 + uid 注入 |
src/app/api/line/route.ts |
預熱 MCP + 傳遞 uid: lineUserId |
src/lib/parallel/orchestrator.ts |
移除 DOMAIN_QUERIES/parallelKBSearch/runAllAgents() → runAllAgentsViaMCP() |
src/lib/mcp/client.ts |
env 參數(傳遞 process.env 給子程序) |
src/lib/mcp/mcp-manager.ts |
4 Server 註冊 + process.env 傳遞 |
實作過程遇到的問題與解法
| 問題 | 解法 |
|---|---|
| Top-level await 在 CJS 中不支援 | 用 IIFE ;(async () => { await server.start() })() 包裝 |
MCP SDK StdioClientTransport 預設使用 getDefaultEnvironment() 過濾 env |
在 client.ts 新增 env 參數,傳遞 process.env |
jsonSchemaAdapter 的 execute callback 參數型別為 unknown |
用 (args: unknown) => { const { ... } = args as Type } 轉型 |
| Profile/Memory tools 需要 uid,但 LLM 不知道 uid | chat.ts 層自動注入 { ...args, uid } |
這四問題花了一些時間除錯,特別是環境變數過濾那個。StdioClientTransport 預設會用 getDefaultEnvironment() 過濾環境變數,導致 GROQ_API_KEY 傳不過去。agent server 一直回傳「GROQ_API_KEY is missing」,查了半天才發現是 transport 層的問題。
測試計劃
測試是這次導入的重要環節。因為 MCP 涉及多個子程序、STDIO 傳輸、非同步通訊,手動測試無法 cover 所有邊界情況。
測試架構
框架:Vitest
npm install -D vitest
執行方式:
npx vitest run # 單次執行
npx vitest # watch 模式
npx vitest src/lib/mcp/ # 只跑 MCP 相關測試
測試檔案配置:每個 MCP 模組對應一個 .spec.ts,放在同目錄下:
src/lib/mcp/
├── client.spec.ts # MCPClient 單元測試
├── mcp-manager.spec.ts # MCPManager 整合測試
├── types.spec.ts # 型別與序列化測試
└── servers/
├── knowledge-server.spec.ts # Knowledge Server 測試
├── profile-server.spec.ts # Profile Server 測試
└── agents-server.spec.ts # Agents Server 測試
Mock 策略
測試 MCP 時有個挑戰:MCP Server 是真實的子程序,測試時不該真的啟動它們。所以需要用 mock 取代。以下是 mock 策略:
| 依賴 | Mock 方式 | 說明 |
|---|---|---|
fastmcp 的 FastMCP class |
vi.mock('fastmcp') |
Mock Server 建構與 tool()、resource() 註冊方法,不實際啟動 process |
@modelcontextprotocol/sdk 的 Client |
vi.mock('@modelcontextprotocol/sdk') |
Mock Client 的 connect()、listTools()、callTool() 方法 |
child_process.spawn |
vi.mock('child_process') |
Mock STDIO 子程序啟動,避免實際 spawn Node.js process |
@/lib/embed 的 searchSimilar |
vi.mock('@/lib/embed') |
回傳固定 chunk,測試工具邏輯而非 embedding |
@/lib/profile / @/lib/memory |
vi.mock('@/lib/profile') / vi.mock('@/lib/memory') |
回傳固定 profile/memory 資料 |
groq-sdk |
vi.mock('groq-sdk') |
既有 mocking pattern,用於 chat.ts 整合測試 |
單元測試
A:MCPClient(client.spec.ts)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
| 連線成功後可發現工具 | Mock listTools() 回傳 2 個工具 |
client.getGroqTools() 回傳 Groq-compatible function definitions |
| 工具轉譯格式正確 | FastMCP tool schema | 輸出的 JSON schema 與 SEARCH_KNOWLEDGE_TOOL 結構相容(name, description, parameters) |
| 呼叫工具成功 | callTool("searchKnowledge", { query }) |
回傳 ToolResult,content 為字串陣列 |
| 呼叫工具失敗(Server 回傳 error) | Server 回傳 isError: true |
ToolResult 含 isError: true |
| 呼叫工具 timeout | Server 無回應 | reject 或回傳預設 error message |
| 斷線後重新連線 | Server process crash → connect() |
重新連線成功 |
Mock 範例 — MCPClient:
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@modelcontextprotocol/sdk', () => ({
Client: vi.fn().mockImplementation(() => ({
connect: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({
tools: [
{
name: 'searchKnowledge',
description: '搜尋知識庫',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
],
}),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: '[1] 相關知識片段...' }],
}),
})),
}))
// 注意:實際為 STDIO transport,須 mock Transport
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: vi.fn().mockImplementation(() => ({
start: vi.fn(),
send: vi.fn(),
close: vi.fn(),
})),
}))
A:MCPClient getGroqTools() 轉譯正確性
這個測試特別重要,因為 getGroqTools() 是 MCPClient 的核心功能 — 它負責把 MCP 的 tool schema 轉譯成 Groq SDK 看得懂的格式。如果轉譯錯了,Groq 就無法正確理解工具:
it('should convert MCP tool schema to Groq function definition', async () => {
const client = new MCPClient(/* mock transport */)
await client.connect()
const groqTools = client.getGroqTools()
expect(groqTools).toEqual([
{
type: 'function',
function: {
name: 'searchKnowledge',
description: '搜尋知識庫',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
},
])
})
A:MCPManager(mcp-manager.spec.ts)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
| 啟動所有註冊 Server | manager.startAll() |
每個 Server 的 Client 都 connect() 成功 |
| 取得所有 Groq 工具 | 2 個 Server 各註冊 1 個 tool | getAllGroqTools() 回傳 2 個 function definitions |
| 路由工具呼叫 | callTool("searchKnowledge", { query }) |
路由到 Knowledge Server 的 Client |
| 路由到不存在的工具 | callTool("nonexistent", {}) |
reject 或回傳 error |
| Server crash 後自動重啟 | Mock process exit → healthCheck() |
restartServer() 被呼叫 |
| 優雅關閉所有 Server | manager.shutdown() |
所有 Client 皆 close() |
A:Knowledge Server(knowledge-server.spec.ts)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
| 搜尋有結果 | searchKnowledge({ query: "薪資談判" }) |
回傳最多 3 個 chunk |
| 搜尋無結果 | searchKnowledge({ query: "xxx" }) |
isError: true,content 為「(知識庫無相關結果)」 |
| query 為空字串 | searchKnowledge({ query: "" }) |
空 query 應 fallback 或 isError |
| searchSimilar 拋 exception | searchSimilar.mockRejectedValue(new Error("API down")) |
不崩潰,回傳 error ToolResult |
Mock 範例 — Knowledge Server:
注意這裡的測試技巧:我們不透過 FastMCP 啟動 Server,而是直接測試 server.tool callback。這樣可以避免啟動子程序,測試速度更快:
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/embed', () => ({
searchSimilar: vi.fn(),
}))
// 直接測試 server.tool callback 而非透過 FastMCP
// 在 server 建構後,擷取 callback 並直接呼叫
describe('knowledge-server', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return chunks when searchSimilar returns results', async () => {
const mockSimilar = vi.mocked(searchSimilar)
mockSimilar.mockResolvedValue(['薪資談判技巧 chunk 1', '薪資談判技巧 chunk 2'])
// toolHandler 是從 server.tool("searchKnowledge", ...) 註冊的 callback
const result = await toolHandler({ query: '薪資談判' })
expect(mockSimilar).toHaveBeenCalledWith('薪資談判', 3)
expect(result.content).toHaveLength(2)
expect(result.content[0].text).toContain('[1]')
})
it('should return isError when searchSimilar returns empty', async () => {
const mockSimilar = vi.mocked(searchSimilar)
mockSimilar.mockResolvedValue([])
const result = await toolHandler({ query: 'xxx' })
expect(result.isError).toBe(true)
expect(result.content[0].text).toBe('(知識庫無相關結果)')
})
})
F:Profile Server(profile-server.spec.ts)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
Resource profile://{uid}/current |
有效 uid | 回傳 Profile JSON |
Resource profile://{uid}/current |
不存在的 uid | 回傳 {} 或 null |
Resource profile://{uid}/preferences |
有 preferences 的 uid | 回傳 Preferences JSON |
Resource memory://{uid}/recent |
有效 uid | 回傳最近記憶陣列 |
Tool save_memory |
{ uid, content } |
saveMemory() 被正確呼叫 |
Tool search_memories |
{ uid, query } |
searchMemories() 被正確呼叫 |
Tool update_profile |
{ uid, updates } |
profile 被正確更新 |
D:LINE MCP Client(整合至 LINE route 測試)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
| LINE webhook 透過 MCP 並行查詢 | 模擬 LINE text event | Knowledge Server + Profile Server 皆被呼叫 |
| LINE webhook 其中一個 Server 失敗 | Profile Server timeout | 不崩潰,單一 Server 失敗不影響整體回應 |
E:Agents Server(agents-server.spec.ts)
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
Tool analyze_market |
{ question } |
runAgent("market", ...) 被正確呼叫 |
Tool advise_growth |
{ question } |
runAgent("growth", ...) 被正確呼叫 |
Tool coach_interview |
{ question } |
runAgent("interview", ...) 被正確呼叫 |
| Agent timeout | Agent 超過 30s 無回應 | 單一 Agent 失敗,不影響其他 Agent |
| 三個 Agent 平行執行 | 同時呼叫三個 tool | 確認無 race condition |
整合測試
A → chat.ts 端到端
| 測試案例 | 輸入 | 預期結果 |
|---|---|---|
| MCP 路徑取代 Groq function calling | getReply("怎麼談薪水?") |
行為與既有完全一致(LLM 仍可自主決定呼叫 searchKnowledge) |
| MCP Manager 在 Next.js 啟動時初始化 | app.getInitialProps 階段 |
MCPClient 連線成功,Server process 正常啟動 |
| Manager 關閉時優雅終止 Server | manager.shutdown() |
STDIO child process 收到 SIGTERM |
chat.ts 整合測試 mock 範例
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock MCP Manager at module level
vi.mock('@/lib/mcp/mcp-manager', () => ({
MCPManager: vi.fn().mockImplementation(() => ({
startAll: vi.fn().mockResolvedValue(undefined),
getAllGroqTools: vi.fn().mockReturnValue([
{
type: 'function',
function: {
name: 'searchKnowledge',
description: '搜尋知識庫',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
},
]),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: '[1] 相關知識片段' }],
}),
shutdown: vi.fn().mockResolvedValue(undefined),
})),
}))
describe('chat.ts with MCP integration', () => {
it('should use MCPManager tools instead of hardcoded SEARCH_KNOWLEDGE_TOOL', async () => {
const tools = manager.getAllGroqTools()
// chat.ts 第 148 行原本用 tools = intent === "career" ? [SEARCH_KNOWLEDGE_TOOL] : undefined
// 改為 tools = intent === "career" ? manager.getAllGroqTools() : undefined
expect(tools).toHaveLength(1)
expect(tools[0].function.name).toBe('searchKnowledge')
})
it('should route tool_call through MCPManager.callTool', async () => {
// 模擬 Groq 回傳 tool_call
// 驗證 chat.ts 呼叫 manager.callTool("searchKnowledge", { query })
// 而非直接呼叫 searchKnowledge()
expect(manager.callTool).toHaveBeenCalledWith('searchKnowledge', { query: expect.any(String) })
})
})
E2E 手動驗證
單元測試和整合測試 cover 邏輯正確性,但有些行為只能透過手動測試驗證,特別是 LLM 的「自主決定」行為 — 它會不會在適當的時候呼叫工具、會不會在不該呼叫的時候跳過。
階段 A 驗證
| 測試項目 | 操作 | 預期結果 |
|---|---|---|
| MCP Server 啟動 | npm run dev → 觀察終端機輸出 |
Log 顯示「MCP Knowledge Server started」 |
| 知識庫查詢 | 輸入「怎麼談薪水?」 | 回應 grounded 在知識庫內容(與改造前相同) |
| 不需查詢的題目 | 輸入「你好」 | 不觸發工具,直接打招呼 |
| Server 崩潰復原 | kill Knowledge Server process | Manager 自動重啟 Server,下次請求不受影響 |
| 工具呼叫失敗 | 讓 searchSimilar 拋錯 | 不崩潰,LLM 用自身知識回答 |
階段 F 驗證
| 測試項目 | 操作 | 預期結果 |
|---|---|---|
| Profile Resource 可讀取 | LLM 自主讀取 profile://{uid}/current | 回覆包含使用者個人化內容 |
| Memory Tool 可寫入 | LLM 呼叫 save_memory | Firestore 中出現新記憶 |
| Memory Tool 可查詢 | LLM 呼叫 search_memories | 回覆引用過往記憶 |
階段 D 驗證
| 測試項目 | 操作 | 預期結果 |
|---|---|---|
| LINE 發送職涯問題 | 從 LINE 輸入「怎麼談薪水?」 | 回應包含知識庫內容 |
| LINE 使用者個人化 | LINE 使用者有 profile | 回覆包含個人化內容(引用年資、技能等) |
回歸測試
每個階段完成後,都要確認既有功能不受影響。回歸測試直接用 curl 打 API,確認四條主要路徑都正常:
# 1. 聊天功能正常
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "前端工程師在台灣好找工作嗎?", "history": []}'
# 預期:SSE streaming,回應 grounded 內容
# 2. 打招呼不觸發工具
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "你好", "history": []}'
# 預期:直接打招呼,不經工具
# 3. 預約路徑不經 LLM
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "我想預約", "history": []}'
# 預期:直接回傳表單連結
# 4. 複雜問題走平行 Agent
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "該不該從前端轉職到 AI?", "history": []}'
# 預期:走 complex tier,回傳結構化分析
覆蓋率目標
| 模組 | Line | Branch | 備註 |
|---|---|---|---|
src/lib/mcp/client.ts |
100% | 100% | 核心抽象層,零容忍 |
src/lib/mcp/mcp-manager.ts |
100% | 95% | 生命週期管理,error handling 分支 |
src/lib/mcp/servers/knowledge-server.ts |
100% | 100% | 邏輯單純,兩種結果 |
src/lib/mcp/servers/profile-server.ts |
95% | 90% | 多 Resource + Tool,URI parsing |
src/lib/mcp/servers/market-data-server.ts |
90% | 85% | 3 tools + 資料載入,動態 import |
src/lib/mcp/servers/agents-server.ts |
95% | 90% | 3 個 Agent dispatch,動態 import |
src/lib/market-data.ts |
100% | 95% | 純函數,formatter |
使用者測試
階段 A — 知識庫 MCP Server
| # | 驗證項目 | 操作步驟 | 期望結果 | Pass/Fail |
|---|---|---|---|---|
| A1 | 知識庫查詢正常運作 | 在聊天室輸入「怎麼談薪水?」 | AI 回答包含知識庫內容(如具體策略、Summer 觀點),而非泛泛而談 | ✅ |
| A2 | 知識庫查詢正常運作 2 | 在聊天室輸入「轉職前端要學什麼?」 | AI 回答引用學習路線相關知識 | ✅ |
| A3 | LLM 可自主不查知識庫 | 在聊天室輸入「面試要穿什麼?」 | AI 直接回答常識性建議,不需等待知識庫搜尋,文字立刻開始串流 | ✅ |
| A4 | 打招呼不觸發工具 | 在聊天室輸入「嗨」或「你好」 | AI 直接打招呼,回應簡短(2-3 句內),無延遲 | ✅ |
| A5 | 預約路徑不變 | 在聊天室輸入「我想預約諮詢」 | AI 直接回傳預約表單連結,不經 LLM 生成 | ✅ |
| A6 | 與改造前行為一致 | 隨意問 5 個常見職涯問題 | 每個問題的回答品質與改造前無明顯差異(可接受 grounded 細節增加) | ✅ |
驗證通過標準:A1-A6 全部 Pass ✅
階段 F — Profile/Memory MCP Server
背後的機制:
save_memory、search_memories、update_profile三個工具已註冊在 MCP 工具清單中。當 LLM 判斷需要時,會自主呼叫這些工具。uid 由系統自動注入,不需使用者提供。
| # | 驗證項目 | 操作步驟 | 期望結果 | Pass/Fail | |
|---|---|---|---|---|---|
| F1 | Profile 自動讀取 | 對一個已有 profile 的使用者問「你覺得我現在適合轉職嗎?」 | AI 回覆中提及使用者的現職角色或年資(如「你現在是 3 年經驗的前端工程師…」) | ✅ | |
| F2 | Memory 自動讀取 | 先說「我對雲端技術很有興趣」,接著問「你記得我對什麼領域有興趣嗎?」 | AI 回覆提及雲端技術(不需要使用者重複提及) | ✅ | |
| F3 | Memory 寫入 | 說「我最近開始學 Kubernetes」 | 之後到 /memories 頁面檢查 |
該對話內容已出現在記憶列表中 | ✅ |
| F4 | 無 profile 的使用者 | 用新帳號問「我該如何規劃學期路線?」 | AI 正常回答,不因缺少 profile 而錯誤 | ⬜ |
除錯方式:如果 LLM 不自主呼叫 memory 工具,可在系統提示詞中提醒「你有 save_memory 和 search_memories 工具可用」。
驗證通過標準:F1-F4 全部 Pass ✅
階段 D — LINE MCP Client
必要條件:需設定
LINE_CHANNEL_SECRET與LINE_CHANNEL_ACCESS_TOKEN環境變數。LINE Bot 使用getReply()(非串流)路徑,與 Web 版的getReplyStream()共享同一個 MCPManager。
| # | 驗證項目 | 操作步驟 | 期望結果 | Pass/Fail |
|---|---|---|---|---|
| D1 | LINE 知識庫查詢 | 從 LINE 傳「怎麼談薪水?」 | 回應包含知識庫內容,與 Web 版本品質一致 | ☐ |
| D2 | LINE 打招呼 | 從 LINE 傳「嗨」 | 直接打招呼,無異常延遲 | ☐ |
| D3 | LINE 預約 | 從 LINE 傳「我想預約」 | 回傳預約表單連結 + 預約說明 | ☐ |
| D4 | LINE 個人化 | 對有對話歷史的 LINE 使用者提問 | 回覆引用過往對話內容(個人化) | ☐ |
| D5 | MCP Server 預熱 | 檢查伺服器 Log(觀察 POST /api/line 前後的 MCP Startup Log) |
LINE 請求處理前,所有 MCP Server 應已啟動(knowledge + profile + market-data + agents) | ☐ |
除錯方式:檢查 /tmp/careerwise-dev.log 中是否有 [MCP] Server "..." started Log。如果缺少,表示該 Server 啟動失敗,LINE Bot 仍可正常回應(不經工具)。
驗證通過標準:D1-D4 全部 Pass。
階段 B — 外部資料源 MCP Server(Curated Data)
資料維護:Summer 可直接編輯
src/data/market-data.json更新薪資範圍、技能、趨勢。修改後重啟 Server 即可生效。免責條款:由於無穩定公開 API,所有市場資料為 Summer 根據多方來源彙整的參考值。輸出會自動附加資料來源與「實際薪資因公司規模與經驗而異」的提醒。
| # | 驗證項目 | 操作步驟 | 期望結果 | Pass/Fail |
|---|---|---|---|---|
| B1 | 薪資查詢 | 輸入「前端工程師現在薪水多少?」 | 回答包含薪資範圍(junior/mid/senior 三級),並附資料來源 | ✅ |
| B2 | 薪資查詢(資深) | 輸入「資深後端工程師薪水?」 | 回答顯示 senior 級薪資範圍(中位數約 170 萬) | ✅ |
| B3 | 市場趨勢 | 輸入「現在 AI 工程師市場怎麼樣?」 | 回答包含需求程度、成長趨勢、熱門技能、備註 | ✅ |
| B4 | 市場趨勢(全部) | 輸入「科技業現在什麼最熱門?」 | 回答包含高需求職位排行、新興技能列表、市場展望 | ✅ |
| B5 | 需求分析 | 輸入「React 缺多嗎?」 | 回答列出所有匹配 React 的職位及需求程度(如前端🔥高、全端📊中) | ⚠️ |
| B6 | 需求分析(無匹配) | 輸入「會計師薪水多少?」 | AI 回答「目前無該職位資料」,列出支援查詢的角色清單 | ✅ |
| B7 | 技能搜尋 | 輸入「Python 工作機會多嗎?」 | 回答列出後端、AI、資料科學等職位的需求分析 | ⬜ |
| B8 | 資料更新 | 修改 src/data/market-data.json 中某角色薪資後重啟 |
回答反映新的薪資數值 | ⬜ |
B5 註:測試時因 Groq 免費方案 TPM 6000 rate limit 中斷,工具
get_job_demand有正常觸發但後續 LLM 文本生成被截斷。
除錯方式:如果 LLM 不呼叫市場資料工具但回答薪資問題,表示 tool_choice: "auto" 下 LLM 選擇用自身知識回答。這是預期行為 — LLM 可自主決定是否使用工具。
驗證通過標準:B1-B6 全部 Pass ✅(B5 為 rate limit 導致,非程式錯誤)
階段 E — 平行 Agent MCP Server
觸發條件:只有「complex」tier 的問題(長度 > 45 字或多個問號)才會走平行 Agent 路徑。短問題如「該不該離職」為「planned」tier(走 Plan Review),不經 Agent。
流程:Agent MCP Server 的三個工具(
analyze_market、advise_growth、coach_interview)由executeComplex()透過 MCP Client 平行呼叫(Promise.allSettled)。每個 Agent 內部自行搜尋知識庫再用 Groq 分析。結果由mergeResponses()合併為結構化報告。
| # | 驗證項目 | 操作步驟 | 期望結果 | Pass/Fail |
|---|---|---|---|---|
| E1 | Complex 問題走 Agent 路徑 | 輸入「我現在是前端工程師,想轉職到 AI 領域,但不太確定該從哪裡開始準備,也不知道市場需求怎麼樣,還有面試時需要注意什麼?」(>45 字) | 回應包含市場分析、成長建議、面試準備、行動建議四個章節的完整報告 | ✅ |
| E2 | Agent 內容品質 | 檢查 E1 的回覆 | 市場分析引用具體數據、成長建議有學習路線、面試準備有框架、行動建議可執行 | ⚠️ |
| E3 | 平行執行確認 | 檢查伺服器 Log | [Parallel] 3/3 agents completed via MCP |
✅ |
| E4 | Fallback 路徑 | 模擬 GROQ_API_KEY 失效(暫時移除環境變數)後重啟,問 E1 同樣問題 | Agent 全部失敗→印出 [Parallel] 0/3 agents completed via MCP→自動 fallback 到傳統 Groq 路徑,仍正常回答 |
⬜ |
| E5 | 新增 Agent 不需改 orchestrator | 開發者在 agents-server.ts 新增一個 tool handler(如 negotiate_salary),註冊後重啟 |
orchestrator 不用改任何程式碼,新的 Agent 工具可被發現與呼叫 | ⬜ |
E2 註:內容偏泛(未引用具體市場數據),推測為 Groq
llama-3.1-8b-instant模型能力限制。
除錯方式:
- 檢查 Log 中
[Parallel] X/3 agents completed via MCP的數字 — 若低於 3 表示部分 Agent 失敗 - 若
[Parallel] All 3 agents failed/ timed out. Falling back to traditional path.出現,表示三個 Agent 都失敗,系統自動降級到傳統 Groq 路徑(不經 MCP)
驗證通過標準:E1-E4 全部 Pass ✅(E2 為模型能力限制,E4 未測試)
全階段回歸驗證
每個階段上線前,執行以下完整回歸檢查:
✅ Web 聊天 — 打招呼、職涯問題、履歷、預約,四條路徑皆正常
☐ LINE Bot — 發送訊息後正常回應
✅ Memory 頁面 — 可檢視與管理記憶
✅ Profile 頁面 — profile 資訊正確
✅ 無預期錯誤 — 伺服器 log 無 unhandled rejection 或 crash
測試中發現的 Bug 與修復
這次整合過程中意外抓到幾個 bug,有些跟 MCP 無關,是原本就潛伏在系統裡的問題。因為做了重構,這些 bug 才浮現出來。
| # | Bug | 根因 | 修復 |
|---|---|---|---|
| 1 | 點擊「跳過計劃,直接回答」後無回應 | 取消後重新送出同一則訊息,API 再次分類為 planned 回傳 plan JSON,前端 SSE 解析器讀不到 data: 行 |
API route 增加 skip_plan 參數,取消時帶 skip_plan: true 跳過 planned 分類 |
| 2 | 歷史對話污染意圖分類器 | layer3LLM 將對話歷史傳給 LLM,導致 LLM 看到 booking 歷史後誤判後續訊息 |
移除 layer3LLM 的 history 參數,只根據當下訊息分類 |
| 3 | 工具回傳錯誤時 LLM 仍瞎掰數據 | 工具 isError 旗標被忽略,LLM 拿到錯誤訊息仍自行編造答案 |
anyFailed 時跳過第二次 LLM 呼叫,直接顯示工具錯誤內容 |
| 4 | searchKnowledge 與 get_salary_range 工具衝突 |
searchKnowledge 描述含「薪資等議題」,讓 LLM 在薪資問題上優先選知識庫 |
移除 searchKnowledge 描述中的「薪資」字樣,改為「薪資查詢請改用 get_salary_range」 |
| 5 | 記憶問題(記得、興趣)被誤判為 booking |
LLM 分類器看到「想進一步討論」等 booking 關鍵字過度匹配 | layer1 規則增加 記得|興趣|領域 → career |
這些 bug 如果不是因為做了 MCP 整合、重新檢視整個架構,可能還會潛伏很久。有時候重構的附加價值就是這樣 — 逼你重新審視每個環節。
測試限制
| 限制 | 說明 |
|---|---|
| Groq 免費方案 TPM 6000 | 並行測試時容易耗盡配額,導致 LLM 文本生成中斷。B5 及部分長回應受影響 |
| Llama 3.1 8B 模型能力 | 工具選擇與指令遵循有隨機性,部分回答偏泛。升級至 70B 或 GPT-4o 可改善 |
| HuggingFace Embedding API | 無 HF_TOKEN 時可能被 rate limit,導致語意搜尋降級為關鍵字匹配 |
| LINE Bot | 未測試(無 LINE Channel 憑證) |
常見問題與解決方式
| 問題 | 可能原因 | 檢查方式 | 解決方式 |
|---|---|---|---|
| 對話回應正常但從不呼叫工具 | MCP Server 未啟動或工具清單為空 | 檢查 Log 是否有 [MCP] Server "knowledge-server" started |
重啟 npm run dev,確認無啟動錯誤 |
| 回應很慢(>5s) | MCP 子程序啟動中(首次請求) | 觀察後續請求是否變快 | 這是正常現象(lazy init),後續請求會從快取取得工具清單 |
| Server Log 出現「MCP error -32000」 | MCP Server 子程序 crash 或拒絕連線 | 檢查 ps aux \| grep npx tsx |
重啟 dev server;若持續發生,檢查 Server 檔案是否有語法錯誤 |
| 平行 Agent 回傳「0/3 agents completed」 | GROQ_API_KEY 未傳遞給 MCP 子程序 | 檢查 Log 是否有「GROQ_API_KEY is missing」 | 確認 .env.local 有設定;MCP Client 2.0 以上會自動傳遞 env |
| 市場資料查不到特定職位 | market-data.json 中無該角色資料 |
檢查 src/data/market-data.json 的 roles 列表 |
直接編輯 JSON 新增角色後重啟 |
| LINE Bot 無回應 | LINE Channel Secret 未設定或簽章驗證失敗 | 檢查 Log 是否有「Invalid signature」 | 確認 .env.local 有正確的 LINE 憑證 |
修改 market-data.json 後沒生效 |
Server 未重啟 | 檢查 Log 是否顯示舊資料 | 編輯後需重啟 npm run dev(或 production rebuild) |
已知風險與解法
| 風險 | 影響 | 緩解 |
|---|---|---|
| fastmcp / MCP SDK 與現有 Next.js 版本相容性 | 無法引入依賴 | 已驗證 Next.js 16 + moduleResolution bundler 可正常使用 |
| MCP Client STDIO 子程序管理 | 子程序 crash 導致工具失效 | MCPManager 實作 health check + auto-restart |
| Groq 的 tool_choice 行為不變 | MCP 轉譯的工具定義與原生不同 | MCPClient 輸出與 SEARCH_KNOWLEDGE_TOOL 完全相同的 JSON schema 結構,確保 Groq 行為一致 |
| Resources URI 需要 runtime uid | Resource 無法靜態定義 uid | Profile Server 在 URI template 中嵌入 {uid},Client 端填入實際 uid |
| LINE Bot 無狀態 | 每次請求需重新建立 MCP Client | MCPManager 以 singleton 在應用啟動時初始化,LINE handler 直接引用 |
| MCP 子程序缺少環境變數 | agents-server 無法使用 GROQ_API_KEY | client.ts 傳遞 process.env 給 StdioClientTransport,已在實作中修復 |
| 多子程序記憶體開銷 | 4 個 Node.js 子程序約 200-400MB | STDIO 模式比 HTTP 模式輕量;若記憶體不足可合併 Server 或改用 in-process transport |
附錄:技術對照
若對 MCP 標準還不太熟悉,這裡用 CareerWise 的實例對照 MCP 的元件概念:
| 文章元件 | CareerWise 對應 |
|---|---|
| LLM(大腦) | Groq llama-3.1-8b-instant |
| MCP 客戶端(翻譯官) | MCPClient class(client.ts)→ 4 個 instance 分別連線 4 個 Server |
| MCP 伺服器(保鏢+圖書館員) | knowledge-server / profile-server / market-data-server / agents-server |
| 資源(Resources) | profile://{uid}/current, profile://{uid}/preferences, memory://{uid}/recent |
| 工具(Tools) | 10 tools:searchKnowledge, save_memory, search_memories, update_profile, get_salary_range, get_market_trends, get_job_demand, analyze_market, advise_growth, coach_interview |
| 提示範本(Prompts) | 保留 Summer’s system prompt(暫不移除) |
| STDIO 傳輸 | Next.js 子程序啟動 4 個 FastMCP Server,透過 npx tsx 執行 |
| 動態發現 | tools/list → getGroqTools() 自動轉譯為 Groq function schema |
| 安全機制 | MCP Server 內部權限控制 + Firestore security rules + 環境變數隔離 |
對 UI/UX 的影響
MCP 是後端架構變動,使用者不會直接看到「MCP」這個詞。但有些變化是使用者有感觸的。
原本的問題
導入 MCP 前,每新增一個工具就要修改主程式的 tool definition,加 function、加 schema、加 dispatch logic。開發週期長,工具 bug 也容易影響主程式的穩定性——如果 searchKnowledge 寫壞了,整個 chat 可能都掛掉。
導入後的改變
- 工具更穩定。每個 MCP Server 是獨立子程序,一個 Server crash 不影響其他工具。使用者比較不會遇到「AI 突然不能查資料」的情形。
- 新功能上線更快。加一個新工具只需要註冊一個新 Server,不需要改主程式。使用者更快用到新功能(如市場行情查詢、模擬面試)。
- 首次使用有感延遲。MCP Server 採 lazy init,第一次呼叫工具時需要啟動子程序(約 1-3s)。後續請求從快取取得工具清單,速度正常。
MCP 沒有改變對話介面本身——使用者看到的還是同一個聊天框。差異在於 AI 能做到的事變多了,而且更可靠。
總結
回頭看這次的導入,MCP 不是什麼革命性的技術,它就是一個標準化的協議。但就是這個「標準化」,讓工具的管理從混亂變有序,從「改一個工具就要動主程式」變成「加一個新工具就是註冊一個 Server」。動態發現工具這件事,真的回不去了。當 LLM 應用的工具愈來愈多、愈來愈難管理的話,MCP 是一個值得考慮的方向。不用一次全上,從一個工具開始試水溫就好。