AI Avatar 多智能体交互系统技术报告

1. 项目概述

这是一个面向游戏角色交互的集中式多智能体调度系统。UE5 负责采集场景内角色状态、发送结构化请求并执行动作;Python / FastAPI 负责协议校验、LLM Function Calling 调度、动作归一化和几何换算;LLM 本身只负责理解语义、拆解任务、选择角色,再生成动作调用。

说到底,我想解决的并不是“让 AI 能说话”这么简单,而是“怎么把一条自然语言指令,稳定地变成游戏世界里多个角色的可执行动作”。

这个项目要解决的核心问题主要有三个:

  1. 单轮自然语言指令需要同时调度多个场景角色,传统单 Agent 对话链路很难稳定覆盖。
  2. LLM 直接生成 UE5 世界坐标时很容易出现空间幻觉,尤其是“向前走 15 米”“跟着她一起走”这类相对位移任务,稳定性会明显下降。
  3. UE5 侧需要一条可验证、可扩展、后续还能平滑迁移到 C++ 原生模块的消息协议和执行链路,而不是继续依赖自由文本解析。

技术栈:Unreal Engine 5C++BlueprintPythonFastAPIPydantic v2LLM Function Calling


2. 我想把它做成什么

如果用一句话来概括,我想做的是把 LLM 从“直接控制游戏世界”的不稳定执行者,降成一个只做意图理解和动作规划的调度器;真正和游戏世界强耦合的状态、坐标、实体路由、协议校验,都应该收回到后端和引擎侧。

这套设计对应的工程目标是:

  1. 单次请求下支持多角色批量调度。
  2. 将角色状态序列化为结构化上下文,而不是拼接自然语言描述。
  3. 用严格协议替代自由 JSON,确保 UE5 可以稳定映射动作。
  4. 将空间数学从 LLM 中剥离,避免“看起来合理、实际上错位”的位移结果。
  5. 让 Blueprint 原型可以平滑下沉为 UE5 C++ Subsystem / Router 核心。

3. 系统架构

3.1 模块分层

层级 模块 责任 当前落地
Engine UI 层 WBP_AIChatPanel 采集输入、遍历场景角色、组装请求、接收响应 Blueprint
Engine Routing 层 ReceiveAICommand / Command Router agent_id + action_code 路由动作到实体 Blueprint 原型,目标迁移到 C++
Engine Execution 层 BP_AIAgent 执行 MoveTo 等具体行为 Blueprint
Orchestrator 层 FastAPI /chat 协议校验、调度 LLM、执行工具、归一化动作 Python
Agent Registry 层 GameMaster / AgentProfile / BaseTool 角色人设、能力集合、动态工具 schema Python
Reasoning 层 兼容 OpenAI 接口的 LLM 意图理解、角色选择、工具调用、回复生成 外部模型

3.2 为什么我选集中式而不是分布式

我没有采用“每个角色一个 Agent,自主互相对话”的分布式架构,而是先选择了集中式调度器,原因很直接:

  1. 场景真值只在 UE5 里最可靠,集中式架构更容易把“当前有哪些角色、每个角色的坐标和朝向是什么”一次性送进上下文。
  2. 多角色协同时,单个调度器更容易保证任务分配一致,避免两个 Agent 对同一角色重复下指令。
  3. Function Calling 天然更适合“由一个决策器产出多个结构化动作”这种场景。

当然,这样做也有很明确的取舍:

  1. 好处是协议清晰、调试成本低、整体可控性更强。
  2. 代价是中央调度器会成为单点瓶颈,后续必须补上缓存、超时控制和并发治理。

4. 端到端执行链路

4.1 请求阶段

  1. 玩家在 WBP_AIChatPanel 输入自然语言。
  2. 点击发送后,Widget 遍历场景内全部 BP_AIAgent,采集 agent_id / agent_x / agent_y / agent_yaw
  3. Widget 将 user_text + agents_state[] 组装成 ChatRequest,通过 VaRest 发送给 FastAPI /chat

