Condividi tramite


Che cos'è un plug-in?

I plug-in sono un componente chiave del kernel semantico. Se hai già usato plug-in dalle estensioni ChatGPT o Copilot in Microsoft 365, hai già familiarità con loro. Con i plug-in, è possibile incapsulare le API esistenti in una raccolta che può essere usata da un'intelligenza artificiale. In questo modo è possibile offrire all'intelligenza artificiale la possibilità di eseguire azioni che non sarebbero in grado di eseguire in altro modo.

Dietro le quinte, il Kernel Semantico sfrutta chiamata di funzione, una funzionalità nativa della maggior parte dei modelli di linguaggio di grandi dimensioni più recenti per consentire ai modelli di linguaggio di grandi dimensioni di eseguire pianificazione e richiamare le API. Con la chiamata di funzione, i LLM possono richiedere, cioè chiamare, una determinata funzione. Il kernel semantico indirizza quindi la richiesta alla funzione appropriata nella base di codice e restituisce i risultati al LLM in modo che il LLM possa generare una risposta finale.

Semantic Kernel Plugin

Non tutti gli SDK di intelligenza artificiale hanno un concetto analogo ai plug-in (la maggior parte ha solo funzioni o strumenti). Negli scenari aziendali, tuttavia, i plug-in sono utili perché incapsulano un set di funzionalità che rispecchiano il modo in cui gli sviluppatori aziendali sviluppano già servizi e API. I plugin funzionano bene anche con l'iniezione di dipendenze. All'interno del costruttore di un plug-in, è possibile inserire servizi necessari per eseguire il lavoro del plug-in (ad esempio, connessioni di database, client HTTP e così via). Questa operazione è difficile da eseguire con altri SDK che non dispongono di plug-in.

Anatomia di un plug-in

A livello generale, un plug-in è un gruppo di funzioni che possono essere esposte ad app e servizi di intelligenza artificiale. Le funzioni all'interno dei plug-in possono quindi essere orchestrate da un'applicazione di intelligenza artificiale per eseguire richieste utente. All'interno del kernel semantico, è possibile richiamare queste funzioni automaticamente con chiamate di funzione.

Nota

In altre piattaforme, le funzioni vengono spesso definite "strumenti" o "azioni". Nel kernel semantico si usa il termine "funzioni" perché vengono in genere definite come funzioni native nella codebase.

Solo fornendo funzioni, tuttavia, non è sufficiente per creare un plug-in. Per attivare l'orchestrazione automatica con chiamate di funzione, i plug-in devono anche fornire dettagli che descrivono in modo semantico il loro comportamento. Tutto ciò che deriva dall'input, dall'output e dagli effetti collaterali della funzione deve essere descritto in modo che l'intelligenza artificiale possa comprendere, in caso contrario, l'intelligenza artificiale non chiamerà correttamente la funzione.

Ad esempio, il plug-in WriterPlugin di esempio a destra include funzioni con descrizioni semantiche che descrivono le operazioni eseguite da ogni funzione. Un LLM può quindi usare queste descrizioni per scegliere le funzioni migliori da chiamare per soddisfare la richiesta di un utente.

Nell'immagine a destra, un LLM probabilmente richiamerebbe le funzioni ShortPoem e StoryGen per soddisfare le richieste degli utenti grazie alle descrizioni semantiche fornite.

Descrizione semantica all'interno del plug-in WriterPlugin

Importazione di diversi tipi di plug-in

Esistono due modi principali per importare i plug-in nel kernel semantico: usando codice nativo o usando una specifica OpenAPI . Il primo consente di creare plug-in nella codebase esistente in grado di sfruttare le dipendenze e i servizi già presenti. Quest'ultimo consente di importare plug-in da una specifica OpenAPI, che può essere condivisa tra diversi linguaggi di programmazione e piattaforme.

Di seguito viene fornito un semplice esempio di importazione e uso di un plug-in nativo. Per altre informazioni su come importare questi diversi tipi di plug-in, vedere gli articoli seguenti:

Suggerimento

Quando si inizia, è consigliabile usare plug-in di codice nativo. Man mano che l'applicazione matura e mentre si lavora tra team multipiattaforma, è possibile prendere in considerazione l'uso delle specifiche OpenAPI per condividere plug-in in diversi linguaggi di programmazione e piattaforme.

I diversi tipi di funzioni plug-in

