LLM驱动的NPC在Ultima Online (ServUO)中的运作方式

Reddit r/LocalLLaMA 工具

摘要

一位开发者开源了一个7500行的C#集成方案,通过本地LLM驱动Ultima Online的NPC,具备热重载配置、故障开放降级能力,并能完全访问游戏世界的对象模型。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/05 05:09

# "如何在《网络创世纪》(ServUO) 中实现LLM驱动的NPC" 来源:https://blog.zolty.systems/posts/2026-06-01-llm-npc-integration-servuo ## TL;DR 我将集成代码开源了,它在我运行的《网络创世纪》(ServUO) 服务器实例上,为NPC提供了本地LLM的支持。这段代码大约7500行C#,直接放入服务器实例的 `Scripts/Custom/` 目录,启动时编译即可 —— 无需单独构建,无需部署服务。本文是项目[故事版](https://blog.zolty.systems/2026-06-03-llm-npcs-ultima-online/)的代码级补充:介绍配置热重载机制、模型客户端如何将异步结果回传到游戏线程、LLM如何完全脱离模拟循环,以及确定性允许列表如何让非确定性模型安全地运行在有状态的游戏世界中。整个系统是**故障开放的**:如果模型变慢、宕机或输出错误,NPC会静默降级为标准ServUO NPC。代码在GitHub上: **ZoltyMat/uo-llm-npc (https://github.com/ZoltyMat/uo-llm-npc)**。 ## 问题的形状 ServUO —— RunUO模拟器的现代继承者 —— 有一个特性使得整个项目可行:它在服务器启动时编译 `Scripts/` 下的C#代码。把一个 `.cs` 文件放进 `Scripts/Custom/` 并重启服务器实例,你的代码就变成了实时游戏逻辑,可以完全访问游戏世界的对象模型。没有NuGet,没有 `csproj`,没有单独的构建产物。服务器实例**就是**构建系统。这就是为什么这个项目以脚本形式提供,而不是一个服务。 集成代码是同一个文件夹下的16个文件: ``` Scripts/Custom/LLMNpc/ ├── LLMConfig.cs # 配置加载 + LLMReload,默认文件写入器 ├── LLMClient.cs # 兼容OpenAI的聊天客户端 ├── LLMConversation.cs # 每对(玩家, NPC) 短期记忆 ├── LLMTalkingMobile.cs # 赋予LLM声音的生物基类 ├── LLMAmbientSpeech.cs # 让已有的标准NPC拥有声音 ├── LLMAmbientMemory.cs # 为这些NPC提供磁盘备份的身份/关系 ├── NpcIdentity.cs # 生成、持久化的每个NPC个性 ├── NpcActions.cs # 修饰性动作允许列表 ├── NpcChatter.cs # NPC之间的环境对话 ├── Errand.cs # 任务阶段模型 ├── ErrandDirector.cs # 确定性心跳 ├── ErrandPolicy.cs # 每种NPC类型巡逻范围限制 ├── BritanniaGeography.cs # 位置 → NPC服务城镇 ├── LLMRag.cs # 可选的Qdrant世界观/风格/日志知识库 ├── AnomalyDirector.cs # 稀有的第四面墙"异常"事件 └── LLMNpcCommands.cs # 游戏内GM命令 ``` 这份清单有趣的地方在于,其中只有很少一部分是"调用模型"。`LLMClient.cs` 是唯一与LLM进行HTTP通信的文件。其他所有文件都是让一个被点击的铁匠感觉像真人的脚手架 —— 而这个脚手架才是真正的项目。 ## 无需重启即可重载的配置 一个UO服务器实例是一个长期运行的有状态进程。为了修改一个配置标志而重启它,意味着踢掉所有玩家并从磁盘重新加载整个世界。所以集成代码第一个需要的就是热重载配置。 `LLMConfig.cs` 从 `Config/LLMNpc.cfg` 读取纯 `键=值` 文件。如果文件在第一次启动时不存在,它会写入一个带有完整注释的默认文件。游戏内的GM命令 `[LLMReload` 可以实时重新读取它: ```csharp public static void Reload() { var next = Load(Path.Combine(Core.BaseDirectory, "Config", "LLMNpc.cfg")); Volatile.Write(ref _current, next); // 原子交换; 读取者永不撕裂 Console.WriteLine("LLMNpc: config reloaded."); } ``` 配置在热路径上被频繁读取,因此它是一个单一不可变快照,通过原子方式替换,而不是一个可变的字段集合。最重要的可调参数包括: ``` Enabled=false # 主开关 BaseUrl=http://127.0.0.1:11434/v1 # 任何兼容OpenAI的端点 Model=qwen3-coder:30b CooldownMs=2500 # 每个NPC调用间最小毫秒数 MaxMemoryTurns=6 # 每对对话重放的历史轮数 MaxReplyChars=240 # 说话长度的硬上限 RagEnabled=false # 可选的Qdrant世界观支撑 ChatterEnabled=false # NPC间环境对话 AnomalyEnabled=false # 稀有的第四面墙事件 ``` 默认端点指向本地 [Ollama](https://ollama.com/) 的 `127.0.0.1`,因此一个全新克隆开箱即用,目标是免费、本地、无需认证的模型。如果你希望通过 LiteLLM 之类的网关路由,只需将 `BaseUrl` 指向网关并在 `ApiKey` 中填入密钥即可。游戏代码对此毫无感知 —— 它只使用 OpenAI 聊天 API,别无其他。 > 示例配置中有一个规则重复了三次:**永远不要提交你真实的 `Config/LLMNpc.cfg`。** 它可能包含端点和API密钥。仓库将其 gitignored,只提供 `LLMNpc.cfg.example`。发布的全部意义就在于**不**泄露秘密。 ## 模型客户端与故障开放契约 这是塑造 `LLMClient.cs` 的约束:**ServUO 对游戏逻辑是单线程的。** 只有一个游戏线程,在那个线程之外触碰 `Mobile`(NPC、玩家)就会导致世界损坏。但是,对一个可能因为冷启动而耗四秒的模型进行HTTP调用,绝对不能阻塞这个线程,否则整个服务器实例会为所有玩家同时冻结。因此,客户端在线程池任务中进行网络调用,然后在*接触任何游戏对象之前*,将*结果*回传到游戏线程: ```csharp public static void ChatAsync(string system, string user, Action<string> onReply) { Task.Run(async () => { string reply = null; try { reply = await PostChat(system, user); } // 离线线程HTTP catch { /* 吞掉: 故障开放 */ } // 在触碰任何Mobile之前回到游戏线程。 Timer.DelayCall(TimeSpan.Zero, () => { if (!string.IsNullOrEmpty(reply)) onReply(Sanitize(reply)); // NPC在这里说话 }); }); } ``` `Timer.DelayCall` 是 ServUO 的惯用法,意思是"下一帧在游戏线程上执行这个"。这一行代码就是整个线程模型:异步I/O在线程池上,状态修改在游戏线程上,绝不反过来。 注意那个吞掉异常的 `catch`。这就是**故障开放契约**,在整个代码库中到处可见。每个模型调用、每个Qdrant调用都有超时,并且有一条路径:失败意味着"什么都没发生"而不是抛出一个错误。一个死掉的端点不会向玩家抛出堆栈跟踪——NPC只会像普通ServUO NPC那样说一条预设的台词。最坏情况下,整个LLM层宕机,游戏回退到1997年发布时的样子。 ## 让模型在有状态世界中安全的允许列表 当你要让语言模型在一个有持久状态的世界中影响行为时,必须要回答一个问题:它实际上能**做什么**?生成文本是无害的。但如果模型输出的任何令牌都可以被解释为游戏内动作,那么一次幻觉就是一个**事件**。 答案是,在 `NpcActions.cs` 中定义一个封闭的、仅限修饰性动作的允许列表,并在*任何模型输出被信任之前*以确定性方式检查: ```csharp // 仅限修饰性动作。模型可以通过名称请求这些动作之一; // 它输出的任何其他内容都不会被解释为动作。 private static readonly HashSet<string> Allowed = new() { "bow", "nod", "shake_head", "laugh", "point", "wave", "shrug", "salute", "clap", "yawn", }; public static bool TryResolve(string token, out int animationId) { animationId = 0; if (token == null || !Allowed.Contains(token.Trim().ToLowerInvariant())) return false; // 不在菜单上 → 丢弃 animationId = AnimationFor(token); return true; } ``` 模型**从**菜单中进行选择;它不能发明一个动词。它不能通过*描述*来移动NPC、打开门、掉落物品或开始战斗。如果它幻觉出"然后巫妖摧毁了村庄",动词 `raze` 不在集合中,所以唯一发生的是文本。 这是我在日常工作中用于构建代理型CI的相同原则:**确定性护栏要先于LLM判断。** 正则和允许列表是确定的;模型的行为良好只是概率性的。你要把廉价、确定的检查放在昂贵、可能出错的前面。一个非确定性系统,其最坏情况只是一个奇怪的动画,这样的系统是可以发布的。 ## 让LLM远离模拟循环 整个项目中最大的设计规则:**模型生成单词,从不生成游戏状态。** NPC自己运行任务和跨地图旅程,但LLM从不被咨询来*推进*某个任务。`ErrandDirector.cs` 用一个单一的确定性心跳驱动所有这些活动,以玩家为中心进行扫描: ```csharp private static void Heartbeat() { foreach (NetState ns in NetState.Instances) // 仅连接中的玩家 { Mobile player = ns.Mobile; if (player == null) continue; foreach (Mobile m in player.GetMobilesInRange(SimRange)) { if (m is ILLMNpc npc && npc.HasErrand) npc.AdvanceErrand(); // 纯状态机 } } } ``` 由此产生两件事。首先,**屏幕外的NPC不消耗任何资源** —— 心跳只关注在某个连接中玩家附近的NPC,所以空服务器实例处于空闲状态,没有人在看的模型永远不会被调用。其次,任务进度是一个简单的状态机(`Errand.cs` 定义阶段;`ErrandPolicy.cs` 限制每种NPC类型可以巡逻多远,这样银行家就待在柜台边,玩家仍然可以办理银行业务)。模型的唯一参与是可选地将任务的*目的文本*重写为更丰富的单行描述 —— 这是一个异步、故障开放的修饰,其确定性版本总是作为回退方案。模拟从不等待推理。 同一个心跳还机会性地驱动NPC间的闲谈(`NpcChatter.cs`)和稀有的异常事件(`AnomalyDirector.cs`)—— 它们利用已有的扫描,而不是启动自己的定时器,通过分层冷却和低概率来限制,这样城镇广场上只有低语声,而不是推理大爆发。 `LLMRag.cs` 是唯一可选、默认关闭的子系统,它针对 [Qdrant](https://qdrant.tech/) 向量数据库完成三个相关工作,都共享一个嵌入端点(Ollama 原生 `/api/embeddings`): - **世界观支撑** (`uo_lore`) —— 在不列颠尼亚世界观资料库中进行向量搜索,这样NPC关于世界的说法大体符合正典。 - **语音风格示例** (`bg3_style`) —— 与原型匹配的示例台词(节奏和机智,去除设定),为回复的*表达方式*增色,而不决定其内容。 - **每个NPC的功绩日志** (`npc_journal`) —— 每个已完成的任务被嵌入并存储到对应NPC,在聊天路径中会回忆该NPC少量过往功绩,这样商人就能记得它实际完成的旅程。 每个组成部分都与聊天路径一样是故障开放的:任何嵌入或搜索错误只是让这次回复失去支撑基础,NPC照常说话。RAG让NPC**更好**;它绝不是NPC说话的必要条件。Qdrant宕机的爆炸半径是"回复在几秒钟内没那么有根基",而不是"全镇沉默"。 ## 在重启后依然存在的身份和记忆 两种状态让NPC感觉像人而不是无状态应答器,它们都通过世界保存持久化,而不是保存在RAM中。 `NpcIdentity.cs` **一次性**从职业相关的池中生成角色 —— 城镇、出身、性情、背景故事种子、说话风格、私人动机、飘忽的情绪 —— 并序列化到 mobile 对象上。同一个来自Minoc的脾气暴躁的退伍兵铁匠,下周重启后还是同一个角色。模型不是每次调用都发明一个角色;它被赋予一个固定的角色表并被要求表演。 对于**已有的**标准NPC —— 那些不是自定义子类的(如 `LLMAmbientSpeech.cs` 通过一个全局的语音监听器赋予声音的库存商人、银行家、守卫)—— 没有可序列化的子类。所以 `LLMAmbientMemory.cs` 使用一个以 mobile 的稳定序列号为键的磁盘备份存储,跨重启保存了生成的身份和玩家个人关系。 `LLMConversation.cs` 保存每个(玩家, NPC) 对的短期对话记忆,由 `MaxMemoryTurns` 限制,这样话多的常客不会慢慢让每个提示膨胀到推理变慢。 ## 测试那些只在运行时才会出问题的东西 我以一种尴尬的方式学到的教训:**绿色构建只证明代码能编译,不证明NPC能说话。** 真正的问题都在运行时、在游戏世界中——端点可达但返回垃圾信息、角色提示打字错误导致所有NPC沉默、允许列表过滤器过于激进吞掉了所有表情。这些都编译器看不到。 所以测试采用一个无头测试工具,在没有图形客户端的情况下驱动世界:启动服务器实例,以合成玩家身份连接,走到一个NPC前说点什么,然后断言返回了合理的回复,并且任何表情都在允许列表中。这捕获了"编译正常但什么都不说"这类单元测试无法发现的bug,因为bug只会在世界运行且模型真正参与时才会出现。 这个测试运行在与我所构建的所有其他工作负载相同的家规下 —— 仅amd64镜像,每个 `buildx` 使用 `--provenance=false`(k3s的containerd默认拒绝buildx添加的构建证明清单),每次拉取都通过Harbor代理缓存。 ## 获取代码 该集成是公开的、MIT许可的,并且已移除所有与环境相关的内容: **github.com/ZoltyMat/uo-llm-npc (https://github.com/ZoltyMat/uo-llm-npc)** 它是脚本,不是服务器 —— 你自备ServUO服务器实例,把文件夹放到 `Scripts/Custom/`,复制 `LLMNpc.cfg.example` 为 `Config/LLMNpc.cfg`,将 `BaseUrl` 指向一个模型,设置 `Enabled=true`。推理由你提供的任何兼容OpenAI的端点完成;我在 [Mac Studio](https://www.amazon.com/s?k=Apple+Mac+Studio+M3+Ultra&tag=zoltyblog07-20) 上运行本地的Ollama,NPC说一句尖酸话的边际成本只是电费,没有其他。 如果我要改变一件事,那就是冷启动延迟 —— 安静一段时间后的第一句话会因为模型需要重新加载到VRAM而卡顿。固定模型可以解决这个问题,但会耗尽机器上其他所有任务,所以NPC只是挠挠头,晚了一拍回答。对于一个我将无限期运行的业余世界,这是一个我愿意接受的权衡。 *为什么*建造这个 —— 以及当巫妖们开始像神王一样互相称呼时是什么感觉 —— 更完整的故事在[配套文章](https://blog.zolty.systems/2026-06-03-llm-npcs-ultima-online/)中。

相似文章

OpenSkill:LLM智能体的开放世界自进化

Hugging Face Daily Papers

OpenSkill是一个框架,让LLM智能体能够从开放世界资源中自进化技能和验证信号,无需目标任务监督,在多个基准测试中实现高性能。

为MMORPG添加离线模式和自定义服务器

Lobsters Hottest

一位开发者详细介绍了为其自制MMORPG Trolddom添加离线模式和自定义服务器支持所面临的技术挑战,灵感来源于Stop Killing Games运动,涵盖了架构和实现方面。