Un agent IA local avec Ollama : outils spécialisés, validation et limites strictes de pas
Un agent d’IA local sur Ollama qui se comporte comme suit : listes d’outils restreintes, boucle limitée, sortie structurée avec Pydantic, état explicite et ensemble de tests de cas d’échec.
Sources: I Built a Local AI Agent With Ollama — and the Hard Part Wasn’t the Model
La première version d’un agent de facturation locale semble impressionnante pendant environ cinq minutes. La tâche est simple : aider le propriétaire d’une petite entreprise à récupérer les factures en retard. Cet agent peut rechercher les clients, trouver les factures non payées, déterminer le retard et rédiger un rappel. Une demande de test lui demande de trouver les factures en retard pour Acme Plumbing et de préparer un rappel amical. Il répond immédiatement en fournissant une facture, le solde restant dû et un e-mail bien rédigé.
Le problème, c’est qu’une partie de ces informations est inventée. Le client existe, mais le numéro de facture n’existe pas. Le montant semble plausible, mais il est incorrect. L’agent a produit l’apparence d’un résultat réussi sans effectuer le travail réel.
Cet épisode enseigne la leçon la plus importante concernant la création d’agents : paraître intelligent ne signifie pas être fiable. Un agent est fiable lorsque chaque affirmation significative trouve son origine dans une action contrôlée ou une source fiable. Ce qui suit permet de reconstruire l’agent de facturation à partir d’Ollama et de Python, en y ajoutant des outils explicites, une validation ainsi qu’un cycle d’agent compact. L’inférence a lieu sur la machine locale, mais ces principes s’appliquent à presque tous les frameworks d’agents.
Pourquoi Ollama
Une configuration locale évite d’envoyer les données des clients à un fournisseur de modèles externe pendant le développement, et elle permet de changer de modèles ouverts sans devoir reconstruire l’application. Ollama met à disposition des modèles localement via une simple API de chat. La mise en appel d’outils est également prise en charge, de sorte qu’un modèle peut demander l’exécution d’une fonction au lieu de fournir une réponse inventée. La configuration est rapide :
ollama pull qwen3
pip install ollama
Une demande de chat normale depuis Python a cette apparence :
from ollama import chat
response = chat(
model="qwen3",
messages=[
{"role": "user", "content": "Explain what an overdue invoice is."}
],
)print(response.message.content)
Cela donne un chatbot, pas encore un agent.
Un chatbot répond, un agent agit par étapes
Le terme agent est utilisé de manière vague. Une définition fonctionnelle est un modèle qui fonctionne en boucle, avec accès à des outils, à un état et à des règles déterminant ce qu’il peut faire ensuite. Pour la tâche de facturation, la boucle se déroule comme suit :
- L’utilisateur fait une demande.
- Le modèle décide s’un outil est nécessaire.
- L’application valide la demande d’outil.
- L’application exécute l’outil.
- Le résultat est ajouté à la conversation.
- Le modèle demande un autre outil ou donne une réponse finale.
Le modèle ne touche jamais à la base de données. Il ne peut que demander à l’application d’appeler un outil défini par le développeur. Cette frontière est importante car les modèles de langage sont doués pour interpréter les intentions et générer du texte, tandis que le code d’application excelle en matière de permissions, de requêtes, de calculs et de règles métier.
Utilisez d’abord des outils spécialisés
Une erreur fréquente consiste à utiliser un seul outil puissant, par exemple manage_invoices, capable de rechercher, mettre à jour, rédiger et envoyer des informations. Cela rend le débogage difficile et facilite dangereusement les abus. Des outils petits, chacun ayant une seule fonction, fonctionnent mieux :
find_customertransforme le nom d’un client en un ID de client réel.get_overdue_invoicesrenvoie les factures en retard correspondant à cet ID.draft_remindergénère du texte sans envoyer quoi que ce soit.send_remindereffectue l’action externe et nécessite une approbation.
Une couche de données locales simplifiée fournit les faits :
CUSTOMERS = {
"acme plumbing": {
"customer_id": "cus_1042",
"name": "Acme Plumbing",
"email": "billing@example.com",
}
}
INVOICES = [
{
"invoice_id": "INV-2048",
"customer_id": "cus_1042",
"amount": 1850.00,
"days_overdue": 18,
}
]
def find_customer(name: str) -> dict:
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
Regardez ce que ces fonctions s’abstiennent de faire. Elles ne devinent jamais. L’absence d’un client génère une erreur explicite, et l’absence de factures correspondantes produit une liste vide.
Outils réels, pas d’accès imaginaire
Ollama accepte les fonctions Python en tant qu’outils, et les noms, paramètres ainsi que les docstrings fournissent au modèle ce dont il a besoin pour en choisir la bonne.
import json
from ollama import chat
def find_customer(name: str) -> dict:
"""Find a customer by business name and return its verified record."""
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
"""Return overdue invoices for a verified customer ID."""
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
TOOLS = {
"find_customer": find_customer,
"get_overdue_invoices": get_overdue_invoices,
}
Le dictionnaire du registre n’est pas seulement une commodité, car il s’agit d’une liste d’autorisation. Même si le modèle demande une fonction nommée delete_customer, l’application refuse car aucun outil de ce type n’existe dans TOOLS.
La boucle de l’agent
C’est la boucle qui transforme l’appel à des outils en un agent. Elle commence par une instruction système :
SYSTEM_PROMPT = """
You are an invoice assistant.
Rules:
- Never invent a customer, invoice, email address, balance, or date.
- Use find_customer before requesting invoices.
- Only use customer IDs returned by tools.
- If a tool returns an error or no records, explain that clearly.
- You may draft communication, but you cannot send it.
"""
def run_agent(user_request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_request},
] for _ in range(6):
response = chat(
model="qwen3",
messages=messages,
tools=list(TOOLS.values()),
) messages.append(response.message) if not response.message.tool_calls:
return response.message.content for call in response.message.tool_calls:
name = call.function.name
arguments = call.function.arguments if name not in TOOLS:
result = {"error": "tool_not_allowed"}
else:
try:
result = TOOLS[name](**arguments)
except (TypeError, ValueError) as error:
result = {
"error": "invalid_tool_arguments",
"detail": str(error),
} messages.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result),
}
) return "I stopped because the task exceeded the maximum number of steps."
Le plafond de six itérations est intentionnel, car un agent ne doit jamais fonctionner indéfiniment en réexaminant le même problème. La boucle traite également les résultats des outils comme des preuves fraîches. Le modèle résout d’abord le nom « Acme Plumbing », obtient cus_1042, et ne demande des factures pour cet identifiant vérifié qu’après cela.
Un meilleur prompt n’a pas suffi
Les hallucinations incitent un développeur à ajouter une phrase supplémentaire au prompt du système, par exemple une demande d’être précis et de ne jamais inventer quoi que ce soit. Cette instruction aide, mais ce n’est pas un moyen de contrôle. La solution fiable vient de la restructuration :
- La base de données reste la source de vérité.
- Le modèle peut demander des données mais ne peut pas inventer de record qui soit considéré comme valide.
- Les noms des outils sont listés et les arguments sont validés.
- La boucle dispose d’une limite stricte d’itérations.
- La lecture et l’écriture sont séparées.
Les prompts influencent le comportement, et le code le met en œuvre.
Sortie structurée à la frontière
Le texte libre convient à un e-mail amical, mais il présente des risques lorsque une autre partie de l’application traite la réponse. Si l’agent doit afficher une page de révision avec le client, les IDs des factures, le solde total et un brouillon, un modèle Pydantic peut définir ce contrat :
from pydantic import BaseModel, Field
class ReminderReview(BaseModel):
customer_name: str
invoice_ids: list[str]
total_due: float = Field(ge=0)
draft_subject: str
draft_body: str
requires_approval: bool = True
Ollama accepte un schéma JSON via son paramètre format :
review_response = chat(
model="qwen3",
messages=messages,
format=ReminderReview.model_json_schema(),
)
review = ReminderReview.model_validate_json(
review_response.message.content
)
Cela garantit que la réponse a la forme attendue. Cela ne prouve pas que les valeurs sont exactes, donc invoice_ids et total_due sont encore comparés aux résultats réels de l’outil avant que le bouton d’approbation n’apparaisse. La validation du schéma vérifie si quelque chose a la bonne forme. La validation métier vérifie si c’est autorisé et vrai. Un agent de production a besoin des deux.
Etat, ce n’est pas de la mémoire
Les conversations longues révèlent un autre problème : le modèle oublie quel client a été sélectionné ou mélange une ancienne facture dans une nouvelle demande. La cause en est le fait de traiter l’intégralité du transcript de la conversation comme étant l’état de l’application. La conversation constitue un contexte utile, mais l’état critique doit figurer dans une structure explicite :
task_state = {
"customer_id": "cus_1042",
"verified_invoice_ids": ["INV-2048"],
"approved_actions": [],
}
Le modèle peut résumer les événements, mais l’application détient les identifiants et les approbations vérifiés. Si la conversation est tronquée pour tenir dans la fenêtre de contexte, ces informations restent intactes.
La longueur du contexte a également un impact sur les performances, car plus de contexte consomme plus de mémoire et ralentit le traitement. Plutôt que d’envoyer indéfiniment chaque message et chaque ligne de base de données, transmettez uniquement l’ensemble minimal d’informations nécessaires à la décision en cours.
Local ne signifie pas sûr
Exécuter un modèle via Ollama permet de garder l’inférence sous contrôle local, mais cela ne sécurise pas le reste de l’application. Un agent peut toujours fuiter des données dans les journaux, appeler une extrémité interne non sécurisée, suivre des instructions malveillantes cachées dans un document ou agir avec des permissions excessives. Les principes de sécurité habituels du backend s’appliquent :
- Accordez à chaque outil uniquement les permissions dont il a besoin.
- Éloignez les secrets des prompts et du code source.
- Considérez le texte récupéré comme des données non fiables, et jamais comme des instructions système.
- Enregistrez chaque appel à l’outil avec son résultat, sa latence et la décision finale.
- Supprimez les champs sensibles des résultats avant qu’ils n’entrent dans le contexte du modèle.
- Demandez l’approbation pour les actions externes ou irréversibles.
- Rendez les écritures importantes idempotentes, afin que les tentatives de réexécution ne les dupliquent pas.
Le modèle ne doit jamais constituer la limite de sécurité.
Tests
Les démonstrations de parcours idéal cachent la plupart des échecs de l’agent, il est donc utile de créer un petit ensemble d’évaluation. Les cas couvrent un client connu avec une facture en retard et un autre sans aucune facture en retard, un nom qui ne correspond à personne, deux noms d’entreprise similaires, une tentative d’envoi qui passe outre l’approbation, une instruction injectée pour ignorer les règles, un argument de outil mal formaté et un outil qui atteint sa limite de temps. Pour chacun, notez les outils attendus, les actions autorisées et le résultat final, puis relancez l’ensemble chaque fois que le modèle, la prompt du système ou les descriptions des outils changent.
La mesure la plus utile n’est pas de savoir si la réponse finale sonne bien, mais plutôt si l’agent a suivi la bonne séquence d’étapes vérifiées.
La version fonctionnelle
Après la refonte, la demande originale laisse une trace comme celle-ci :
User request
→ find_customer(name="Acme Plumbing")
→ verified customer_id: cus_1042
→ get_overdue_invoices(customer_id="cus_1042")
→ verified invoice: INV-2048, $1,850, 18 days overdue
→ generate draft
→ wait for human approval
C’est moins magique que la première démo, mais bien plus utile. Un client inexistant pousse l’agent à s’arrêter et à demander des précisions. Aucune facture en retard n’est signalée en tant que telle. Une demande d’outil inconnue est rejetée par l’application, et un boucle qui tourne trop longtemps s’arrête en toute sécurité. Rien de tout cela n’est spectaculaire, mais tout cela contribue à renforcer la confiance.
La leçon finale
Ollama facilite l’exécution de modèles locaux performants, mais le modèle n’est qu’une partie du tout. Le véritable agent est le système qui l’entoure : la boucle, les contrats d’outil, les données de référence, la couche de validation, la machine à états, les limites de permissions et l’étape d’approbation.
Pour un premier agent, omettez les dix outils et l’accès complet à l’application. Commencez par un problème réel et deux outils en lecture seule restreints, enregistrez chaque décision, ajoutez des cas d’échec, et n’accordez l’accès d’écriture qu’après que la voie de lecture se soit révélée extrêmement fiable. Le changement essentiel consiste à remplacer la question de savoir comment rendre le modèle plus autonome par celle de savoir comment rendre chaque étape observable, vérifiable et sûre. L’autonomie constitue une bonne démonstration, tandis qu’une autonomie contrôlée donne naissance à un produit utile.