2026-UE5-AI-开发进展及框架分析

发布于 1 小时前  3 次阅读


2026-UE5-AI-开发进展及框架分析

目前 AI 相关功能仍未发布,这里拉取的 ue5-main 分支
笔者版本:e9c4b5cecf4365053d6a8081c5380dccbf1bbe7e [e9c4b5c]
提交日期: 2026年3月27日 19:24:13

UE 的 RoadMap 只提到了 AI Assistant,其他的开发计划都没有列出来,但是很多内容近期都已经开始了,为了避免重复造轮子,以及参考下官方的实现思路,减轻开发成本,考虑记录本篇内容,用于跟踪最新进展,确认 UE 开发计划、框架逻辑,文中不会过多跟踪细节。

主体设计

UE 建了很多插件,但大部分都还是空的,目前比较核心的有如下几个:

AIAssistant,ECABridge,ToolsetRegistry

  1. AI Assistant,UE 嵌入编辑器的一个 AI 助手,用来调用 UE 自己的 LLM 服务
  2. ToolsetRegistry,核心的工具集,所有的工具都会注册到这里,可以给 LLM 提供工具列表,也会转发所有的调用到具体工具。同时 FBlueprintLibraryToolset 也提供了 UFunction 的自动收集调用能力。
  3. ECABridge,MCP 的核心交互类,MCP Server 提供给外部 LLM 服务,手动实现了大量的 Command(目前有 250 文件)

ToolsetRegistry

通过架构图不难看出,ToolsetRegistry 是一个比较核心的模块,来负责注册、协调转发工具逻辑。

UToolsetRegistrySubsystem

ToolsetRegistry 模块内定义了一个 Editor 的 Subsystem,其持有着 ToolsetRegistry,也就是所有的工具集:

UCLASS(BlueprintType, MinimalAPI)
class UToolsetRegistrySubsystem : public UEditorSubsystem
{
    GENERATED_BODY()
...
    // The set of registered toolset handlers.
    UE::ToolsetRegistry::FToolsetRegistry ToolsetRegistry;
};

FToolsetRegistry

FToolsetRegistry 中有两个 TMap,一个是核心的工具集,另一个是自定义 Json 与 UPROPERTY 属性转换的转换器,提供的各个方法也都是这两个 Map 元素的注册、反注册、执行,还有一个比较关键的 GetToolsetJsonSchemas 方法,来提供工具列表的获取:

namespace UE::ToolsetRegistry
{
...
    class FToolsetRegistry {
...
        UE_API FString GetToolsetJsonSchemas() const;
...
        // The set of registered toolset handlers.
        TMap<FString, TSharedPtr<FToolset>> ToolsetHandlers;

        // The set of registered converters.
        TMap<FString, TSharedPtr<FToolsetJsonConverter>> JsonConverters;

        // Callbacks to be called when the toolset registry changes.
        FOnToolsetRegistryChanged OnToolsetRegistryChanged;
    };

}
FToolset

第一个 Map 的 Value FToolset 是工具集的基类,其预留了四个纯虚方法,类很单纯,ExecuteTool 也只是单纯的转发过来,处理逻辑各个 Set 自己按需完成即可。

实现子类注册后会在 FToolsetRegistry 中转调这些方法,这里会以 GetToolsetName 的结果作为键保存到 ToolsetHandlers:

namespace UE::ToolsetRegistry
{
    /// Base class for toolsets.
    class FToolset
    {
    public:
...
        virtual TFuture<TValueOrError<FString, FString>> ExecuteTool(const FString& ToolName, const FString& JsonInput) = 0;
        virtual FString GetToolsetJsonSchema() const = 0;
        virtual FString GetToolsetName() const = 0;
        virtual FString GetToolsetVersion() const = 0;
        virtual UClass* GetToolsetClass() const { return nullptr; }
    };
}

最终工具执行时会以 ToolsetName.ToolName 的形式调用,所以会在 ToolsetHandlers 先找到匹配的 Toolset 然后分发调用到各个 ExecuteTool 方法执行。

因为接口也很朴素,可以按实现自行完成,所以目前比较特殊的逻辑,反而都在几个子 ToolSet 里了:

FBlueprintLibraryToolset

这个是比较关键的一个 Toolset,支持传入一个 UClass 来自动生成这一 Toolset:

