Niveau 3
Concevoir un outil
- user
- assistant
- tool_result
EventBus
Le problème
Quand vous écrivez une fonction, vous savez ce qu'elle fait. Le modèle, non : tout ce qu'il reçoit, c'est l'enseigne sur la porte de l'Atelier. Si l'enseigne est vague, le modèle devine. Une mauvaise supposition coûte un tour, une erreur et plus de tokens dans l'historique. Pire, un outil vague peut « réussir » avec un résultat inutile sans que personne ne s'en aperçoive.
Dans le combat, une créature sauvage bloque le chemin et le dresseur demande quel partenaire envoyer. L'Oracle a
quatre outils, et le menu des attaques montre exactement ce que le modèle reçoit pour chacun : un nom, ses
paramètres et une description. Cela suffit pour écarter do_stuff, pour écarter
heal_party parce que sa description dit « jamais en combat », pour savoir qu'il faut d'abord
découvrir le type de la créature, et pour passer à check_matchup son entrée au bon format du premier
coup.
Anatomie d'un bon outil
-
Nom
Au lieu de
do_stuff, utilisez identify_wild, check_matchupUn verbe et un nom précis disent au modèle ce que fait l'outil avant qu'il ne lise quoi que ce soit d'autre.
-
Description
Au lieu de
"Does stuff.", utilisez Ce qu'il fait, quand l'utiliser et ce qu'il renvoie.C'est la seule documentation que reçoit le modèle. Écrivez-la pour quelqu'un qui ne peut pas lire votre code.
-
Paramètres
Au lieu de
x: string, utilisez attack et defend, chacun avec sa description, et le format écrit noir sur blanc : un type en minuscules, comme water.Indiquez ce qui est obligatoire et interdisez le reste, pour qu'une mauvaise entrée échoue vite au lieu de faire quelque chose de bizarre.
-
Sortie
Au lieu de
Toute la table des types, 18 types sur 18, utilisez Une ligne : grass vs water: 2x, super effective.Tout ce qu'un outil renvoie entre dans l'historique, et le modèle le relit à chaque tour.
-
Erreurs
Au lieu de
"Invalid input", utilisez "defend must be one lowercase type, like water. Call identify_wild to get it."Une erreur que le modèle peut comprendre est une erreur qu'il peut corriger au tour suivant.
Une règle de plus : moins d'outils, mais plus précis. Chaque outil que vous ajoutez, c'est une enseigne de plus que le modèle lit à chaque tour, et une façon de plus de choisir le mauvais.
Le code
Avec astorlm : tool() prend un schéma Zod, le transforme en JSON Schema que lit le
modèle, et vérifie l'entrée du modèle avec lui avant que votre code ne s'exécute. Si l'entrée ne correspond pas,
le modèle reçoit une erreur claire au lieu que votre fonction plante.
À partir de zéro : Un outil, c'est un JSON Schema que le modèle lit plus une fonction qu'il ne
voit jamais. Voici le do_stuff de Crooky et les deux outils que l'Oracle a utilisés pendant le
combat, côte à côte. Seuls les schémas voyagent jusqu'au modèle, dans le champ tools de chaque
requête du niveau 2.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const type = z
.string()
.regex(/^[a-z]+$/, 'a type is one lowercase word, like water; call identify_wild to get the wild one')
// Describe the input once with Zod: astorlm turns it into the JSON Schema the model
// reads, and checks the model's input against it before running your code.
const checkMatchup = tool({
name: 'check_matchup',
description:
'How hard one type hits another. Use it to choose which partner to send into a battle. ' +
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.',
schema: z.object({
attack: type.describe('The attacking type, one lowercase word, e.g. "grass".'),
defend: type.describe('The defending type, one lowercase word, e.g. "water".'),
}),
execute: async ({ attack, defend }) => {
const times = typeChart[attack]?.[defend] ?? 1 // your code; the model never sees it
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
},
})
const identifyWild = tool({
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
schema: z.object({}),
execute: async () => {
const wild = battle.opponent() // your code
return `${wild.name} · type: ${wild.type}`
},
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [checkMatchup, identifyWild],
})
await agent.run('A wild creature appeared! Should I send Emberpup (fire) or Sproutle (grass)?')
// A tool is two things: a description the model reads, and code the model never sees.
// ✗ Crooky's tool. The model has to guess what it does and what x means.
const doStuffSchema = {
type: 'function',
function: {
name: 'do_stuff',
description: 'Does stuff.',
parameters: { type: 'object', properties: { x: { type: 'string' } } },
},
}
// ✓ Named for what it does, described for someone who can't read the code.
const checkMatchupSchema = {
type: 'function',
function: {
name: 'check_matchup',
description:
'How hard one type hits another. ' + // what it does
'Use it to choose which partner to send into a battle. ' + // when to use it
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.', // what comes back
parameters: {
type: 'object',
properties: {
attack: { type: 'string', description: 'The attacking type, one lowercase word, e.g. "grass".' },
defend: { type: 'string', description: 'The defending type, one lowercase word, e.g. "water".' },
},
required: ['attack', 'defend'],
additionalProperties: false,
},
},
}
// ✓ A small helper tool, so the model never has to guess what it is facing.
const identifyWildSchema = {
type: 'function',
function: {
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
}
// The code behind the names. In a real game, these read the battle state.
export function identifyWild(): string {
const wild = battle.opponent()
return `${wild.name} · type: ${wild.type}`
}
export function checkMatchup({ attack, defend }: { attack: string; defend: string }): string {
// An error the model can fix on its next turn: what went wrong, and what to send instead.
for (const value of [attack, defend]) {
if (!/^[a-z]+$/.test(value)) {
return `Error: a type is one lowercase word, like water. Got "${value}". Call identify_wild to get the wild one.`
}
}
const times = typeChart[attack]?.[defend] ?? 1
// Short, on-topic output: every character here goes into the history, and gets read every turn.
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
}
// Only the schemas travel to the model, in the `tools` field of each request.
export const toolSchemas = [checkMatchupSchema, identifyWildSchema]
# A tool is two things: a description the model reads, and code the model never sees.
import re
# ✗ Crooky's tool. The model has to guess what it does and what x means.
DO_STUFF_SCHEMA = {
"type": "function",
"function": {
"name": "do_stuff",
"description": "Does stuff.",
"parameters": {"type": "object", "properties": {"x": {"type": "string"}}},
},
}
# ✓ Named for what it does, described for someone who can't read the code.
CHECK_MATCHUP_SCHEMA = {
"type": "function",
"function": {
"name": "check_matchup",
"description": (
"How hard one type hits another. " # what it does
"Use it to choose which partner to send into a battle. " # when to use it
"Returns one line: the multiplier (2x, 1x or 0.5x) and what it means." # what comes back
),
"parameters": {
"type": "object",
"properties": {
"attack": {"type": "string", "description": 'The attacking type, one lowercase word, e.g. "grass".'},
"defend": {"type": "string", "description": 'The defending type, one lowercase word, e.g. "water".'},
},
"required": ["attack", "defend"],
"additionalProperties": False,
},
},
}
# ✓ A small helper tool, so the model never has to guess what it is facing.
IDENTIFY_WILD_SCHEMA = {
"type": "function",
"function": {
"name": "identify_wild",
"description": "Identify the wild creature you are facing. Returns its name and type.",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
}
# The code behind the names. In a real game, these read the battle state.
def identify_wild():
wild = battle.opponent()
return f"{wild['name']} · type: {wild['type']}"
def check_matchup(attack, defend):
# An error the model can fix on its next turn: what went wrong, and what to send instead.
for value in (attack, defend):
if not re.fullmatch(r"[a-z]+", value):
return f'Error: a type is one lowercase word, like water. Got "{value}". Call identify_wild to get the wild one.'
times = TYPE_CHART.get(attack, {}).get(defend, 1)
# Short, on-topic output: every character here goes into the history, and gets read every turn.
return f"{attack} vs {defend}: {times}x" + (", super effective" if times > 1 else "")
# Only the schemas travel to the model, in the `tools` field of each request.
TOOL_SCHEMAS = [CHECK_MATCHUP_SCHEMA, IDENTIFY_WILD_SCHEMA]
Vérifiez vos outils comme le ferait le modèle
- Ne lisez que le schéma. Cachez le code et demandez-vous : saurais-je quand appeler ceci, et quoi lui passer ?
- Observez les premiers appels. Si le modèle continue d'envoyer une mauvaise entrée, la solution est presque toujours dans la description, pas dans le prompt.
- Mesurez la sortie. Un outil qui renvoie toute la table des types alors qu'une ligne suffirait remplit l'historique à toute vitesse.