Voyager, l’agent IA qui joue à Minecraft (2/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 en totale autonomie à Minecraft. 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 deuxième article d'une série. Dans le premier article, Voyager a été présenté dans son principe, de même que les technologies sur lesquelles il s'appuie, et la lecture du code a commencé pour étudier la manière dont Voyager collecte des informations sur l'environnement dans lequel il contrôle le bot – le personnage dans Minecraft. Désormais, il est possible d'étudier la manière dont Voyager utilise un LLM pour générer la prochaine tâche à faire accomplir par le bot.
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 un prochain numéro de Programmez!.

L'IA, nous voulons l'IA !

Après ce détour par le serveur HTTP écrit en JavaScript, retour au client, donc au programme principal écrit en Python. Pour rappel, l'on abordait la boucle principale, VoyagerEnv.step () venant d'être appelée, qui s'est contentée de soumettre la requête /step. D'ailleurs, que devient le tableau bot.cumulativeObs retourné ? Il est stocké dans Voyager.last_events, c'est tout.
Va-t-on enfin parler d'IA dans cet article ? Oui, car l'impatient lecteur qui parcourt le code de Voyager en même temps que cet article peut constater que le premier appel dans la boucle, c'est :
task, context = self.curriculum_agent.propose_next_task(
	events=self.last_events,
	chest_observation=self.action_agent.render_chest_observation(),
	max_retries=5,
)
L'agent Curriculum est donc le premier agent rencontré. La manière dont il est utilisé est commune à tous les agents : constituer des prompts système et utilisateur en injectant des données dans des modèles, les fournir au chat, récupérer sa réponse – qu'il lui a été demandé de produire dans un certain format –, et l'exploiter. Mais avant de se plonger dans le code de CurriculumAgent.propose_next_task () pour voir cela en détail, il faut préciser en quoi consistent les paramètres que Voyager.learn () lui passe. Le premier a déjà été présenté, et le troisième est trivial. Pour ce qui concerne le deuxième, c'est est le résultat retourné par ActionAgent.render_chest_observation ().
ActionAgent.render_chest_observation () se contente de traduire en texte le contenu de ActionAgent.chest_memory. Cette propriété est un dictionnaire initialement vide. Son contenu est actualisé par ActionAgent.update_chest_memory (), appelée ainsi par Voyager.step () :
self.action_agent.update_chest_memory(events[-1][1]["nearbyChests"])
Pour rappel, events est le résultat retourné par VoyagerEnv.step (), donc le tableau bot.cumulativeObs retourné par le serveur HTTP en réponse à une requête /step, autrement dit le tableau des observations. Ici, Voyager.step () passe donc les données relatives aux coffres trouvés à proximité lors la dernière observation. Plus précisément, il s'agit d'un dictionnaire dont les clés sont les positions des coffres à proximité, et les valeurs sont les contenus de ces coffres. Par ailleurs, quand il est connu, le contenu est lui-même un dictionnaire dont les clés sont les noms des objets, et les valeurs sont les nombres de ces objets dans le coffre :
Contenu connu ? Entrée
Oui <position>: { <nom>: <nombre>, ... }
Non <position>: "Unknown"
ActionAgent.update_chest_memory () actualise ActionAgent.chest_memory en recoupant son contenu avec celui qui lui est fourni :
  • un nouveau coffre est ajouté ;
  • un coffre déjà inventorié dont le contenu est fourni est mis à jour avec ce contenu ;
  • un coffre déjà inventorié dont le contenu est "Invalid" est supprimé.
Comment la valeur associée à la position d'un coffre peut-elle être "Invalid" ? Pour le comprendre, il faut retourner dans le code du serveur HTTP, plus particulièrement dans le constructeur de la classe Chests dont la méthode .observe () fournit l'observation relative aux coffres à proximité. En particulier, ce code contient :
bot.on("closeChest", (chestItems, position) => {
	this.chestsItems[position] = chestItems;
});
bot.on("removeChest", (chestPosition) => {
	this.chestsItems[chestPosition] = "Invalid";
});
Ainsi, le tableau Chests.chestItems est mis à jour en fonction d'actions du bot signalées par des événements :
  • quand le bot referme un coffre, le contenu du coffre est mise à jour avec l'inventaire communiqué ;
  • quand le bot se rend à une position où il était censé tomber sur un bloc, mais où il ne le trouve pas, le coffre est retiré.
Ces événements sont générés par les fonctions listItemsInChest () et moveToChest (), respectivement (/control_primitives/useChest.js). Elles sont appelées par d'autres fonctions pour faire accomplir au bot des actions plus complexes : getItemFromChest (), depositItemIntoChest () et checkItemInsideChest () notamment. Par exemple, checkItemInsideChest () appelle moveToChest () pour déplacer le bot jusqu'au coffre, et s'il le trouve lui fait ouvrir et récupérer l'inventaire de son contenu via bot.openContainer (), puis refermer via closeChest () qui appelle listItemsInChest ().
Et quand donc ces fonctions sont-elles appelées ? C'est là où il est enfin question d'IA, car comme cela sera montré plus loin, elles vont être portées à la connaissance d'un LLM en injectant leur code dans le prompt qui sert à lui demander de générer le code JavaScript pour faire accomplir une tâche au bot, en lui demandant d'y faire appel dans ce code au besoin.
Mais pour l'heure, il faut revenir à l'appel à CurriculumAgent.propose_next_task (), qui reçoit donc en deuxième paramètre le résultat retourné par ActionAgent.render_chest_observation (), qui ne fait que traduire en texte le contenu de ActionAgent.chest_memory :
Coffre Texte
<position>: { <nom>: <nombre>, ... } (<x>, <y>, <z>): {‘<nom>' : <nombre>, ...}
<position>: {} (<x>, <y>, <z>): Empty
<position>: "Unknown" (<x>, <y>, <z>): Unknown items inside
Au final, s'il y a des coffres, cela donne... :
Chests:
(<x>, <y>, <z>): {'<nom>': <nombre>, ...}
(<x>, <y>, <z>): Unknown items inside
(<x>, <y>, <z>): {'<nom>': <nombre>, ...}
(<x>, <y>, <z>): Empty
(<x>, <y>, <z>): Unknown items inside
(<x>, <y>, <z>): Unknown items inside
...
...et s'il n'y en a pas, cela donne :
Chests: None

La première fois avec Curriculum

La boucle principale appelle donc CurriculumAgent.propose_next_task () en lui fournissant essentiellement la liste des observations et un texte qui décrit le contenu des coffres à proximité – inventaire d'objets, vide ou inconnu.
La première fois qu'elle est appelée, cette méthode constate que le bot n'a pas progressé – le suivi de la progression est assuré via un compteur CurriculumAgent.progress, une propriété qui retourne la longueur CurriculumAgent.completed_tasks, une liste des tâches accomplies. En conséquence, l'agent retourne une tâche prédéfinie, soit plus précisément deux choses :
task = "Mine 1 wood log"
context = "You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs."
Il s'agit donc de textes, le premier décrivant la tâche à accomplir, et le second expliquant la manière de s'y prendre, que l'on désignera donc désormais comme le contexte de la tâche.
Rompant avec le déroulé chronologique du code, profitons de l'occasion pour voir que l'agent Curriculum fait si jamais le bot a déjà accompli au moins une tâche. Dans ce cas, l'agent commence par vérifier si l'inventaire du bot n'est pas sur le point d'être saturé, auquel cas il lui commande de déposer des objets dans un coffre s'il s'en trouve alentour, sinon de déposer un coffre pour autant que son inventaire en contient un, et sinon de fabriquer un coffre. Pour déterminer quel est le cas de figure, l'agent se fonde sur une des multiples entrées de la dernière observation en date que Status.observe () a générées côté serveur HTTP, auquel l'agent accède ainsi côté client :
events[-1][1]["status"]["inventoryUsed"]
Côté serveur HTTP, cette entrée est générée par bot.inventoryUsed (), une méthode injectée dans le bot lors de sa création (/lib/mineflayer/skillLoaders.js), sur la base de la valeur de bot.inventory, une propriété du bot alimentée par Mineflayer. Inutile de rentrer dans le détail, sinon pour préciser le format de l'entrée au final : c'est tout simplement le nombre d'entrées utilisées dans l'inventaire.
Dans Minecraft, l'inventaire comprend 36 entrées. L'agent teste si le nombre d'entrées occupées est supérieur ou égal à 33. Si tel est le cas, il considère que l'inventaire est sur le point de saturer, et fait usage de chest_observation – comme on l'a vu, la description en texte des coffres alentour qui lui a été passée –, pour localiser un éventuel coffre "Unknown items inside" ou "Empty". S'il en trouve un, l'agent retourne la tâche de déposer des objets dans ce coffre :
task = f"Deposit useless items into the chest at {position}"
context = (
	f"Your inventory have {inventoryUsed} occupied slots before depositing. "
	"After depositing, your inventory should only have 20 occupied slots. "
	"You should deposit useless items such as andesite, dirt, cobblestone, etc. "
	"Also, you can deposit low-level tools, "
	"For example, if you have a stone pickaxe, you can deposit a wooden pickaxe. "
	"Make sure the list of useless items are in your inventory "
	"(do not list items already in the chest), "
	"You can use bot.inventoryUsed() to check how many inventory slots are used."
)
S'il n'en trouve pas, l'agent regarde si events[-1][1]["inventory"], le dictionnaire généré par Inventory.observe () côté serveur HTTP, contient une entrée "chest". S'il en trouve une, il retourne la tâche de déposer le coffre :
task = "Place a chest"
context = (
	f"You have a chest in inventory, place it around you. "
	f"If chests is not None, or nearby blocks contains chest, this task is success."
)
Sinon, s'il n'y a donc pas de coffre alentour où déposer un objet, et pas de coffre dans l'inventaire à déposer, l'agent retourne la tâche de fabriquer un coffre :
task = "Craft 1 chest"
context = "Craft 1 chest with 8 planks of any kind of wood."
Autrement, si donc le bot a déjà accompli au moins une tâche et que son inventaire n'est pas sur le point de saturer, l'agent Curriculum crée une tâche de toute pièce. Comment ?

Un curriculum long comme le bras

Comme déjà mentionné, un agent fournit à un LLM des prompts système et utilisateur, élaborés à partir de modèles. L'agent Curriculum appelle donc des méthodes pour produire ces deux prompts :
messages = [
	self.render_system_message(),
	self.render_human_message(
		events=events, chest_observation=chest_observation
	),
]
Tous les agents comprennent de telles méthodes .render_system_message () et .render_human_message (), qui retournent un prompt système et un prompt utilisateur, respectivement.
Dans le cas présent, CurriculumAgent.render_system_message () se contente de retourner le contenu d'un fichier texte chargé par load_prompt () (/prompts/__init__.py), à savoir /prompts/curriculum.txt. C'est un prompt assez long, dont l'on détaillera la structure en s'appuyant sur de larges extraits – au lecteur de se reporter au fichier pour consulter le prompt dans son intégralité :
  • il donne un rôle au LLM :
    You are a helpful assistant that tells me the next immediate task to do in Minecraft. My ultimate goal is to discover as many diverse things as possible, accomplish as many diverse tasks as possible and become the best Minecraft player in the world.
    
  • il lui décrit les informations qu'il va lui fournir, c'est-à-dire une série de questions et de réponses, et les états du bot et de son environnement :
    Question 1: ...
    Answer: ...
    Question 2: ...
    Answer: ...
    Question 3: ...
    Answer: ...
    ...
    Biome: ...
    Time: ...
    Nearby blocks: ...
    Other blocks that are recently seen: ...
    Nearby entities (nearest to farthest): ...
    Health: Higher than 15 means I'm healthy.
    Hunger: Higher than 15 means I'm not hungry.
    Position: ...
    Equipment: If I have better armor in my inventory, you should ask me to equip it.
    Inventory (xx/36): ...
    Chests: You can ask me to deposit or take items from these chests. There also might be some unknown chest, you should ask me to open and check items inside the unknown chest.
    Completed tasks so far: ...
    Failed tasks that are too hard: ...
    
  • il lui donne des règles à respecter (extrait) :
    You must follow the following criteria:
    1) You should act as a mentor and guide me to the next task based on my current learning progress.
    2) Please be very specific about what resources I need to collect, what I need to craft, or what mobs I need to kill.
    <...>
    
  • il lui décrit le format de la réponse attendue :
    You should only respond in the format as described below:
    RESPONSE FORMAT:
    Reasoning: Based on the information I listed above, do reasoning about what the next task should be.
    Task: The next task.
    
  • il lui fournit un exemple :
    Here's an example response:
    Reasoning: The inventory is empty now, chop down a tree to get some wood.
    Task: Obtain a wood log.
    
