厌倦了假AI对话?用本地大模型给UE注入真智能(已开源!)

厌倦了假AI对话?用本地大模型给UE注入真智能(已开源!)

UE4.27 + UnLua + FastAPI + Ollama:本地 LLM NPC 对话系统技术实现详解

项目地址UE4-LocalLLM-Chat
关键词 :UE4.27, UnLua, FastAPI, Ollama, LLM, NPC对话, 异步HTTP, 游戏AI
技术栈:C++ | Lua | Python | HTTP | Ollama


一、项目背景

在传统游戏开发中,NPC 对话通常依赖 preset 的对话树或简单的关键词匹配,灵活性极为有限。随着本地大语言模型(LLM)的成熟,我们可以在消费级硬件上运行具备真实对话能力的模型(如 qwen2.5:7b),并集成到游戏引擎中。

本文详细讲解 UE4-LocalLLM-Chat 的技术实现------这是一套将本地 LLM 接入 UE4.27 游戏的完整方案,涵盖三层架构:

技术 职责
服务端 Python / FastAPI / Ollama LLM 推理 + 会话管理
引擎桥 C++ / UE4.27 / UnLua Interface HTTP 通信 + 异步回调
表现层 Lua / UnLua / UMG UI 交互 + 定时轮询

二、整体架构

复制代码
┌──────────────────────────────────────────────────────────┐
│                     UE4.27 Game Client                    │
│                                                            │
│   ┌─────────────────┐          ┌──────────────────────┐  │
│   │   UMG_MAIN.lua   │  DoSend  │  ProjectChatGameMode │  │
│   │  (UnLua Widget)  │─────────▶│  (C++, IUnLua        │  │
│   │                  │          │   Interface)         │  │
│   │  - NPCSystemPrompt          │                      │  │
│   │  - CheckReply()   │         │  SendChatMessage()   │  │
│   │  - K2_SetTimer    │         │  LastChatReply       │  │
│   └─────────────────┘          │  bChatReplyReady     │  │
│                                  └──────────┬───────────┘  │
│                                             │              │
└─────────────────────────────────────────────┼──────────────┘
                                              │ HTTP POST
                                              │ localhost:18080
                                              ▼
┌──────────────────────────────────────────────────────────┐
│                  FastAPI Server (Python)                   │
│                                                            │
│   POST /v1/chat/session?session_id=UMG_Chat               │
│   ┌─────────────────────────────────────────────────┐    │
│   │ Body: {                                          │    │
│   │   "messages": [{"role":"user","content":"..."}], │    │
│   │   "system_prompt": "你是冒险岛NPC安牛...",         │    │
│   │   "max_tokens": 256                              │    │
│   │ }                                                │    │
│   └────────────────────┬────────────────────────────┘    │
│                        ▼                                   │
│   ┌─────────────────────────────────────────────────┐    │
│   │            Session Manager                       │    │
│   │  sessions["UMG_Chat"] = [                        │    │
│   │    {role:"system", content: NPCSystemPrompt},    │    │
│   │    {role:"user",   content: "你好"},             │    │
│   │    {role:"assistant", content: "你好冒险者!"},   │    │
│   │    ...                                           │    │
│   │  ]                                               │    │
│   └────────────────────┬────────────────────────────┘    │
│                        ▼                                   │
│            httpx.AsyncClient                              │
│               POST localhost:11434/api/chat               │
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│                Ollama (localhost:11434)                    │
│                   qwen2.5:7b                               │
└──────────────────────────────────────────────────────────┘

数据流一句话:Lua 传入 NPC 人设 → C++ 构建 JSON 发 HTTP → Python 管理会话 → Ollama 推理 → 异步回传 → Lua 轮询取结果。


三、Python 服务端:FastAPI + Ollama

3.1 为什么用 FastAPI

  • 原生异步:基于 asyncio + httpx,不阻塞事件循环
  • 自动文档:Swagger UI 自动生成,调试方便
  • Pydantic 校验:请求体自动类型检查
  • 轻量:单文件即可运行,无复杂配置

3.2 ChatRequest 模型设计

python 复制代码
class ChatMessage(BaseModel):
    role: str          # "user" | "assistant" | "system"
    content: str