All'interno di un plug-in, in genere si avranno due diversi tipi di funzioni, quelli che recuperano i dati per la generazione aumentata di recupero (RAG) e quelli che automatizzano le attività. Anche se ogni tipo è funzionalmente identico, vengono in genere usati in modo diverso all'interno delle applicazioni che usano il kernel semantico.

Ad esempio, con le funzioni di recupero, è possibile usare strategie per migliorare le prestazioni, ad esempio la memorizzazione nella cache e l'uso di modelli intermedi più economici per il riepilogo. Mentre con le funzioni di automazione delle attività, è probabile che si voglia implementare processi di approvazione con intervento umano per assicurarsi che le attività vengano completate correttamente.

Per altre informazioni sui diversi tipi di funzioni plug-in, vedere gli articoli seguenti:

Introduzione ai plug-in

L'uso dei plug-in all'interno del kernel semantico è sempre un processo in tre passaggi:

  1. Definire il tuo plug-in
  2. Aggiungere il plug-in al kernel
  3. E quindi richiamare le funzioni del plug-in in un prompt con funzione che chiama

Di seguito verrà fornito un esempio generale di come usare un plug-in all'interno del kernel semantico. Per informazioni più dettagliate su come creare e usare i plug-in, vedere i collegamenti precedenti.

1) Definire il plug-in

Il modo più semplice per creare un plug-in consiste nel definire una classe e annotare i relativi metodi con l'attributo KernelFunction. In questo caso, il kernel semantico sa che si tratta di una funzione che può essere chiamata da un'intelligenza artificiale o a cui viene fatto riferimento in un prompt.

Puoi anche importare plugin da una specifica OpenAPI .

Di seguito verrà creato un plug-in che può recuperare lo stato delle luci e modificarne lo stato.

Consiglio

Poiché la maggior parte dei modelli linguistici di grandi dimensioni (LLM) sono stati addestrati con Python per le chiamate di funzioni, è consigliabile usare lo snake case per i nomi delle funzioni e delle proprietà, anche se si utilizza l'SDK di C# o Java.

using System.ComponentModel;
using Microsoft.SemanticKernel;

public class LightsPlugin
{
   // Mock data for the lights
   private readonly List<LightModel> lights = new()
   {
      new LightModel { Id = 1, Name = "Table Lamp", IsOn = false, Brightness = 100, Hex = "FF0000" },
      new LightModel { Id = 2, Name = "Porch light", IsOn = false, Brightness = 50, Hex = "00FF00" },
      new LightModel { Id = 3, Name = "Chandelier", IsOn = true, Brightness = 75, Hex = "0000FF" }
   };

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

   [KernelFunction("get_state")]
   [Description("Gets the state of a particular light")]
   [return: Description("The state of the light")]
   public async Task<LightModel?> GetStateAsync([Description("The ID of the light")] int id)
   {
      // Get the state of the light with the specified ID
      return lights.FirstOrDefault(light => light.Id == id);
   }

   [KernelFunction("change_state")]
   [Description("Changes the state of the light")]
   [return: Description("The updated state of the light; will return null if the light does not exist")]
   public async Task<LightModel?> ChangeStateAsync(int id, LightModel LightModel)
   {
      var light = lights.FirstOrDefault(light => light.Id == id);

      if (light == null)
      {
         return null;
      }

      // Update the light with the new state
      light.IsOn = LightModel.IsOn;
      light.Brightness = LightModel.Brightness;
      light.Hex = LightModel.Hex;

      return light;
   }
}

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 byte? Brightness { get; set; }

   [JsonPropertyName("hex")]
   public string? Hex { get; set; }
}
from typing import TypedDict, Annotated

class LightModel(TypedDict):
   id: int
   name: str
   is_on: bool | None
   brightness: int | None
   hex: str | None