namespace UE::ToolsetRegistry
{
    class FBlueprintLibraryToolset : public FToolset
    {
    public:
        UE_API FBlueprintLibraryToolset(UClass* LibraryClass);
...
        // Map of valid tool names to tool call objects.
        TMap<FString, TSharedPtr<FObjectFunctionToolCall>> Tools;

        // Generate schema from reflection data.
        TSharedPtr<FJsonObject> GenerateSchema() const;

        // Generate Tools list.
        void GenerateToolCallObjects();

        // Helper method to generate list of valid tools.
        TArray<TObjectPtr<UFunction>> GetValidToolMethodsList() const;
    };
}

构造时,其会调用 GenerateToolCallObjects 方法, 然后调用 GetValidToolMethodsList 收集可用的方法,最终填充到 Tools 中:

    void FBlueprintLibraryToolset::GenerateToolCallObjects()
    {
...
        TArray<TObjectPtr<UFunction>> ValidTools = GetValidToolMethodsList();
        for (UFunction* Function : ValidTools)
        {
            check(Function);
            Tools.Add(
                Function->GetName(),
                FObjectFunctionToolCall::Create(
                    TNotNull<UObject*>(Toolset.Get()), TNotNull<UFunction*>(Function)));
        }
    }

GetValidToolMethodsList 方法中,会迭代传入 UClass 的 UFunction,将所有公开、静态的方法收集(额外过滤了含有 BlueprintInternalUseOnly meta 的):

    TArray<TObjectPtr<UFunction>> FBlueprintLibraryToolset::GetValidToolMethodsList() const
    {
...
        for (TFieldIterator<UFunction> FuncIt(Toolset.Get(), EFieldIterationFlags::None); FuncIt; ++FuncIt)
        {
            TObjectPtr<UFunction> Function = *FuncIt;
            if (Function)
            {
                bool bValidFunction = true;

                // Skip functions marked as for internal use only.
                if (Function->HasMetaData(TEXT("BlueprintInternalUseOnly")))
                {
                    bValidFunction = false;
                }

                // Skip functions unless they are public and static.
                if (!Function->HasAllFunctionFlags(FUNC_Public | FUNC_Static))
                {
                    bValidFunction = false;
                }

                if (bValidFunction)
                {
                    ValidTools.Add(Function);
                }
            }
        }
        return ValidTools;
    }

GetToolsetJsonSchema 时会转调 GenerateSchema 方法,其同样会调用 GetValidToolMethodsList 方法,然后迭代 UFunction,并将其转为 FJsonObject,最终再对 Function 名称修正,并补充一些额外信息完成 Schema 生成:

    TSharedPtr<FJsonObject> FBlueprintLibraryToolset::GenerateSchema() const
    {
...
        TArray<TSharedPtr<FJsonValue>> ToolsArray;
        TArray<TObjectPtr<UFunction>> ValidTools = GetValidToolMethodsList();
        for (UFunction* Function : ValidTools)
        {
            check(Function);

            TSharedPtr<FJsonObject> ToolEntry =
                UE::ToolsetRegistry::Internal::ToolsetJson::StructToJsonSchema(Function);
...
            ToolsArray.Add(MakeShared<FJsonValueObject>(ToolEntry));
        }
        Schema->SetArrayField(FString(TEXT("tools")), ToolsArray);
        return Schema;
    }

这里注意在 StructToJsonSchema 中,虽然以 Struct 传入,但是内部流程中 UFunction 会被特殊处理,以 JSON-RPC 的格式输出:

    bool VisitUStruct(const FValidConstUStruct Struct, const TSharedRef<FJsonObject>& OutputSchema,
        const FJsonSchemaMemberPath& MemberPath, const FJsonSchemaPropertyFilter& PropertyFilter,
        const EVisitorStackElementFlags Flags, const void* InstanceMemory)
    {
        EditorMetadata.CurrentPropertyMemberPath = MemberPath;

        const bool bUseJsonRpcFormat = Struct->IsA(UFunction::StaticClass());
...
        if (bUseJsonRpcFormat)
        {
            // If struct is a function, output JSON RPC -
            // https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts#L928
            // Extension for 'name' -
            // https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts#L312

            // NOTE - We know we are a UFunction, so will have a valid Outer.
            OutputSchema->SetStringField(TEXT("name"), *(Struct->GetOuter()->GetName() + "." + Struct->GetAuthoredName()));
...
        }
    }

