네이티브 코드를 플러그인으로 추가하기

네이티브 코드를 플러그인으로 추가하기

AI 에이전트에 기본으로 지원되지 않는 능력을 더하는 가장 쉬운 방법은 네이티브 코드를 플러그인으로 감싸는 것이에요. 이렇게 하면 앱 개발자로서 이미 갖고 있는 스킬을 그대로 활용해 AI 에이전트의 능력을 확장할 수 있습니다.

뒤에서는 어떤 일이 일어날까요? Semantic Kernel은 여러분이 제공한 설명과 함께 리플렉션(reflection)을 사용해 플러그인을 AI 에이전트에게 의미적으로 설명해 줍니다. 이 덕분에 AI 에이전트는 플러그인의 능력과 상호작용 방식을 이해할 수 있어요.

출처: 공식 문서 — Add native code as a plugin

LLM에 올바른 정보 제공하기

플러그인을 작성할 때는 AI 에이전트가 플러그인과 함수의 능력을 이해할 수 있도록 올바른 정보를 제공해야 합니다. 여기에는 다음이 포함돼요.

  • 플러그인의 이름
  • 함수들의 이름
  • 함수들의 설명
  • 함수들의 파라미터
  • 파라미터의 스키마
  • 반환 값의 스키마

Semantic Kernel의 가치는 이 정보 중 대부분을 코드로부터 자동으로 생성한다는 데 있어요. 개발자로서 여러분이 해야 할 일은 함수와 파라미터의 의미적 설명을 제공해 AI 에이전트가 이해하게 하는 것뿐입니다. 코드에 주석과 어노테이션을 잘 달아 두었다면 이 정보는 이미 손에 있을 가능성이 커요.

아래에서는 네이티브 코드를 AI 에이전트에 제공하는 두 가지 방법과, 이 의미적 정보를 제공하는 방법을 살펴볼게요.

클래스를 사용해 플러그인 정의하기

네이티브 플러그인을 만드는 가장 쉬운 방법은 클래스로 시작해서 메서드에 KernelFunction 특성을 붙이는 거예요. 그리고 AI 에이전트가 함수를 이해하는 데 필요한 정보를 주기 위해 Description 어노테이션을 넉넉히 쓰는 것도 권장합니다.

팁. 아래 LightsPlugin여기에서 정의한 LightModel을 사용해요.

C#

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

Python

from typing import Annotated

