</> 技術筆記Tech Notes

用 C# 接本機的 Ollama,做一個聽得懂人話的預約系統

一句話變成一筆預約,模型跑在自己的電腦上

講到接 LLM,大家第一個想到的通常是 OpenAI 或 Gemini 的 API,第二個想到的是「所以要用 Python 吧」。

這兩件事其實都不一定。模型可以跑在自己的電腦上,語言也可以是 C#。

在本機跑有幾個很實際的好處:使用者打的句子不會送到別人家的伺服器(做企業內部系統時這點常常是關鍵)、沒有用量帳單、也不用擔心對方哪天改 API 或漲價。代價是你得自己準備機器,而且模型比不上最頂的那幾個。

這篇是我拿 .NET Core MVC 接本機 Ollama 做的一個小原型:會議室預約助理。目標是把

幫我預約明天的 Alpha 會議室,從下午兩點開始用一個小時

這種句子,變成程式能直接處理的 JSON。

先準備環境

  • .NET SDK 6.0 以上

  • Ollama,裝完會在背景跑一個服務

  • 一個模型,我用的是 Llama 3 的 8B 量化版:

ollama run llama3:8b-instruct-q4_K_M

量化版的好處是一般筆電就跑得動,記憶體吃得不多,速度也還可以接受。

這個原型要 AI 做的事

其實只有兩件:

  1. 意圖辨識:這個人想幹嘛?預約、查詢,還是取消?

  2. 實體擷取:把關鍵資訊抓出來,會議室、日期、時間、時長。

然後回一包乾淨的 JSON 給我:

{
  "intent": "book_room",
  "entities": {
    "room_name": "Alpha 會議室",
    "date": "2025-09-28",
    "start_time": "14:00",
    "duration_minutes": 60
  }
}

拿到這個之後,剩下的就是一般的 C# 程式了,查空檔、寫資料庫、回傳結果,跟 AI 一點關係都沒有。AI 只負責把自然語言翻成結構化資料這一段。

開始寫

步驟 1:開專案

# 建立一個名為 OllamaBookingDemo 的新 MVC 專案
dotnet new mvc -n OllamaBookingDemo

cd OllamaBookingDemo

# 加入 Newtonsoft.Json,處理 JSON 方便一點
dotnet add package Newtonsoft.Json

步驟 2:定義資料模型

Ollama 的請求跟回應各一組,再加上我們自己要的預約結果。

Models/OllamaRequest.cs

using Newtonsoft.Json;
using System.Collections.Generic;

namespace OllamaBookingDemo.Models
{
    public class OllamaRequest
    {
        [JsonProperty("model")]
        public string Model { get; set; }

        [JsonProperty("messages")]
        public List<OllamaMessage> Messages { get; set; }

        [JsonProperty("format")]
        public string Format { get; set; }

        [JsonProperty("stream")]
        public bool Stream { get; set; }
    }

    public class OllamaMessage
    {
        [JsonProperty("role")]
        public string Role { get; set; }

        [JsonProperty("content")]
        public string Content { get; set; }
    }
}

Models/OllamaResponse.cs

using Newtonsoft.Json;

namespace OllamaBookingDemo.Models
{
    public class OllamaResponse
    {
        [JsonProperty("message")]
        public OllamaMessage Message { get; set; }
        // 其他欄位用不到就先不接
    }
}

Models/BookingIntent.cs

using Newtonsoft.Json;

namespace OllamaBookingDemo.Models
{
    public class BookingIntent
    {
        [JsonProperty("intent")]
        public string Intent { get; set; }

        [JsonProperty("entities")]
        public BookingEntities Entities { get; set; }
    }

    public class BookingEntities
    {
        [JsonProperty("room_name")]
        public string RoomName { get; set; }

        [JsonProperty("date")]
        public string Date { get; set; }

        [JsonProperty("start_time")]
        public string StartTime { get; set; }

        [JsonProperty("duration_minutes")]
        public int? DurationMinutes { get; set; }
    }
}

DurationMinutes 用可為 null 的 int?,因為使用者很可能沒講時長。