最终执行时,会从 Tools 中找到对应的 FObjectFunctionToolCall 对象,然后调用其 Execute 方法,在这个方法中,会对参数校验,并填充默认参数。随后分配了一块 UFunction PropertiesSize 大小的内存 FrameMemory 并初始化,再通过 ToolsetJson::JsonDataToStruct 将来自大模型的参数写入,并补充世界上下文(获得第一个 PIE 或 Game 的 World),最终调用 Function->Invoke 完成函数调用:

    TFuture<FJsonValueOrError> FObjectFunctionToolCall::Execute(
        const TOptional<FFunctionInputParamsJson>& FunctionInputParamsJson,
        TSharedPtr<FToolCallScriptExceptionHandler> ExceptionHandler) const
    {
...
        // Get function input params JSON as a JSON object.
        TSharedPtr<FJsonObject> FunctionInputParamsJsonObject;
        FJsonValueOrError JsonValueOrError =
            BuildValidFunctionInputParamsJsonObject(FunctionInputParamsJson);
        if (JsonValueOrError.HasError())
        {
            return FJsonValueOrErrorFuture::Make(MoveTemp(JsonValueOrError));
        }
...
        // Allocate the frame memory.
        uint8* FrameMemory;
        {
            // Invoke must use properties size, because it's allocating locals, 
            // i.e. for script functions.
            const int32 FrameSize = Function->PropertiesSize;
            FrameMemory = static_cast<uint8*>(FMemory_Alloca_Aligned(FrameSize,
                Function->GetMinAlignment()));
            FMemory::Memzero(FrameMemory, FrameSize);
        }
...
        // We need to manually allocate each property in the frame memory.
        for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
        {
            if (const FProperty* ParameterProp = *It;
                !ParameterProp->HasAnyPropertyFlags(CPF_ZeroConstructor))
            {
                ParameterProp->InitializeValue_InContainer(FrameMemory);
            }
        }
...
        if (FrameMemory &&
            !ToolsetJson::JsonDataToStruct(
                FunctionInputParamsJsonObject.ToSharedRef(), Function, FrameMemory))
        {
...
        }
...
        MaybeSetWorldContextPropertyInFrameMemory(Function, FrameMemory);
...
        Function->Invoke(InstanceObject, StackFrame, ReturnValueAddress);
        FProperty* ReturnProperty = Function->GetReturnProperty();

...
        TSharedPtr<FJsonValue> ReturnJsonValue =
            ReturnProperty
            ? ToolsetJson::PropertyToJsonData(ReturnProperty, ReturnValueAddress)
            : MakeShared<FJsonValueNull>();
        return FJsonValueOrErrorFuture::Make(MakeResult(MakeValue(ReturnJsonValue)));
}

函数执行后,将返回值转为 Json 返回,完成调用(这里还有一个特殊的 UToolCallAsyncResult 返回类型,会等待异步操作完成后处理 TODO)。

现有的注册都是继承 UToolsetDefinition 的实现,例如 UAgentSkillToolset、UGameplayCueToolset 等,但是从上面的分析中可以看到,通篇并没有利用到 UToolsetDefinition 的任何能力,只要是 UClass 即可,所以实际上引擎内现有的类也都可以作为 FBlueprintLibraryToolset 注册到 FToolsetRegistry 中以供大模型调用。

FECABridgeToolset

所有手动定义的工具,都在这个 Toolset 里,其相关实现,均会通过 FECACommandRegistry 这个单例来进行调用。

FECACommandRegistry 单例中存储有通过 REGISTER_ECA_COMMAND 宏注册的 IECACommand,并会将最终调用转发到每一个 IECACommand 中。

现有的 Command 已经有很多个(目前有 250 个文件),例如:

REGISTER_ECA_COMMAND(FECACommand_GetActorsInLevel)
REGISTER_ECA_COMMAND(FECACommand_CreateActor)
REGISTER_ECA_COMMAND(FECACommand_DeleteActor)
REGISTER_ECA_COMMAND(FECACommand_SetActorTransform)
REGISTER_ECA_COMMAND(FECACommand_GetActorProperties)
REGISTER_ECA_COMMAND(FECACommand_SetActorProperty)
REGISTER_ECA_COMMAND(FECACommand_FindActors)
REGISTER_ECA_COMMAND(FECACommand_DuplicateActor)

大多是偏编辑器侧的操作类工具。

FMCPClientToolset

