Chatty Valley:《星露谷物語》裝置端 AI 模組(第一部分)
我如何微調一個 1.2B 裝置端 AI 模型,讓萊納斯在《星露谷物語》裡真的能對話。全程跑在你的 CPU 上,不需要 API 金鑰。這是 Chatty Valley 開發日誌的第一部分。
我在《星露谷物語》裡花掉的時數,說出來實在有點丟臉。半夜還在礦井裡往下鑽,鑽到血量根本撐不住的深度,然後是骷髏洞穴,明明有一整座山的證據說我不該再去,我還是一直回去。白天做的則是很平靜的事:巡一輪松露點、把多出來的丟進榨油機、出貨,隔天再來一次。
我很喜歡這款遊戲。我沒有要「修好」它。這篇文章裡沒有一句話是在抱怨它的文本,那些對話寫得比它需要的還要好。
只是玩到兩百多個小時之後,你會發現整個鎮上的台詞你都背起來了。你走到萊納斯面前,他要說什麼關於荒野的話你早就知道,於是連看都沒看就把對話框按過 去。固定數量的台詞,碰上不合理的遊玩時數,本來就贏不了。
所以這是我讓山谷再活一點的嘗試。而鵜鶘鎮剛好是小型語言模型幾乎完美的目標:角色固定、有一大批官方對話可以拿來學語氣,而且玩家本來就站在原地讀對話框。
Chatty Valley 讓你可以直接打字問萊納斯,他會回你。模型就裝在模組裡面。不用 API 金鑰、不用帳號、不用伺服器,每則訊息也不花錢。你把 744 MB 解壓縮到 Mods 資料夾,就可以跟他聊天。它跑在你的 CPU 上,每則回覆大約一秒半。
這是開發日誌的第一部分。
讓模型講話像萊納斯,花了一個下午。那是簡單的部分。難的是讓他別再附和從沒發生過的事,以及後來發現在這個尺寸上,不管我從哪個方向下手,都訓練不進去。
所以這是誠實版本,包含我原本很有把握的一項技術最後完全沒有作用的那一段。
技術堆疊:一個 1.2B 模型、一個 LoRA,以及 llama.cpp
基礎模型: Liquid AI 的 LFM2.5-1.2B-Instruct,量化到 Q4_K_M。697 MB。 轉接器: 一個承載萊納斯語氣的 LoRA。21 MB。 執行環境: 透過 LLamaSharp 呼叫 llama.cpp,純 CPU,不需要 GPU。 佔用: 約 1 GB 記憶體,載入 1.6 秒,每則回覆 1 到 2 秒。
架構是一個共用的基礎模型,加上每個角色一個小轉接器。多加一位村民的成本是 21 MB 加一次訓練,不用再塞進一個 700 MB。這個決定很早就下了,也是後面整個鎮都有機會的唯一原因。
我其實很想讓同系列的 350M 模型能用,因為 219 MB 和每秒 92 個 token,拿去請陌生人下載實在好聽太多。計畫書上寫著由評測來決定尺寸,它也真的決定了,直接輾過我的偏好。
微調過的 350M 把語氣和格式抓得很漂亮。它產出的單句讀起來就是萊納斯。它只是撐不住一段對話:你隨口問一句稍微有點繞的話,得到的是流暢、完全對味的胡言亂語,一個你根本沒問的問題的答案,然後同一個念頭再講一次,怕你沒看到。相比之下,1.2B 把對話撐得穩多了,所以我出的是那個。
值得知道的是,我手上每一項自動指標都說 350M 沒問題。 無破折號率、越獄洩漏率、句長分佈、退化、數字年齡洩漏,兩個尺寸全部乾淨。這個失敗只有真的去跟它聊天才看得出來。所以我寫了一支腳本化的日常語氣探測,把當初把它打壞的對話形狀原封不動重播一次,從那之後沒有任何一個轉接器可以在沒通過那支探測和一輪實機試玩的情況下出貨。自動化軸線抓得到回歸。它們看不見連貫性。
逼出獨立 sidecar 行程的那道 .NET 高牆
這是一個我沒預料到、也繞不過去的限制。
《星露谷物語》跑在 .NET 6 上。LLamaSharp 0.27.0 是 llama.cpp 的 .NET 綁定,它會遞移相依到 .NET 10 的套件,而且連在 netstandard2.0 的相依群組裡也一樣。System.Text.Json 10、System.Numerics.Tensors 10,還有好幾個 Microsoft.Extensions 抽象層。那些組件在遊戲所使用的 .NET 6 執行環境裡載不起來。
所以用這個綁定要在《星露谷物語》行程內做託管推理,是徹底不可能的。我花了一段時間確認這件事,才願意接受。
解法是 sidecar。ChattyValley.Sidecar 是一個獨立的 .NET 10 行程,模型歸它管,透過具名管道每行處理一次請求和一次回應。模組本身是一個很薄的 .NET 6 用戶端,完全沒有 LLamaSharp 參考。它負責啟動 sidecar、等管道接上,然後在結束時把它關掉。
最後有三個目標框架各自吃重,對一個只有兩個行程的模組來說聽起來很荒謬。拆解如下:
ChattyValley.Mod,.NET 6。 薄用戶端。它必須跟遊戲一致。ChattyValley.Sidecar,.NET 10。 擁有模型的行程,版本由 LLamaSharp 的相依鏈決定。ChattyValley.Runtime,.NET 8。 sidecar 載入的 LLamaSharp 包裝層。這一個花了我丟臉的時間才釘住。
所以沒錯,.NET 8 真的在裡面,而原因才是有趣的部分。LLamaSharp 0.27.0 出兩個建置,netstandard2.0 和 net8.0。你的目標框架決定拿到哪一個。.NET 6 函式庫會綁到 netstandard2.0,因為 .NET 6 根本無法使用 net8.0 組件。.NET 10 的 sidecar 解析到 net8.0,因為那是它能用的最接近的建置。
於是當 Runtime 掛在 .NET 6 上時,我編譯所依據的組件和主機實際載入的組件是兩個不同的檔案。浮上來的症狀是 Method not found: set_Temperature,就發生在設定取樣溫度的那一刻。改成以 .NET 8 為目標之後,兩端都解析到 lib/net8.0,這也是專案現在的做法。
關於這個解釋的極限,我老實說:我始終沒有查明為什麼壞掉的偏偏是那一個成員。去看中繼資料,DefaultSamplingPipeline.Temperature 在兩個建置裡看起來一模一樣,宣告型別一樣、簽章一樣,getter 和 setter 都在。對齊之後問題就好了,所以我就不挖了。真正能帶走的是那條解析規則,例外本身反而沒那麼重要:面對多目標套件,你的 TFM 決定你編譯的是哪個建置,而較新的主機可能會悄悄解析到另一個。