class ChatRequest(BaseModel):
    messages: list[ChatMessage]
    system_prompt: Optional[str] = None   # 可选的定制 NPC 人设
    temperature: float = 0.7
    max_tokens: int = 2048
    top_p: float = 0.9
    stream: bool = False

system_prompt 是核心扩展字段------默认情况下使用 config.py 中的通用提示词,但客户端可以按需传入任意 NPC 人设,实现一个服务端支撑多个 NPC 角色的对话。

3.3 会话管理

python 复制代码
sessions: dict[str, list[dict]] = {}

def get_history(session_id: str) -> list[dict]:
    if session_id not in sessions:
        sessions[session_id] = [
            {"role": "system", "content": config.system_prompt}
        ]
    return sessions[session_id]
  • session_id 隔高不同 NPC/玩家的对话上下文
  • 首次访问自动初始化系统提示词
  • 历史裁剪策略:始终保留 system prompt + 最近 20 轮对话(40 条消息)
python 复制代码
def add_to_history(session_id, role, content):
    history = get_history(session_id)
    history.append({"role": role, "content": content})
    # 裁剪:保留 system + 最近 N 轮
    max_messages = 1 + config.max_history_turns * 2
    if len(history) > max_messages:
        system_msgs = [m for m in history if m["role"] == "system"]
        other_msgs = [m for m in history if m["role"] != "system"]
        sessions[session_id] = system_msgs + other_msgs[-(max_messages - len(system_msgs)):]

3.4 定制 SystemPrompt 的动态注入

这是整个系统的关键设计:客户端可以在每次请求中携带 system_prompt 字段,服务端收到后替换会话的 system message:

python 复制代码
@app.post("/v1/chat/session")
async def chat_with_session(req: ChatRequest, session_id: str = "default"):
    history = get_history(session_id)

    # 动态替换 NPC 人设
    if req.system_prompt:
        sys_msg = {"role": "system", "content": req.system_prompt}
        if history and history[0]["role"] == "system":
            history[0] = sys_msg
        else:
            history.insert(0, sys_msg)

    # 追加用户消息
    for msg in req.messages:
        add_to_history(session_id, msg.role, msg.content)

    # 调用 Ollama
    result = await call_ollama_chat(
        get_history(session_id), req.temperature, req.max_tokens, req.top_p
    )
    content = result.get("message", {}).get("content", "")
    add_to_history(session_id, "assistant", content)
    return ChatResponse(content=content, done=True)

3.5 Ollama 调用

python 复制代码
async def call_ollama_chat(messages, temperature, max_tokens, top_p) -> dict:
    async with httpx.AsyncClient(timeout=120.0) as client:
        resp = await client.post(
            f"{config.ollama_host}/api/chat",
            json={
                "model": config.model_name,
                "messages": messages,
                "stream": False,
                "options": {
                    "temperature": temperature,
                    "num_predict": max_tokens,
                    "top_p": top_p,
                },
            },
        )
        resp.raise_for_status()
        return resp.json()

关键参数映射:

  • Ollama 的 num_predict = OpenAI 的 max_tokens
  • stream: False 适合 UE4 的非流式请求(一次拿完整回复)

四、C++ 引擎桥:GameMode + IUnLuaInterface

4.1 为什么选 GameMode 而非独立 Actor

  • GameMode 在游戏启动时自动实例化,无需手动放置
  • 全局单例,从任何 Lua 脚本通过 UE.UGameplayStatics.GetGameMode(self) 即可获取
  • 生命周期与游戏一致,不会意外销毁

4.2 UnLua 接口实现

cpp 复制代码
class AProjectChatGameMode : public AGameModeBase, public IUnLuaInterface
{
    GENERATED_BODY()
public:
    virtual FString GetModuleName_Implementation() const override
    {
        return TEXT("ProjectChat.ProjectChatGameMode");
    }

    UFUNCTION(BlueprintCallable, Category = "AI Chat")
    void SendChatMessage(
        const FString& SessionId,
        const FString& UserMessage,
        const FString& SystemPrompt = TEXT("")
    );

    UPROPERTY(BlueprintReadOnly, Category = "AI Chat")
    FString LastChatReply;

