Поделиться через


Добавление нативного кода в качестве подключаемого модуля

Самый простой способ предоставить агенту ИИ возможности, которые не поддерживаются в собственном коде, заключается в том, чтобы упаковать машинный код в подключаемый модуль. Это позволяет использовать существующие навыки в качестве разработчика приложений для расширения возможностей агентов ИИ.

В фоновом режиме семантическое ядро будет использовать предоставляемые вами описания и рефлексию для семантического описания подключаемого модуля агенту ИИ. Это позволяет агенту ИИ понять возможности подключаемого модуля и как взаимодействовать с ним.

Предоставление LLM правильной информации

При создании подключаемого модуля необходимо предоставить агенту ИИ правильные сведения, чтобы понять возможности подключаемого модуля и ее функций. В том числе:

  • Имя подключаемого модуля
  • Имена функций
  • Описания функций
  • Параметры функций
  • Схема параметров
  • Схема возвращаемого значения

Значение семантического ядра заключается в том, что он может автоматически создавать большую часть этих сведений из самого кода. В качестве разработчика это означает, что необходимо предоставить семантические описания функций и параметров, чтобы агент ИИ смог их понять. Если вы правильно закомментируете и аннотируете код, скорее всего, у вас уже есть эта информация.

Ниже мы рассмотрим два различных способа предоставления вашему агенту ИИ нативного кода, а также методы передачи семантической информации.

Определение подключаемого модуля с помощью класса

Самый простой способ создать собственный подключаемый модуль — начать с класса, а затем добавить методы, помеченные атрибутом KernelFunction . Кроме того, рекомендуется использовать заметку Description для предоставления агенту ИИ необходимых сведений для понимания функции.

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());
    }
}

Совет

Так как LLM преимущественно обучены в коде Python, рекомендуется использовать snake_case для имен функций и параметров (даже если используется C# или Java). Это поможет агенту ИИ лучше понять функцию и его параметры.

Совет

Функции могут указывать Kernel, KernelArguments, ILoggerFactory, ILogger, IAIServiceSelector, CultureInfo, IFormatProvider, CancellationToken в качестве параметров, и они не будут объявлены в LLM и будут автоматически заданы при вызове функции. Если вы используете KernelArguments вместо явных входных аргументов, код будет отвечать за преобразование типов.

Если у функции есть сложный объект в качестве входной переменной, семантический ядро также создаст схему для этого объекта и передает его агенту ИИ. Как и в случае функций, следует предоставлять Description примечания для свойств, которые неочевидны для ИИ. Ниже приведено определение класса LightState и перечисления 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;
    }
}

Примечание.

Хотя это "веселый" пример, он делает хорошую работу, показывающую, насколько сложны параметры подключаемого модуля могут быть. В этом случае у нас есть сложный объект с четырьмя различными типами свойств: целое число, строка, логическое значение и перечисление. Значение семантического ядра заключается в том, что он может автоматически создать схему для этого объекта и передать его агенту ИИ и маршалировать параметры, созданные агентом ИИ, в правильный объект.

После завершения разработки класса подключаемого модуля его можно добавить в ядро с помощью методов AddFromType<> или AddFromObject.

После завершения разработки класса подключаемого модуля его можно добавить в ядро с помощью метода add_plugin.

После завершения разработки класса подключаемого модуля его можно добавить в ядро с помощью методов AddFromType<> или AddFromObject.

Совет

При создании функции всегда спрашивайте себя о том, как я могу предоставить ИИ дополнительную помощь в использовании этой функции? Это может включать использование определенных типов входных данных (избегайте строк, где это возможно), предоставления описаний и примеров.

Добавление плагина с помощью метода AddFromObject

Этот AddFromObject метод позволяет добавить экземпляр класса подключаемого модуля непосредственно в коллекцию подключаемых модулей, если вы хотите напрямую контролировать способ создания подключаемого модуля.

Например, для конструктора LightsPlugin класса требуется список огней. В этом случае можно создать экземпляр класса подключаемого модуля и добавить его в коллекцию подключаемых модулей.

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));

Добавление модуля AddFromType<> с помощью метода

При использовании AddFromType<> метода ядро автоматически будет использовать внедрение зависимостей для создания экземпляра класса подключаемого модуля и добавления его в коллекцию подключаемых модулей.

Это полезно, если конструктор требует внедрения служб или других зависимостей в подключаемый модуль. Например, для нашего LightsPlugin класса может потребоваться логгер и служба освещения, чтобы их внедрили вместо списка источников света.

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);
   }
}

С помощью внедрения зависимостей можно добавить необходимые службы и подключаемые модули в построителе ядра перед созданием ядра.

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();

Определение подключаемого модуля с помощью коллекции функций

Определение подключаемого модуля с помощью коллекции функций встречается реже, но это всё ещё полезная практика. Это особенно полезно, если необходимо динамически создать подключаемый модуль из набора функций во время выполнения.

При использовании этого процесса необходимо использовать фабрику функций для создания отдельных функций перед добавлением их в подключаемый модуль.

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"
    )
]);

Дополнительные стратегии добавления нативного кода с помощью инъекции зависимостей