class LightsPlugin:
   lights: list[LightModel] = [
      {"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"},
   ]

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

   @kernel_function
   async def get_state(
      self,
      id: Annotated[int, "The ID of the light"]
   ) -> Annotated[LightModel | None], "The state of the light"]:
      """Gets the state of a particular light."""
      for light in self.lights:
         if light["id"] == id:
               return light
      return None

   @kernel_function
   async def change_state(
      self,
      id: Annotated[int, "The ID of the light"],
      new_state: LightModel
   ) -> Annotated[Optional[LightModel], "The updated state of the light; will return null if the light does not exist"]:
      """Changes the state of the light."""
      for light in self.lights:
         if light["id"] == id:
               light["is_on"] = new_state.get("is_on", light["is_on"])
               light["brightness"] = new_state.get("brightness", light["brightness"])
               light["hex"] = new_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));
    lights.put(2, new LightModel(2, "Porch light", false));
    lights.put(3, new LightModel(3, "Chandelier", true));
  }

  @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 = "id", description = "The ID of the light to change") int id,
      @KernelFunctionParameter(name = "isOn", description = "The new state of the light") boolean isOn) {
    System.out.println("Changing light " + id + " " + isOn);
    if (!lights.containsKey(id)) {
      throw new IllegalArgumentException("Light not found");
    }

    lights.get(id).setIsOn(isOn);

    return lights.get(id);
  }
}

Si noti che vengono fornite descrizioni per la funzione, il valore restituito e i parametri. Questo è importante per l'intelligenza artificiale per comprendere cosa fa la funzione e come usarlo.

Consiglio

Non avere paura di fornire descrizioni dettagliate per le funzioni se l'intelligenza artificiale ha difficoltà nel chiamarle. Esempi di pochi scatti, raccomandazioni per quando usare (e non usare) la funzione e indicazioni su dove ottenere i parametri obbligatori possono essere utili.

2) Aggiungere il plug-in al kernel

Dopo aver definito il plug-in, è possibile aggiungerlo al kernel creando una nuova istanza del plug-in e aggiungendola alla raccolta di plug-in del kernel.

Questo esempio illustra il modo più semplice per aggiungere una classe come plug-in con il metodo AddFromType. Per conoscere altre modalità di aggiungere plugin, consultare l'articolo sull'aggiunta di plugin nativi.

var builder = new KernelBuilder();
builder.Plugins.AddFromType<LightsPlugin>("Lights")
Kernel kernel = builder.Build();
kernel = Kernel()
kernel.add_plugin(
   LightsPlugin(),
   plugin_name="Lights",
)
// Import the LightsPlugin
KernelPlugin lightPlugin = KernelPluginFactory.createFromObject(new LightsPlugin(),
    "LightsPlugin");
// Create a kernel with Azure OpenAI chat completion and plugin
Kernel kernel = Kernel.builder()
    .withAIService(ChatCompletionService.class, chatCompletionService)
    .withPlugin(lightPlugin)
    .build();

3) Richiamare le funzioni del plug-in

Infine, è possibile fare in modo che l'intelligenza artificiale richiami le funzioni del plug-in usando la chiamata di funzione. Di seguito è riportato un esempio che illustra come guidare l'intelligenza artificiale a chiamare la funzione get_lights dal plug-in Lights prima di chiamare la funzione change_state per accendere una luce.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;

// Create a kernel with Azure OpenAI chat completion
var builder = Kernel.CreateBuilder().AddAzureOpenAIChatCompletion(modelId, endpoint, apiKey);

// Build the kernel
Kernel kernel = builder.Build();
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

// Add a plugin (the LightsPlugin class is defined below)
kernel.Plugins.AddFromType<LightsPlugin>("Lights");

// Enable planning
OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new() 
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

// Create a history store the conversation
var history = new ChatHistory();
history.AddUserMessage("Please turn on the lamp");

// Get the response from the AI
var result = await chatCompletionService.GetChatMessageContentAsync(
   history,
   executionSettings: openAIPromptExecutionSettings,
   kernel: kernel);

// Print the results
Console.WriteLine("Assistant > " + result);

// Add the message from the agent to the chat history
history.AddAssistantMessage(result);
import asyncio

from semantic_kernel import Kernel
from semantic_kernel.functions import kernel_function
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.functions.kernel_arguments import KernelArguments

from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import (
    AzureChatPromptExecutionSettings,
)

async def main():
   # Initialize the kernel
   kernel = Kernel()

   # Add Azure OpenAI chat completion
   chat_completion = AzureChatCompletion(
      deployment_name="your_models_deployment_name",
      api_key="your_api_key",
      base_url="your_base_url",
   )
   kernel.add_service(chat_completion)

   # Add a plugin (the LightsPlugin class is defined below)
   kernel.add_plugin(
      LightsPlugin(),
      plugin_name="Lights",
   )

   # Enable planning
   execution_settings = AzureChatPromptExecutionSettings()
   execution_settings.function_call_behavior = FunctionChoiceBehavior.Auto()

   # Create a history of the conversation
   history = ChatHistory()
   history.add_message("Please turn on the lamp")

   # Get the response from the AI
   result = await chat_completion.get_chat_message_content(
      chat_history=history,
      settings=execution_settings,
      kernel=kernel,
   )

   # Print the results
   print("Assistant > " + str(result))

   # Add the message from the agent to the chat history
   history.add_message(result)

