Condividi tramite


Aggiungere codice nativo come plug-in

Il modo più semplice per fornire a un agente di intelligenza artificiale funzionalità non supportate in modo nativo consiste nel eseguire il wrapping del codice nativo in un plug-in. In questo modo è possibile sfruttare le competenze esistenti come sviluppatore di app per estendere le funzionalità degli agenti di intelligenza artificiale.

Dietro le quinte, Semantic Kernel userà quindi le descrizioni fornite, insieme alla reflection, per descrivere semanticamente il plug-in per l'agente di intelligenza artificiale. In questo modo l'agente di intelligenza artificiale può comprendere le funzionalità del plug-in e come interagire con esso.

Fornire all'LLM le informazioni corrette

Quando si crea un plug-in, è necessario fornire all'agente di intelligenza artificiale le informazioni corrette per comprendere le funzionalità del plug-in e le relative funzioni. Questo include:

  • Nome del plug-in
  • Nomi delle funzioni
  • Descrizioni delle funzioni
  • Parametri delle funzioni
  • Schema dei parametri
  • Schema del valore restituito

Il valore del kernel semantico è che può generare automaticamente la maggior parte di queste informazioni dal codice stesso. In qualità di sviluppatore, questo significa semplicemente che è necessario fornire le descrizioni semantiche delle funzioni e dei parametri in modo che l'agente di intelligenza artificiale possa comprenderli. Se tuttavia si commenta correttamente e si annota il codice, è probabile che queste informazioni siano già disponibili.

Di seguito verranno illustrati i due modi diversi per fornire all'agente di intelligenza artificiale codice nativo e come fornire queste informazioni semantiche.

Definizione di un plug-in tramite una classe

Il modo più semplice per creare un plug-in nativo consiste nell'iniziare con una classe e quindi aggiungere metodi annotati con l'attributo KernelFunction . È anche consigliabile usare liberamente l'annotazione Description per fornire all'agente di intelligenza artificiale le informazioni necessarie per comprendere la funzione.

public class LightsPlugin
{
   private readonly List<LightModel> _lights;

   public LightsPlugin(LoggerFactory loggerFactory, List<LightModel> lights)
   {
      _lights = lights;
   }

   [KernelFunction("get_lights")]
   [Description("Gets a list of lights and their current state")]
   public async Task<List<LightModel>> GetLightsAsync()
   {
      return _lights;
   }

   [KernelFunction("change_state")]
   [Description("Changes the state of the light")]
   public async Task<LightModel?> ChangeStateAsync(LightModel changeState)
   {
      // Find the light to change
      var light = _lights.FirstOrDefault(l => l.Id == changeState.Id);

      // If the light does not exist, return null
      if (light == null)
      {
         return null;
      }

      // Update the light state
      light.IsOn = changeState.IsOn;
      light.Brightness = changeState.Brightness;
      light.Color = changeState.Color;

      return light;
   }
}
from typing import List, Optional, Annotated

class LightsPlugin:
    def __init__(self, lights: List[LightModel]):
        self._lights = lights

    @kernel_function
    async def get_lights(self) -> List[LightModel]:
        """Gets a list of lights and their current state."""
        return self._lights

    @kernel_function
    async def change_state(
        self,
        change_state: LightModel
    ) -> Optional[LightModel]:
        """Changes the state of the light."""
        for light in self._lights:
            if light["id"] == change_state["id"]:
                light["is_on"] = change_state.get("is_on", light["is_on"])
                light["brightness"] = change_state.get("brightness", light["brightness"])
                light["hex"] = change_state.get("hex", light["hex"])
                return light
        return None
public class LightsPlugin {

    // Mock data for the lights
    private final Map<Integer, LightModel> lights = new HashMap<>();

    public LightsPlugin() {
        lights.put(1, new LightModel(1, "Table Lamp", false, LightModel.Brightness.MEDIUM, "#FFFFFF"));
        lights.put(2, new LightModel(2, "Porch light", false, LightModel.Brightness.HIGH, "#FF0000"));
        lights.put(3, new LightModel(3, "Chandelier", true, LightModel.Brightness.LOW, "#FFFF00"));
    }