Autant pour le prompt système. Retourné par CurriculumAgent.render_human_message (), le prompt utilisateur est pour sa part généré de manière nettement plus alambiquée.
La méthode reçoit donc en paramètres :
  • events, dont l'on rappelle que c'est la version Python du tableau – une liste en Python, pour adopter une terminologie rigoureuse – des observations bot.cumulativeObs retourné par le serveur HTTP en réponse à une requête /step ;
  • chest_observation, dont l'on rappelle que c'est un texte qui décrit le contenu des coffres alentour, identifiés par leurs positions, constitué à partir d'informations relatives à la dernière observation en date dans events, ou "Chests: None" s'il ne s'en trouve point.
Pour commencer, CurriculumAgent.render_human_message () appelle CurriculumAgent.render_observation (). Après quelques contrôles – vérifier que la dernière observation est bien le produit d'un événement "observe" ; identifier le biome comme "underground" si jamais la liste des blocs à proximité events[-1][1]["voxels"] ne contient pas certain blocs –, cette méthode produit quelques bouts de texte :
  • La concaténation de la liste des noms des blocs que le bot a croisés events[-1][1]["blockRecords"], à condition qu'ils ne figurent pas dans celle des blocs à proximité events[-1][1]["voxels"] ni dans celle des blocs qui figurent dans son inventaire events[-1][1]["inventory"]. Noter que dans ce bout de texte, un nom de bloc ne figure qu'une fois.
  • La concaténation des noms des mobs à proximité events[-1][1]["status"]["entities"] triés par ordre croissant de distance au bot – c'est un dictionnaire généré par Status.getEntities () sur le serveur HTTP, qui recense les mobs qui se trouvent à moins de 32 blocs de distance du bot, en ne faisant figurer que le mob le plus proche pour un nom de mob donné. Noter que dans ce bout de texte, un nom de mob ne figure qu'une fois.
  • La concaténation des descriptions des tâches accomplies. Pour rappel, une tâche est décrite par un énoncé, comme "Mine 1 wood log", et un contexte, comme "You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs.", mais seul l'énoncé est donc repris ici.
  • La concaténation des tâches ratées. Même remarque.
