我在《星露谷物語》裡花掉的時數,說出來實在有點丟臉。半夜還在礦井裡往下鑽,鑽到血量根本撐不住的深度,然後是骷髏洞穴,明明有一整座山的證據說我不該再去,我還是一直回去。白天做的則是很平靜的事:巡一輪松露點、把多出來的丟進榨油機、出貨,隔天再來一次。
我很喜歡這款遊戲。我沒有要「修好」它。這篇文章裡沒有一句話是在抱怨它的文本,那些對話寫得比它需要的還要好。
只是玩到兩百多個小時之後,你會發現整個鎮上的台詞你都背起來了。你走到萊納斯面前,他要說什麼關於荒野的話你早就知道,於是連看都沒看就把對話框按過去。固定數量的台詞,碰上不合理的遊玩時數,本來就贏不了。
所以這是我讓山谷再活一點的嘗試。而鵜鶘鎮剛好是小型語言模型幾乎完美的目標:角色固定、有一大批官方對話可以拿來學語氣,而且玩家本來就站在原地讀對話框。
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 決定你編譯的是哪個建置,而較新的主機可能會悄悄解析到另一個。


在模組資料夾裡放一個 .exe,我自己也不太開心,Nexus 的使用者對它有戒心完全合理。換成我也會,而且我對別人的模組經常這樣。
但同樣的判斷我還是會再做一次,因為兩種失敗模式的代價差很多。行程外崩潰只會殺掉一個背景程序。行程內崩潰會殺掉遊戲,連同玩家還沒存的一切,而且是在我永遠測不到的硬體上。因為我的模組而損失一整天真正的農場進度,比 sidecar 靜靜重啟糟糕太多了。用 P/Invoke 直接呼叫隨附的原生函式庫、讓 sidecar 退役,這件事在清單上,而那是打包問題,跟架構無關。
第十二則訊息之後才現形的滑動視窗錯誤
這個是我最喜歡的一個,因為修正只有兩行,而找到它花了好幾個小時。
對話本來好好的,然後突然就不好了。回覆開始飄離玩家剛剛說的話,而當你正跟一個你開始有點喜歡的人聊到一半時,那種詭異感非常特別。它在我的測試框架裡從來沒重現過,因為那個框架每 次都送出完整的對話歷史。
而模組送出的是最近 12 則訊息的滑動視窗,好讓提示詞貼近模型訓練時的分佈。每一筆訓練範例都從一個 user 回合開始。聊天資料就長這樣。
現在來數。產生回覆時的歷史一定結尾在玩家的訊息上,所以長度一定是奇數。從一個奇數長度的清單取最後 12 筆,一個偶數,你就會從往後一格的位置開始。第一次滑動之後的每一個提示詞開頭都長這樣:
system
assistant <- an orphaned villager reply, answering a question the model can no longer see
user
assistant
...
模型被交到手上的,是一則沒有提問的回覆,格式是它在訓練裡一次都沒見過的。事後回看,聊天紀錄講得很明白:不連貫就從 sentToModel 第一次讀到 12 的那個回合開始。
修正是一個 ConversationWindow,讓視窗永遠從 user 回合開起。更有用的教訓在後半段:我的探測框架重現不了這個錯誤,因為它跟模組沒有共用視窗程式碼。 它送的是完整歷史。它測的是一條玩家永遠不會走到的路徑。現在框架套用同一個視窗,這個失敗要多少次有多少次。
他會附和從沒發生過的事
語氣很早就解決了。設定正確性花了一次仔細的整理:我透過遊戲自己的內容管線把萊納斯的真實對話抽出來,做了一份對照 wiki 驗證過的設定表,然後拿它把整份資料集掃過一遍。
角色完整性又花了五個版本,而我到現在也還沒完全贏。
失敗一:憑空發明的關係。 在持續追問之下,模型會發明一段跟阿比蓋爾的友情。一起分莓果、她的脾氣、「她到現在還叫我朋友」。根本原因出在分佈上,跟教條無關。其他鎮民只佔訓練目標的大約 2%,而指向玩家的親密內容大約 12%,而且資料裡沒有任何東西教它在多回合的壓力下守住認知邊界。更糟的是,每一次發明都會以既定事實的身分重新進入對話視窗,然後越滾越大。
修正是一套明確的存取模型:先真相,再視角,最後語氣。第一層是他可以講完整故事的官方關係。第二層是每位村民一條觀察通道,也就是他從自己住的地方真的看得到的那件事。第三層是溫暖的距離感加上轉移話題。親密詞彙保留給玩家和第一層,而且這條邊界必須在壓力下守得住。接著是 40 段新對話,專門操練這一點。這招有效。現在連續八個回合的逼問,換來的是溫暖的距離感,沒有發明出來的友情。