步驟 3:呼叫 Ollama 的服務

這是整個原型的核心。

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using OllamaBookingDemo.Models;

namespace OllamaBookingDemo.Services
{
    public interface IOllamaService
    {
        Task<BookingIntent> GetBookingIntentAsync(string userInput);
    }

    public class OllamaService : IOllamaService
    {
        private readonly HttpClient _httpClient;
        private const string ModelName = "llama3:8b-instruct-q4_K_M";
        private const string OllamaApiUrl = "http://localhost:11434/api/chat";

        public OllamaService(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }

        public async Task<BookingIntent> GetBookingIntentAsync(string userInput)
        {
            // 注意:verbatim string 裡面的雙引號要打兩次
            string systemPrompt = $@"
你是一位專業的會議室預約助理。你的任務是分析使用者的輸入,並抽取出會議預約所需的資訊。
你必須只回傳一個合法的 JSON 物件,不能包含任何額外的說明或文字。
這個 JSON 物件必須包含兩個欄位:""intent"" 和 ""entities""。
可能的 intent 包含:""book_room""、""query_booking""、""cancel_booking""、""unknown""。
""entities"" 物件需要包含的資訊有:""room_name""、""date""、""start_time""、""duration_minutes""。
今天的日期是 {DateTime.Today:yyyy-MM-dd}。
如果有缺漏的資訊,請將對應值設為 null。
";

            var requestPayload = new OllamaRequest
            {
                Model = ModelName,
                Messages = new List<OllamaMessage>
                {
                    new OllamaMessage { Role = "system", Content = systemPrompt },
                    new OllamaMessage { Role = "user", Content = userInput }
                },
                Format = "json", // 讓 Ollama 保證吐出合法 JSON
                Stream = false
            };

            var jsonPayload = JsonConvert.SerializeObject(requestPayload);
            var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");

            var response = await _httpClient.PostAsync(OllamaApiUrl, content);
            response.EnsureSuccessStatusCode();

            var responseString = await response.Content.ReadAsStringAsync();
            var ollamaResponse = JsonConvert.DeserializeObject<OllamaResponse>(responseString);

            // AI 回的 content 本身是一段 JSON 字串,要再拆一次
            if (ollamaResponse?.Message?.Content != null)
            {
                var bookingIntent = JsonConvert.DeserializeObject<BookingIntent>(ollamaResponse.Message.Content);
                return bookingIntent;
            }

            return null;
        }
    }
}

有兩個地方值得多講幾句。

System Prompt 就是規格書。 你要把「你是誰、要做什麼、只准回 JSON、欄位有哪些、缺資料怎麼辦」全部講清楚。模糊的指令換來模糊的輸出,這件事跟寫需求給人做沒兩樣。

Format = "json" 別忘了。 這是 Ollama 內建的功能,開下去它會保證回傳合法 JSON。少了這行,模型很愛在 JSON 前後加一句「好的,這是您要的資料:」,然後你的反序列化就炸了。

步驟 4:註冊服務

using OllamaBookingDemo.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

// 註冊 HttpClient 和 OllamaService
builder.Services.AddHttpClient();
builder.Services.AddScoped<IOllamaService, OllamaService>();

var app = builder.Build();
app.Run();

步驟 5:Controller 和畫面

Controllers/HomeController.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using OllamaBookingDemo.Models;
using OllamaBookingDemo.Services;

namespace OllamaBookingDemo.Controllers
{
    public class HomeController : Controller
    {
        private readonly IOllamaService _ollamaService;

        public HomeController(IOllamaService ollamaService)
        {
            _ollamaService = ollamaService;
        }

        public IActionResult Index()
        {
            return View();
        }

        [HttpPost]
        public async Task<IActionResult> ProcessQuery(string query)
        {
            BookingIntent result = null;
            if (!string.IsNullOrWhiteSpace(query))
            {
                result = await _ollamaService.GetBookingIntentAsync(query);
            }

            ViewBag.Query = query; // 把原本打的字帶回去,不然畫面會清空
            return View("Index", result);
        }
    }
}

