docs/decisions/0016-custom-prompt-template-formats.md
Semantic Kernel currently supports a custom prompt template language that allows for variable interpolation and function execution. Semantic Kernel allows for custom prompt template formats to be integrated e.g., prompt templates using Handlebars syntax.
The purpose of this ADR is to describe how a custom prompt template formats will be supported in the Semantic Kernel.
By default the Kernel uses the BasicPromptTemplateEngine which supports the Semantic Kernel specific template format.
Below is an expanded example of how to create a semantic function from a prompt template string which uses the built-in Semantic Kernel format:
IKernel kernel = Kernel.Builder
.WithPromptTemplateEngine(new BasicPromptTemplateEngine())
.WithOpenAIChatCompletionService(
modelId: openAIModelId,
apiKey: openAIApiKey)
.Build();
kernel.ImportFunctions(new TimePlugin(), "time");
string templateString = "Today is: {{time.Date}} Is it weekend time (weekend/not weekend)?";
var promptTemplateConfig = new PromptTemplateConfig();
var promptTemplate = new PromptTemplate(templateString, promptTemplateConfig, kernel.PromptTemplateEngine);
var kindOfDay = kernel.RegisterSemanticFunction("KindOfDay", promptTemplateConfig, promptTemplate);
var result = await kernel.RunAsync(kindOfDay);
Console.WriteLine(result.GetValue<string>());
We have an extension method var kindOfDay = kernel.CreateSemanticFunction(promptTemplate); to simplify the process to create and register a semantic function but the expanded format is shown above to highlight the dependency on kernel.PromptTemplateEngine.
Also the BasicPromptTemplateEngine is the default prompt template engine and will be loaded automatically if the package is available and not other prompt template engine is specified.
Some issues with this:
Kernel only supports a single IPromptTemplateEngine so we cannot support using multiple prompt templates at the same time.IPromptTemplateEngine is stateless and must perform a parse of the template for each renderIPromptTemplate (i.e., PromptTemplate) which stores the template string and uses the IPromptTemplateEngine to render it every time. Note implementations of IPromptTemplate are currently stateful as they also store the parameters.The BasicPromptTemplateEngine uses the TemplateTokenizer to parse the template i.e. extract the blocks.
Then it renders the template i.e. inserts variables and executes functions. Some sample timings for these operations:
| Operation | Ticks | Milliseconds |
|---|---|---|
| Extract blocks | 1044427 | 103 |
| Render variables | 168 | 0 |
Sample template used was: "{{variable1}} {{variable2}} {{variable3}} {{variable4}} {{variable5}}"
Note: We will use the sample implementation to support the f-string template format.
Using HandlebarsDotNet for the same use case results in the following timings:
| Operation | Ticks | Milliseconds |
|---|---|---|
| Compile template | 66277 | 6 |
| Render variables | 4173 | 0 |
By separating the extract blocks/compile from the render variables operation it will be possible to optimise performance by compiling templates just once.
There are two interfaces provided:
public interface IPromptTemplateEngine
{
Task<string> RenderAsync(string templateText, SKContext context, CancellationToken cancellationToken = default);
}
public interface IPromptTemplate
{
IReadOnlyList<ParameterView> Parameters { get; }
public Task<string> RenderAsync(SKContext executionContext, CancellationToken cancellationToken = default);
}
A prototype implementation of a handlebars prompt template engine could look something like this:
public class HandlebarsTemplateEngine : IPromptTemplateEngine
{
private readonly ILoggerFactory _loggerFactory;
public HandlebarsTemplateEngine(ILoggerFactory? loggerFactory = null)
{
this._loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
}
public async Task<string> RenderAsync(string templateText, SKContext context, CancellationToken cancellationToken = default)
{
var handlebars = HandlebarsDotNet.Handlebars.Create();
var functionViews = context.Functions.GetFunctionViews();
foreach (FunctionView functionView in functionViews)
{
var skfunction = context.Functions.GetFunction(functionView.PluginName, functionView.Name);
handlebars.RegisterHelper($"{functionView.PluginName}_{functionView.Name}", async (writer, hcontext, parameters) =>
{
var result = await skfunction.InvokeAsync(context).ConfigureAwait(true);
writer.WriteSafeString(result.GetValue<string>());
});
}
var template = handlebars.Compile(templateText);
var prompt = template(context.Variables);
return await Task.FromResult(prompt).ConfigureAwait(true);
}
}
Note: This is just a prototype implementation for illustration purposes only.
Some issues:
IPromptTemplate interface is not used and causes confusion.There is one implementation of IPromptTemplate provided in the Semantic Kernel core package.
The RenderAsync implementation just delegates to the IPromptTemplateEngine.
The Parameters list get's populated with the parameters defined in the PromptTemplateConfig and any missing variables defined in the template.
Handlebars does not support dynamic binding of helpers. Consider the following snippet:
HandlebarsHelper link_to = (writer, context, parameters) =>
{
writer.WriteSafeString($"<a href='{context["url"]}'>{context["text"]}</a>");
};
string source = @"Click here: {{link_to}}";
var data = new
{
url = "https://github.com/rexm/handlebars.net",
text = "Handlebars.Net"
};
// Act
var handlebars = HandlebarsDotNet.Handlebars.Create();
handlebars.RegisterHelper("link_to", link_to);
var template = handlebars1.Compile(source);
// handlebars.RegisterHelper("link_to", link_to); This also works
var result = template1(data);
Handlebars allows the helpers to be registered with the Handlebars instance either before or after a template is compiled.
The optimum would be to have a shared Handlebars instance for a specific collection of functions and register the helpers just once.
For use cases where the Kernel function collection may have been mutated we will be forced to create a Handlebars instance at render time
and then register the helpers. This means we cannot take advantage of the performance improvement provided by compiling the template.
In no particular order:
IKernelinstance.Kernel instance.IPromptTemplateEngine and replace with IPromptTemplateFactory.IPromptTemplateEngine and replace with IPromptTemplateFactoryBelow is an expanded example of how to create a semantic function from a prompt template string which uses the built-in Semantic Kernel format:
// Semantic function can be created once
var promptTemplateFactory = new BasicPromptTemplateFactory();
string templateString = "Today is: {{time.Date}} Is it weekend time (weekend/not weekend)?";
var promptTemplateConfig = new PromptTemplateConfig();
// Line below will replace the commented out code
var promptTemplate = promptTemplateFactory.CreatePromptTemplate(templateString, promptTemplateConfig);
var kindOfDay = ISKFunction.CreateSemanticFunction("KindOfDay", promptTemplateConfig, promptTemplate)
// var promptTemplate = new PromptTemplate(promptTemplate, promptTemplateConfig, kernel.PromptTemplateEngine);
// var kindOfDay = kernel.RegisterSemanticFunction("KindOfDay", promptTemplateConfig, promptTemplate);
// Create Kernel after creating the semantic function
// Later we will support passing a function collection to the KernelBuilder
IKernel kernel = Kernel.Builder
.WithOpenAIChatCompletionService(
modelId: openAIModelId,
apiKey: openAIApiKey)
.Build();
kernel.ImportFunctions(new TimePlugin(), "time");
// Optionally register the semantic function with the Kernel
kernel.RegisterCustomFunction(kindOfDay);
var result = await kernel.RunAsync(kindOfDay);
Console.WriteLine(result.GetValue<string>());
Notes:
BasicPromptTemplateFactory will be the default implementation and will be automatically provided in KernelSemanticFunctionExtensions. Developers will also be able to provide their own implementation.PromptTemplateConfig.TemplateFormat to create the appropriate IPromptTemplate instance.promptTemplateConfig as a parameter to CreateSemanticFunction. That change is outside of the scope of this ADR.The BasicPromptTemplateFactory and BasicPromptTemplate implementations look as follows:
public sealed class BasicPromptTemplateFactory : IPromptTemplateFactory
{
private readonly IPromptTemplateFactory _promptTemplateFactory;
private readonly ILoggerFactory _loggerFactory;
public BasicPromptTemplateFactory(IPromptTemplateFactory promptTemplateFactory, ILoggerFactory? loggerFactory = null)
{
this._promptTemplateFactory = promptTemplateFactory;
this._loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
}
public IPromptTemplate? CreatePromptTemplate(string templateString, PromptTemplateConfig promptTemplateConfig)
{
if (promptTemplateConfig.TemplateFormat.Equals(PromptTemplateConfig.SEMANTICKERNEL, System.StringComparison.Ordinal))
{
return new BasicPromptTemplate(templateString, promptTemplateConfig, this._loggerFactory);
}
else if (this._promptTemplateFactory is not null)
{
return this._promptTemplateFactory.CreatePromptTemplate(templateString, promptTemplateConfig);
}
throw new SKException($"Invalid prompt template format {promptTemplateConfig.TemplateFormat}");
}
}
public sealed class BasicPromptTemplate : IPromptTemplate
{
public BasicPromptTemplate(string templateString, PromptTemplateConfig promptTemplateConfig, ILoggerFactory? loggerFactory = null)
{
this._loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
this._logger = this._loggerFactory.CreateLogger(typeof(BasicPromptTemplate));
this._templateString = templateString;
this._promptTemplateConfig = promptTemplateConfig;
this._parameters = new(() => this.InitParameters());
this._blocks = new(() => this.ExtractBlocks(this._templateString));
this._tokenizer = new TemplateTokenizer(this._loggerFactory);
}
public IReadOnlyList<ParameterView> Parameters => this._parameters.Value;
public async Task<string> RenderAsync(SKContext executionContext, CancellationToken cancellationToken = default)
{
return await this.RenderAsync(this._blocks.Value, executionContext, cancellationToken).ConfigureAwait(false);
}
// Not showing the implementation details
}
Note:
ExtractBlocks is called lazily once for each prompt templateRenderAsync doesn't need to extract the blocks every timeChosen option: "Obsolete IPromptTemplateEngine and replace with IPromptTemplateFactory", because
addresses the requirements and provides good flexibility for the future.