Voyager, l’agent IA qui joue à Minecraft (1/4)

En mai 2023, une équipe de chercheurs a publié un papier pour présenter Voyager, un agent qui s'appuie notamment sur un LLM pour jouer à Minecraft de manière autonome. A l'époque marginale, cette approche qui consiste à exploiter le potentiel de l'IA générative en faisant d'un modèle une brique parmi d'autres d'un système est désormais devenue centrale.
Voyager, l'agent IA qui joue à Minecraft
Quelque part dans Minecraft, l'aventure continue...
Dans ces conditions, la relecture attentive du papier et du code mis à disposition à l'époque permet de comprendre comment s'y prendre pour ne pas être dépassé par les développements d'une ingénierie très particulière, visiblement promise à un bel avenir depuis que la croissance des scaling laws semble ralentir, et qu'en conséquence l'industrie cherche à cueillir les low-hanging fruits autrement que par le prompt engineering.
C'est le premier article d'une série. Après avoir présenté Voyager dans son principe, et les technologies sur lesquelles il s'appuie, la lecture du code commence avec une partie assez aride mais nécessaire, à savoir la manière dont Voyager collecte des informations sur l'environnement dans lequel il contrôle le bot – le personnage dans Minecraft.
NB : Ce billet a été rédigé début décembre par un humain et non une boîte de conserve, et sera publié dans le HS n° 17 de Programmez! consacré à l'IA.

Lecteur, prends garde à toi

Mis bout-à-bout, les articles de cette série forme un article très long. Quand je dis "très long", c'est vraiment très long – plus d'une cinquantaine de pages Word, si l'on comprend les quelques pages de bibliographie –, car l'objectif que s'est donné l'auteur, c'est de laisser "no stone unturned" pour accéder à une véritable compréhension de ce qu'il faut faire pour créer une entité qui évolue en apparente autonomie dans un environnement complexe, cela en s'appuyant sur l'IA générative, plus particulièrement un LLM.
Ainsi, le code de Voyager a été lu ligne par ligne sans aucune assistance, même pas celle d'un débogueur – en fait, ce code n'a jamais été exécuté, car il faudrait pour cela disposer d'un abonnement à OpenAI pour accéder à un LLM, et s'il est bien quelque chose que l'auteur refuse, c'est d'enrichir Sam Altman.
Par code, il faut entendre du code écrit en Python et en JavaScript – deux langages que tout développeur, en plus du C et du C++, doit parfaitement maîtriser de nos jours – et d'un paquet de textes, qui dans une certaine mesure constitue aussi du code, mais en langage naturel, puisqu'il s'agit de prompts.
Même s'il faut déplorer qu'il a été rédigé par des spacers et non des tabbers, le code de Voyager est lisible. Toutefois, il convient d'avertir le lecteur sur deux points :
  • ce code est très peu commenté alors qu'il fait de tours et des détours parfois compliqués à suivre, ce qui constitue d'ailleurs l'une des raisons pour lesquelles le présent article a été écrit – vu son intérêt pédagogique, il fallait rendre ce code accessible à tous ;
  • dans cet article, ce code sera grandement détaillé, mais pas dans ses moindres détails – notamment ce qui concerne les checkpoints –, si bien que les explications seront plus faciles à suivre si l'on se reporte parallèlement au code, qui est donc en accès libre sur un repo de GitHub.
Au fil de la lecture du code, des enseignements généraux seront tirés pour qui prétend créer son propre agent, pas forcément pour jouer à Minecraft. Dans le dernier article de cette série, désormais doté d'un bagage pratique pour parler d'agent, il sera possible de prendre du recul en se référant à diverses sources – articles, podcasts, et autres vidéos – pour acquérir une vision plus générale du sujet : en quoi les agents constituent-ils une perspective prometteuse ? dans quels domaines trouve-t-on déjà des agents ? comment pourraient évoluer la manière d'en créer ?
Un dernier point. Dans les textes qui sont repris, tout particulièrement les prompts, ce qui figure en rouge, entre signes inférieurs et supérieurs, et en français pour encore mieux le distinguer, est un commentaire pour indiquer ce qui doit figurer à l'endroit correspondant. Par exemple, il faudrait ici faire figurer une réponse à la place de <réponse> :
Question 1: How to obtain a diamond ore?
<réponse>
D'ailleurs, un premier enseignement : l'absence de norme pour faire figurer un commentaire dans un prompt est un problème qui n'a pas été anticipé. Il illustre bien la porosité entre le domaine des instructions et celui des données dans un prompt, dont l'on a pointé combien elle peut être dangereuse dans un précédent article consacré aux attaques sur les LLMs.

Alex ou Steve, avec un cerveau