    UPROPERTY(BlueprintReadOnly, Category = "AI Chat")
    bool bChatReplyReady;
};

关键设计决策 ------ 属性轮询代替委托回调

传统做法是用 DECLARE_DYNAMIC_MULTICAST_DELEGATE 广播 HTTP 响应,但在 UnLua 2.3.6 中动态多播委托无法从 Lua 侧绑定。因此改用简单属性:

  • SendChatMessage() 变为 void 异步:调用后立即返回,不阻塞
  • bChatReplyReady 标志位:HTTP 回调中置 true
  • LastChatReply 存储结果:Lua 定时器轮询 bChatReplyReady 后取走

4.3 HTTP 异步发送核心实现

cpp 复制代码
void AProjectChatGameMode::SendChatMessage(
    const FString& SessionId,
    const FString& UserMessage,
    const FString& SystemPrompt)
{
    bChatReplyReady = false;
    LastChatReply = TEXT("");

    TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
    Request->SetURL(FString::Printf(
        TEXT("http://localhost:18080/v1/chat/session?session_id=%s"),
        *SessionId));
    Request->SetVerb("POST");
    Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));

    // 构建 JSON body
    TSharedPtr<FJsonObject> BodyJson = MakeShareable(new FJsonObject);

    TArray<TSharedPtr<FJsonValue>> Messages;
    TSharedPtr<FJsonObject> MsgObj = MakeShareable(new FJsonObject);
    MsgObj->SetStringField(TEXT("role"), TEXT("user"));
    MsgObj->SetStringField(TEXT("content"), UserMessage);
    Messages.Add(MakeShareable(new FJsonValueObject(MsgObj)));
    BodyJson->SetArrayField(TEXT("messages"), Messages);
    BodyJson->SetNumberField(TEXT("max_tokens"), 256);

    if (!SystemPrompt.IsEmpty())
    {
        BodyJson->SetStringField(TEXT("system_prompt"), SystemPrompt);
    }

    FString Body;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Body);
    FJsonSerializer::Serialize(BodyJson.ToSharedRef(), Writer);
    Request->SetContentAsString(Body);

    // 异步回调:HTTP 完成后写入属性
    Request->OnProcessRequestComplete().BindLambda(
        [this](FHttpRequestPtr Req, FHttpResponsePtr Resp, bool bSuccess)
    {
        FString Reply;
        if (bSuccess && Resp.IsValid())
        {
            TSharedPtr<FJsonObject> JsonObj;
            TSharedRef<TJsonReader<>> Reader =
                TJsonReaderFactory<>::Create(Resp->GetContentAsString());
            if (FJsonSerializer::Deserialize(Reader, JsonObj) && JsonObj.IsValid())
            {
                Reply = JsonObj->GetStringField(TEXT("content"));
            }
        }
        LastChatReply = Reply;
        bChatReplyReady = true;
    });

    Request->ProcessRequest();
}

关键注意事项

  1. Build.cs 依赖 :必须添加 Http, Json, JsonUtilities 模块
  2. 回调线程安全OnProcessRequestComplete() 的回调在 HTTP 线程执行,直接写 FString 属性在 UE4 中通常是安全的(简单类型),但复杂操作应使用 AsyncTask(ENamedThreads::GameThread, ...) 切回主线程
  3. UE4 的 FJsonObject API 较繁琐 :需要用 MakeShareable 管理智能指针,TJsonWriterFactory 序列化

4.4 为什么不用同步 HTTP

最初尝试同步方案:SendChatMessage 返回 FString,内部用 FPlatformProcess::Sleep 等待。但 Sleep 阻塞了游戏线程,导致画面冻结 1-3 秒。

后来尝试 FTicker::GetCoreTicker().Tick() 替代 Sleep,但 FHttpManager 是模块内部类型无法直接访问,编译不通过。

最终方案 ------ 属性轮询 ------ 是最简洁且不依赖内部 API 的解决方案。


五、Lua 表现层:UnLua + UMG + 定时器轮询

5.1 UnLua Widget 生命周期

lua 复制代码
---@type UMG_Main_C
local M = UnLua.Class()

