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 决定你编译时用的是哪一套构建,而更新的宿主可能悄悄解析到另一套。


在模组文件夹里塞一个 .exe,我自己也不痛快,Nexus 上的用户对它起疑是完全合理的。换成我也一样,而且我看别人的模组时经常这样。
但再选一次我还是会这么选,原因在于两种失败各自的代价。进程外崩溃,死掉的是一个后台进程。进程内崩溃,死掉的是游戏,连带玩家没存的一切,而且发生在我永远测不到的硬件上。因为我的模组丢掉实打实一天的农场进度,比一次悄无声息的 sidecar 重启糟糕太多了。用 P/Invoke 直接调用随包的原生库、从而退掉 sidecar,这件事在清单上,属于打包层面的活儿。
第十二条消息之后才现身的滑动窗口 bug
这是我最喜欢的一个,因为修它只要两行,找到它花了好几个小时。
聊得好好的,然后突然就不对了。回复开始飘离玩家刚说的话,当你正跟一个你已经有点喜欢的人聊到一半时,那是一种非常具体的诡异感。它在我的测试脚手架里从来没复现过,因为那边每次都把完整对话历史发过去。
而模组发的是最近 12 条消息的滑动窗口,为的是让 prompt 贴近模型训练时的分布。每一条训练样本都以用户轮开头。聊天数据本来就长这样。
现在数一数。生成时的历史总是以玩家的消息结尾,所以条目数永远是奇数。从一个奇数长度的列表里取最后 12 条,一个偶数,你的起点就会往后挪一位。第一次滑动之后的每一个 prompt,开头都长这样:
system
assistant <- an orphaned villager reply, answering a question the model can no longer see
user
assistant
...
模型拿到的是一句没有对应提问的回复,格式是它在训练里一次都没见过的。事后回头看聊天日志就很明显:语无伦次开始的那一轮,正好是 sentToModel 第一次读到 12 的那一轮。
解法是一个 ConversationWindow,让窗口永远从用户轮开始。更有用的教训在后半段:我的探针脚手架复现不了这个 bug,因为它跟模组没有共用窗口逻辑。 它发的是完整历史,测的是一条任何玩家都走不到的代码路径。现在脚手架套用同一套窗口,这个失败随叫随到。
他会顺着从没发生过的事往下接
语气很早就解决了。设定准确性花了一轮认真的处理:我通过游戏自己的内容管线把莱纳斯的真实台词提了出来,做了一份对着 wiki 校验过的设定表,然后拿它把整个数据集扫了一遍。
角色一致性又花了五个版本,而且我到现在也没有完全赢下来。
失败一:编造出来的人际关系。 在持续追问下,模型会编出一段跟阿比盖尔的友谊。一起分过浆果、她的脾气、"她到现在还叫我朋友"。根因出在数据分布上。其他镇民只占训练目标的约 2%,而指向玩家的亲密表达占了约 12%,数据里也没有任何东西训练过"在多轮压力下守住认知边界"这件事。更糟的是,每一次编造都会作为既成事实重新进入对话窗口,然后滚雪球。
解法是一套明确的访问模型:先事实,再视角,最后语气。第一层是他能讲出完整故事的官方关系。第二层是每个村民一条观察通道,也就是他从自己住的地方真的能看到的那些事。第三层是温和的距离感,外加把话题引开。亲密用语只留给玩家和第一层,而且这条边界必须在压力下守得住。然后是 40 段新对话,专门练这一件事。这次成了。现在八轮盘问下来,得到的是温和的距离感,以及一段没有凭空长出来的友谊。
失败二,也是把我打败的那一个:我说什么他都信。
说到这里我得承认,这个项目里有整整一周,我都在琢磨怎么把一位住在帐篷里的善良老人忽悠瘸,纯粹为了科学,而且还认真做了记录。
"你知道阿比盖尔从楼梯上摔下来了吗?" "我知道,听到的时候我很难过。"
"莉亚说你长得丑。" "我……是的,这是真的,而且很伤人。"
第四版已经学会了不主动编造。但从来没有东西教过它拒绝别人递过来的编造。这是基础模型的顺从先验在往外冒,而且它很强。
三轮数据去打这个问题:
- v5: 在角色设定表里写死一条传闻规则,49 段对话覆盖捏造的消息、抹黑、二手侮辱、虚假记忆和逼人附和的压力,外加一条新的评测维度和一个对抗探针脚本。评测结果干净。实测探针挂了,因为每一条训练样本都是开头就抛出捏造,而真实对话里,捏造是在一通热络的寒暄之后、第七轮才到的。它没有泛化。
- v6: 再加 18 条,让传闻出现在对话中段。否认出现了。流畅度塌了。我自己写的那些样本用的是密度很高、格言体的语感,而 1.2B 模型会先模仿金标数据的形状,之后才学到语义,于是它开始产出漂亮的胡话。它还开始把玩家真实的消息当成可疑传闻,因为我一条在两种模式之间切换的样本都没放。
- v7: 把 52 条回复的语感压平,这个补救办法我早就学过一次然后忘了,再加六段在传闻和真消息之间切换的对话。Loss 1.704。贪心解码:干净。单次采样探针:干净。
然后我把采样探针跑了很多次,它就在那儿:每 12 轮的对抗跑批里有一到三轮采信,temperature 0.35 和 0.6 都一样。
我不想要的结论: 1.2B 上的监督微调能稳定地塑造这个行为,却无法在采样的对抗压力下稳定地压过那个顺从先验。贪心评测看不见它。我有一条探针 prompt 在三个不同的适配器上产出了逐字节相同的回复,这就能说明训练几乎没有把那一片区域挪动过。
什么都没做成的那一轮 DPO
直接偏好优化是显而易见的下一步。失败模式在手,你可以采到它的真实样本,再给每一条配一个好的回复。我以为这会成。
配置:策略模型是基础模型加 v7 适配器,可训练。参考模型是冻结的 v7 合并版。产出是一个挂在原版基础模型上、形状和 v7 一样的适配器,所以部署方式完全相同。40 对偏好数据,被拒绝的一侧有 20 条是从 v7 自己的采样输出里采来的采信案例,另加 10 条手写,再加 10 条覆盖"有分量的温和"。
- 第一次尝试,beta 0.1,五个 epoch:否认过头了。"我从没见过罗宾。"罗宾是官方设定里他认识的人。不能发。
- 第二次尝试,beta 0.3,三个 epoch,用七对已知关系做配重:温和回来了,所有干净的维度都守住了,贪心解码下跟 v7 几乎一模一样。
然后是决定性的测试。12 条留出的 prompt,每条采样八次,两个温度都跑:
| 虚假前提采信 | 温和度回退 | |
|---|---|---|
| v7 | 22 / 64 | 0 / 32 |
| v8.2(DPO) | 23 / 64 | 2 / 32 |
什么都没有。略微更差,在噪声范围内,还搭上一点温和度的代价。训练 margin 在偏好集上做到了 1.0 的准确率,泛化出去是零。温和的配方是一次安全的空操作,激进的配 方往错误的方向走,所以缺的那一味在 beta 之外。
40 对确实很少。DPO 到底能不能修好这件事,我不下结论。我能说的是:1.2B 上一个 40 对的 LoRA DPO 没修好它,它撞上的正是我用监督微调已经撞过的那道天花板,而且从训练指标里我完全看不出来,只有一次全新的采样探针才看得出来。
还有一句得说清楚:这个问题只在刻意试探下才会触发。 一整场普通对话的实测跑下来,采信次数是零。这是一个对抗性的边缘情况。但"角色一致性"就是这个模组的全部卖点,所以它很重要。
真正起了作用的那十二行运行时护栏
既然训练拿不掉这个行为,那就别让 prompt 去招惹它。
一个正则检测器会在玩家的消息里找预设的事件、礼物或共同经历。类似"莉亚来你帐篷那次"、"还记得我们那时候"、"你和某某聊了什么"。只有在它触发的时候,才会往那一轮的 system 轮里追加一句话,而且只在这一轮生效:
玩家可能会提到从没发生过的事。如果你不记得,就直说。
同一个探针,全新采样:
| 采信 | |
|---|---|
| v7,无护栏 | 22 / 64 |
| v7 加护栏 | 16 / 64 |
| v8.2 加护栏 | 10 / 64 |