这个 Toolset 主要是为了获取外部工具,作为 MCP Client 存在,将外部的 MCP Server 的工具注册到 ToolsetRegistry,以便 AI Assistant 可以调用,具体的逻辑不再深入。

TODO

FToolsetJsonConverter

第二个 Map 主要是提供针对特殊属性的 Json 的转换器,对于注册过的属性,在 PropertyToJsonData 时会使用该转换器处理相关逻辑,基类提供的虚方法如下,很简单看名称理解即可:

namespace UE::ToolsetRegistry
{
    class FToolsetJsonConverter
    {
...
        virtual bool CanConvertProperty(TNotNull<const FProperty*> Property) = 0;
        virtual TSharedPtr<FJsonObject> PropertyToJsonSchema(TNotNull<const FProperty*> Property) = 0;
        virtual TSharedPtr<FJsonValue> PropertyToDefault(TNotNull<const FProperty*> Property, const FString& DefaultString) = 0;
        virtual TSharedPtr<FJsonValue> PrTNotNull<FProperty*> Property, const void* Value) = 0;
        virtual bool JsonDataToProperty(const TSharedPtr<FJsonValue>& JsonValue, TNotNull<FProperty*> Property,void* OutValue) = 0;
...
    };
}

这里提供这一个 Converter,笔者理解是期望能够提供 AI 更友好的 Json 数据,更有语义的数据,并且过滤一些无用数据,这里以 FToolsetTransformConverter 为例,其内部会把原 Transform 转换为 FToolsetTransform 再导出:

USTRUCT(BlueprintType, MinimalAPI)
struct FToolsetTransform
{
    GENERATED_BODY()
public:
    /// The world-space location.
    UPROPERTY(BlueprintReadWrite, Category = "Transform")
    TOptional<FVector> Location;

    /// The world-space rotation.
    UPROPERTY(BlueprintReadWrite, Category = "Transform")
    TOptional<FRotator> Rotation;

    /// The scale.
    UPROPERTY(BlueprintReadWrite, Category = "Transform")
    TOptional<FVector> Scale;
};

这里的结构很明显是更易于理解的,并且用了 Optional 可以避免无用导出,这里导出一下对比看下,scale 没有导出,旋转也由四元数变成了欧拉角:

{
    "toolsetTransform":
    {
        "location":
        {
            "x": 100,
            "y": 200,
            "z": 300
        },
        "rotation":
        {
            "pitch": 1.5707963705062877,
            "yaw": 2.3561945557594317,
            "roll": 0.78539818525314409
        }
    },
    "originTransform":
    {
        "rotation":
        {
            "x": -0.0065699261822421738,
            "y": -0.013845038824877948,
            "z": 0.020463884940290476,
            "w": 0.99967313677173952
        },
        "translation":
        {
            "x": 100,
            "y": 200,
            "z": 300
        },
        "scale3D":
        {
            "x": 1,
            "y": 1,
            "z": 1
        }
    }
}

其他的一些现有的继承也大都如此,不一一赘述。

AI Assistant

AIAssistant 会调用 GetToolsetRegistry().GetToolsetJsonSchemas() 来获取支持的 Tool 功能。

TODO

ECABridge

FECABridgetToolset

上文已经简单提到 FECABridgetToolset,其内部通过 FECACommandRegistry 单例,调用通过 REGISTER_ECA_COMMAND 宏注册的 IECACommand,将最终调用转发到每一个 IECACommand 中。

ECAMCPServer

除此之外,ECABridge 模块还提供了 FECAMCPServer,可以监听外部 LLM 调用:

HandleToolsList 方法返回提供的工具集,这里先组装的 FECACommandRegistry 的命令,然后是 ToolsetRegistry 的:

TSharedPtr<FJsonObject> FECAMCPServer::HandleToolsList()
...
TArray<TSharedPtr<FJsonValue>> FECAMCPServer::BuildToolDefinitions()
{
    TArray<TSharedPtr<FJsonValue>> Tools;
    TSet<FString> ExistingNames;

    for (const TSharedPtr<IECACommand>& Command : FECACommandRegistry::Get().GetAllCommands())
    {
        ...
    }

#if WITH_TOOLSET_REGISTRY
    // Direction 2: Also include tools from ToolsetRegistry (excluding our own ECABridge toolset
    // to avoid duplication — we already added those commands above)
    AppendToolsetRegistryTools(Tools, ExistingNames); // 这里因为 ECACommand 也注册了,还会过滤掉重复的
#endif

    return Tools;
}

