--- status: approved contact: markwallace-microsoft date: 2023-10-26 deciders: matthewbolanos, markwallace-microsoft, SergeyMenshykh, RogerBarreto consulted: dmytrostruk informed: --- # Custom Prompt Template Formats ## Context and Problem Statement 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](https://handlebarsjs.com/) syntax. The purpose of this ADR is to describe how a custom prompt template formats will be supported in the Semantic Kernel. ### Current Design By default the `Kernel` uses the `BasicPromptTemplateEngine` which supports the Semantic Kernel specific template format. #### Code Patterns 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: ```csharp 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()); ``` 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: 1. `Kernel` only supports a single `IPromptTemplateEngine` so we cannot support using multiple prompt templates at the same time. 1. `IPromptTemplateEngine` is stateless and must perform a parse of the template for each render 1. Our semantic function extension methods relay on our implementation of `IPromptTemplate` (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. #### Performance 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.** #### Implementing a Custom Prompt Template Engine There are two interfaces provided: ```csharp public interface IPromptTemplateEngine { Task RenderAsync(string templateText, SKContext context, CancellationToken cancellationToken = default); } public interface IPromptTemplate { IReadOnlyList Parameters { get; } public Task RenderAsync(SKContext executionContext, CancellationToken cancellationToken = default); } ``` A prototype implementation of a handlebars prompt template engine could look something like this: ```csharp public class HandlebarsTemplateEngine : IPromptTemplateEngine { private readonly ILoggerFactory _loggerFactory; public HandlebarsTemplateEngine(ILoggerFactory? loggerFactory = null) { this._loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; } public async Task 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()); }); } 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: 1. The `IPromptTemplate` interface is not used and causes confusion. 1. There is no way to allow developers to support multiple prompt template formats at the same time. 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 Considerations Handlebars does not support dynamic binding of helpers. Consider the following snippet: ```csharp HandlebarsHelper link_to = (writer, context, parameters) => { writer.WriteSafeString($"{context["text"]}"); }; 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. ## Decision Drivers In no particular order: - Support creating a semantic function without a `IKernel`instance. - Support late binding of functions i.e., having functions resolved when the prompt is rendered. - Support allowing the prompt template to be parsed (compiled) just once to optimize performance if needed. - Support using multiple prompt template formats with a single `Kernel` instance. - Provide simple abstractions which allow third parties to implement support for custom prompt template formats. ## Considered Options - Obsolete `IPromptTemplateEngine` and replace with `IPromptTemplateFactory`. - ### Obsolete `IPromptTemplateEngine` and replace with `IPromptTemplateFactory` ISKFunction class relationships 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: ```csharp // 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()); ``` **Notes:** - `BasicPromptTemplateFactory` will be the default implementation and will be automatically provided in `KernelSemanticFunctionExtensions`. Developers will also be able to provide their own implementation. - The factory uses the new `PromptTemplateConfig.TemplateFormat` to create the appropriate `IPromptTemplate` instance. - We should look to remove `promptTemplateConfig` as a parameter to `CreateSemanticFunction`. That change is outside of the scope of this ADR. The `BasicPromptTemplateFactory` and `BasicPromptTemplate` implementations look as follows: ```csharp 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 Parameters => this._parameters.Value; public async Task RenderAsync(SKContext executionContext, CancellationToken cancellationToken = default) { return await this.RenderAsync(this._blocks.Value, executionContext, cancellationToken).ConfigureAwait(false); } // Not showing the implementation details } ``` **Note:** - The call to `ExtractBlocks` is called lazily once for each prompt template - The `RenderAsync` doesn't need to extract the blocks every time ## Decision Outcome Chosen option: "Obsolete `IPromptTemplateEngine` and replace with `IPromptTemplateFactory`", because addresses the requirements and provides good flexibility for the future.