Voyager a été porté à notre connaissance en mai 2023 via la publication d'un papier et la mise à disposition de tout son code. Le lecteur nécessairement intéressé trouvera tout cela sur un repo GitHub de MineDojo, en référence à un premier projet éponyme qui remonte à juin 2022.
MineDojo visait à définir la manière de concevoir un generalist embodied agent, autrement dit un agent susceptible de faire tout ce qu'il est possible de faire dans son environnement, le cas d'usage étant un agent dans Minecraft. Voyager s'inscrit dans cette lignée, mais alors que MineDojo misait sur une connaissance de Minecraft procédant d'un apprentissage préalable à partir d'une montagne de démonstrations de tâches accomplies – vidéos, didacticiels, et forums –, Voyager ne mise sur aucun apprentissage préalable, du moins directement. En effet, comme il sera possible de le constater dans le détail, la connaissance de Minecraft de Voyager est incorporée dans les LLMs – différentes versions de GPT – et les APIs qu'il mobilise – Mineflayer et ses modules, et quelques scripts ad hoc très simples qui reposent dessus –, et pour le reste acquise et stockée dans une base de données vectorielle au fil des pérégrinations.
Ceux qui veulent voir Voyager à l'œuvre, et se faire au passage une première idée de son mécanisme, peuvent regarder la brève vidéo que lui a consacré la chaîne Two Minutes Papers sur YouTube. Personnellement, l'auteur du présent article n'a pas testé Voyager – de même que l'auteur de la vidéo pointée à l'instant, d'ailleurs, qui a repris des images produites par les créateurs de l'agent. L'on fera confiance à ces derniers pour ce qui concerne la mesure des performances. C'est qu'il ne sera pas question de l'atteinte de l'objectif dans cet article, mais de la manière de l'atteindre, bref du mécanisme : une IA qui joue à Minecraft, comment est-ce possible ?
Toutefois, un mot sur ce dont Voyager est prétendu capable. D'après ses créateurs, l'agent fonctionne vraiment bien, surtout en comparaison avec les agents SOTA – l'acronyme récurrent dans le milieu de l'IA pour "state of the art" – de l'époque. Donnant au LLM l'instruction de lui permettre de "discover as many diverse things as possible, accomplish as many diverse tasks as possible and become the best Minecraft player in the world", il parvient à faire monter le personnage qu'il contrôle dans Minecraft en compétences jusqu'à lui faire produire les objets les plus sophistiqués d'alors, notamment la fameuse épée en "diams", comme la désigne Aypierre. En cela, il fait mieux et plus rapidement que les autres :
L'évolution des performances de Voyager par rapport à d'autres agents
L'évolution des performances de Voyager par rapport à d'autres agents. (source)

Le mécanisme, à gros traits

Dans leur papier, les créateurs de Voyager expliquent que l'agent comprend trois composants essentiels [Figure 2] :
  • Un automatic curriculum, qui consiste à demander au LLM de générer la description de la prochaine tâche à accomplir dans l'objectif déjà mentionné – accomplir le plus de tâches possibles pour devenir le meilleur joueur de Minecraft au monde – sur la base d'une description de l'état de l'agent et de son environnement. La description générée est en langage naturel, mentionnant par exemple "Obtain a wood log".
  • Un iterative prompting mechanism, qui consiste à demander au LLM de générer le code JavaScript qui permet de contrôler l'agent dans Minecraft, en s'appuyant au besoin sur du code déjà stocké dans une bibliothèque. Ce code est vérifié par un interpréteur JavaScript, et exécuté s'il est valide. Sur la base du retour de l'interpréteur et de Minecraft, le LLM est alors interrogé pour lui demander d'évaluer si le code a permis d'accomplir la tâche, et de reprendre tout le processus si ce n'est pas le cas.
  • Une skill library, qui correspond donc à la bibliothèque évoquée, et qui n'est autre qu'une base de données vectorielle qui permet d'associer la description d'une tâche au code JavaScript dont le LLM a précédemment validé qu'il s'exécutait et permettait d'accomplir la tâche en question.
Le fonctionnement général de Voyager
Le fonctionnement général de Voyager. (source)
Le papier ne rentre pas plus dans les détails, et il faut donc lire le code pour y accéder. C'est que ce bref exposé soulève de pressantes questions d'opérationnalisation. Concrètement, comment une tâche est-elle déterminée ? Son code, validé et exécuté ? Son accomplissement, évalué ? La montée en compétences qui en résulte, appréciée ? Et bien évidemment, comment les erreurs qui peuvent survenir au fil de ce long processus sont-elles gérées ?

UN GRAND TOUR SOUS LE CAPOT