Un bout de texte prend la valeur "None" si jamais il n'y a rien à concaténer.
Ensuite, CurriculumAgent.render_observation () procède à un filtrage des objets d'une copie de l'inventaire events[-1][1]["inventory"] :
# filter out optional inventory items if required
if self.progress < self.warm_up["optional_inventory_items"]:
	inventory = {
		k: v
		for k, v in inventory.items()
		if self._core_inv_items_regex.search(k) is not None
	}
Pourquoi ? Le commentaire – l'un des rares dans le code –, n'est pas très éclairant, mais cela n'a pas l'air anecdotique. En creusant, il apparaît que, dans le constructeur de CurriculumAgent :
  • CurriculumAgent.warm_up["optional_inventory_items"] est initialisé à 7 ;
  • CurriculumAgent._core_inv_items_regex est le fruit de la compilation de l'expression régulière r".*_log|.*_planks|stick|crafting_table|furnace|cobblestone|dirt|coal|.*_pickaxe|.*_sword|.*_axe".
Autrement dit, tant que le bot n'a pas réussi au moins 7 tâches, la copie de son inventaire est purgée de tout objet dont le nom ne correspond pas à l'expression régulière. Sachant que cette copie est reprise pour décrire l'inventaire du bot dans le résultat retourné par CurriculumAgent.render_observation (), l'hypothèse qu'il faut faire, c'est qu'au début de l'exploration, cela permet de focaliser l'attention du LLM sur des objets primordiaux en lui offrant une vision de l'inventaire qui s'y réduit.
Si tel était bien l'objectif des créateurs de Voyager, cela viendrait en rajouter à la remarque formulée précédemment, à savoir que toute l'intelligence de Voyager ne réside pas que dans les LLMs qu'il mobilise, et qu'en plus de faire appel à des programmes extérieurs pour faire accomplir des tâches au bot, et d'intervenir sur le bot pour débloquer des situations, une autre technique consiste à jouer avec ce qui est donné à voir de la situation du bot au LLM. Intéressant...
Au final, CurriculumAgent.render_observation () reprend les bouts de texte et d'autres valeurs tirées de la dernière observation en date events[-1][1], et retourne un dictionnaire :
observation = {
	"context": "",
	"biome": f"Biome: {biome}\n\n",
	"time": f"Time: {time_of_day}\n\n",
	"nearby_blocks": f"Nearby blocks: {', '.join(voxels) if voxels else 'None'}\n\n",
	"other_blocks": f"Other blocks that are recently seen: {other_blocks}\n\n",
	"nearby_entities": f"Nearby entities: {nearby_entities}\n\n",
	"health": f"Health: {health:.1f}/20\n\n",
	"hunger": f"Hunger: {hunger:.1f}/20\n\n",
	"position": f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n",
	"equipment": f"Equipment: {equipment}\n\n",
	"inventory": f"Inventory ({inventory_used}/36): {inventory if inventory else 'Empty'}\n\n",
	"chests": chest_observation,
	"completed_tasks": f"Completed tasks so far: {completed_tasks}\n\n",
	"failed_tasks": f"Failed tasks that are too hard: {failed_tasks}\n\n",
}
Retour donc à CurriculumAgent.render_human_message (), dont la lecture est loin d'être terminée. Si jamais le nombre de tâches réussies par le bot est supérieur ou égal à CurriculumAgent.warm_up["context"], initialisé à 15 dans le constructeur de la classe, elle appelle CurriculumAgent.run_qa () pour poser des questions au LLM. En effet, comme le lecteur attentif n'aura pas manqué de le constater, le prompt utilisateur est censé contenir un mini-FAQ...