Если вы работаете с инъекцией зависимостей, существуют дополнительные стратегии для создания и добавления плагинов в ядро. Ниже приведены некоторые примеры добавления плагина с помощью внедрения зависимостей.

Внедрение коллекции подключаемых модулей

Совет

Мы рекомендуем сделать коллекцию подключаемых модулей временной службой, чтобы она была удалена после каждого использования, так как коллекция подключаемых модулей мутируется. Создание новой коллекции подключаемых модулей для каждого использования не требует больших затрат, поэтому не должно вызвать проблемы с производительностью.

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);
});

Совет

Как упоминалось в статье о ядре, ядро является чрезвычайно легковесным, поэтому создание нового ядра для каждого временного использования не является проблемой производительности.

Генерируйте подключаемые модули в виде одноэлементных модулей

Подключаемые модули не изменяемы, поэтому их обычно безопасно создавать в виде синглтонов. Это можно сделать с помощью фабрики плагинов и добавления полученного плагина в коллекцию служб.

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);
});

Добавление модуля add_plugin, используя метод

Этот add_plugin метод позволяет добавить экземпляр подключаемого модуля в ядро. Ниже приведен пример того, как можно создать LightsPlugin класс и добавить его в ядро.

# 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)

Добавление плагина с помощью метода createFromObject

Этот createFromObject метод позволяет создать плагин ядра из объекта с аннотированными методами.

// 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();

Предоставление LLM схемы типа возвращаемых функций

В настоящее время для предоставления метаданных возвращаемого типа функции моделям ИИ не существует четко определенного стандарта. До тех пор, пока такой стандарт не будет установлен, для сценариев можно рассмотреть следующие методы, если имена свойств возвращаемого типа недостаточны для того, чтобы LLM могли анализировать их содержание, или если необходимо связать дополнительные контекстуальные или обработочные инструкции с возвращаемым типом для моделирования или улучшения ваших сценариев.

Прежде чем использовать любой из этих методов, рекомендуется дать более описательные названия свойствам возвращаемого типа, так как это самый простой способ улучшить понимание возвращаемого типа и также является выгодным с точки зрения использования токенов.

Указать тип возвращаемого значения в описании функции

Чтобы применить этот метод, включите схему возвращаемого типа в атрибут описания функции. Схема должна детализировать имена свойств, описания и типы, как показано в следующем примере:

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)
   {
      ...
   }
}

Некоторые модели могут иметь ограничения на размер описания функции, поэтому рекомендуется сохранить схему краткой и включать только основные сведения.

В случаях, когда сведения о типе не являются критически важными и минимизация потребления маркеров является приоритетом, рекомендуется предоставить краткое описание возвращаемого типа в атрибуте описания функции вместо полной схемы.

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)
   {
      ...
   }
}

Оба подхода, упомянутые выше, требуют вручную добавления схемы типа возвращаемого значения и её обновления при каждом изменении типа возврата. Чтобы избежать этого, рассмотрим следующий метод.

Предоставить схему возвращаемого типа функции как часть значения, которое она возвращает.

Этот метод предполагает предоставление LLM как возвращаемого значения функции, так и ее схемы, а не только самого значения. Это позволяет LLM использовать схему для рассуждения о свойствах возвращаемого значения.

Для реализации этого метода необходимо создать и зарегистрировать фильтр вызова автоматической функции. Для получения дополнительной информации см. статью о фильтре автоматического вызова функций. Этот фильтр должен упаковать возвращаемое значение функции в пользовательский объект, содержащий исходное возвращаемое значение и ее схему. Ниже приведен пример:

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());

После регистрации фильтра теперь можно указать описания для возвращаемого типа и его свойств, которые будут автоматически извлечены семантическим ядром:

[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; }
}

Этот подход устраняет необходимость вручную предоставлять и обновлять схему возвращаемого типа при каждом изменении типа возврата, так как схема автоматически извлекается семантическим ядром.

Предоставление дополнительных сведений о функциях

При создании подключаемого модуля в Python в декораторе kernel_function можно указать дополнительные сведения о функциях. Эти сведения будут использоваться агентом ИИ для лучшего понимания функций.

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

В приведенном выше примере показано, как переопределить имя функции и указать описание функции. По умолчанию имя функции — это имя функции, а описание пусто. Если имя функции достаточно описательно, вам не потребуется описание, что сэкономит токены. Однако если поведение функции не очевидно из имени, необходимо указать описание ИИ.

Так как LLMs преимущественно обучены на языке Python, рекомендуется использовать имена функций, которые следуют соглашениям об именовании Python, что означает, что при соблюдении соглашений в вашем коде на Python редко требуется переопределять имена функций.

Следующие шаги

Теперь, когда вы знаете, как создать подключаемые модули, вы можете узнать, как использовать их с вашим агентом ИИ. В зависимости от типа функций, которые вы добавили в подключаемые модули, следует следовать разным шаблонам. Сведения о функциях извлечения см. в статье об использовании функций извлечения. Сведения о функциях автоматизации задач см. в статье об использовании функций автоматизации задач.