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


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

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

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

Предоставление 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 typing import TypedDict

class LightModel(TypedDict):
    id: int
    name: str
    is_on: bool | None
    brightness: int | None
    hex: str | None
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 методов.

Совет

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

Добавление подключаемого 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; }
}

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

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

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