from semantic_kernel.functions import kernel_function

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
    ) -> LightModel | None:
        """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

Java

// withbrightness/LightsPlugin.java 참고: semantic-kernel-samples-java 저장소 샘플 (plugin)

C#에서 알아두면 좋아요.

  • LLM이 대부분 Python 코드로 학습됐기 때문에, C#이나 Java를 쓰더라도 함수 이름과 파라미터에 스네이크 케이스(snake_case)를 쓰는 걸 권장해요. AI 에이전트가 함수와 파라미터를 더 잘 이해하는 데 도움이 됩니다.
  • 함수는 Kernel, KernelArguments, ILoggerFactory, ILogger, IAIServiceSelector, CultureInfo, IFormatProvider, CancellationToken을 파라미터로 지정할 수 있어요. 이런 것들은 LLM에 광고되지 않고 함수가 호출될 때 자동으로 설정됩니다. 명시적 입력 인자 대신 KernelArguments에 의존한다면 타입 변환은 여러분 코드의 책임이에요.

복잡한 객체가 입력 변수로 쓰이면 Semantic Kernel은 그 객체의 스키마도 생성해 AI 에이전트에 전달해요. 함수와 마찬가지로 AI가 쉽게 알 수 없는 속성에는 Description 어노테이션을 붙여야 합니다. 아래는 LightState 클래스와 Brightness enum의 정의예요.

C#

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
}

Python

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

Java

// withbrightness/LightModel.java 참고: semantic-kernel-samples-java 저장소 샘플 (model)

참고. 이건 "재미있는" 예시지만, 플러그인의 파라미터가 얼마나 복잡해질 수 있는지를 잘 보여줘요. 이 단 한 케이스에서 정수, 문자열, boolean, enum이라는 가지 타입의 속성을 가진 복잡한 객체를 다룹니다. Semantic Kernel의 가치는 이 객체의 스키마를 자동 생성해 AI 에이전트에 전달하고, AI 에이전트가 만든 파라미터를 올바른 객체로 마샬링(marshal)한다는 데 있어요.

플러그인 클래스 작성을 마쳤다면, C#/Java에서는 AddFromType<> 또는 AddFromObject 메서드로, Python에서는 add_plugin 메서드로 커널에 추가할 수 있습니다.

팁. 함수를 만들 때 항상 "AI가 이 함수를 쓰는 데 어떻게 추가 도움을 줄 수 있을까?"라고 자문해 보세요. 여기에는 구체적인 입력 타입 사용(가능하면 문자열 피하기), 설명, 예시 제공이 포함됩니다.

C#: 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));

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

C#: 의존성 주입으로 네이티브 코드 추가하는 추가 전략

의존성 주입을 쓰고 있다면 커널에 플러그인을 만들고 추가하는 추가 전략이 있어요.

플러그인 컬렉션 주입하기. 플러그인 컬렉션은 변경 가능(mutable)하므로, 매 사용 후 폐기되도록 트랜잭트(transient) 서비스로 만드는 걸 권장합니다. 사용마다 새 플러그인 컬렉션을 만드는 건 저렴해서 성능 문제가 되지 않아요.

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

팁. 커널 문서에서 언급했듯이 커널은 매우 가볍기 때문에 사용마다 트랜잭트로 새 커널을 만드는 것도 성능 문제가 아니에요.

플러그인을 싱글턴으로 생성하기. 플러그인은 변경 불가(immutable)하므로 싱글턴으로 만들어도 보통 안전합니다. 플러그인 팩토리를 사용해 결과 플러그인을 서비스 컬렉션에 추가하면 돼요.

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

Python: 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)

Java: createFromObject 메서드로 플러그인 추가하기

createFromObject 메서드는 어노테이션이 붙은 메서드를 가진 Object에서 커널 플러그인을 만들게 해 줍니다.

// withbrightness/LightsAppNonInteractive.java 참고: semantic-kernel-samples-java 저장소 샘플 (importplugin)

이 플러그인은 그다음 커널에 추가할 수 있어요.

// withbrightness/LightsAppNonInteractive.java 참고: semantic-kernel-samples-java 저장소 샘플 (buildkernel)

C#: 함수 반환 타입 스키마를 LLM에 제공하기

현재 AI 모델에 함수 반환 타입 메타데이터를 제공하는, 산업 전반의 잘 정의된 표준은 없어요. 그런 표준이 생기기 전까지, 반환 타입 속성의 이름만으로는 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이 스키마를 사용해 반환 값의 속성을 추론할 수 있습니다.

이 기법을 구현하려면 자동 함수 호출 필터(auto function invocation filter)를 만들고 등록해야 해요. 자세한 내용은 자동 함수 호출 필터 문서를 참고하세요. 이 필터는 함수의 반환 값을, 원본 반환 값과 스키마를 모두 담은 사용자 정의 객체로 감싸야 합니다.

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

필터가 등록되면 이제 반환 타입과 그 속성에 설명을 제공할 수 있고, Semantic Kernel이 자동으로 추출합니다.

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

이 접근법은 스키마가 Semantic Kernel에 의해 자동으로 추출되므로, 반환 타입이 바뀔 때마다 스키마를 수동으로 제공·갱신할 필요가 없어요.

Python: 함수에 대한 더 많은 정보 제공하기

Python에서 플러그인을 만들 때 kernel_function 데코레이터에서 함수에 대한 추가 정보를 제공할 수 있어요. 이 정보는 AI 에이전트가 함수를 더 잘 이해하는 데 사용됩니다.

from typing import Annotated

from semantic_kernel.functions import kernel_function

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
    ) -> LightModel | None:
        """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

위 예시는 함수 이름을 오버라이드하고 함수에 설명을 제공하는 방법을 보여줘요. 기본적으로 함수 이름은 함수명이고 설명은 비어 있어요. 함수 이름이 충분히 설명적이라면 설명이 필요 없어 토큰을 아낄 수 있습니다. 하지만 이름만으로 함수 동작이 명확하지 않다면 AI를 위해 설명을 제공해야 해요.

LLM이 대부분 Python 코드로 학습됐으므로, Python 네이밍 규칙을 따르는 함수 이름을 쓰는 걸 권장합니다. Python 코드에서 그 규칙을 따르면 함수 이름을 오버라이드할 일이 거의 없어요.

다음 단계

이제 플러그인을 만드는 방법을 알았으니, AI 에이전트와 함께 사용하는 방법을 배울 수 있어요. 플러그인에 추가한 함수 유형에 따라 따라야 할 패턴이 달라집니다. 검색 함수라면 검색 함수 사용하기 문서를, 작업 자동화 함수라면 작업 자동화 함수 사용하기 문서를 참고하세요.

더 알아보기 (Learn more)