用 ASP.NET Core 打造 Semantic Kernel + MCP 的 AI Agent 協調層

「能做事」的 AI Agent,而不只是聊天視窗時,很快就會遇到一個架構問題:AI 推理、工具呼叫、業務邏輯,這三件事該怎麼分層?

用 ASP.NET Core 打造 Semantic Kernel + MCP 的 AI Agent 協調層

前言

當團隊決定把 LLM 真正落地成「能做事」的 AI Agent,而不只是聊天視窗時,很快就會遇到一個架構問題:AI 推理、工具呼叫、業務邏輯,這三件事該怎麼分層?

Microsoft 的 Semantic Kernel(SK) 擅長「協調(orchestration)」——管理 Prompt、Function、對話狀態;而 Model Context Protocol(MCP) 擅長「工具連接(tool connectivity)」——用一套標準協定讓任何 LLM 都能發現、呼叫外部工具,不必為每個 AI 框架各寫一套 function calling 格式。

這篇文章會用一個範例,示範如何在 ASP.NET Core 專案中,用一個「薄 Controller(thin controller)」把這兩者串起來,做成一個可以被前端或其他系統呼叫的 AI Agent 入口。


一、整體架構

使用者請求
   │
   ▼
ASP.NET Core Controller(協調入口)
   │  1. 驗證請求
   │  2. 查詢/識別使用者
   │  3. 呼叫 MCP Tool
   ▼
IMcpClientService(抽象化的 MCP Client)
   │
   ▼
MCP Server(暴露一個或多個 Tool)
   │
   ▼
Semantic Kernel / LLM 推理
   │
   ▼
結構化回應 → 回傳給 Controller → 回傳給使用者

這裡的關鍵設計原則是:Controller 不做推理、不做複雜業務運算,只負責「驗證 → 轉發 → 整理回應」。所有跟 AI 相關的邏輯,都封裝在 MCP Server 端的 Tool 裡;Controller 只是一個乾淨的協調層。


二、為什麼不直接在 Controller 裡呼叫 Semantic Kernel?

一個常見的新手做法,是直接在 Controller 裡 new 一個 Kernel、掛上 Plugin、呼叫 InvokeAsync。這樣做在 Demo 階段沒問題,但正式環境會遇到幾個問題:

  • 難以測試:Controller 直接依賴 SK 的具體實作,單元測試時很難 mock。
  • 難以水平擴充:如果之後想把 MCP Server 換成獨立服務、甚至讓多個不同語言的 Client 共用同一組 Tool,Controller 內嵌的呼叫邏輯會綁死在單一專案裡。
  • 職責混雜:Controller 同時做驗證、AI 呼叫、資料庫存取,違反單一職責原則。

所以比較穩健的做法,是把 MCP 呼叫包成一個介面:

public interface IMcpClientService
{
    Task<ToolResponse> CallToolsAsync(
        string toolName,
        Dictionary<string, object?> parameters);

    Task<IEnumerable<string>> ListToolNamesAsync(string serverUrl);
}

Controller 只依賴這個介面,實作細節(要連哪個 MCP Server、用什麼傳輸協定 stdio/SSE/HTTP)都被隱藏起來,之後要更換或 mock 都很單純。


三、範例 Controller

以下是一個精簡過的協調層 Controller 範例,示範核心流程:

[ApiController]
[Route("api/agent")]
public class AgentRouteController : ControllerBase
{
    private readonly IMcpClientService _mcpClient;
    private readonly IUserService _userService;
    private readonly ILogger<AgentRouteController> _logger;

    public AgentRouteController(
        IMcpClientService mcpClient,
        IUserService userService,
        ILogger<AgentRouteController> logger)
    {
        _mcpClient = mcpClient;
        _userService = userService;
        _logger = logger;
    }

