Cet article fait partie de la série Assistant vocal sur Raspberry Pi.
À l’article #5, on injectait la météo dans chaque conversation, même pour une question comme “quel est ton nom?”. C’est du gaspillage de tokens. Le function calling règle ça : le LLM décide lui-même quand appeler un outil, et uniquement quand c’est nécessaire.
Le code complet de cet article est disponible sur GitHub.
Avant (article #5) :
Météo injectée dans TOUS les messages → tokens gaspillés
Après (article #6) :
"Comment je m'habille ce matin?" → LLM appelle get_weather() → répond avec les données
"Quel est ton nom?" → LLM répond directement, aucun outil
On entre dans le territoire des agents IA.
Comment ça marche ?
Le cycle complet :
1. On envoie les messages + la liste des outils disponibles au LLM
2. Le LLM décide :
a. Pas besoin d'outil → répond directement
b. Besoin d'un outil → retourne un tool_use block avec les paramètres
3. On exécute l'outil localement
4. On renvoie le résultat au LLM (tool_result)
5. Le LLM génère la réponse finale
Le LLM ne fait rien lui-même, il demande. On exécute, on lui donne les résultats.
Étape 1 : L’interface ITool
Crée Services/Tools/ITool.cs :
using System.Text.Json.Nodes;
namespace AudioAssistant.Services.Tools;
public interface ITool
{
string Name { get; }
string Description { get; }
JsonObject GetParametersSchema();
Task<string> ExecuteAsync(string input, CancellationToken cancellationToken);
}
Name et Description sont envoyés au LLM. La Description est ce que le LLM lit pour décider quand appeler l’outil. Soignez-la.
Étape 2 : WeatherTool
Crée Services/Tools/WeatherTool.cs :
using System.Text.Json.Nodes;
namespace AudioAssistant.Services.Tools;
public class WeatherTool : ITool
{
private readonly IWeatherService _weather;
public WeatherTool(IWeatherService weather)
{
_weather = weather;
}
public string Name => "get_weather";
public string Description =>
"Retourne la météo actuelle à Blainville. Appelle cet outil quand l'utilisateur pose " +
"des questions sur la météo, comment s'habiller, s'il faut un parapluie, ou planifier " +
"une sortie extérieure.";
public JsonObject GetParametersSchema() => new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject(),
["required"] = new JsonArray()
};
public Task<string> ExecuteAsync(string input, CancellationToken cancellationToken)
=> _weather.GetCurrentWeatherAsync(cancellationToken);
}
L’outil délègue à IWeatherService qu’on a créé à l’article #5. Pas de duplication.
Étape 3 : ToolRegistry
Le ToolRegistry centralise tous les outils. Crée Services/Tools/ToolRegistry.cs :
namespace AudioAssistant.Services.Tools;
public class ToolRegistry
{
private readonly Dictionary<string, ITool> _tools = new();
public ToolRegistry(IEnumerable<ITool> tools)
{
foreach (var tool in tools)
_tools[tool.Name] = tool;
}
public IEnumerable<ITool> GetAll() => _tools.Values;
public ITool? GetByName(string name) =>
_tools.TryGetValue(name, out var tool) ? tool : null;
}
Étape 4 : Mettre à jour ClaudeService
C’est la pièce centrale : le cycle tool_use, exécution, tool_result. Le SDK Anthropic.SDK 5.10 utilise Function et l’alias CommonTool pour éviter l’ambiguïté entre Anthropic.SDK.Common.Tool et Anthropic.SDK.Messaging.Tool.
using Anthropic.SDK;
using Anthropic.SDK.Common;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Options;
using AudioAssistant.Services.Tools;
using CommonTool = Anthropic.SDK.Common.Tool;
namespace AudioAssistant.Services;
public class ClaudeService : ILlmService
{
private readonly AssistantOptions _options;
private readonly ToolRegistry _toolRegistry;
private readonly ILogger<ClaudeService> _logger;
public ClaudeService(
IOptions<AssistantOptions> options,
ToolRegistry toolRegistry,
ILogger<ClaudeService> logger)
{
_options = options.Value;
_toolRegistry = toolRegistry;
_logger = logger;
}
public async Task<string> ChatAsync(List<ConversationMessage> history, CancellationToken cancellationToken)
{
_logger.LogInformation("Envoi à Claude ({Count} messages)...", history.Count);
var client = new AnthropicClient(_options.ClaudeApiKey);
var systemPrompt = history
.FirstOrDefault(m => m.Role == "system")?.Content ?? "";
var messages = history
.Where(m => m.Role != "system")
.Select(m => new Message
{
Role = m.Role == "user" ? RoleType.User : RoleType.Assistant,
Content = new List<ContentBase> { new TextContent { Text = m.Content } }
})
.ToList();
IList<CommonTool> tools = _toolRegistry.GetAll()
.Select(t => new Function(t.Name, t.Description, t.GetParametersSchema()))
.Select(f => (CommonTool)f)
.ToList();
var parameters = new MessageParameters
{
Model = _options.ClaudeModel,
MaxTokens = 512,
System = new List<SystemMessage> { new SystemMessage(systemPrompt) },
Messages = messages,
Tools = tools
};
var response = await client.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
if (response.StopReason == "tool_use")
{
var toolUse = response.Content.OfType<ToolUseContent>().FirstOrDefault();
if (toolUse != null)
{
_logger.LogInformation("Claude appelle l'outil : {Tool}", toolUse.Name);
var tool = _toolRegistry.GetByName(toolUse.Name);
if (tool != null)
{
var toolResult = await tool.ExecuteAsync(
toolUse.Input?.ToString() ?? "", cancellationToken);
_logger.LogInformation("Résultat de l'outil : {Result}", toolResult);
messages.Add(new Message
{
Role = RoleType.Assistant,
Content = response.Content
});
messages.Add(new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new ToolResultContent
{
ToolUseId = toolUse.Id,
Content = new List<ContentBase>
{
new TextContent { Text = toolResult }
}
}
}
});
var finalResponse = await client.Messages.GetClaudeMessageAsync(
new MessageParameters
{
Model = _options.ClaudeModel,
MaxTokens = 512,
System = new List<SystemMessage> { new SystemMessage(systemPrompt) },
Messages = messages,
Tools = tools
}, cancellationToken);
var finalText = finalResponse.Content.OfType<TextContent>()
.FirstOrDefault()?.Text?.Trim() ?? "Je n'ai pas de réponse.";
_logger.LogInformation("Réponse finale de Claude : \"{Text}\"", finalText);
return finalText;
}
}
}
var text = response.Content.OfType<TextContent>()
.FirstOrDefault()?.Text?.Trim() ?? "Je n'ai pas de réponse.";
_logger.LogInformation("Réponse de Claude : \"{Text}\"", text);
return text;
}
}
Étape 5 : Simplifier ContextService
La météo n’est plus injectée au démarrage. InitializeAsync redevient Initialize synchrone, et le constructeur n’a plus besoin de IWeatherService :
using Microsoft.Extensions.Options;
namespace AudioAssistant.Services;
public interface IContextService
{
void Initialize();
List<ConversationMessage> AddUserMessage(string userInput);
void AddAssistantMessage(string response);
void Reset();
}
public class ContextService : IContextService
{
private readonly AssistantOptions _options;
private readonly List<ConversationMessage> _history = new();
private readonly ILogger<ContextService> _logger;
public ContextService(IOptions<AssistantOptions> options, ILogger<ContextService> logger)
{
_options = options.Value;
_logger = logger;
}
public void Initialize()
{
_history.Clear();
_history.Add(new ConversationMessage("system", _options.SystemPrompt));
_logger.LogInformation("Contexte initialisé.");
}
public List<ConversationMessage> AddUserMessage(string userInput)
{
_history.Add(new ConversationMessage("user", userInput));
_logger.LogInformation("Historique : {Count} messages", _history.Count);
return _history;
}
public void AddAssistantMessage(string response)
{
_history.Add(new ConversationMessage("assistant", response));
}
public void Reset()
{
_history.Clear();
_logger.LogInformation("Historique réinitialisé.");
}
}
Étape 6 : Mettre à jour Worker.cs
await _context.InitializeAsync() devient _context.Initialize() :
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Assistant démarré. Initialisation du contexte...");
_context.Initialize();
_logger.LogInformation("Appuie sur le bouton pour parler.");
while (!stoppingToken.IsCancellationRequested)
{
_gpio.WaitForButtonPress(stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
try
{
var audioFile = await _recorder.RecordAsync(stoppingToken);
var texte = await _transcription.TranscribeAsync(audioFile, stoppingToken);
if (string.IsNullOrWhiteSpace(texte))
{
await _speech.SpeakAsync("Je n'ai pas bien entendu. Peux-tu répéter?", stoppingToken);
}
else
{
var history = _context.AddUserMessage(texte);
var reponse = await _llm.ChatAsync(history, stoppingToken);
_context.AddAssistantMessage(reponse);
await _speech.SpeakAsync(reponse, stoppingToken);
}
if (File.Exists(audioFile))
File.Delete(audioFile);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogError(ex, "Erreur dans le pipeline");
await _speech.SpeakAsync("Une erreur s'est produite.", stoppingToken);
}
}
}
Étape 7 : Mettre à jour Program.cs
On enregistre les outils avant les services LLM :
using AudioAssistant;
using AudioAssistant.Services;
using AudioAssistant.Services.Tools;
using Microsoft.Extensions.Options;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<AssistantOptions>(
builder.Configuration.GetSection("Assistant"));
builder.Services.AddSingleton<IGpioService, GpioService>();
builder.Services.AddSingleton<IAudioRecorderService, AudioRecorderService>();
builder.Services.AddSingleton<ITranscriptionService, WhisperTranscriptionService>();
builder.Services.AddSingleton<ISpeechService, PiperSpeechService>();
builder.Services.AddSingleton<IContextService, ContextService>();
builder.Services.AddHttpClient<IWeatherService, WeatherService>();
builder.Services.AddSingleton<ITool, WeatherTool>();
builder.Services.AddSingleton<ToolRegistry>();
builder.Services.AddHttpClient<OllamaService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddTransient<ClaudeService>();
builder.Services.AddTransient<ILlmService>(sp =>
{
var options = sp.GetRequiredService<IOptions<AssistantOptions>>().Value;
return options.LlmProvider.Equals("claude", StringComparison.OrdinalIgnoreCase)
? (ILlmService)sp.GetRequiredService<ClaudeService>()
: sp.GetRequiredService<OllamaService>();
});
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
Tester
Lance dotnet run et compare les deux types de questions :
Tu : "Est-ce que je devrais sortir les enfants aujourd'hui?"
Logs: Claude appelle l'outil : get_weather
Logs: Résultat de l'outil : Partiellement nuageux, 12.3°C...
Alex: "Oui, c'est une belle journée pour sortir!"
Tu : "Quel est ton nom?"
Logs: (aucun appel d'outil)
Alex: "Je m'appelle Alex!"
Le LLM est l’orchestrateur. On ne lui dit pas quand appeler quoi, il décide.
Ajouter d’autres outils
La force du pattern : un nouvel outil, c’est un fichier et une ligne dans Program.cs. Rien d’autre ne change.
Crée Services/Tools/TimeTool.cs :
using System.Text.Json.Nodes;
namespace AudioAssistant.Services.Tools;
public class TimeTool : ITool
{
public string Name => "get_current_time";
public string Description =>
"Retourne la date et l'heure actuelle. Appelle cet outil quand l'utilisateur " +
"demande l'heure, la date, le jour de la semaine, ou le mois.";
public JsonObject GetParametersSchema() => new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject(),
["required"] = new JsonArray()
};
public Task<string> ExecuteAsync(string input, CancellationToken cancellationToken)
{
var now = DateTime.Now;
return Task.FromResult($"{now:dddd d MMMM yyyy}, {now:HH:mm}");
}
}
Enregistre dans Program.cs, après WeatherTool :
builder.Services.AddSingleton<ITool, WeatherTool>();
builder.Services.AddSingleton<ITool, TimeTool>();
builder.Services.AddSingleton<ToolRegistry>();
C’est tout. L’assistant connaît maintenant l’heure sans que ClaudeService ou Worker aient besoin de changer.
Et Ollama dans tout ça ?
OllamaService n’a pas été mis à jour pour supporter les tools. llama3.2:1b supporte le function calling en théorie, mais la fiabilité est inégale. Pour l’instant, Ollama reste le mode “local gratuit sans tools” et Claude le mode “qualité production avec tools”. Si vous voulez tester avec un modèle local, mistral:7b est plus fiable, mais il dépasse la RAM confortable d’un Pi 4 Go.
Le code complet de cet article est disponible sur GitHub.
Articles de la série
- Setup des deux Raspberry Pi
- Worker Service .NET 10 et pipeline audio
- Intégration Ollama et contexte maison
- Mémoire, détection de silence et systemd
- Météo en temps réel et swap Claude API
- Function Calling : enseigner des outils à l’assistant (cet article)
- Bilan, leçons apprises et perspectives v2
Dans l’article #7, on fait le bilan de la série : ce qui a bien fonctionné, les surprises, et ce que serait la v2.
Cet article a été rédigé avec l’aide de l’IA et révisé par moi.