네이티브 코드를 플러그인으로 추가하기
네이티브 코드를 플러그인으로 추가하기
AI 에이전트에 기본으로 지원되지 않는 능력을 더하는 가장 쉬운 방법은 네이티브 코드를 플러그인으로 감싸는 것이에요. 이렇게 하면 앱 개발자로서 이미 갖고 있는 스킬을 그대로 활용해 AI 에이전트의 능력을 확장할 수 있습니다.
뒤에서는 어떤 일이 일어날까요? Semantic Kernel은 여러분이 제공한 설명과 함께 리플렉션(reflection)을 사용해 플러그인을 AI 에이전트에게 의미적으로 설명해 줍니다. 이 덕분에 AI 에이전트는 플러그인의 능력과 상호작용 방식을 이해할 수 있어요.
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 에이전트와 함께 사용하는 방법을 배울 수 있어요. 플러그인에 추가한 함수 유형에 따라 따라야 할 패턴이 달라집니다. 검색 함수라면 검색 함수 사용하기 문서를, 작업 자동화 함수라면 작업 자동화 함수 사용하기 문서를 참고하세요.