# Run the main function
if __name__ == "__main__":
    asyncio.run(main())
// Enable planning
InvocationContext invocationContext = new InvocationContext.Builder()
    .withReturnMode(InvocationReturnMode.LAST_MESSAGE_ONLY)
    .withToolCallBehavior(ToolCallBehavior.allowAllKernelFunctions(true))
    .build();

// Create a history to store the conversation
ChatHistory history = new ChatHistory();
history.addUserMessage("Turn on light 2");

List<ChatMessageContent<?>> results = chatCompletionService
    .getChatMessageContentsAsync(history, kernel, invocationContext)
    .block();

System.out.println("Assistant > " + results.get(0));

Con il codice precedente, si dovrebbe ottenere una risposta simile alla seguente:

Ruolo Messaggio
🔵 Utente Accendere la lampada
🔴 assistente (chiamata di funzione) Lights.get_lights()
🟢 Strumento [{ "id": 1, "name": "Table Lamp", "isOn": false, "brightness": 100, "hex": "FF0000" }, { "id": 2, "name": "Porch light", "isOn": false, "brightness": 50, "hex": "00FF00" }, { "id": 3, "name": "Chandelier", "isOn": true, "brightness": 75, "hex": "0000FF" }]
assistente (chiamata di funzione) Lights.change_state(1, { "isOn": true })
🟢 strumento { "id": 1, "name": "Table Lamp", "isOn": true, "brightness": 100, "hex": "FF0000" }
🔴 Assistente La lampada è ora attiva

Consiglio

Anche se è possibile richiamare direttamente una funzione plug-in, questo non è consigliato perché l'intelligenza artificiale deve essere quella che decide quali funzioni chiamare. Se è necessario un controllo esplicito sulle funzioni chiamate, è consigliabile usare i metodi standard nella codebase anziché i plug-in.

Consigli generali per la creazione di plug-in

Considerando che ogni scenario ha requisiti univoci, utilizza progettazioni di plug-in distinti e può incorporare più LLM, è difficile fornire una guida unica per la progettazione del plug-in. Tuttavia, di seguito sono riportati alcuni consigli generali e linee guida per garantire che i plug-in siano adatti all'intelligenza artificiale e possano essere usati in modo semplice ed efficiente da LLMs.

Importare solo i plug-in necessari

Importare solo i plug-in che contengono funzioni necessarie per lo scenario specifico. Questo approccio non solo riduce il numero di token di input utilizzati, ma riduce anche al minimo l'occorrenza di chiamate errate a funzioni che non sono utilizzate nello scenario. In generale, questa strategia dovrebbe migliorare l'accuratezza nelle chiamate di funzione e ridurre il numero di falsi positivi.

Inoltre, OpenAI consiglia di usare non più di 20 strumenti in una singola chiamata API; idealmente, non più di 10 strumenti. Come dichiarato da OpenAI: "È consigliabile usare non più di 20 strumenti in una singola chiamata API. Gli sviluppatori visualizzano in genere una riduzione della capacità del modello di selezionare lo strumento corretto dopo aver definito tra 10 e 20 strumenti."* Per altre informazioni, vedere la relativa documentazione all'indirizzo Guida per chiamate di funzioni OpenAI.

Rendere i plugin compatibili con l'intelligenza artificiale