4.2 调度阶段

  1. FastAPI 使用 Pydantic 严格校验请求协议。
  2. GameMaster 根据当前激活角色动态构建 prompt 和 tool schema。
  3. LLM 通过 Function Calling 生成一个或多个工具调用。
  4. 后端逐个执行工具,并把结果归一化成 UE5 可执行的 actions[]

4.3 执行阶段

  1. UE5 收到 ChatResponse 后,读取 reply_text 作为角色回应。
  2. Widget 遍历 actions[],按 agent_id 找到目标 Actor。
  3. 找到目标后调用 ReceiveAICommand(action_code, target_x, target_y)
  4. BP_AIAgent 依据 action_code 进入对应执行分支,当前已经验证 MoveTo -> AIMoveTo 这条链路。

这条链路里最关键的一点是:从头到尾,LLM 都没有直接接触 UE5 Actor,也没有直接控制世界状态;它输出的只是一个经过协议约束的“动作意图”。


5. 协议设计:为什么我要用严格结构化协议

5.1 请求协议

后端使用 Pydantic 定义了严格请求结构:

1
2
3
4
5
6
7
8
9
10
11
{
"user_text": "让小白向前走 5 米,莓咕噜跟着一起走",
"agents_state": [
{
"agent_id": "ue_bot_01",
"agent_x": 120.0,
"agent_y": 300.0,
"agent_yaw": 90.0
}
]
}

对应实现:

  1. AgentState / ChatRequest / ActionItem / ChatResponse 使用 extra="forbid" 禁止脏字段进入系统。
  2. ChatResponse.actions[] 采用 action_code + action_params 的二层结构,避免把所有动作揉成一个超大 JSON。

代码锚点:

  1. 协议模型:mock_server.py:17-61
  2. 请求入口:mock_server.py:461-469

5.2 工具 schema

工具 schema 由 BaseTool 统一生成,并强制注入 agent_id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"type": "function",
"function": {
"name": "MoveTo",
"parameters": {
"type": "object",
"properties": {
"agent_id": {"type": "string"},
"target_x": {"type": "number"},
"target_y": {"type": "number"}
},
"required": ["agent_id", "target_x", "target_y"],
"additionalProperties": false
}
}
}

这一步的意义不是“给 LLM 看一份说明书”,而是把“谁来执行动作”提升成协议级强约束,避免多角色场景里最常见的串指令问题。

代码锚点:

  1. agent_core.py:18-41
  2. agent_core.py:45-134

6. 语义 - 数学解耦:核心稳定性设计

这是整个项目里最关键的工程决策之一。

6.1 问题

LLM 很擅长理解“向前走 10 米”“跟她一起向右移动”这种自然语言,但它并不擅长稳定地做 UE5 坐标系下的几何换算。直接让模型输出绝对坐标,常见问题主要有:

  1. 单位混乱,米和厘米混用。
  2. 忽略角色当前朝向,导致“向前”被错误理解成世界坐标轴上的固定方向。
  3. 在跟随类动作里,把参考角色和执行角色搞混。

6.2 方案

我把动作拆成了两层:

  1. 语义层:LLM 只输出 MoveRelative(forward, right)MoveWithAgent(reference_agent_id, forward, right)
  2. 数学层:后端读取 UE5 上传的当前状态,在服务端完成 yaw 旋转、坐标换算和目标点落地,最后再统一归一化成 MoveTo(target_x, target_y)

换算逻辑如下:

1
2
3
dx = forward * cos(yaw) - right * sin(yaw)
dy = forward * sin(yaw) + right * cos(yaw)
target = current_position + (dx, dy)

代码锚点:

  1. execute_move_relativemock_server.py:222-258
  2. execute_move_with_agentmock_server.py:261-300
  3. 工具分发入口:mock_server.py:303-387

6.3 结果

这一步把空间计算从“概率性输出”变成了“确定性函数”,本质上就是把大模型从控制环里拿掉,改成一个“自然语言编译器”。

在我看来,这也是整个项目里最能体现设计思路的一块。因为它不是单纯换了个 prompt,而是明确划清了大模型应该做什么、不应该做什么。