function M:Construct()
    -- 绑定控件事件
    self.m_SendButton.OnClicked:Add(self, M.OnSendClicked)
    self.m_EditableTextBox.OnTextCommitted:Add(self, M.OnInputCommitted)
    self:AddChatLine("系统", "欢迎来到 AI 对话!")
end

前提条件 :UMG_MAIN Widget Blueprint 必须在 Class Settings 中实现 IUnLuaInterface 接口,否则 Construct() 不会被 UnLua 调用。

5.2 发送消息与启动轮询

lua 复制代码
function M:DoSend()
    local textStr = UE.UKismetTextLibrary.Conv_TextToString(
        self.m_EditableTextBox:GetText())
    if textStr == "" then return end

    -- 禁用输入防重复
    self.m_EditableTextBox:SetIsEnabled(false)
    self.m_SendButton:SetIsEnabled(false)

    local gm = UE.UGameplayStatics.GetGameMode(self)
    self._gm = gm
    self._pollCount = 0

    -- 异步发送(第三个参数为定制 NPC 人设)
    gm:SendChatMessage("UMG_Chat", textStr, NPCSystemPrompt)

    -- 启动定时器,每 0.2 秒检查回复
    self._pollHandle = UE.UKismetSystemLibrary.K2_SetTimer(
        self, "CheckReply", 0.2, true)
end

K2_SetTimer 在 UnLua 中的签名简化 :UnLua 2.3.6 中只需要 4 个参数------(对象, 函数名字符串, 间隔秒数, 是否循环)。标准的 5 参数形式(含 initialDelay)在 UnLua 中会报 number needed but got boolean 错误。

5.3 CheckReply 轮询逻辑

lua 复制代码
function M:CheckReply()
    if not self._gm then return end

    if self._gm.bChatReplyReady then
        self:StopPollTimer()
        local reply = self._gm.LastChatReply

        if reply and reply ~= "" then
            self:AddChatLine("NPC", reply)
        else
            self:AddChatLine("系统", "API 无响应")
        end

        self.m_EditableTextBox:SetIsEnabled(true)
        self.m_SendButton:SetIsEnabled(true)
        self._gm = nil
        return
    end

    -- 超时保护:15 秒 = 75 次 × 0.2s
    self._pollCount = (self._pollCount or 0) + 1
    if self._pollCount > 75 then
        self:StopPollTimer()
        self:AddChatLine("系统", "API 请求超时")
        self.m_EditableTextBox:SetIsEnabled(true)
        self.m_SendButton:SetIsEnabled(true)
        self._gm = nil
    end
end

轮询频率选择 0.2 秒的考量:

  • Ollama 7B 模型生成回复通常需 1-4 秒
  • 0.2 秒间隔的延迟用户几乎无感
  • 15 秒超时覆盖极端情况(模型加载中/Ollama 卡死)

5.4 屏幕 HUD 输出(绕过 RichTextBlock 乱码)

采用引擎原生 HUD 输出:

lua 复制代码
function M:AddChatLine(Speaker, Message)
    local prefix, color
    if Speaker == "玩家" then
        prefix = "你: "
        color = UE.FLinearColor(0.3, 0.8, 1, 1)
    elseif Speaker == "NPC" then
        prefix = "NPC: "
        color = UE.FLinearColor(0.3, 1, 0.4, 1)
    else
        prefix = "[系统] "
        color = UE.FLinearColor(0.7, 0.7, 0.7, 1)
    end
    Screen.Print(prefix .. Message, color, 15)
end

Screen.Print 底层调用 UKismetSystemLibrary.PrintString,直接走引擎渲染管线,无编码问题。

5.5 定制 NPC 人设

lua 复制代码
local NPCSystemPrompt = [[
你是一个生活在冒险二次元世界的NPC角色,名字叫XX。
你性格活泼开朗,喜欢帮助冒险者。
请始终用自然的语气说话,偶尔加上颜文字动作描述。
回答控制在2-3句话以内。
]]

通过 Lua → C++ SendChatMessage 第三个参数 → JSON system_prompt 字段 → Python 会话替换,整条链路将人设传递给 Ollama。


六、关键技术踩坑与解决