Per migliorare la capacità di LLM di comprendere e utilizzare i plug-in, è consigliabile seguire queste linee guida:

  • Usare nomi di funzione descrittivi e concisi: Assicurarsi che i nomi delle funzioni consemettano chiaramente lo scopo per aiutare il modello a comprendere quando selezionare ogni funzione. Se un nome di funzione è ambiguo, è consigliabile rinominarlo per maggiore chiarezza. Evitare di usare abbreviazioni o acronimi per abbreviare i nomi delle funzioni. Utilizzare il DescriptionAttribute per fornire contesto aggiuntivo e istruzioni solo quando necessario, riducendo al minimo l'utilizzo dei token.

  • Ridurre al minimo i parametri della funzione: Limitare il numero di parametri di funzione e usare i tipi primitivi quando possibile. Questo approccio riduce il consumo di token e semplifica la firma della funzione, rendendo più facile per LLM abbinare i parametri della funzione in modo efficace.

  • Denominare chiaramente i parametri della funzione: Assegnare nomi descrittivi ai parametri della funzione per chiarire il loro scopo. Evitare di usare abbreviazioni o acronimi per abbreviare i nomi dei parametri, in quanto questo aiuterà l'LLM a ragionare sui parametri e fornire valori accurati. Come per i nomi delle funzioni, usare il DescriptionAttribute solo quando necessario per ridurre al minimo l'utilizzo dei token.

Trovare un giusto equilibrio tra il numero di funzioni e le relative responsabilità

Da un lato, avere funzioni con una singola responsabilità è una procedura consigliata che consente di mantenere le funzioni semplici e riutilizzabili in più scenari. D'altra parte, ogni chiamata alla funzione comporta un sovraccarico in termini di latenza di andata e ritorno di rete e del numero di token di input e output utilizzati: i token di input vengono usati per inviare la definizione della funzione e ricevere il risultato della chiamata al LLM, mentre i token di output sono consumati nella ricezione della chiamata alla funzione dal modello. In alternativa, è possibile implementare una singola funzione con più responsabilità per ridurre il numero di token usati e il sovraccarico di rete, anche se ciò comporta una ridotta riutilizzabilità in altri scenari.

Tuttavia, il consolidamento di molte responsabilità in una singola funzione può aumentare il numero e la complessità dei parametri di funzione e il relativo tipo restituito. Questa complessità può causare situazioni in cui il modello potrebbe avere difficoltà a trovare una corrispondenza corretta con i parametri della funzione, con conseguente mancata corrispondenza di parametri o valori di tipo non corretto. Pertanto, è essenziale trovare il giusto equilibrio tra il numero di funzioni per ridurre il sovraccarico di rete e il numero di responsabilità di ogni funzione, assicurandosi che il modello possa corrispondere con precisione ai parametri della funzione.

Trasformare le funzioni del kernel semantico

Usare le tecniche di trasformazione per le funzioni del kernel semantico come descritto nel post di blog Transforming Semantic Kernel Functions:

  • Modificare il comportamento della funzione: Esistono scenari in cui il comportamento predefinito di una funzione potrebbe non essere allineato al risultato desiderato e non è possibile modificare l'implementazione della funzione originale. In questi casi, è possibile creare una nuova funzione che incapsula l'originale e ne modifica il comportamento di conseguenza.

  • Fornire informazioni di contesto: Le funzioni possono richiedere parametri che l'LLM non può o non deve inferire. Ad esempio, se una funzione deve agire per conto dell'utente corrente o richiede informazioni di autenticazione, questo contesto è in genere disponibile per l'applicazione host ma non per l'LLM. In questi casi, è possibile trasformare la funzione per richiamare quella originale, fornendo le informazioni di contesto necessarie dall'applicazione ospitante, insieme agli argomenti forniti dall'LLM.

  • Elenco di parametri di modifica, tipi e nomi: Se la funzione originale ha una firma complessa che l'LLM fatica a interpretare, è possibile trasformare la funzione in una firma più semplice che l'LLM può comprendere più facilmente. Ciò può comportare la modifica di nomi di parametri, tipi, il numero di parametri e l'appiattimento o la trasformazione di parametri complessi, tra le altre regolazioni.

Utilizzo dello stato locale

Quando si progettano plug-in che operano su set di dati relativamente grandi o riservati, ad esempio documenti, articoli o messaggi di posta elettronica contenenti informazioni riservate, è consigliabile usare lo stato locale per archiviare i dati originali o i risultati intermedi che non devono essere inviati all'LLM. Le funzioni per tali scenari possono accettare e restituire un ID di stato, consentendo di cercare e accedere ai dati in locale invece di passare i dati effettivi all'LLM, solo per riceverli come argomento per la chiamata alla funzione successiva.

Archiviando i dati in locale, è possibile mantenere le informazioni private e sicure evitando l'utilizzo di token non necessari durante le chiamate di funzione. Questo approccio non solo migliora la privacy dei dati, ma migliora anche l'efficienza complessiva nell'elaborazione di set di dati di grandi dimensioni o sensibili.