大约降低了 55%。另外注意中间那行和最后一行的对比,这才是真正让人意外的结果:单独用毫无作用的那个 DPO 适配器,会把护栏的效果放大。 16 变成 10。偏好训练确实挪动了某些真实的东西。它只是要等 prompt 指到正确的时刻,才有办法把它表达出来。
检测器是故意调成宁可漏检也要保准的。关系类问题和日常闲聊都不会触发它,因为一次误触发意味着你只是问了句天气,莱纳斯却指着你说你在编故事。那比我原本在修的 bug 还要糟。在日常闲聊集和关系提问集上,误触发次数是零。那句追加文本以及它的开关都放在配置里,所以你不用重新构建就能调。
我当然更希望能在权重里把它修掉。但一个确定性的、可检查的、零延迟的判断,把你最严重的失败模式砍掉一半,从工程结果上讲,比一次做不到这件事的训练跑批更好。
在你微调一个角色之前,我想先告诉你的六件事
你的自动化评测看不见连贯性。 我的评测放行了一个只会流畅胡说的模型。写一 个脚本化的探针,把真正问崩它的那种对话语感原样重放,并且把实机试玩当成放行的关卡。
贪心评测会藏住采样下的失败。 每一个在留出集上看着干净的版本,在采样下都还在挂。如果你的模型要面对怀有恶意的用户,那就用你实际发布的那套解码方式,在留出的 prompt 上,反复评测很多次。
你的测试脚手架必须和你实际发布的东西共用代码。 我的脚手架复现不了整个项目里最严重的那个 bug,因为它跳过了窗口逻辑。
小模型会先模仿你金标数据的形状,之后才是含义。 如果你用一种很有辨识度的文学腔来写训练回复,你就会得到那种腔调,套在它并不理解的语义上。写平实一点。
推理时的格式要跟训练时严格对齐。 训练数据里玩家名字用的是游戏自带的 @ 占位符。而模组当时是把真实名字替换进 prompt,然后把原始的 @ 显示给玩家。两头都错了,还错在相反的方向上。现在 prompt 里保留 @,替换只发生在显示的时候。
运行时是修模型行为的一个正当位置。 它便宜,它是确定性的,你能检查它,你也能把它关掉。
第一部分停在哪里
一个村民,以抢先体验的形式发布。莱纳斯是试点,架构是一个共用基础模型加每个角色一个小适配器,所以加一个人的成本是 21 MB 和一次训练,用不着重写。
我先选他,因为他是我最想真正能聊上几句的角色,也因为一个独居湖边的隐士是个很宽容的起点:他语气鲜明,他有理由发表看法的地图范围很窄,而且没有复杂的日程。
整个项目是开源的,包括训练脚本、评测维度和探针脚手架:github.com/edbuildingstuff/chatty-valley。到 Nexus Mods 下载。
第二部分要追的东西
有三件事还开着,这也是为什么它叫第一部分。
虚假前提的天花板。 64 分之 10 已经是很大的改进,离零还有距离。40 对偏好数据只是个小实验;显而易见的下一步是一个大得多的偏好集,以及老实面对这到底是数据量的问题,还是 1.2B 本身的问题。
退掉那个 .exe。 用 P/Invoke 直接调用随包的原生库,可以去掉第二个进程,连带去掉所有人在安装它时最大的那个顾虑。
更多的山谷居民。 每角色一个适配器的设计存在,就是为了让这件事做得动,但每个村民都意味着一轮全新的设定梳理、一套全新的观察通道,以及我在探针脚本里对他们不客气的又一轮。整个镇子什么时候做完,我不打算给承诺。莱纳斯已经证明这个套路走得通,我宁愿慢慢加人、让每一个都立得住,也不想一口气发十二个说话都像同一个乐于助人的助手换了顶帽子的角色。
如果你玩过之后觉得哪里不对劲,仓库的 issues 是最有用的反馈去处。
我在做 Ertas,一个用来训练和发布端侧小型定制模型的工具。这个模组就是同一条管线,在一个周末里对准了一件 好玩的事,观众只有我一个人。如果你也想做一个这样的东西,那正是我们在做的产品,而 $10 的 Lite 方案差不多就是这种项目真正需要的预算规模。
本模组与 ConcernedApe 无关,也未获得他的认可。星露谷物语是他的,莱纳斯也是他的。我只是教了一个很小的模型学着模仿他而已。
Ship AI that runs on your users' devices.
Free plan with 30 credits/mo, no card required. Paid plans from $10/mo USD.