Sans la contredire, le code de Voyager donne à voir une réalité plus complexe que celle qui vient d'être présentée. Sa lecture permet de comprendre que Voyager s'appuie sur différentes technologies avec lesquelles il va falloir se familiariser – ce qui sera fait dans cet article –, ne serait-ce que pour comprendre à quoi elles servent :
Technologie Langage Description
minecraft-launcher-lib Py Démarrer Minecraft
Mineflayer JS Contrôler un bot dans Minecraft
LangChain Py Chaîner des LLMs
Chroma Py Manipuler une base de données vectorielle
JSPyBridge Py Exécuter Node.js depuis Python
Babel JS Convertir du code JS moderne en code JS rétrocompatible
Par ailleurs, Voyager s'appuie sur plusieurs modules de Mineflayer, écrits donc en JavaScript. Comme ils ne concernent pas le mécanisme auquel l'on s'intéresse, il ne sera pas nécessaire de rentrer dans leur détail, et l'on se contentera donc de les énumérer :
Module Decription
mineflayer-pathfinder Advanced A* pathfinding with a lot of configurable features
mineflayer-tool A utility for automatic tool/weapon selection with a high level API
mineflayer-collectblock Quick and simple block collection API
mineflayer-pvp Easy API for basic PVP and PVE
minecraftHawkEye A utility for using auto-aim with bows
Il convient de noter que Voyager semble aussi s'appuyer sur Gymnasium, un package Python d'OpenAI présenté comme "an API standard for reinforcement learning with a diverse collection of reference environments". En apparence donc, car il faut constater que Gymnasium n'apparaît qu'en tant qu'une classe, VoyagerEnv, en hérite, sans que cela ne semble présenter la moindre utilité. Des recherches n'ont pas permis de comprendre pourquoi ; par ailleurs, interrogé sur le sujet, le principal auteur du papier n'a pas répondu. Une scorie donc, visiblement...
Et les LLMs ? Evidemment, il ne faut pas oublier de les mentionner. Voyager s'appuie sur GPT-4 et GPT-3.5-turbo selon le cas, mais dans les faits surtout sur le premier.

Que la fête commence !

Le point d'entrée de Voyager est pointé dans le README : c'est Voyager.learn (). En effet, pour démarrer l'agent, il faut procéder ainsi :
from voyager import Voyager

# You can also use mc_port instead of azure_login, but azure_login is highly recommended
azure_login = {
	"client_id": "YOUR_CLIENT_ID",
	"redirect_url": "https://127.0.0.1/auth-response",
	"secret_value": "[OPTIONAL] YOUR_SECRET_VALUE",
	"version": "fabric-loader-0.14.18-1.19", # the version Voyager is tested on
}
openai_api_key = "YOUR_API_KEY"

voyager = Voyager(
	azure_login=azure_login,
	openai_api_key=openai_api_key,
)

# start lifelong learning
voyager.learn()
La première chose que fait le constructeur de Voyager (/voyage.py), c'est d'instancier VoyagerEnv (/env/bridge.py) en lui transmettant le numéro de port de Minecraft déjà démarré en local ou les credentials Azure pour se connecter au réseau Minecraft, démarrer Minecraft en local, et obtenir ledit numéro de port. Noter que dans le code, l'exécutable de Minecraft qui s'exécute en local est désigné comme le "Minecraft server", un abus de langage qui peut prêter à confusion – ce devrait être le "Minecraft client", auquel il est possible de se connecter via un port local, par défaut 25565 –, mais passons.
Avant d'en venir là, le constructeur de VoyagerEnv commence par instancier SubprocessMonitor (/env/process_monitor.py), une classe ad hoc qui permet de démarrer et arrêter un processus, en lui passant en l'espèce les informations requises pour se préparer à contrôler un processus "mineflayer" qui correspondra à Node.js exécutant le script /mineflayer/index.js.
Ensuite, dans le cas où il a reçu des credentials, le constructeur de VoyagerEnv instancie MinecraftInstance (/env/minecraft_launcher.py), une classe du package minecraft-launcher-lib, auquel il confie le soin de tenter de se connecter au réseau Minecraft distant et de récupérer le chemin d'accès à l'exécutable de Minecraft en local, récupérant ainsi toutes les informations à utiliser pour démarrer ce dernier.
Ce n'est que lorsque voyager.learn () est appelée sans paramètre, comme indiqué dans le README, que Voyager.reset () est appelée à son tour, et qu'elle appelle VoyagerEnv.reset (), laquelle stocke le numéro de port de Minecraft dans VoyageEnv.reset_options, pour autant qu'il existe encore, c'est-à-dire qu'il a été passé à la place de credentials Azure. Puis, elle appelle VoyagerEnv.check_processs ().
C'est là où les choses véritablement se mettent en branle. S'il s'agissait de se connecter à Azure, cette méthode démarre au besoin Minecraft en local puisque son chemin d'accès est désormais connu, et récupère son numéro de port qu'elle stocke dans VoyageEnv.reset_options. Désormais qu'elle dispose du numéro de port – connexion à Azure ou non –, VoyagerEnv.check_processs () entreprend de démarrer le processus "mineflayer" évoqué plus tôt, bref /minecraft/index.js.
Concrètement, c'est un serveur HTTP basé sur Express, à l'écoute sur le port 3000 en local. Aussitôt qu'elle a réussi à le démarrer, la méthode lui soumet une requête /start. Et c'est là, dans la méthode app.post () du serveur Express, que le bot est créé :
bot = mineflayer.createBot({
	host: "localhost", // minecraft server ip
	port: req.body.port, // minecraft server port
	username: "bot",
	disableChatSigning: true,
	checkTimeoutInterval: 60 * 60 * 1000,
});
Tout cela était un peu tortueux, mais le comprendre est essentiel au regard de l'objectif poursuivi dans cet article, à savoir éclaircir comment fonctionne la mécanique. A ce stade, le lecteur doit voir clairement comment les premières briques du puzzle s'assemblent :
  • le "serveur" Minecraft qui tourne en local, à l'écoute vraisemblablement sur le port 25565 ;
  • un serveur HTTP à l'écoute sur le port 3000, qui attend des requêtes pour créer et contrôler le bot via Mineflayer, qui traduit des appels à son API en messages envoyés au "serveur" Minecraft ;
  • des méthodes .reset () pour relancer tout ça.