    @DefineKernelFunction(name = "get_lights", description = "Gets a list of lights and their current state")
    public List<LightModel> getLights() {
        System.out.println("Getting lights");
        return new ArrayList<>(lights.values());
    }

    @DefineKernelFunction(name = "change_state", description = "Changes the state of the light")
    public LightModel changeState(
            @KernelFunctionParameter(
                    name = "model",
                    description = "The new state of the model to set. Example model: " +
                            "{\"id\":99,\"name\":\"Head Lamp\",\"isOn\":false,\"brightness\":\"MEDIUM\",\"color\":\"#FFFFFF\"}",
                    type = LightModel.class) LightModel model
    ) {
        System.out.println("Changing light " + model.getId() + " " + model.getIsOn());
        if (!lights.containsKey(model.getId())) {
            throw new IllegalArgumentException("Light not found");
        }

        lights.put(model.getId(), model);

        return lights.get(model.getId());
    }
}

Suggerimento

Poiché i modelli LLM vengono prevalentemente addestrati nel codice Python, è consigliabile usare snake_case per le funzioni e i parametri (anche se si stia usando C# o Java). Ciò consentirà all'agente di intelligenza artificiale di comprendere meglio la funzione e i relativi parametri.

Suggerimento

Le funzioni possono specificare Kernel, KernelArguments, ILoggerFactory, ILogger, IAIServiceSelector, CultureInfo, IFormatProvider, CancellationToken come parametri e questi non verranno annunciati a LLM e verranno impostati automaticamente quando viene chiamata la funzione . Se ci si basa su KernelArguments anziché su argomenti di input espliciti, il codice sarà responsabile di eseguire le conversioni di tipo.

Se la funzione ha un oggetto complesso come variabile di input, il kernel semantico genererà anche uno schema per tale oggetto e lo passerà all'agente di intelligenza artificiale. Analogamente alle funzioni, è necessario fornire Description annotazioni per le proprietà che non sono ovvie per l'intelligenza artificiale. Di seguito è riportata la definizione per la LightState classe e l'enumerazione Brightness .

using System.Text.Json.Serialization;

public class LightModel
{
   [JsonPropertyName("id")]
   public int Id { get; set; }

   [JsonPropertyName("name")]
   public string? Name { get; set; }

   [JsonPropertyName("is_on")]
   public bool? IsOn { get; set; }

   [JsonPropertyName("brightness")]
   public Brightness? Brightness { get; set; }

   [JsonPropertyName("color")]
   [Description("The color of the light with a hex code (ensure you include the # symbol)")]
   public string? Color { get; set; }
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Brightness
{
   Low,
   Medium,
   High
}
from enum import Enum
from typing import TypedDict

class Brightness(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class LightModel(TypedDict):
    id: int
    name: str
    is_on: bool | None
    brightness: Brightness | None
    color: Annotated[str | None, "The color of the light with a hex code (ensure you include the # symbol)"]
public class LightModel {

    private int id;
    private String name;
    private Boolean isOn;
    private Brightness brightness;
    private String color;


    public enum Brightness {
        LOW,
        MEDIUM,
        HIGH
    }

    public LightModel(int id, String name, Boolean isOn, Brightness brightness, String color) {
        this.id = id;
        this.name = name;
        this.isOn = isOn;
        this.brightness = brightness;
        this.color = color;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Boolean getIsOn() {
        return isOn;
    }

    public void setIsOn(Boolean isOn) {
        this.isOn = isOn;
    }

    public Brightness getBrightness() {
        return brightness;
    }

    public void setBrightness(Brightness brightness) {
        this.brightness = brightness;
    }

    public String getColor() {
        return color;
    }

    public void setColor(String color) {
        this.color = color;
    }
}

Nota

Anche se si tratta di un esempio "divertente", è un buon lavoro che mostra quanto sia complesso i parametri di un plug-in. In questo singolo caso, è presente un oggetto complesso con quattro tipi diversi di proprietà: un numero intero, una stringa, un valore booleano e un'enumerazione. Il valore del kernel semantico è che può generare automaticamente lo schema per questo oggetto e passarlo all'agente di intelligenza artificiale e effettuare il marshalling dei parametri generati dall'agente di intelligenza artificiale nell'oggetto corretto.

Dopo aver creato la classe del plug-in, è possibile aggiungerla al kernel usando i AddFromType<> metodi o AddFromObject .

Dopo aver creato la classe del plug-in, è possibile aggiungerla al kernel usando il metodo add_plugin.

Dopo aver creato la classe del plug-in, è possibile aggiungerla al kernel usando i AddFromType<> metodi o AddFromObject .

Suggerimento

Quando si crea una funzione, chiedersi sempre "come è possibile fornire all'intelligenza artificiale ulteriore assistenza per usare questa funzione?" Ciò può includere l'uso di tipi di input specifici (evitare stringhe laddove possibile), fornire descrizioni ed esempi.

Aggiunta di un plug-in tramite il AddFromObject metodo

Il AddFromObject metodo consente di aggiungere un'istanza della classe plug-in direttamente alla raccolta di plug-in nel caso in cui si voglia controllare direttamente come viene costruito il plug-in.

Ad esempio, il costruttore della LightsPlugin classe richiede l'elenco di luci. In questo caso, è possibile creare un'istanza della classe plugin e aggiungerla alla raccolta di plug-in.

List<LightModel> lights = new()
   {
      new LightModel { Id = 1, Name = "Table Lamp", IsOn = false, Brightness = Brightness.Medium, Color = "#FFFFFF" },
      new LightModel { Id = 2, Name = "Porch light", IsOn = false, Brightness = Brightness.High, Color = "#FF0000" },
      new LightModel { Id = 3, Name = "Chandelier", IsOn = true, Brightness = Brightness.Low, Color = "#FFFF00" }
   };

kernel.Plugins.AddFromObject(new LightsPlugin(lights));

Aggiunta di un plug-in tramite il AddFromType<> metodo

Quando si usa il AddFromType<> metodo , il kernel userà automaticamente l'inserimento delle dipendenze per creare un'istanza della classe plugin e aggiungerla alla raccolta di plug-in.

Ciò è utile se il costruttore richiede l'inserimento di servizi o altre dipendenze nel plug-in. Ad esempio, la LightsPlugin classe potrebbe richiedere l'inserimento di un logger e un servizio di luce invece di un elenco di luci.

public class LightsPlugin
{
   private readonly Logger _logger;
   private readonly LightService _lightService;

   public LightsPlugin(LoggerFactory loggerFactory, LightService lightService)
   {
      _logger = loggerFactory.CreateLogger<LightsPlugin>();
      _lightService = lightService;
   }

   [KernelFunction("get_lights")]
   [Description("Gets a list of lights and their current state")]
   public async Task<List<LightModel>> GetLightsAsync()
   {
      _logger.LogInformation("Getting lights");
      return lightService.GetLights();
   }

   [KernelFunction("change_state")]
   [Description("Changes the state of the light")]
   public async Task<LightModel?> ChangeStateAsync(LightModel changeState)
   {
      _logger.LogInformation("Changing light state");
      return lightService.ChangeState(changeState);
   }
}

Con l'inserimento delle dipendenze, è possibile aggiungere i servizi e i plug-in necessari al generatore di kernel prima di compilare il kernel.

var builder = Kernel.CreateBuilder();

// Add dependencies for the plugin
builder.Services.AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Trace));
builder.Services.AddSingleton<LightService>();

// Add the plugin to the kernel
builder.Plugins.AddFromType<LightsPlugin>("Lights");

// Build the kernel
Kernel kernel = builder.Build();

Definizione di un plug-in tramite una raccolta di funzioni

Meno comune ma ancora utile è definire un plug-in usando una raccolta di funzioni. Ciò è particolarmente utile se è necessario creare dinamicamente un plug-in da un set di funzioni in fase di esecuzione.

L'uso di questo processo richiede l'uso della factory di funzioni per creare singole funzioni prima di aggiungerle al plug-in.

kernel.Plugins.AddFromFunctions("time_plugin",
[
    KernelFunctionFactory.CreateFromMethod(
        method: () => DateTime.Now,
        functionName: "get_time",
        description: "Get the current time"
    ),
    KernelFunctionFactory.CreateFromMethod(
        method: (DateTime start, DateTime end) => (end - start).TotalSeconds,
        functionName: "diff_time",
        description: "Get the difference between two times in seconds"
    )
]);

Strategie aggiuntive per integrare codice nativo tramite Dependency Injection

Se lavori con Dependency Injection, ci sono strategie aggiuntive che puoi adottare per creare e aggiungere plugin al kernel. Di seguito sono riportati alcuni esempi di come aggiungere un plug-in usando l'iniezione di dipendenze.

Inserire una raccolta di plug-in

Suggerimento

È consigliabile rendere la raccolta di plug-in un servizio temporaneo in modo che venga eliminata dopo ogni utilizzo perché la raccolta di plug-in è modificabile. La creazione di una nuova raccolta di plug-in per ogni uso è economica, quindi non dovrebbe essere un problema di prestazioni.

var builder = Host.CreateApplicationBuilder(args);

// Create native plugin collection
builder.Services.AddTransient((serviceProvider)=>{
   KernelPluginCollection pluginCollection = [];
   pluginCollection.AddFromType<LightsPlugin>("Lights");

   return pluginCollection;
});

// Create the kernel service
builder.Services.AddTransient<Kernel>((serviceProvider)=> {
   KernelPluginCollection pluginCollection = serviceProvider.GetRequiredService<KernelPluginCollection>();

   return new Kernel(serviceProvider, pluginCollection);
});

Suggerimento

Come accennato nell'articolo del kernel, il kernel è estremamente leggero, quindi la creazione di un nuovo kernel per ogni uso come temporaneo non è un problema di prestazioni.

Genera i plugin come singole istanze

I plug-in non sono modificabili, quindi in genere è sicuro crearli come singleton. Questa operazione può essere eseguita usando la factory del plug-in e aggiungendo il plug-in risultante alla raccolta di servizi.

var builder = Host.CreateApplicationBuilder(args);

// Create singletons of your plugin
builder.Services.AddKeyedSingleton("LightPlugin", (serviceProvider, key) => {
    return KernelPluginFactory.CreateFromType<LightsPlugin>();
});

// Create a kernel service with singleton plugin
builder.Services.AddTransient((serviceProvider)=> {
    KernelPluginCollection pluginCollection = [
      serviceProvider.GetRequiredKeyedService<KernelPlugin>("LightPlugin")
    ];

    return new Kernel(serviceProvider, pluginCollection);
});

Aggiunta di un plug-in tramite il add_plugin metodo

Il add_plugin metodo consente di aggiungere un'istanza del plug-in al kernel. Di seguito è riportato un esempio di come costruire la LightsPlugin classe e aggiungerla al kernel.

# Create the kernel
kernel = Kernel()

# Create dependencies for the plugin
lights = [
    {"id": 1, "name": "Table Lamp", "is_on": False, "brightness": 100, "hex": "FF0000"},
    {"id": 2, "name": "Porch light", "is_on": False, "brightness": 50, "hex": "00FF00"},
    {"id": 3, "name": "Chandelier", "is_on": True, "brightness": 75, "hex": "0000FF"},
]

# Create the plugin
lights_plugin = LightsPlugin(lights)

# Add the plugin to the kernel
kernel.add_plugin(lights_plugin)

Aggiunta di un plug-in tramite il createFromObject metodo

Il createFromObject metodo consente di compilare un plug-in kernel da un oggetto con metodi annotati.

// Import the LightsPlugin
KernelPlugin lightPlugin = KernelPluginFactory.createFromObject(new LightsPlugin(),
        "LightsPlugin");

Questo plug-in può quindi essere aggiunto a un kernel.

// Create a kernel with Azure OpenAI chat completion and plugin
Kernel kernel = Kernel.builder()
        .withAIService(ChatCompletionService.class, chatCompletionService)
        .withPlugin(lightPlugin)
        .build();

Fornire a LLM lo schema del tipo di ritorno delle funzioni.

Attualmente non esiste uno standard ben definito a livello dell'industria per fornire ai modelli di intelligenza artificiale i metadati dei tipi di ritorno delle funzioni. Fino a quando non viene stabilito uno standard di questo tipo, è possibile considerare le tecniche seguenti per gli scenari in cui i nomi delle proprietà del tipo restituito non sono sufficienti per consentire di ragionare sul loro contenuto, o quando è necessario associare istruzioni aggiuntive di contesto o gestione al tipo restituito per modellare o migliorare i tuoi scenari.

Prima di usare una di queste tecniche, è consigliabile fornire nomi più descrittivi per le proprietà del tipo di ritorno, poiché questo è il modo più semplice per migliorare la comprensione del tipo di ritorno da parte dell'LLM ed è anche conveniente in termini di utilizzo dei token.

Fornire informazioni sul tipo restituito dalla funzione nella descrizione della funzione

Per applicare questa tecnica, includere lo schema del tipo restituito nell'attributo di descrizione della funzione. Lo schema deve descrivere in dettaglio i nomi, le descrizioni e i tipi delle proprietà, come illustrato nell'esempio seguente:

public class LightsPlugin
{
   [KernelFunction("change_state")]
   [Description("""Changes the state of the light and returns:
   {  
       "type": "object",
       "properties": {
           "id": { "type": "integer", "description": "Light ID" },
           "name": { "type": "string", "description": "Light name" },
           "is_on": { "type": "boolean", "description": "Is light on" },
           "brightness": { "type": "string", "enum": ["Low", "Medium", "High"], "description": "Brightness level" },
           "color": { "type": "string", "description": "Hex color code" }
       },
       "required": ["id", "name"]
   } 
   """)]
   public async Task<LightModel?> ChangeStateAsync(LightModel changeState)
   {
      ...
   }
}

Alcuni modelli possono avere limitazioni sulle dimensioni della descrizione della funzione, pertanto è consigliabile mantenere conciso lo schema e includere solo informazioni essenziali.

Nei casi in cui le informazioni sul tipo non sono critiche e ridurre al minimo l'utilizzo dei token è una priorità, è consigliabile fornire una breve descrizione del tipo restituito nell'attributo description della funzione anziché nello schema completo.

public class LightsPlugin
{
   [KernelFunction("change_state")]
   [Description("""Changes the state of the light and returns:
        id: light ID,
        name: light name,
        is_on: is light on,
        brightness: brightness level (Low, Medium, High),
        color: Hex color code.
    """)]
   public async Task<LightModel?> ChangeStateAsync(LightModel changeState)
   {
      ...
   }
}

Entrambi gli approcci indicati in precedenza richiedono l'aggiunta manuale dello schema del tipo restituito e l'aggiornamento ogni volta che il tipo restituito cambia. Per evitare questo problema, prendere in considerazione la tecnica successiva.

Indicare lo schema del tipo di ritorno della funzione come parte del valore di ritorno della funzione

Questa tecnica implica la fornitura del valore restituito della funzione e del relativo schema a LLM, anziché solo del valore restituito. In questo modo l'LLM può usare lo schema per determinare le proprietà del valore restituito.

Per implementare questa tecnica, è necessario creare e registrare un filtro di invocazione automatica della funzione. Per altre informazioni, vedere l'articolo filtro di invocazione automatica della funzione. Questo filtro deve incapsulare il valore restituito della funzione in un oggetto personalizzato che contiene sia il valore restituito originale che il relativo schema. Di seguito è riportato un esempio:

private sealed class AddReturnTypeSchemaFilter : IAutoFunctionInvocationFilter
{
    public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func<AutoFunctionInvocationContext, Task> next)
    {
        await next(context); // Invoke the original function

        // Crete the result with the schema
        FunctionResultWithSchema resultWithSchema = new()
        {
            Value = context.Result.GetValue<object>(),                  // Get the original result
            Schema = context.Function.Metadata.ReturnParameter?.Schema  // Get the function return type schema
        };

        // Return the result with the schema instead of the original one
        context.Result = new FunctionResult(context.Result, resultWithSchema);
    }

    private sealed class FunctionResultWithSchema
    {
        public object? Value { get; set; }
        public KernelJsonSchema? Schema { get; set; }
    }
}

// Register the filter
Kernel kernel = new Kernel();
kernel.AutoFunctionInvocationFilters.Add(new AddReturnTypeSchemaFilter());

Con il filtro registrato, è ora possibile fornire descrizioni per il tipo restituito e le relative proprietà, che verranno estratte automaticamente dal kernel semantico:

[Description("The state of the light")] // Equivalent to annotating the function with the [return: Description("The state of the light")] attribute
public class LightModel
{
    [JsonPropertyName("id")]
    [Description("The ID of the light")]
    public int Id { get; set; }

    [JsonPropertyName("name")]
    [Description("The name of the light")]
    public string? Name { get; set; }

    [JsonPropertyName("is_on")]
    [Description("Indicates whether the light is on")]
    public bool? IsOn { get; set; }

    [JsonPropertyName("brightness")]
    [Description("The brightness level of the light")]
    public Brightness? Brightness { get; set; }

    [JsonPropertyName("color")]
    [Description("The color of the light with a hex code (ensure you include the # symbol)")]
    public string? Color { get; set; }
}

Questo approccio elimina la necessità di fornire e aggiornare manualmente lo schema del tipo restituito ogni volta che il tipo restituito cambia, poiché lo schema viene estratto automaticamente dal kernel semantico.

Fornire altri dettagli sulle funzioni

Quando si crea un plug-in Python, è possibile fornire informazioni aggiuntive sulle funzioni nel kernel_function decorator. Queste informazioni verranno usate dall'agente di intelligenza artificiale per comprendere meglio le funzioni.

from typing import List, Optional, Annotated

class LightsPlugin:
    def __init__(self, lights: List[LightModel]):
        self._lights = lights

    @kernel_function(name="GetLights", description="Gets a list of lights and their current state")
    async def get_lights(self) -> List[LightModel]:
        """Gets a list of lights and their current state."""
        return self._lights

    @kernel_function(name="ChangeState", description="Changes the state of the light")
    async def change_state(
        self,
        change_state: LightModel
    ) -> Optional[LightModel]:
        """Changes the state of the light."""
        for light in self._lights:
            if light["id"] == change_state["id"]:
                light["is_on"] = change_state.get("is_on", light["is_on"])
                light["brightness"] = change_state.get("brightness", light["brightness"])
                light["hex"] = change_state.get("hex", light["hex"])
                return light
        return None

L'esempio precedente illustra come eseguire l'override del nome della funzione e fornire una descrizione per la funzione. Per impostazione predefinita, il nome della funzione è il nome della funzione e la descrizione è vuota. Se il nome della funzione è sufficientemente descrittivo, non sarà necessaria una descrizione, che salverà i token. Tuttavia, se il comportamento della funzione non è ovvio dal nome, è necessario fornire una descrizione per l'intelligenza artificiale.

Poiché i moduli APM vengono prevalentemente sottoposti a training sul codice Python, è consigliabile usare nomi di funzione che seguono le convenzioni di denominazione Python, il che significa che raramente è necessario eseguire l'override dei nomi delle funzioni se si seguono le convenzioni nel codice Python.

Passaggi successivi

Ora che si sa come creare un plug-in, è ora possibile imparare a usarli con l'agente di intelligenza artificiale. A seconda del tipo di funzioni aggiunte ai plug-in, ci sono modelli diversi da seguire. Per le funzioni di recupero, vedere l'articolo uso delle funzioni di recupero. Per le funzioni di automazione delle attività, vedere l'articolo uso delle funzioni di automazione delle attività .