6.1 同步 HTTP 卡死游戏线程

复制代码
时间线:同步方案
User clicks Send
  → Lua: DoSend()
    → C++: SendChatMessage() [阻塞]
      → HTTP POST.... 等待 3 秒......
      → 解析响应
    → 返回 FString
  → Lua: AddChatLine()
→ 画面冻结 3 秒后恢复

根因 :UE4 的 FHttpModule 基于 libcurl,回调通过 FTicker 在游戏线程 Tick 中触发。如果在同一次 Tick 中 Sleep 等待回调,回调永远得不到执行------死锁

6.2 Blueprint 函数路由

UnLua Widget 需要在蓝图中添加一个空的 CheckReply 函数:

  • 在 UMG_MAIN 的 MyBlueprint → Functions 中添加
  • 函数体留空(UnLua 自动路由到 Lua 的同名函数)
  • 编译保存

K2_SetTimer 按函数名查找------蓝图必须有此函数签名,UnLua 才能在运行时将调用转发到 Lua 实现。


七、性能分析

7.1 延迟分布(qwen2.5:7b, RTX 5080 16GB)

阶段 耗时 说明
HTTP 请求发送 < 1ms localhost 回环
Python 会话管理 < 1ms 纯内存操作
Ollama 推理 1-4s 取决于生成长度
HTTP 回调 + JSON 解析 < 5ms 简单反序列化
定时器轮询等待 0-200ms 0.2s 间隔,平均等待 100ms
总计 1-2s 用户体感在可接受范围

7.2 内存占用

  • Python 服务端:~80MB(FastAPI + httpx 常驻)
  • 每个会话:~数 KB(纯文本历史,20 轮约 4KB)
  • Ollama 推理:~5-6GB VRAM(qwen2.5:7b 4bit 量化)

八、扩展方向

  1. 多 NPC 支持 :不同 session_id 对应不同 NPC,每个 NPC 独立 SystemPrompt
  2. 流式输出 :利用 SSE /v1/chat/session/stream 实现 NPC 逐字说话效果
  3. Function Calling:让 LLM 能触发游戏内事件(开门、给物品等)
  4. RAG 知识库:注入世界观设定文档,让 NPC "知道"游戏背景
  5. 语音合成:TTS 将 LLM 文本转为语音,配合口型动画

九、总结

本文介绍的 UE4-LocalLLM-Chat 系统通过三层解耦架构(Lua → C++ → Python),成功将本地 LLM 集成到了 UE4.27 游戏中。核心设计思想:

  • 异步优先:HTTP 异步发送 + 定时器轮询,避免阻塞游戏主循环
  • 接口最小化:C++ 暴露最少 API(一个函数 + 两个属性),Lua 承担业务逻辑
  • 可扩展的人设系统:SystemPrompt 从客户端→服务端全程可定制,一个后端支撑无限 NPC

看看效果吧:


完整代码已开源:github.com/zhangxuhan/UE4-LocalLLM-Chat


相关推荐
csdn小瓯2 小时前
日志规范化与结构化输出:构建可观测的 AI 后端系统
人工智能
udc小白2 小时前
Excel实现LSTM示例
人工智能·深度学习·神经网络·机器学习·excel·lstm
完成大叔2 小时前
对话管理模式驱动的智能助手应用
人工智能
不懒不懒2 小时前
【LangChain RAG 入门实战:PDF 文档检索问答】
人工智能
@蔓蔓喜欢你2 小时前
浏览器扩展开发:打造个性化浏览体验
人工智能·ai
a58808112 小时前
星际争霸1原版安装包——游戏玩法、配置要求与详细安装教程
windows·游戏·游戏程序
海兰2 小时前
【应用实战】基于Dify与多Agent的凭证与档案管理
人工智能
嗝o゚2 小时前
昇腾CANN cann-recipes-infer 仓:LLaMA 推理最佳实践,从模型到服务
人工智能·llama·cann
2601_958815162 小时前
iPhone 17 护眼钢化膜怎么选?悟赫德观复盾护景贴解析
人工智能·科技·智能手机·圆偏振光护眼·观复盾护景贴·护眼钢化膜·iphone17护眼钢化膜