Alex, ma soeur Alex, que vois-tu à l'horizon ?

Impossible pour l'IA de contrôler le bot sans être renseignée sur l'état de ce dernier et son environnement. Voyager met donc en place un dispositif d'observation qui s'appuie sur Mineflayer pour tout cela depuis Minecraft.
Comme déjà mentionné, Voyager.learn () appelle VoyagerEnv.reset (), ce qui débouche notamment sur une requête /start. La fonction qui traite cette requête sur le serveur HTTP crée donc le bot, et entre autres initialise un système de remontées d'observations :
obs.inject(bot, [
	OnChat,
	OnError,
	Voxels,
	Status,
	Inventory,
	OnSave,
	Chests,
	BlockRecords,
]);
obs est l'import de /env/mineflayer/observation/base.js. Ce module contient donc une fonction inject (), qui prend pour paramètres le bot ainsi qu'un tableau de classes provenant de différents modules, logés dans le même répertoire. Une telle classe hérite de Observation, l'équivalent d'une classe abstraite – quelle blague, en JavaScript, mais quelle idée aussi d'avoir introduit des classes dans ce langage : du sugar coating pour les nuls... – qui ne fait qu'imposer la nécessité de définir une méthode .observe (). Par exemple, Voxels :
class Voxels extends Observation {
	constructor(bot) {
		super(bot);
		this.name = "voxels";
	}

	observe() {
		return Array.from(getSurroundingBlocks(this.bot, 8, 2, 8));
	}
}
Dans ce cas, Voxels.observe () retourne un tableau qui contient les différents noms des blocs – à la base, c'est un Set, donc un nom n'apparaît qu'une fois – qui se trouvent dans un parallélépipède de 17 x 17 x 5 blocs de côté au centre duquel se trouve le bot, pourvu qu'il ne s'agisse par d'air. Un passage en revue des autres classes permet de se faire une idée plus précise de ce que, sur ce modèle, elles remontent :
Classe Description
Status
Un dictionnaire d'une foule de données relatives au bot et à son environnement, au format :
{ <paramètre>:  <valeur>, ... }
Inventory
Un dictionnaire des objets qui figurent dans l'inventaire du bot au format :
{ <nom>: <nombre>, ... }
Chests
Un dictionnaire du contenu d'au plus 999 coffres trouvés à une distance maximale de 16 blocs du bot, chaque coffre étant identifié par sa position – un objet Vec3 du module vec3 utilisée par Mineflayer, dont la méthode .toString () produit quelque chose comme "(-10.0, 5.2, 13.74)" –, au format :
{ <position>: { <nom>: <nombre>, ... }, ... }
Si le contenu d'un coffre n'est pas connu au moment de l'observation, l'entrée dans le dictionnaire pour ce coffre prend la forme :
z<position>: "Unknown"
Voxels
Un tableau des noms des blocs, qui ne sont pas de l'air, dans le parallélépipède 17 x 17 x 5 centré sur le bot, chaque nom ne figurant qu'une fois, au format :
[ <nom>, ... ]
BlockRecords
Un tableau mis à jour tous les 100 ticks – un événement qui survient toutes les 50 ms – des noms des blocs, qui ne sont pas de l'air, dans le parallélépipède 17 x 17 x 5 centré sur le bot, chaque nom ne figurant qu'une fois, en excluant les noms des blocs trouvés dans l'inventaire du bot – sans doute pour tenir compte du fait que d'une mise à jour à l'autre, le bot a pu en ramasser –, au format :
[ <nom>, ... ]
Quant aux classes onChat, onError et onSave, leurs méthodes .observe () retournent des informations sur l'événement correspondant quand il survient – à une subtilité près pour onChat, qui ne le fait que si le message n'est pas une commande donnée à Minecraft, s'en tenant donc aux messages de Minecraft. Par exemple, onError :
class onError extends Observation {
	constructor(bot) {
		super(bot);
		this.name = "onError";
		this.obs = null;
		bot.on("error", (err) => {
			// Save entity status to local variable
			this.obs = err;
			this.bot.event(this.name);
		});
	}