    [HttpPost("route")]
    public async Task<IActionResult> Route([FromBody] AgentRequest request)
    {
        // 1. 基本驗證
        if (request is null || string.IsNullOrEmpty(request.SessionId))
        {
            return BadRequest(new { message = "SessionId 不可為空" });
        }

        // 2. 使用者識別(容錯:允許用 Email 或帳號查詢,不分大小寫)
        var user = await _userService.FindUserAsync(request.UserIdentifier);
        if (user is null)
        {
            return NotFound(new { message = "使用者不存在" });
        }

        // 3. 呼叫 MCP Tool
        try
        {
            var result = await _mcpClient.CallToolsAsync(
                "RouteAgentTool",
                new Dictionary<string, object?>
                {
                    ["userId"] = user.Id,
                    ["prompt"] = request.Prompt,
                    ["sessionId"] = request.SessionId
                });

            if (string.IsNullOrEmpty(result.Error) && !string.IsNullOrEmpty(result.RawResult))
            {
                return Ok(new { status = 200, response = result.RawResult });
            }

            return NotFound(new { status = 404, response = result.Error ?? "無有效回應" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "呼叫 MCP Tool 失敗");
            return StatusCode(500, new { message = "AI Agent 服務暫時無法回應" });
        }
    }
}

四、幾個實務上值得注意的設計細節

1. 使用者識別要做「容錯查詢」

企業系統裡,同一個使用者常常有多種識別方式(Email、帳號、員編)。與其要求前端一定要傳對欄位,不如在後端做一層容錯:先用主要欄位查,查不到再用次要欄位(不分大小寫)比對一次。這能大幅降低串接失敗率,代價是多一次查詢,通常可以接受。

2. 回應「有效性」不要只靠字串比對

如果 AI 回應「這個問題我無法回答」之類的文字,很容易讓人直覺地寫:

bool isValid = !result.RawResult.Contains("無法回答");

這樣做短期有效,但只要 Prompt 或模型輸出的措辞一改,判斷就會失靈。比較穩健的做法是:

  • 讓 MCP Tool/Prompt 端直接回傳結構化欄位(例如 "status": "unsupported"),而不是靠自然語言文字判斷;
  • 或至少把這些「魔法字串」抽成設定檔/常數集中管理,方便日後調整。

3. 用 Session 維持多輪對話狀態

AI Agent 很少是一問一答就結束,通常需要維持上下文(例如使用者上一輪問的是什麼類別的問題)。常見做法是:

  • 呼叫 Agent 前,先透過一個 GetOrCreateSession 端點,取得/建立 SessionId
  • 每次呼叫都帶上 SessionId,並在 Server 端把最近幾輪的對話記錄存起來(DB 或 Cache 皆可),必要時回查最新一筆記錄的分類/狀態,回傳給前端。

4. 例外處理要分層

MCP 呼叫可能因為「連線失敗」「逾時」「回應格式錯誤」失敗,這三種情況對維運人員的意義完全不同,建議在 catch 區塊至少做基本分類記錄,而不是全部包成同一種 500/404,方便之後查 log 定位問題。


五、什麼時候該用 MCP,什麼時候不需要

MCP 不是萬靈丹,導入前可以先問自己幾個問題:

  • 需要多個不同的 AI Client(例如公司內部 Agent、GitHub Copilot、其他廠商的 Copilot)共用同一組工具嗎? 如果是,MCP 的標準化發現機制會很有價值。
  • 只是做一個固定資料源的內部小工具? 如果需求單純、只有一個呼叫端,直接用 Semantic Kernel 的原生 Plugin/Function Calling 反而更輕量,不需要多一層協定開銷。
  • 對延遲非常敏感嗎? MCP 走 JSON-RPC,會有序列化與往返成本,如果需要毫秒級回應,這層開銷需要納入評估。

六、小結

這個架構的核心精神很簡單:讓 Controller 保持「薄」,把 AI 推理與工具邏輯都交給 MCP Server/Semantic Kernel 處理。這樣做的好處是:

  • Controller 容易測試、容易維護;
  • MCP Server 可以被多個不同的 Client/團隊重複使用;
  • 未來要更換底層 LLM 供應商,或是把 Agent 邏輯搬到獨立服務,都不需要動到最外層的 API 入口。

如果你正在把 LLM 從「聊天視窗」升級成「真正能串接系統的 AI Agent」,這套「Controller + MCP Client 介面 + Semantic Kernel」的分層方式,會是一個蠻穩妥的起手式。