Des questions ! Toujours des questions !

Tout comme CurriculumAgent.render_human_message () qui l'appelle, CurriculumAgent.run_qa () prend en paramètres events, la liste des observations, et chest_observation, le texte qui décrit le contenu des coffres alentour. La méthode commence par appeler CurriculumAgent.run_qa_step1_ask_questions (), en lui passant ces paramètres qui décidemment sont beaucoup relayés.
Cette nouvelle méthode commence par appeler CurriculumAgent.render_system_message_qa_step1_ask_questions () et CurriculumAgent.render_human_message_qa_step1_ask_questions () pour constituer des prompts système et utilisateur.
CurriculumAgent.render_system_message_qa_step1_ask_questions () se contente de retourner le contenu du fichier texte /prompts/curriculum_qa_step1_ask_questions.txt. Ici encore, c'est un prompt assez long, et l'on détaillera sa structure en s'appuyant sur de larges extraits – le lecteur est renvoyé au fichier pour consulter le prompt dans son intégralité. Sa structure reproduit en tout point celle du premier prompt qui a été décrit : (1) donner un rôle au LLM ; (2) lui décrire les informations que l'on va lui donner ; (3) lui donner les règles à respecter ; (4) lui donner le format que sa réponse doit adopter ; (5) lui donner des exemples. A ceci près que les deux dernières étapes sont ici interverties.
  • il donne un rôle au LLM :
    You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world.
    
  • il lui décrit les informations qu'il va lui fournir, c'est-à-dire une série des états du bot et de son environnement, ainsi que la liste des tâches réussies et ratées par le bot :
    Biome: ...
    Time: ...
    Nearby blocks: ...
    Other blocks that are recently seen: ...
    Nearby entities (nearest to farthest): ...
    Health: ...
    Hunger: ...
    Position: ...
    Equipment: ...
    Inventory (xx/36): ...
    Chests: ...
    Completed tasks so far: ...
    Failed tasks that are too hard: ...
    
  • il lui donne des règles à respecter :
    1) You should ask at least 5 questions (but no more than 10 questions) to help me decide the next immediate task to do. Each question should be followed by the concept that the question is about.
    2) Your question should be specific to a concept in Minecraft.
      Bad example (the question is too general):
        Question: What is the best way to play Minecraft?
        Concept: unknown
      Bad example (axe is still general, you should specify the type of axe such as wooden axe):
        What are the benefits of using an axe to gather resources?
        Concept: axe
      Good example:
        Question: How to make a wooden pickaxe?
        Concept: wooden pickaxe
    3) Your questions should be self-contained and not require any context.
      Bad example (the question requires the context of my current biome):
        Question: What are the blocks that I can find in my current biome?
        Concept: unknown
      Bad example (the question requires the context of my current inventory):
        Question: What are the resources you need the most currently?
        Concept: unknown
      Bad example (the question requires the context of my current inventory):
        Question: Do you have any gold or emerald resources?
        Concept: gold
      Bad example (the question requires the context of my nearby entities):
        Question: Can you see any animals nearby that you can kill for food?
        Concept: food
      Bad example (the question requires the context of my nearby blocks):
        Question: Is there any water source nearby?
        Concept: water
      Good example:
        Question: What are the blocks that I can find in the sparse jungle?
        Concept: sparse jungle
    4) Do not ask questions about building tasks (such as building a shelter) since they are too hard for me to do.
    
  • il lui fournit des exemples (extrait) :
    Here are some more question and concept examples:
    Question: What are the ores that I can find in the sparse jungle?
    Concept: sparse jungle
    (the above concept should not be "ore" because I need to look up the page of "sparse jungle" to find out what ores I can find in the sparse jungle)
    Question: How can you obtain food in the sparse jungle?
    Concept: sparse jungle
    (the above concept should not be "food" because I need to look up the page of "sparse jungle" to find out what food I can obtain in the sparse jungle)
    Question: How can you use the furnace to upgrade your equipment and make useful items?
    Concept: furnace
    <...>
    
  • il lui décrit le format de la réponse attendue (extrait) :
    Reasoning: 
    Question 1: ...
    Concept 1: ...
    Question 2: ...
    Concept 2: ...
    <...>
    