	observe() {
		const result = this.obs;
		this.obs = null;
		return result;
	}
}
Comme il est possible de le constater, le constructeur rajoute un gestionnaire d'événement Mineflayer "error" au bot. Ce gestionnaire stocke le message d'erreur dans onError.obs. Lorsqu'elle est appelée, onError.observe () retourne la valeur de onError.obs, donc ce message.
Tout cela renseigne sur ce qui est observé, mais pas sur la manière dont c'est observé. Qu'est-ce qui déclenche une observation ? Pour le savoir, il faut revenir à l'appel à obs.inject () par Voyager.reset (), et maintenant que l'on sait ce qui lui est passé, se pencher sur son code :
<
function inject(bot, obs_list) {
	bot.obsList = [];
	bot.cumulativeObs = [];
	bot.eventMemory = {};
	obs_list.forEach((obs) => {
		bot.obsList.push(new obs(bot));
	});
	bot.event = function (event_name) {
		let result = {};
		bot.obsList.forEach((obs) => {
			if (obs.name.startsWith("on") && obs.name !== event_name) {
				return;
			}
			result[obs.name] = obs.observe();
		});
		bot.cumulativeObs.push([event_name, result]);
	};
	bot.observe = function () {
		bot.event("observe");
		const result = bot.cumulativeObs;
		bot.cumulativeObs = [];
		return JSON.stringify(result);
	};
}
obs_list est donc un tableau de classes dotées d'une méthode .observe (). La fonction parcourt cette liste pour instancier chacune, et stocke les objets résultants dans une nouvelle propriété bot.obsList. Ensuite, elle rajoute deux méthodes au bot :
  • bot.event () appelle la méthode .observe () de chacun des objets évoqués à l'instant, sauf si le nom de la classe est formé du préfixe "on" suivi du nom d'un événement différent de celui qui lui est passé, et stocke le résultat dans un dictionnaire. Bref, bot.event () stocke systématiquement ce que retourne la méthode .observe () de Status, Inventory, Chests,Voxels et BlockRecords, et éventuellement ce que retourne celle de onChatonError et onSave selon qu'elle est appelée par bot.event ("onChat"), bot.event ("onError") ou bot.event ("onSave"). Pour finir, elle rajoute un petit tableau de deux entrées – le nom de l'événement qui lui a été passé, et ce dictionnaire – à un tableau, bot.cumulativeObs.
  • bot.observe () appelle bot.event () au prétexte d'un événement "observe", et retourne le produit de la sérialisation en JSON de bot.cumulativeObs, qu'elle purge alors.
Au final, c'est un peu tortueux, car pour reprendre l'exemple de onError, le gestionnaire de l'événement "error" mis en place par son constructeur stocke le message d'erreur dans onError.obs, puis appelle bot.event ("onError"), qui appelle onError.observe (), qui lui retourne onError.obs, donc le message en question. Il en va de même pour onChat et onSave. Ces tours et détours sont le prix à payer pour faire de bot.event () le gestionnaire central des événements, qui peut être appelé directement comme c'est le cas pour l'événement "observe".
De tout cela, il résulte que bot.cumulativeObs accumule des entrées au format suivant :
[
	[
		<event_name>,	// "observe", "onChat", "onError" ou "onSave"
		{
			voxels: <résultat retourné par Voxels.observe ()>,
			status: <résultat retourné par Status.observe ()>,
			inventory: <résultat retourné par Inventory.observe ()>,
			nearbyChests: <résultat retourné par Chests.observe ()>
			blockRecords: <résultat retourné par BlockRecords.observe ()>,
			on<event_name>:	<résultat retourné par on<event_name>.observe ()>
		}
	]
	...
]
Pour finir, tout se résume donc à savoir quand bot.observe () est appelée. Elle l'est par les gestionnaires des requêtes /start et /step du serveur HTTP. Il a déjà été question de /start, car elle est soumise au démarrage par VoyagerEnv.check_processs (). Pour ce qui concerne /step, elle est essentiellement soumise par VoyagerEnv.step (), dont l'on verra qu'elle est appelée par la boucle principale de Voyager.

Entrent les agents

Avant d'en venir à la boucle principale, il faut en terminer avec l'initialisation. Le constructeur de Voyager ne se contente pas d'instancier VoyagerEnv. Pour ce qu'il lui reste à faire d'essentiel, il instancie plusieurs classes qui, dans le code, sont désignées comme celles d'agents qui s'appuient sur des LLMs :
Classe Source Modèle Température
ActionAgent /agents/action.py gpt-4 0
CurriculumAgent /agents/curriculum.py gpt-4 0
CriticAgent /agents/critic.py gpt-4 0
SkillManager /agents/skill.py gpt-3.5-turbo 0
Dans tous les cas, le constructeur d'un agent reçoit les paramètres nécessaires pour démarrer un LLM, ainsi qu'un paramètre mode, qui détermine s'ils doivent s'en remettre au LLM ou à l'utilisateur. Par exemple, en mode "auto", l'agent Curriculum demande au LLM de générer la prochaine tâche à accomplir par le bot, tandis qu'en mode "manual", il demande à l'utilisateur de la saisir. Dans le cadre de cet article, l'on ne s'intéressera évidement qu'au mode "auto".
Démarrer un LLM, c'est en fait instancier la classe ChatOpenAI de LangChain :
self.llm = ChatOpenAI(
	model_name=model_name,
	temperature=temperature,
	request_timeout=request_timout,
)
Comme chacun sait, dans un LLM, la température est cet hyperparamètre qui permet d'introduire une part d'aléatoire dans le choix du prochain token à produire. Ici, elle est systématiquement à 0, si bien que le LLM n'est pas censé diverger du plus probable. Pour ce qui concerne les modèles, il s'agit presque toujours de GPT-4, le choix de GPT-3.5 Turbo s'expliquant peut-être par choix économique à l'époque – d'ailleurs, c'est la valeur par défaut pour model_name.
Pour le lecteur qui a toutes les chances de la découvrir, il convient de prendre un instant pour présenter LangChain, car c'est, avec Chroma, l'une des technologies IA sur lesquelles Voyager se fonde.
Package Python incontournable pour qui prétend développer une application à base d'IA générative- 4000 contributeurs, 132 000 applications, 130 millions de téléchargement en octobre dernier –, LangChain est présenté comme "a framework for developing applications powered by language models", plus précisément des "applications that connect external sources of data and computation to LLMs". Pour ce faire, LangChain donne notamment accès à des abstractions des principaux chats... :
langchain_openai.ChatOpenAI
langchain_anthropic.ChatAnthropic
langchain_mistralai.ChatMistralAI
...
...pour pouvoir y accéder depuis du code Python, mais aussi et surtout, les mobiliser dans des chaînages, d'où le nom. Sur ce point, comme nous sommes entre développeurs, un bout de code tiré du didacticiel vaudra mieux qu'un long discours pour faire comprendre tout l'intérêt de la chose :
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

