Chatty Valley:星露谷物语的端侧 AI 对话模组(第一部分)
我微调了一个 1.2B 端侧 AI 模型,让星露谷物语里的莱纳斯能真正跟你聊天。模型随模组本地跑在你的 CPU 上,不需要 API key。这是 Chatty Valley 开发日志的第一部分。
我在星露谷物语上投进去的小时数,说出来是真的有点丢人。深夜顶着一格血往矿洞更深处钻,然后是骷髅洞穴,明明有一大堆证据表明我不该再去,我还是一次次回去。白天则是那些安稳的事:巡一遍松露点,多出来的丢进榨油机,装船卖掉,第二天再来一遍。
我很爱这个游戏。我没有想去修好它。下面这些话里没有一句是在抱怨它的文本,那些对白写得比它其实需要的水准还要好。
只是玩到两百小时之后的某个时刻,你会发现整个镇子你都背下来了。走到莱纳斯面前,你已经知道他要讲哪句关于荒野的话,于是看都不看就把对话框按了过去。固定条数的台词,撞上一个不讲 道理的游戏时长,本来就是要输的。
所以我试着让这个山谷更活一点。而鹈鹕镇碰巧是小语言模型近乎完美的目标:角色阵容固定,有海量的官方台词可以拿来学语气,而且玩家本来就站在原地读对话框。
Chatty Valley 让你能打字问莱纳斯,然后他会回你。模型就装在模组里面。不需要 API key,不需要账号,不需要服务器,每条消息也不花钱。把 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 决定你编译时用的是哪一套构建,而更新的宿主可能悄悄解析到另一套。