Autant pour le prompt système. Pour ce qui concerne le prompt utilisateur, CurriculumAgent.render_human_message_qa_step1_ask_questions () appelle CurriculumAgent.render_observation () déjà longuement détaillée, et retourne la concaténation de toutes les valeurs du dictionnaire qu'elle lui retourne.
CurriculumAgent.run_qa_step1_ask_questions () utilise les deux prompts ainsi générés pour interroger le LLM. Enfin, une première interaction avec un LLM ! Comment cela se passe ? Roulement de tambour, et... :
qa_response = self.qa_llm(messages).content
Ah ! ouais, une ligne code tout de même. En soi, ce n'est pas très surprenant qu'il soit aussi simple d'interfacer une application avec un LLM depuis le code de cette dernière, mais c'est tout de même intéressant à constater, car cela semble devoir rendre les choses à la fois plus faciles et plus risquées. Dans le code de combien d'applications ne verra-t-on pas fleurir ce type d'appel par paresse, alors qu'il n'est en rien anodin ? C'est qu'il faut prendre la mesure de toutes les conséquences en termes de régularité et de niveau de performance, de coûts financiers, de conséquences sur l'environnement, de sécurité du système d'information – sur ce point le lecteur intéressé se reportera à cet article –, d'accumulation de dette technique, etc.
Comment la réponse d'un LLM, qui est nécessairement du texte, peut-elle être exploitée par un programme ? Le détail de la manière dont CurriculumAgent.run_qa_step1_ask_questions () retraite le résultat est la suivante :
# Regex pattern to extract question and concept pairs
pattern = r"Question \d+: (.+)\nConcept \d+: (.+)"
# Extracting all question and concept pairs from the text
pairs = re.findall(pattern, qa_response)
# Storing each question and concept in separate lists
questions_new = [pair[0] for pair in pairs]
concepts_new = [pair[1] for pair in pairs]
assert len(questions_new) == len(concepts_new)
questions.extend(questions_new)
concepts.extend(concepts_new)
Comme il est possible de le constater, le texte est retraité à l'aide d'expressions régulières pour en extraire les questions et les concepts qu'elles abordent, en misant sur le fait que le LLM aura respecté le format qui lui était demandé. Au passage, la capacité d'un LLM à générer une réponse dans un certain format est un sujet en soi – quelque part, il en sera de nouveau question quand il s'agira de lui demander de générer du code JavaScript –, mais beaucoup de progrès ont été accomplis depuis Voyager pour obtenir notamment du JSON.
Finalement, CurriculumAgent.run_qa_step1_ask_questions () retourne une liste des textes des questions et une liste des textes des concepts qu'elles abordent à CurriculumAgent.run_qa (), dont il devient possible de reprendre la lecture du code après la première instruction. Pour qui veut comprendre comment il est possible de s'appuyer sur un LLM pour créer un agent, les choses deviennent alors encore plus intéressantes, car une base de données vectorielle est alors exploitée.

Les questions à la moulinette vectorielle

En effet, chacune des questions générées par le LLM est prétexte à interroger une base de données vectorielles. S'agissant d'une étape importante dans l'utilisation de l'IA par Voyager, il faut en donner à voir le code intégralement :
if self.qa_cache_questions_vectordb._collection.count() > 0 :
	docs_and_scores = (
		self.qa_cache_questions_vectordb.similarity_search_with_score(
			question, k=1
		)
	)
	if docs_and_scores and docs_and_scores[0][1] < 0.05 :
		question_cached = docs_and_scores[0][0].page_content
		assert question_cached in self.qa_cache
		answer_cached = self.qa_cache[question_cached]
		questions.append(question_cached)
		answers.append(answer_cached)
		continue