llm = ChatOpenAI(openai_api_key="...")
prompt = ChatPromptTemplate.from_messages([
	("system", "You are world class technical documentation writer."),
	("user", "{input}")
])
output_parser = StrOutputParser()
chain = prompt | llm | output_parser
chain.invoke({"input": "how can langsmith help with testing?"})
On le voit, l'avant-dernière ligne mobilise l'opérateur pipe pour chaîner comme par magie la production du prompt, sa soumission au LLM, et le traitement de la réponse retournée par ce dernier, ce qui simplifie grandement la vie. Le lecteur curieux pourra lever le mystère en jetant un œil sur le code de la classe Runnable de LangChain (/libs/core/langchain_core/runnables/base.py) où réside la surcharge :
class Runnable(Generic[Input, Output], ABC):
	//...
	def pipe(
		self,
		*others: Union[Runnable[Any, Other], Callable[[Any], Other]],
		name: Optional[str] = None,
	) -> RunnableSerializable[Input, Other]:
		//...
	//...
A ce stade, il n'est pas utile de rentrer dans les détails du reste de la construction des agents, sinon pour mentionner que les constructeurs de SkillManager et de CurriculumAgent créent des bases de données vectorielles en instanciant la classe Chroma sur un même modèle. Par exemple, pour SkillManager :
self.vectordb = Chroma(
	collection_name="skill_vectordb",
	embedding_function=OpenAIEmbeddings(),
	persist_directory=f"{ckpt_dir}/skill/vectordb",
)
Comme c'était le cas avec LangChain, le lecteur a toutes les chances de découvrir Chroma, si bien qu'il convient d'en dire un mot.
Chroma est présentée comme "a AI-native open-source vector database focused on developer productivity and happiness" facile à faire comprendre à l'appui d'un exemple tiré du didacticiel.
Après avoir installé le package langchain-chroma – Voyager utilise un package langchain.vectorstores, désormais obsolète –, il est possible de créer une base de données vectorielle en spécifiant les embeddings utilisés... :
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
vector_store = Chroma(
	collection_name="example_collection",
	embedding_function=embeddings,
	persist_directory="./chroma_langchain_db",  # Where to save data locally, remove if not neccesary
)
...ce qui permet d'ajouter des documents à la base... :
from uuid import uuid4
from langchain_core.documents import Document

document_1 = Document(
	page_content="I had chocolate chip pancakes and scrambled eggs for breakfast this morning.",
	metadata={"source": "tweet"},
	id=1,
)
...
documents = [
	document_1,
	...
]
uuids = [str(uuid4()) for _ in range(len(documents))]
vector_store.add_documents(documents=documents, ids=uuids)
...et d'effectuer des opérations propres à une telle base, comme la recherche de documents similaires :
results = vector_store.similarity_search(
	"LangChain provides abstractions to make working with LLMs easy",
	k=2,
	filter={"source": "tweet"},
)
for res in results:
	print(f"* {res.page_content} [{res.metadata}]")
A ce stade, les bases créées ne sont pas encore utilisées. Elles serviront plus loin à stocker des tâches et les programmes JavaScript qui permettent de les accomplir, seule forme d'apprentissage en vigueur dans Voyager, dont la connaissance repose autrement dans les LLMs sur lesquels il s'appuie.
Enfin, le constructeur de Voyager instancie la classe EventRecorder (/utils/record_utils.py), mais il n'en sera pas question dans cet article, s'agissant d'un mécanisme de traçabilité.

L'essentiel, c'est la boucle