Views/Home/Index.cshtml

@model OllamaBookingDemo.Models.BookingIntent
@{
    ViewData["Title"] = "會議室預約助理";
}

<div class="text-center">
    <h1 class="display-4">會議室預約 AI 助理</h1>
    <p>用平常講話的方式輸入就好</p>
</div>

<div class="row">
    <div class="col-md-8 offset-md-2">
        <form asp-action="ProcessQuery" method="post">
            <div class="form-group">
                <textarea name="query" class="form-control" rows="3" placeholder="例如:幫我預約明天的 Alpha 會議室,從下午兩點開始用一個小時">@ViewBag.Query</textarea>
            </div>
            <br />
            <div class="form-group text-center">
                <button type="submit" class="btn btn-primary">送出</button>
            </div>
        </form>
    </div>
</div>

@if (Model != null)
{
    <hr />
    <div class="row mt-4">
        <div class="col-md-8 offset-md-2">
            <h3>AI 解析結果:</h3>
            @if (Model.Intent != "unknown" && Model.Entities != null)
            {
                <div class="card">
                    <div class="card-header">
                        <strong>意圖:</strong> @Model.Intent
                    </div>
                    <ul class="list-group list-group-flush">
                        <li class="list-group-item"><strong>會議室:</strong> @(Model.Entities.RoomName ?? "未指定")</li>
                        <li class="list-group-item"><strong>日期:</strong> @(Model.Entities.Date ?? "未指定")</li>
                        <li class="list-group-item"><strong>開始時間:</strong> @(Model.Entities.StartTime ?? "未指定")</li>
                        <li class="list-group-item"><strong>時長 (分鐘):</strong> @(Model.Entities.DurationMinutes?.ToString() ?? "未指定")</li>
                    </ul>
                </div>
            }
            else
            {
                <div class="alert alert-warning">
                    看不懂這句話,換個說法試試看。
                </div>
            }
        </div>
    </div>
}

跑起來

確認 Ollama 在背景跑著,然後 dotnet run,打開瀏覽器輸入那句「幫我預約明天的 Alpha 會議室,從下午兩點開始用一個小時」,就會看到解析結果。

幾個實作時卡住的地方

一、JSON 要拆兩次

這是最容易踩的坑。Ollama 回給你的是一包 JSON 沒錯,但你要的 JSON 是包在 message.content 這個字串欄位裡面的。

外層是信封,裡面那層字串才是內容

所以要反序列化兩次:先拆外層的信封拿到 content 字串,再把那個字串當 JSON 拆一次,才會得到 BookingIntent。第一次寫的時候我在這裡卡了一陣子,因為型別看起來都對,就是拿不到東西。

二、verbatim string 裡的引號要打兩次

System Prompt 裡面滿滿都是 "intent" 這種帶引號的欄位名,而 C# 的 @"..." 裡面遇到一個 " 就會當作字串結束。所以裡面每個引號都要打兩次寫成 ""intent"",不然連編譯都過不了。

三、今天是幾號,要動態帶進去

模型不知道今天幾號,所以 prompt 一定要告訴它,不然「明天」它算不出來。但這個日期不能寫死,我用 $@"..." 的字串插值把 DateTime.Today 帶進去。寫死的話,這支程式第二天就開始給錯答案了。

之後可以再做的

這只是個能動的原型,離能上線還有一段距離。比較實際的下一步大概是:

  • 把解析結果接到資料庫,真的去查會議室有沒有空

  • 補錯誤處理。Ollama 沒開、模型還沒下載完、回傳的 JSON 欄位缺一半,這些都要擋

  • 等待的時候給個 Loading。本機模型第一次跑要暖機,可能要好幾秒

  • 資訊不齊的時候反問一句「請問是哪一天?」,而不是直接回「看不懂」

不過就算只做到這裡,我覺得已經足夠說明一件事:LLM 不是只能拿來聊天,把它當成「自然語言翻成結構化資料」的工具,是最容易在既有系統裡找到位置的用法。而且這件事,本機的小模型就做得很好了。