7. 集中式多智能体调度:为什么单次请求能指挥多角色

GameMaster 维护了一份角色名册,每个角色都会绑定:

  1. agent_id
  2. 角色设定
  3. 可用工具列表

后端会根据当前场景里真正存在的角色,动态裁剪暴露给 LLM 的工具集合,而不是把所有能力一股脑丢给模型。

例如:

  1. ue_bot_01 只能移动。
  2. meguru_01 除了移动,还具备 PlayEmote

这种设计有两个很直接的好处:

  1. 减少模型误调用不存在能力的概率。
  2. 把“角色能力边界”从 prompt 文本,提升成代码级约束。

代码锚点:

  1. 角色与工具注册:agent_core.py:168-195
  2. 动态工具裁剪:agent_core.py:197-208
  3. 动态指挥 prompt:agent_core.py:210-255

7.1 为什么 prompt 里会有大量硬约束

这不是“提示词工程炫技”,而是在给 LLM 建立一份执行规约:

  1. 能调用工具时就不要空谈。
  2. 多角色场景下必须精确填写 agent_id
  3. 相对移动严禁模型自己去算绝对坐标。
  4. 回复文本只允许输出角色台词,不能把推理过程带回前台。

我还在后端补了一个 sanitize_reply_text,专门清洗 <think> 之类的模型污染输出,算是再加一层保险。

代码锚点:

  1. Prompt 约束:agent_core.py:235-255
  2. 回复清洗:mock_server.py:175-185

8. UE5 侧原型链路:Blueprint 已经验证了什么

根据当前 Blueprint 导出内容,UE5 侧已经验证了下面四件事:

8.1 状态采集闭环

WBP_AIChatPanel 点击发送后会:

  1. GetAllActorsOfClass(BP_AIAgent)
  2. 读取每个角色的 AgentID
  3. 读取位置 K2_GetActorLocation
  4. 读取朝向 K2_GetActorRotation
  5. 组装成 agents_state[]

这一步说明“世界状态上送”不是停留在概念层,而是真的已经跑通了。

8.2 请求构造闭环

Widget 使用 VaRest 构造 POST 请求,将:

  1. user_text
  2. agents_state[]

发往后端 ServerURL

这时候其实已经形成了最小可用的人机交互入口。

8.3 动作路由闭环

收到响应后,Widget 会:

  1. 读取 reply_text
  2. 读取 actions[]
  3. 遍历动作数组
  4. 解析 agent_id / action_code / action_params
  5. 在场景内二次遍历 BP_AIAgent
  6. 找到匹配 AgentID 的实体并调用 ReceiveAICommand

这一步说明消息解析、指令路由和执行链路已经不是写在设计图上的东西,而是真的联调通了。

8.4 实体执行闭环

BP_AIAgent.ReceiveAICommand 当前已经实现:

  1. ActionCode 做字符串分发。
  2. MoveTo 分支将 TargetX / TargetY 组装为目标点。
  3. 保留当前 Z 值,调用 AIMoveTo

虽然现在执行器仍然是 Blueprint,但接口边界已经很接近 C++ 原生化:输入是结构化命令,输出是行为执行结果,而不是一堆散乱蓝图节点互相直连。


9. 从 Blueprint 原型到 C++ 核心:我会怎么下沉

现阶段的原型已经验证了协议、路由和行为执行链路。下一步真正需要下沉到 C++ 的,不是 UI,而是下面这三个核心模块:

  1. UAICommandRouterSubsystem
  2. FAIActionCommand 结构体
  3. 异步命令队列 + 状态映射层

建议的 C++ 抽象如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
USTRUCT(BlueprintType)
struct FAIActionCommand
{
GENERATED_BODY()

UPROPERTY(BlueprintReadOnly)
FString AgentId;

UPROPERTY(BlueprintReadOnly)
FString ActionCode;

UPROPERTY(BlueprintReadOnly)
TMap<FString, double> NumberParams;
};

UCLASS()
class UAICommandRouterSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()