Désormais tout est en place et Voyager peut entamer l'exploration. Si un dessin vaut mieux qu'un long discours, et un bout de code aussi, que dire d'un schéma qui contient un bout de code ? Un schéma de cette sorte permet de visualiser la boucle principale :
La boucle principale de Voyager
La boucle principale de Voyager.
Comme il est possible de le constater, il s'agit plutôt de deux boucles imbriquées :
  • la première est déroulée un nombre de fois par défaut fixé à 160 ;
  • la seconde est déroulée tant que Voyager.step () ne signale pas qu'elle doit s'arrêter.
Les données retournées par Voyager.learn () ne servent à rien, le programme principal se contentant de l'appeler, comme vu précédemment.
Dans la boucle, le premier appel est pour VoyagerEnv.reset (), qui donc s'assure que Minecraft et le serveur HTTP qui permet de contrôler le bot via Mineflayer sont en route. Ensuite, c'est au tour de VoyagerEnv.step (). Cette méthode se contente de soumettre une requête /step au serveur HTTP, et de retourner le résultat – au passage, et pour le dire une fois pour toutes, les échanges entre le programme Python et le serveur se font en JSON.
Côté serveur HTTP, le gestionnaire de la requête /step s'attend à recevoir un objet :
{
	"code": code,
	"programs": programs,
}
A ce stade, le contenu de cet objet se résume à deux chaînes parfaitement vides. L'on y reviendra.
Le gestionnaire commence par définir une fonction interne otherError (), qu'il est intéressant d'étudier pour comprendre comment marche plus généralement la remontée d'informations via une observation qu'un gestionnaire de requêtes du serveur HTTP retourne au client – le programme principal en Python :
let response_sent = false;
function otherError(err) {
	console.log("Uncaught Error");
	bot.emit("error", handleError(err));
	bot.waitForTicks(bot.waitTicks).then(() => {
		if (!response_sent) {
			response_sent = true;
			res.json(bot.observe());
		}
	});
}
Comme son nom l'indique, cette fonction est appelée quand le gestionnaire rencontre une erreur d'un type inattendu, en fait chaque fois qu'une exception parvient à remonter jusqu'à Node.js dans le cours son exécution de /env/mineflayer/index.js :
process.on("uncaughtException", otherError)
otherError () reçoit ainsi un objet Error. Elle appelle alors bot.emit () pour émettre un événement "error". Inutile de chercher la définition de cette méthode dans l'API de Mineflayer, car c'est en fait une méthode de la classe EventEmitter de Node.js dont bot est purement et simplement une instance, comme il est possible de constater dans le code de Mineflayer (/lib/loader.js, pour le lecteur que cela intéresse) :
const bot = new EventEmitter()
otherError () associe à l'événement le résultat retourné par handleError (), à laquelle elle a passé l'objet Error, résultat qui n'est qu'une chaîne décrivant l'erreur, confectionnée en analysant cet objet.
Que devient l'événement "error" ? Emis par le bot, il est reçu par lui-même. En effet, lors de l'initialisation, l'on a vu que la classe onError a été instanciée, et qu'elle a rajouté un gestionnaire de cette erreur au bot. L'on a vu aussi que ce gestionnaire mémorise la chaîne décrivant l'erreur, avant d'appeler bot.event () en lui passant "onError". Et l'on a vu aussi que bot.event () rajoute alors une observation au tableau bot.cumulatibeObs en assemblant les résultats retournés par les méthodes .observe () de différentes instances de classes, dont onError :
[
	<event_name>,	// Ici, "onError"
	{
		voxels: <résultat retourné par Voxels.observe ()>,
		status: <résultat retourné par Status.observe ()>,
		inventory: <résultat retourné par Inventory.observe ()>,
		nearbyChests: <résultat retourné par Chests.observe ()>
		blockRecords: <résultat retourné par BlockRecords.observe ()>,
		onError:	<résultat retourné par onError.observe ()>
	}
]
Pour finir, l'on a vu que bot.cumulativeObs est retourné par bot.observe (), dont l'on voit maintenant qu'elle est justement appelée en dernier lieu par otherError (), après un temps d'attente, pour retourner bot.cumulativeObs au client. La boucle est bouclée.

Une IA assistée, qui n'hésite pas à tricher ?

