厌倦了假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 回调中置trueLastChatReply存储结果: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();
}
关键注意事项:
- Build.cs 依赖 :必须添加
Http,Json,JsonUtilities模块 - 回调线程安全 :
OnProcessRequestComplete()的回调在 HTTP 线程执行,直接写FString属性在 UE4 中通常是安全的(简单类型),但复杂操作应使用AsyncTask(ENamedThreads::GameThread, ...)切回主线程 - 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 量化)
八、扩展方向
- 多 NPC 支持 :不同
session_id对应不同 NPC,每个 NPC 独立 SystemPrompt - 流式输出 :利用 SSE
/v1/chat/session/stream实现 NPC 逐字说话效果 - Function Calling:让 LLM 能触发游戏内事件(开门、给物品等)
- RAG 知识库:注入世界观设定文档,让 NPC "知道"游戏背景
- 语音合成:TTS 将 LLM 文本转为语音,配合口型动画
九、总结
本文介绍的 UE4-LocalLLM-Chat 系统通过三层解耦架构(Lua → C++ → Python),成功将本地 LLM 集成到了 UE4.27 游戏中。核心设计思想:
- 异步优先:HTTP 异步发送 + 定时器轮询,避免阻塞游戏主循环
- 接口最小化:C++ 暴露最少 API(一个函数 + 两个属性),Lua 承担业务逻辑
- 可扩展的人设系统:SystemPrompt 从客户端→服务端全程可定制,一个后端支撑无限 NPC
看看效果吧:



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