answer = self.run_qa_step2_answer_questions(question=question)
assert question not in self.qa_cache
self.qa_cache = answer
self.qa_cache_questions_vectordb.add_texts(
	texts=,
)
U.dump_json(self.qa_cache, f"{self.ckpt_dir}/curriculum/qa_cache.json")
self.qa_cache_questions_vectordb.persist()
questions.append(question)
answers.append(answer)
La base en question est CurriculumAgent.qa_cache_questions_vectordb, créée par le constructeur de CurriculumAgent, et sauvegardée dans /ckpt/curriculum/vectordb/ :
self.qa_cache_questions_vectordb = Chroma(
	collection_name="qa_cache_questions_vectordb",
	embedding_function=OpenAIEmbeddings(),
	persist_directory=f"{ckpt_dir}/curriculum/vectordb",
)
Le lecteur ayant toutes les chances de tomber sur une base de données vectorielles pour la première fois, il convient-de dire un mot sur le concept.
L'une des grandes innovations qui a permis le surgissement des LLMs a été la vectorisation de textes, notamment avec l'apparition de word2vec – la chaîne StatQuest a produit un excellent épisode sur le sujet. Grâce à cela, les mots peuvent être représentés par des points dans un espace de grande dimension, où la proximité géométrique qui sépare deux points tient la proximité sémantique des mots correspondants dans le corpus d'entraînement. Les dimensions de l'espace sont désignées comme les features, et les coordonnées comme les embeddings. Cela permet de se livrer à des opérations vectorielles telles qu'additionner les vecteurs de "river" et "Russia" pour obtenir quelque chose comme celui de "Volga river". Pourtant récipiendaire du prix John Von Neuman de l'IEEE en 2024 pour ses travaux sur le sujet, Christopher Manning explique dans un entretien accordé à TWIM, l'excellent podcast animé par le toujours très pertinent Sam Charrington : "it still seems to me kind of incredible how well something that in retrospect is relatively simple manages to capture word meaning". Comme l'éminent savant, l'humble béotien qui écrit ces lignes n'en revient lui aussi toujours pas...
Comme rapporté dans une explication d'IBM, la vectorisation ne s'est pas limitée au texte, et des systèmes de base de données ont été élaborés sur ce principe pour permettre le stockage et l'indexation de l'association de vecteurs et de métadonnées relatives aux données. Lorsque l'utilisateur saisit une requête sous forme de texte, cette dernière est vectorisée, et le système recherche les vecteurs les plus proches dans la base via l'index – il peut s'agir de calculer la distance géométrique, ou le cosinus de l'angle et donc le produit scalaire. Comme on l'imagine, il peut y avoir plusieurs candidats, et un score, qui découle nécessairement du calcul, peut permettre à l'utilisateur de les départager.
Pour revenir à CurriculumAgent.run_qa (), elle vérifie si la base contient des entrées. Si oui, elle en appelle la méthode Chroma.similarity_search_with_score () en lui passant la question et le nombre de résultats à retourner, un seul en l'occurrence. La méthode retourne une liste de tuples, ici réduite à un seul, composés d'un document et de son score. Si le score du document strictement est inférieur à 0,05, la réponse à la question est tirée de CurriculumAgent.qa_cache. Il s'agit d'un dictionnaire, initialisé à vide par le constructeur de CurriculumAgent, dont le contenu est calqué sur celui de la base : pour chaque question qui se trouve sous forme de document dans la base, l'on trouve une entrée dans ce dictionnaire, dont la clé est la question, et la valeur la réponse à cette dernière.
Si la base ne contient pas d'entrées ou si la requête n'a produit aucun document satisfaisant, CurriculumAgent.run_qa () rajoute la question dans la base, ainsi que cette question et sa réponse à CurriculumAgent.qa_cache. Pour obtenir cette réponse, CurriculumAgent.run_qa () appelle CurriculumAgent.run_qa_step2_answer_questions ().
Comme le lecteur s'en doute, cette nouvelle méthode fonctionne sur le modèle de CurriculumAgent.run_qa_step1_answer_questions (). Elle appelle CurriculumAgent.render_system_message_qa_step2_answer_questions () et CurriculumAgent.render_human_message_qa_step2_answer_questions () pour constituer un prompt système et un prompt utilisateur, respectivement, donne cela au LLM et retourne le texte qu'il génère. La première méthode se contente de reprendre le contenu du fichier texte /prompts/curriculum_qa_step2_answer_questions.txt. Encore un prompt, mais celui-là est particulièrement bref :
You are a helpful assistant that answer my question about Minecraft.

I will give you the following information:
Question: ...

You will answer the question based on the context (only if available and helpful) and your own knowledge of Minecraft.
1) Start your answer with "Answer: ".
2) Answer "Answer: Unknown" if you don't know the answer.
Quant au prompt utilisateur que la seconde méthode génère, c'est encore plus simple, puisqu'il ne s'agit que de la question ou presque. Au final, qu'elle ait trouvée une question proche dans la base de données vectorielles, et sa réponse dans le dictionnaire des questions et réponses, ou qu'elle ait dû rajouter la question à la base, et cette question et sa réponse, générée pour l'occasion par le LLM, au dictionnaire, CurriculumAgent.run_qa () retourne une liste de questions et une liste de réponses qui ont donc été, les unes comme les autres, générées par le LLM.

Petit décrassage avant la route

Ce qui permet de rependre le fil de CurriculumAgent.render_human_message (), dont l'on rappelle qu'elle a été appelée par CurriculumAgent.propose_next_task () pour générer le texte décrivant la prochaine tâche à accomplir par le bot, et le contexte de cette dernière.
Ce qui suit dans CurriculumAgent.render_human_message () coule de source. Les textes des questions et de leurs réponses, ainsi que les textes tirés de la dernière observation, sont utilisés pour compléter le prompt utilisateur tiré de /prompts/curriculum.txt, déjà présenté. Hormis que ce sont au plus cinq questions et leurs réponses qui sont reprises dans ce modèle, la seule subtilité concerne la reprise des textes de l'observation par ce bout de code :
for key in self.curriculum_observations :
	if self.progress >= self.warm_up[key] :
		if self.warm_up[key] != 0 :
			should_include = random.random() < 0.8
		else :
			should_include = True
		if should_include :
			content += observation[key]