public:
void EnqueueCommands(const TArray<FAIActionCommand>& InCommands);
void DispatchNext();
void RouteToActor(const FAIActionCommand& Command);
};

这样拆的好处很明确:

  1. Widget 只负责 UI,不再承担复杂 JSON 解析。
  2. Actor 只负责执行,不再承担路由职责。
  3. 命令分发、重试、超时和队列调度都可以下沉到 Subsystem 统一处理。

10. 关键技术取舍

10.1 Function Calling vs 自由 JSON

我选择 Function Calling,而不是让模型裸输出 JSON 文本。

原因:

  1. schema 更稳定,字段约束更强。
  2. 多角色场景里更容易校验 agent_id 和参数完整性。
  3. 可以把“语义规划”和“动作执行反馈”组织成一个比较稳定的工具调用闭环。

代价:

  1. 需要维护 tool schema。
  2. 新增动作时要同时更新工具定义和 UE5 路由层。

10.2 中央调度器 vs 每角色自治

我选择中央调度器,是因为现阶段的主要痛点不是“角色人格自治”,而是“多角色任务怎么稳定执行”。

收益:

  1. 世界状态更一致。
  2. 更容易做多角色协作。
  3. 整个系统的边界也更容易讲清楚。

代价:

  1. 横向扩展压力会集中在后端。
  2. 调度器的 prompt 复杂度会上升。

10.3 Blueprint 快速验证 vs C++ 一次到位

我先用 Blueprint 验证链路,而不是一开始就全量写 C++。

原因:

  1. 这个项目的高风险点在协议和调度逻辑,不在 UI 节点本身。
  2. Blueprint 更适合快速打通 UE5 <-> HTTP <-> Actor 的最小闭环。
  3. 等协议和动作模型稳定下来,再做 C++ 核心化,返工成本会更低。

代价也很明显:

  1. Blueprint 解析复杂 JSON 的可维护性比较差。
  2. 命令队列、重试和并发控制很难做得优雅。
  3. 一旦行为扩展多起来,蓝图会迅速膨胀。

11. 代码索引

能力点 代码位置
工具 schema 强制注入 agent_id agent_core.py:18-41
MoveTo / MoveRelative / MoveWithAgent / PlayEmote agent_core.py:45-134
角色名册与能力边界 agent_core.py:168-195
动态 prompt 与执行铁律 agent_core.py:210-255
严格协议模型 mock_server.py:17-61
兼容 OpenAI 接口的客户端 mock_server.py:101-150
回复污染清洗 mock_server.py:175-185
相对坐标 -> 绝对坐标换算 mock_server.py:222-300
工具调用解析与动作归一化 mock_server.py:303-387
LLM 工具调用循环 mock_server.py:399-458
FastAPI /chat 接口 mock_server.py:461-469

12. 当前局限性与未来优化方向

这部分我想单独说一下,因为复盘不能只写做成了什么,也得把还没做完的地方讲清楚。

12.1 当前局限性

  1. UE5 侧路由和执行器目前仍以 Blueprint 原型为主,C++ 原生命令核心还没有完全落地。
  2. 引擎侧还没有真正的命令队列、超时重试、取消执行和状态回写闭环。
  3. 当前 Actor 执行链路主要验证了 MoveTo,更多动作类型还需要继续补齐执行器。
  4. HTTP 客户端目前使用 urllib,还没有做连接复用、流式输出和更细粒度的错误分层。
  5. 缺少自动化协议测试、回放测试和端到端压测数据。

12.2 下一步计划

  1. WBP_AIChatPanel 中的 JSON 解析与动作路由下沉到 UGameInstanceSubsystem
  2. FAIActionCommand 统一动作载体,替换蓝图里的临时字段拼装。
  3. 引入异步命令队列和状态机,支持执行中、成功、失败、重试等生命周期。
  4. PlayEmote、交互、跟随、打断等行为补齐 UE5 原生执行器。
  5. 增加契约测试与延迟监控,让这套系统从 Demo 链路继续往可长期维护的交互框架走。

关注Arisu喵!关注Arisu谢谢喵!