外部调用时,在 HandleToolsCall 方法,先看 FECACommandRegistry 有无工具可执行,再将调用转发到 ToolsetRegistryTool 执行工具:

TSharedPtr<FJsonObject> FECAMCPServer::HandleToolsCall(const TSharedPtr<FJsonObject>& Params)
{
...
    if (FECACommandRegistry::Get().HasCommand(ToolName))
    {
        ...
    }
#if WITH_TOOLSET_REGISTRY
    // Direction 2: Try ToolsetRegistry if not found in ECABridge commands
    TSharedPtr<FJsonObject> ToolsetResult;
    if (TryExecuteToolsetRegistryTool(ToolName, Arguments, ToolsetResult))
    {
        return ToolsetResult;
    }
#endif
...
}

这里还是有些冗余,因为自己的命令实际上也注册到了 FToolsetRegistry,估计代码也还会调整。

Blueprint Lisp

这里引用 FECACommand_ParseBlueprintLisp 上的注释:

/**
 * Parse and validate BlueprintLisp code
 * 
 * BlueprintLisp is a LISP-like DSL for representing Blueprint graphs in a format
 * that's easy for AI to read, understand, and generate.
 * 
 * PREFERRED: Use BlueprintLisp format for all Blueprint implementation tasks.
 * It is more concise, easier for AI to reason about, and less error-prone than
 * the JSON-based node commands.
 * 
 * Example:
 *   (event BeginPlay
 *     (let player (GetPlayerCharacter 0))
 *     (branch (IsValid player)
 *       :true (PrintString "Valid!")
 *       :false (PrintString "Invalid!")))
 */

很清晰,Blueprint Lisp 相关类的作用就是,将蓝图与 AI 易于理解和产出的格式互换的工具,注释这里也有一个 BeginPlay 的示例。

目前 ECA 注册了四个命令,解析蓝图 Lisp,Lisp 到 蓝图,蓝图到 Lisp 和 获取 蓝图 List 帮助:

/**
 * Parse and validate BlueprintLisp code
 */
class FECACommand_ParseBlueprintLisp : public IECACommand

/**
 * Convert a Blueprint graph to BlueprintLisp code
 * 
 * PREFERRED: Use this to read Blueprint logic. The Lisp format is more compact
 * and easier to understand than raw node JSON.
 */
class FECACommand_BlueprintToLisp : public IECACommand

/**
 * Convert BlueprintLisp code to Blueprint graph nodes
 * 
 * PREFERRED: Use this to implement Blueprint logic. The Lisp format handles
 * node creation, wiring, and positioning automatically. Much simpler than
 * manually creating nodes and connections via JSON commands.
 */
class FECACommand_LispToBlueprint : public IECACommand

/**
 * Get BlueprintLisp syntax help and examples
 */
class FECACommand_BlueprintLispHelp : public IECACommand

从语法可以看到这个 Lisp 十分简洁,但是也缺少很多信息,例如 Function 来自哪里,代码里转换的时候就花了一些逻辑查找:

        UFunction* Function = nullptr;
        TArray<UClass*> ClassesToSearch = {
            UKismetSystemLibrary::StaticClass(),
            UKismetMathLibrary::StaticClass(),
            UKismetArrayLibrary::StaticClass(),
            UKismetStringLibrary::StaticClass(),
            UGameplayStatics::StaticClass(),
            AActor::StaticClass(),
            APawn::StaticClass(),
            ACharacter::StaticClass(),
            Ctx.Blueprint->ParentClass
        };

        // Add Geometry Script classes for procedural mesh functions
        AddGeometryScriptClasses(ClassesToSearch);

        for (const FString& NameToTry : FuncNamesToTry)
        {
            for (UClass* Class : ClassesToSearch)
            {
                if (Class)
                {
                    Function = Class->FindFunctionByName(*NameToTry);
                    if (Function) break;
                }
            }
            if (Function) break;
        }

而对于大模型如何确认接口这种,也没有做一些统计返回之类的,可能还是要配合其他的 Command 使用,比如 FECACommand_GetBlueprintInfo 这种。

ECABlueprintLispCommands.cpp 这个文件逻辑哐哐写了 6700 行,有空再看了 TODO。

TODO 为什么要打洞 BlueprintReadOnly