Le dictionnaire CurriculumAgent.warm_up a déjà été croisé deux fois, pour conditionner l'apparition de certaines informations dans le prompt utilisateur utilisé par CurriculumAgent.propose_next_task () pour demander au LLM de générer l'énoncé et le contexte de la prochaine tâche à accomplir par le bot :
  • son entrée "optional_inventory_items", initialisée à 7, correspond au nombre de tâches réussies par le bot en-deçà duquel la reprise prévue – plus loin, l'on verra que cette reprise est conditionnée – de l'inventaire du bot dans le prompt est limitée aux objets dont les noms correspondent à l'expression régulière r".*_log|.*_planks|stick|crafting_table|furnace|cobblestone|dirt|coal|.*_pickaxe|.*_sword|.*_axe" ;
  • son entrée "context", initialisée à 15, correspond au nombre de tâches réussies par le bot à partir duquel une série de questions et de réponses générées par CurriculumAgent.run_qa () est reprise dans le ce même prompt.
En fait, ce dictionnaire contient bien d'autres entrées :
Entrée Valeur
context 15
biome 10
time 15
nearby_blocks 0
other_blocks 10
nearby_entities 5
health 15
hunger 15
position 0
equipment 0
inventory 0
optional_inventory_items 7
chests 0
completed_tasks 0
failed_tasks 0
Dans le bout de code, une entrée sert à déterminer s'il faut reprendre dans le prompt l'information correspondante dans le dictionnaire retourné par CurriculumAgent.render_observation () – pour rappel, cette méthode a traduit en textes les informations figurant dans la dernière observation en date. Une telle information n'est reprise que sous plusieurs conditions :
  • évidemment, il faut qu'une entrée correspondante figure dans le dictionnaire CurriculumAgent.warm_up ;
  • le nombre de tâches réussies par le bot est supérieur ou égal à la valeur de cette entrée ;
  • si cette valeur n'est pas nulle, une valeur aléatoire comprise dans [0, 1[ doit être strictement inférieure à 0,8 – bref, l'information n'a approximativement que 8 chances sur 10 d'être reprise.
Comme il a déjà été possible de le constater, la lecture attentive du code de Voyager se justifie par le fait que le diable se loge dans les détails. Ici, c'est tout un tour de chauffe qui est mis en évidence. Dans le prompt fourni au LLM pour lui demander de générer la prochaine tâche à accomplir, les états du bot et de son environnement sont moins documentés tant que le bot n'a pas réussi un certain nombre de tâches, et ils ne le sont ensuite que dans une certaine mesure – tirage au sort. Par exemple, tant que le bot n'a pas réussi 15 tâches, son nombre de points de vie n'est pas documenté. Mention spéciale pour l'inventaire, sur lequel le LLM n'a qu'une vision réduite aux objets primordiaux (bûches, planches, bâtons, établi, four, pierre, terre, charbon, pioche, épée et hache) dans un premier temps.
Vu d'ensemble, il apparaît que durant un tour de chauffe, le LLM n'est pas renseigné sur le biome, l'heure, les autres blocs – ceux que le bot a croisé mais qui ne figurent pas à proximité immédiate ni dans son inventaire –, les créatures alentour, l'état de santé et de satiété du bot, et qu'il ne voit de son inventaire que les objets primordiaux. Il devient assez vite renseigné sur les créatures alentour, puis sur le biome et les autres blocs, et plus tard sur le reste.
Sachant que dans Minecraft, les débuts sont chauds car l'on débarque en slip, et qu'il faut rapido trouver à grailler pour compenser l'énergie claquée pour crafter ses premiers outils, avant que la nuit tombe car c'est alors que les streums déboulent, le LLM apparaît mis en risque durant le warm-up.

C'est celui qui dit, qui fait