Cette petite illustration de la remontée d'informations via une observation achevée, retour au gestionnaire de la requête /step. Ce dernier configure notamment le module mineflayer-pathfinder de Mineflayer. Ce qu'il fait alors mérite d'être relevé :
function onTick() {
	bot.globalTickCounter++;
	if (bot.pathfinder.isMoving()) {
		bot.stuckTickCounter++;
		if (bot.stuckTickCounter >= 100) {
			onStuck(1.5);
			bot.stuckTickCounter = 0;
		}
	}
}
bot.on("physicTick", onTick);
Ainsi, le gestionnaire définit une fonction onTick () qu'il fait appeler 20 fois par secondes – "physicTick" est un événement généré par Mineflayer. Cette fonction interroge le module – il a été chargé via la méthode bot.loadPlugin () de Mineflayer, et injecté dans le bot sous la propriété .pathfinder – pour savoir si le bot se déplace, et si oui incrémente un compteur. Quand ce compteur atteint 100, onTick () appelle onStuck () :
function onStuck(posThreshold) {
	const currentPos = bot.entity.position;
	bot.stuckPosList.push(currentPos);

	// Check if the list is full
	if (bot.stuckPosList.length === 5) {
		const oldestPos = bot.stuckPosList[0];
		const posDifference = currentPos.distanceTo(oldestPos);

		if (posDifference < posThreshold) {
			teleportBot(); // execute the function
		}

		// Remove the oldest time from the list
		bot.stuckPosList.shift();
	}
}
Comme il est possible de le constater, onStuck () rajoute la position courante du bot dans un barillet à cinq entrées. Quand ce dernier est plein, donc quand onTick () a déjà appelé 5 fois de onStuck () car le bot se déplace, onStuck () calcule la distance entre la plus ancienne et la plus récente position, et si elle est inférieure à un certain seuil, téléporte le bot. La fonction teleportBot () utilisée cherche un bloc d'air à une distance d'un bloc autour du bot pour y positionner ce dernier ; si elle n'en trouve pas, elle le repositionne le bot 1,25 bloc plus haut.
Ainsi, le serveur HTTP vérifie régulièrement que si le bot est mis en mouvement par le module mineflayer-pathfinder, il n'est pas bloqué, et le cas échéant cherche à sortir de là. Pourquoi est-ce intéressant ? Parce que cela montre que toute l'intelligence de Voyager ne réside pas que dans les LLMs qu'il mobilise :
  • elle réside pour partie dans un programme extérieur déterministe comme le module mineflayer-pathfinder spécialisé dans le calcul d'itinéraires ;
  • elle réside aussi pour partie dans le programme même de Voyager, qui tente de détecter un comportement erroné et de le corriger en trichant – on ne se téléporte pas sans Eye of Ender dans Minecraft !

Un dernier cadeau au bot avant de rendre la main

Ce qui suit dans le gestionnaire de la requête /step n'est pas encore intéressant, car cela concerne l'exécution du code JavaScript qui est censé permettre au bot d'accomplir une tâche. Or à ce stade, on l'a vu, la requête n'était accompagnée d'aucun code. Pour l'heure, l'on se contentera de terminer de lire le code du gestionnaire en prenant du recul pour finalement résumer ce qu'il fait :
bot.on("physicTick", onTick);
const code = req.body.code;
const programs = req.body.programs;
bot.cumulativeObs = [];
const r = await evaluateCode(code, programs);
process.off("uncaughtException", otherError);
if (r !== "success") {
	bot.emit("error", handleError(r));
}
await returnItems();
// wait for last message
await bot.waitForTicks(bot.waitTicks);
if (!response_sent) {
	response_sent = true;
	res.json(bot.observe());
}
bot.removeListener("physicTick", onTick);
La triche est mise en place pour sortir le bot, déplacé par mineflayer-pathfinder, de l'ornière s'il y tombe, en le marquant à la culotte tous les ticks. Parallèlement, le code pour accomplir une tâche est exécuté, et si une erreur survient, elle est gérée comme vu précédemment. Ensuite, une fonction returnItems () qui traite des objets est appelée, et l'on en dira un mot dans un instant. Enfin, le gestionnaire s'accorde du temps – par défaut 20 ticks dans le constructeur de Voyager –, ce qui donne à Steve ou Alex éventuellement animé par mineflayer-pathfinder le temps de se déplacer, avant de retourner au client le fruit des observations, autrement dit le tableau bot.cumulativeObs, dont l'on rappelle qu'il est purgé ensuite.
A quoi sert returnItems () ? A cela :
  • elle cherche un établi et un four à une distance maximale de 128 blocs, et si elle trouve l'un de ces objets, elle le détruit – sans que cela entraîne un drop de l'objet, car elle a désactivé cette règle du jeu temporairement via un message "/gamerule doTileDrops false" écrit dans le chat – et le donne au bot ;
  • si l'inventaire du bot comprend plus de 32 objets, et qu'il ne comporte pas de coffre, elle lui donne un coffre ;
  • si un drapeau bot.iron_pickaxe, qui est initialisé à false, a toujours cette valeur et que le bot n'a pas de pioche en fer, elle lui en donne une – rien dans le code JavaScript de Voyager ne permet de constater que ce drapeau peut être passé à true.
Sympa pour le bot, qui se trouve dès lors moins dépourvu pour démarrer son exploration. Certes, ce n'est pas trop tricher sur l'établi et le four, car ils ne sont récupérés que s'ils existent – même si rien ne dit que c'est le bot qui les a fabriqués, et qu'il pourrait les atteindre –, mais ça l'est carrément dans le cas du coffre et de la pioche en fer. Voilà qui en rajoute à la remarque fondamentale formulée après avoir relu le code de onStuck ()...
Toutefois, il est vrai que cela n'apparaît qu'un petit coup de pouce au regard de ce que le bot va accomplir tout seul comme un grand, enfin grâce à l'IA.
Par ici pour la suite...
Voyager, l’agent IA qui joue à Minecraft (1/4)