C'est ainsi, au terme de bien des tours et détours dans le code, que CurriculumAgent.render_human_message () retourne le prompt utilisateur. Pour récompenser le lecteur qui a tenu jusqu'ici, voici à quoi il peut ressembler :
Question 1: How to obtain a diamond ore?
<réponse>
Question 2: What are the benefits of using a stone pickaxe over a wooden pickaxe?
<réponse>
Biome: sparse jungle
Time: sunrise
Nearby blocks: diorite, diorite, granite
Other blocks that are recently seen: dirt, water
Nearby entities: cow, enderman
Health: 5.0/20
Hunger: 2.0/20
Position: x=109.3, y=45.0, z=123.6
Equipment: ['iron_helmet', 'diamond_chestplate', 'iron_leggings', 'iron_boots', 'iron_sword', 'torch']
Inventory (2/36): {'golden_apple': 2, 'diamond': 12}
Chests:
(1.1, -12.0, 7.2): {'iron_axe': 2, 'apple': 1, 'tnt': 4}
(-6.0, 12.3, 4.2): Unknown items inside
(3.5, 5.1, -2.0): Empty
Completed tasks so far: {'Mine 1 wood log', 'Craft 1 iron sword'}
Failed tasks that are too hard: {'Kill 1 zombie', 'Make 1 golden apple'}
Dès lors, CurriculumAgent.propose_next_task () peut appeler CurriculumAgent.propose_next_ai_task () en lui passant ces prompts. Cette méthode les utilise pour interroger le LLM, mais ne s'en tient pas là, car elle doit retourner non seulement l'énoncé d'une tâche, mais aussi son contexte.
Pour l'énoncé, c'est assez simple : CurriculumAgent.propose_next_ai_task () appelle CurriculumAgent.parse_ai_message (), laquelle se contente de rechercher dans la réponse du LLM une ligne qui débute par "Task: " et de retourner tout ce qui suit ce préfixe.
Pour ce qui concerne le contexte, CurriculumAgent.propose_next_ai_task () appelle CurriculumAgent.get_task_context () en lui passant l'énoncé de la tâche qu'elle vient de récupérer. Cette méthode commence par retraiter l'énoncé pour former le texte d'une question – pour rappel, en Python, la syntaxe utilisée produit le résultat de la concaténation des chaînes entre parenthèses :
# if include ore in question, gpt will try to use tool with skill touch enhancement to mine
question = (
	f"How to {task.replace('_', ' ').replace(' ore', '').replace(' ores', '').replace('.', '').strip().lower()}"
	f" in Minecraft?"
)
Ces quelques lignes méritent d'être rapportées, car le rare commentaire fournit une explication qui n'est en rien anodine. En effet, c'est donc pour pallier l'incompétence du LLM sur un point très précis qu'il faut retraiter la question qui doit lui être posée, ce qui impose de le connaître à l'avance.
Pour le lecteur qui l'ignore, "Silk touch" – donc mal orthographié dans le commentaire – est un enchantement qui peut être appliqué sur un outil qui sert à miner, pour récupérer le bloc lui-même, et non le résultat de son minage. Par exemple, miner un bloc de charbon avec une pioche enchantée de la sorte produit le bloc de charbon, et non plus ou moins de minerai de charbon. Cela peut être très utile pour récupérer un bloc afin de le réutiliser pour décorer une construction, ou alors pour chercher à le miner plus tard, lorsqu'il aura été possible d'enchanter un outil avec "Fortune" qui permet d'augmenter la chance de tirer beaucoup de minerai du bloc – l'on pense à un bloc rare, comme un bloc de diamant. Le problème qui doit être résolu ici, c'est que si le bot mine un bloc de minerai avec un outil enchanté avec "Silk touch", il va récupérer le bloc et non le minerai, et donc ne pas pouvoir fabriquer d'objets à base de ce minerai. Dès lors, il sera bloqué à l'âge de pierre dans le schéma classique d'évolution en vigueur alors dans Minecraft : bois, pierre, fer, et enfin diamant.
La question formée, CurriculumAgent.get_task_context () procède comme CurriculumAgent.run_qa (), c'est-à-dire qu'elle la recherche dans le cache des questions de l'agent pour récupérer la réponse ; si elle ne la trouve pas, elle pose la question au LLM et stocke la question dans la base de données vectorielle des questions, et la question et la réponse dans le cache.
C'est CurriculumAgent.run_qa_step2_answer_questions () qui est appelée au besoin pour poser la question au LLM. C'est une version simplifiée de CurriculumAgent.render_system_message_qa_step1_ask_questions () croisée plus tôt, qui se contente de reprendre le contenu du fichier texte "curriculum_qa_step2_answer_questions.txt" pour le prompt système... :
You are a helpful assistant that answer my question about Minecraft.

I will give you the following information:
Question: ...

You will answer the question based on the context (only if available and helpful) and your own knowledge of Minecraft.
1) Start your answer with "Answer: ".
2) Answer "Answer: Unknown" if you don't know the answer.
...et une bête chaîne formée du préfixe "Question: " suivi de la question pour le prompt utilisateur.
C'est terminé. CurriculumAgent.propose_next_ai_task () récupère ainsi l'énoncé d'une nouvelle tâche à accomplir pour permettre au bot d'évoluer, générée par le LLM sur la base du curriculum, et le contexte de cette tâche, lui aussi généré par le LLM auquel il a simplement été demandé... comment accomplir cette tâche !
S'il ne parvient pas à obtenir du LLM l'énoncé d'une tâche et son contexte, la méthode repart pour un tour en s'appelant elle-même, dans la limite de 5 fois, après quoi elle soulève une exception "Max retries reached, failed to propose ai task." Ici, il faut pointer que CurriculumAgent.propose_next_ai_task () ne change strictement rien d'une itération à l'autre, ce qui paraît intriguant. En effet, si la température du LLM est à 0, ce dernier n'est-il pas censé générer toujours la même réponse à une même demande ? Or, d'une itération à l'autre, même les prompts restent les mêmes. A moins qu'il ne s'agisse de tenir compte d'un possible plantage de l'appel au serveur d'OpenAI...
CurriculumAgent.propose_next_task () retourne ce beau résultat à Voyager.learn (), ce qui permet de passer le cap de la première étape de la boucle principale. Il reste maintenant à voir comment la tâche et son contexte sont utilisés pour générer le code JavaScript qui permet de faire accomplir la tâche par le bot. Bravo au lecteur qui a tenu jusqu'ici.
Par ici pour la suite...
Voyager, l’agent IA qui joue à Minecraft (2/4)