Partilhar via


Chamada de função com conclusão de chat

O recurso mais poderoso da conclusão do bate-papo é a capacidade de chamar funções do modelo. Isso permite que você crie um bot de bate-papo que pode interagir com seu código existente, tornando possível automatizar processos de negócios, criar trechos de código e muito mais.

Com o Semantic Kernel, simplificamos o processo de usar a chamada de função descrevendo automaticamente suas funções e seus parâmetros para o modelo e, em seguida, manipulando a comunicação de ida e volta entre o modelo e seu código.

Ao usar a chamada de função, no entanto, é bom entender o que realmente está acontecendo nos bastidores para que você possa otimizar seu código e aproveitar ao máximo esse recurso.

Como funciona a chamada de função

Quando você faz uma solicitação para um modelo com a chamada de função habilitada, o Semantic Kernel executa as seguintes etapas:

Passo Description
5 Serializar funções Todas as funções disponíveis (e seus parâmetros de entrada) no kernel são serializadas usando o esquema JSON.
2 Enviar as mensagens e funções para o modelo As funções serializadas (e o histórico de bate-papo atual) são enviadas para o modelo como parte da entrada.
3 O modelo processa a entrada O modelo processa a entrada e gera uma resposta. A resposta pode ser uma mensagem de chat ou uma chamada de função
4 Manipular a resposta Se a resposta for uma mensagem de chat, ela será retornada ao desenvolvedor para imprimir a resposta na tela. Se a resposta for uma chamada de função, no entanto, o Kernel Semântico extrai o nome da função e seus parâmetros.
5 Invoque a função O nome da função extraída e os parâmetros são usados para invocar a função no kernel.
6 Retornar o resultado da função O resultado da função é então enviado de volta para o modelo como parte do histórico de bate-papo. Os passos 2 a 6 são então repetidos até que o modelo envie um sinal de terminação

O diagrama a seguir ilustra o processo de chamada de função:

Chamada de função do kernel semântico

A seção a seguir usará um exemplo concreto para ilustrar como a chamada de função funciona na prática.

Exemplo: Pedir uma pizza

Vamos supor que você tenha um plugin que permite que um usuário peça uma pizza. O plugin tem as seguintes funções:

  1. get_pizza_menu: Devolve uma lista de pizzas disponíveis
  2. add_pizza_to_cart: Adiciona uma pizza ao carrinho do usuário
  3. remove_pizza_from_cart: Remove uma pizza do carrinho do usuário
  4. get_pizza_from_cart: Devolve os detalhes específicos de uma pizza no carrinho do utilizador
  5. get_cart: Devolve o carrinho atual do utilizador
  6. checkout: Faz check-out do carrinho do usuário

Em C#, o plug-in pode ter esta aparência:

public class OrderPizzaPlugin(
    IPizzaService pizzaService,
    IUserContext userContext,
    IPaymentService paymentService)
{
    [KernelFunction("get_pizza_menu")]
    public async Task<Menu> GetPizzaMenuAsync()
    {
        return await pizzaService.GetMenu();
    }

    [KernelFunction("add_pizza_to_cart")]
    [Description("Add a pizza to the user's cart; returns the new item and updated cart")]
    public async Task<CartDelta> AddPizzaToCart(
        PizzaSize size,
        List<PizzaToppings> toppings,
        int quantity = 1,
        string specialInstructions = ""
    )
    {
        Guid cartId = userContext.GetCartId();
        return await pizzaService.AddPizzaToCart(
            cartId: cartId,
            size: size,
            toppings: toppings,
            quantity: quantity,
            specialInstructions: specialInstructions);
    }

    [KernelFunction("remove_pizza_from_cart")]
    public async Task<RemovePizzaResponse> RemovePizzaFromCart(int pizzaId)
    {
        Guid cartId = userContext.GetCartId();
        return await pizzaService.RemovePizzaFromCart(cartId, pizzaId);
    }

    [KernelFunction("get_pizza_from_cart")]
    [Description("Returns the specific details of a pizza in the user's cart; use this instead of relying on previous messages since the cart may have changed since then.")]
    public async Task<Pizza> GetPizzaFromCart(int pizzaId)
    {
        Guid cartId = await userContext.GetCartIdAsync();
        return await pizzaService.GetPizzaFromCart(cartId, pizzaId);
    }

    [KernelFunction("get_cart")]
    [Description("Returns the user's current cart, including the total price and items in the cart.")]
    public async Task<Cart> GetCart()
    {
        Guid cartId = await userContext.GetCartIdAsync();
        return await pizzaService.GetCart(cartId);
    }

    [KernelFunction("checkout")]
    [Description("Checkouts the user's cart; this function will retrieve the payment from the user and complete the order.")]
    public async Task<CheckoutResponse> Checkout()
    {
        Guid cartId = await userContext.GetCartIdAsync();
        Guid paymentId = await paymentService.RequestPaymentFromUserAsync(cartId);

        return await pizzaService.Checkout(cartId, paymentId);
    }
}

Você adicionaria este plugin ao kernel assim:

IKernelBuilder kernelBuilder = new KernelBuilder();
kernelBuilder..AddAzureOpenAIChatCompletion(
    deploymentName: "NAME_OF_YOUR_DEPLOYMENT",
    apiKey: "YOUR_API_KEY",
    endpoint: "YOUR_AZURE_ENDPOINT"
);
kernelBuilder.Plugins.AddFromType<OrderPizzaPlugin>("OrderPizza");
Kernel kernel = kernelBuilder.Build();

Em Python, o plugin pode ter esta aparência:

from semantic_kernel.functions import kernel_function

class OrderPizzaPlugin:
    def __init__(self, pizza_service, user_context, payment_service):
        self.pizza_service = pizza_service
        self.user_context = user_context
        self.payment_service = payment_service

    @kernel_function(name="get_pizza_menu")
    async def get_pizza_menu(self):
        return await self.pizza_service.get_menu()

    @kernel_function(
        name="add_pizza_to_cart",
        description="Add a pizza to the user's cart; returns the new item and updated cart"
    )
    async def add_pizza_to_cart(self, size: PizzaSize, toppings: List[PizzaToppings], quantity: int = 1, special_instructions: str = ""):
        cart_id = await self.user_context.get_cart_id()
        return await self.pizza_service.add_pizza_to_cart(cart_id, size, toppings, quantity, special_instructions)

    @kernel_function(
        name="remove_pizza_from_cart",
        description="Remove a pizza from the user's cart; returns the updated cart"
    )
    async def remove_pizza_from_cart(self, pizza_id: int):
        cart_id = await self.user_context.get_cart_id()
        return await self.pizza_service.remove_pizza_from_cart(cart_id, pizza_id)

    @kernel_function(
        name="get_pizza_from_cart",
        description="Returns the specific details of a pizza in the user's cart; use this instead of relying on previous messages since the cart may have changed since then."
    )
    async def get_pizza_from_cart(self, pizza_id: int):
        cart_id = await self.user_context.get_cart_id()
        return await self.pizza_service.get_pizza_from_cart(cart_id, pizza_id)

    @kernel_function(
        name="get_cart",
        description="Returns the user's current cart, including the total price and items in the cart."
    )
    async def get_cart(self):
        cart_id = await self.user_context.get_cart_id()
        return await self.pizza_service.get_cart(cart_id)

    @kernel_function(
        name="checkout",
        description="Checkouts the user's cart; this function will retrieve the payment from the user and complete the order."
    )
    async def checkout(self):
        cart_id = await self.user_context.get_cart_id()
        payment_id = await self.payment_service.request_payment_from_user(cart_id)
        return await self.pizza_service.checkout(cart_id, payment_id)

Você adicionaria este plugin ao kernel assim:

from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase

kernel = Kernel()
kernel.add_service(AzureChatCompletion(model_id, endpoint, api_key))

# Create the services needed for the plugin: pizza_service, user_context, and payment_service
# ...

# Add the plugin to the kernel
kernel.add_plugin(OrderPizzaPlugin(pizza_service, user_context, payment_service), plugin_name="OrderPizza")

1) Serializando as funções

Quando você cria um kernel com o OrderPizzaPlugin, o kernel serializará automaticamente as funções e seus parâmetros. Isso é necessário para que o modelo possa entender as funções e suas entradas.

Para o plug-in acima, as funções serializadas teriam esta aparência:

[
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-get_pizza_menu",
      "parameters": {
        "type": "object",
        "properties": {},
        "required": []
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-add_pizza_to_cart",
      "description": "Add a pizza to the user's cart; returns the new item and updated cart",
      "parameters": {
        "type": "object",
        "properties": {
          "size": {
            "type": "string",
            "enum": ["Small", "Medium", "Large"]
          },
          "toppings": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": ["Cheese", "Pepperoni", "Mushrooms"]
            }
          },
          "quantity": {
            "type": "integer",
            "default": 1,
            "description": "Quantity of pizzas"
          },
          "specialInstructions": {
            "type": "string",
            "default": "",
            "description": "Special instructions for the pizza"
          }
        },
        "required": ["size", "toppings"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-remove_pizza_from_cart",
      "parameters": {
        "type": "object",
        "properties": {
          "pizzaId": {
            "type": "integer"
          }
        },
        "required": ["pizzaId"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-get_pizza_from_cart",
      "description": "Returns the specific details of a pizza in the user's cart; use this instead of relying on previous messages since the cart may have changed since then.",
      "parameters": {
        "type": "object",
        "properties": {
          "pizzaId": {
            "type": "integer"
          }
        },
        "required": ["pizzaId"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-get_cart",
      "description": "Returns the user's current cart, including the total price and items in the cart.",
      "parameters": {
        "type": "object",
        "properties": {},
        "required": []
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "OrderPizza-checkout",
      "description": "Checkouts the user's cart; this function will retrieve the payment from the user and complete the order.",
      "parameters": {
        "type": "object",
        "properties": {},
        "required": []
      }
    }
  }
]

Há algumas coisas a observar aqui que podem afetar o desempenho e a qualidade da conclusão do bate-papo:

  1. Verbosidade do esquema de função – Serializar funções para o modelo usar não vem de graça. Quanto mais detalhado o esquema, mais tokens o modelo tem que processar, o que pode retardar o tempo de resposta e aumentar os custos.

    Gorjeta

    Mantenha as suas funções o mais simples possível. No exemplo acima, você notará que nem todas as funções têm descrições em que o nome da função é autoexplicativo. Isso é intencional para reduzir o número de tokens. Os parâmetros também são mantidos simples; Tudo o que o modelo não precisa saber (como o cartId OR paymentId) é mantido escondido. Em vez disso, estas informações são fornecidas por serviços internos.

    Nota

    A única coisa com a qual você não precisa se preocupar é com a complexidade dos tipos de retorno. Você notará que os tipos de retorno não são serializados no esquema. Isso ocorre porque o modelo não precisa saber o tipo de retorno para gerar uma resposta. Na etapa 6, no entanto, veremos como tipos de retorno excessivamente detalhados podem afetar a qualidade da conclusão do bate-papo.

  2. Tipos de parâmetros – Com o esquema, você pode especificar o tipo de cada parâmetro. Isso é importante para que o modelo compreenda a entrada esperada. No exemplo acima, o size parâmetro é um enum, e o toppings parâmetro é uma matriz de enums. Isso ajuda o modelo a gerar respostas mais precisas.

    Gorjeta

    Evite, sempre que possível, usar string como tipo de parâmetro. O modelo não pode inferir o tipo de cadeia de caracteres, o que pode levar a respostas ambíguas. Em vez disso, use enums ou outros tipos (por exemplo, int, float, e tipos complexos) sempre que possível.

  3. Parâmetros necessários - Você também pode especificar quais parâmetros são necessários. Isso é importante para que o modelo entenda quais parâmetros são realmente necessários para que a função funcione. Mais tarde, na etapa 3, o modelo usará essas informações para fornecer informações tão mínimas quanto necessário para chamar a função.

    Gorjeta

    Marque os parâmetros apenas conforme necessário se eles forem realmente necessários. Isso ajuda as funções de chamada de modelo de forma mais rápida e precisa.

  4. Descrições de funções – As descrições de funções são opcionais, mas podem ajudar o modelo a gerar respostas mais precisas. Em particular, as descrições podem dizer ao modelo o que esperar da resposta, uma vez que o tipo de retorno não é serializado no esquema. Se o modelo estiver usando funções incorretamente, você também poderá adicionar descrições para fornecer exemplos e orientações.

    Por exemplo, na get_pizza_from_cart função, a descrição diz ao usuário para usar essa função em vez de confiar em mensagens anteriores. Isso é importante porque o carrinho pode ter mudado desde a última mensagem.

    Gorjeta

    Antes de adicionar uma descrição, pergunte-se se o modelo precisa dessas informações para gerar uma resposta. Se não, considere deixá-lo de fora para reduzir a verbosidade. Você sempre pode adicionar descrições mais tarde se o modelo estiver lutando para usar a função corretamente.

  5. Nome do plugin – Como você pode ver nas funções serializadas, cada função tem uma name propriedade. O Kernel Semântico usa o nome do plugin para namespace das funções. Isso é importante porque permite que você tenha vários plugins com funções do mesmo nome. Por exemplo, você pode ter plugins para vários serviços de pesquisa, cada um com sua própria search função. Ao nomear as funções, você pode evitar conflitos e tornar mais fácil para o modelo entender qual função chamar.

    Sabendo disso, você deve escolher um nome de plugin que seja exclusivo e descritivo. No exemplo acima, o nome do plugin é OrderPizza. Isso deixa claro que as funções estão relacionadas ao pedido de pizza.

    Gorjeta

    Ao escolher um nome de plugin, recomendamos remover palavras supérfluas como "plugin" ou "serviço". Isso ajuda a reduzir a verbosidade e torna o nome do plugin mais fácil de entender para o modelo.

2) Enviar as mensagens e funções para o modelo

Uma vez que as funções são serializadas, elas são enviadas para o modelo junto com o histórico de bate-papo atual. Isso permite que o modelo compreenda o contexto da conversa e as funções disponíveis.

Neste cenário, podemos imaginar o usuário pedindo ao assistente para adicionar uma pizza ao seu carrinho:

ChatHistory chatHistory = [];
chatHistory.AddUserMessage("I'd like to order a pizza!");
chat_history = ChatHistory()
chat_history.add_user_message("I'd like to order a pizza!")

Podemos então enviar esse histórico de bate-papo e as funções serializadas para o modelo. O modelo usará essas informações para determinar a melhor maneira de responder.

IChatCompletionService chatCompletion = kernel.GetRequiredService<IChatCompletionService>();

OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new() 
{
    ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
};

ChatResponse response = await chatCompletion.GetChatMessageContentAsync(
    chatHistory,
    executionSettings: openAIPromptExecutionSettings,
    kernel: kernel)
chat_completion = kernel.get_service(type=ChatCompletionClientBase)

execution_settings = AzureChatPromptExecutionSettings(tool_choice="auto")
execution_settings.function_call_behavior = FunctionCallBehavior.EnableFunctions(auto_invoke=True, filters={})

response = (await chat_completion.get_chat_message_contents(
      chat_history=history,
      settings=execution_settings,
      kernel=kernel,
      arguments=KernelArguments(),
  ))[0]

3) O modelo processa a entrada

Com o histórico de bate-papo e as funções serializadas, o modelo pode determinar a melhor maneira de responder. Neste caso, o modelo reconhece que o usuário quer pedir uma pizza. O modelo provavelmente gostaria de chamar a add_pizza_to_cart função, mas como especificamos o tamanho e as coberturas como parâmetros necessários, o modelo solicitará ao usuário estas informações:

Console.WriteLine(response);
chatHistory.AddAssistantMessage(response);

// "Before I can add a pizza to your cart, I need to
// know the size and toppings. What size pizza would
// you like? Small, medium, or large?"
print(response)
chat_history.add_assistant_message(response)

# "Before I can add a pizza to your cart, I need to
# know the size and toppings. What size pizza would
# you like? Small, medium, or large?"

Como o modelo quer que o usuário responda em seguida, um sinal de terminação será enviado para o Semantic Kernel para interromper a chamada automática de função até que o usuário responda.

Neste ponto, o usuário pode responder com o tamanho e coberturas da pizza que deseja pedir:

chatHistory.AddUserMessage("I'd like a medium pizza with cheese and pepperoni, please.");

response = await chatCompletion.GetChatMessageContentAsync(
    chatHistory,
    kernel: kernel)
chat_history.add_user_message("I'd like a medium pizza with cheese and pepperoni, please.")

response = (await chat_completion.get_chat_message_contents(
    chat_history=history,
    settings=execution_settings,
    kernel=kernel,
    arguments=KernelArguments(),
))[0]

Agora que o modelo tem as informações necessárias, ele pode chamar a add_pizza_to_cart função com a entrada do usuário. Nos bastidores, ele adiciona uma nova mensagem ao histórico de bate-papo que se parece com isto:

"tool_calls": [
    {
        "id": "call_abc123",
        "type": "function",
        "function": {
            "name": "OrderPizzaPlugin-add_pizza_to_cart",
            "arguments": "{\n\"size\": \"Medium\",\n\"toppings\": [\"Cheese\", \"Pepperoni\"]\n}"
        }
    }
]

Gorjeta

É bom lembrar que todo argumento necessário deve ser gerado pelo modelo. Isso significa gastar tokens para gerar a resposta. Evite argumentos que exijam muitos tokens (como um GUID). Por exemplo, observe que usamos um int para o pizzaId. Pedir ao modelo para enviar um número de um a dois dígitos é muito mais fácil do que pedir um GUID.

Importante

Este passo é o que torna a chamada de função tão poderosa. Anteriormente, os desenvolvedores de aplicativos de IA tinham que criar processos separados para extrair funções de intenção e preenchimento de slots. Com a chamada de função, o modelo pode decidir quando chamar uma função e quais informações fornecer.

4) Manipule a resposta

Quando o Semantic Kernel recebe a resposta do modelo, ele verifica se a resposta é uma chamada de função. Se for, o Kernel Semântico extrai o nome da função e seus parâmetros. Neste caso, o nome da função é OrderPizzaPlugin-add_pizza_to_cart, e os argumentos são o tamanho e as coberturas da pizza.

Com essas informações, o Semantic Kernel pode organizar as entradas nos tipos apropriados e passá-las para a add_pizza_to_cart função no OrderPizzaPlugin. Neste exemplo, os argumentos se originam como uma cadeia de caracteres JSON, mas são desserializados pelo Kernel Semântico em um PizzaSize enum e um List<PizzaToppings>.

Nota

Empacotar as entradas nos tipos corretos é um dos principais benefícios do uso do Kernel Semântico. Tudo do modelo vem como um objeto JSON, mas o Semantic Kernel pode desserializar automaticamente esses objetos nos tipos corretos para suas funções.

Depois de organizar as entradas, o Semantic Kernel também pode adicionar a chamada de função ao histórico de chat:

chatHistory.Add(
    new() {
        Role = AuthorRole.Assistant,
        Items = [
            new FunctionCallContent(
                functionName: "add_pizza_to_cart",
                pluginName: "OrderPizza",
                id: "call_abc123",
                arguments: new () { {"size", "Medium"}, {"toppings", ["Cheese", "Pepperoni"]} }
            )
        ]
    }
);
from semantic_kernel.contents import ChatMessageContent, FunctionCallContent
from semantic_kernel.contents.utils.author_role import AuthorRole

chat_history.add_message(
    ChatMessageContent(
        role=AuthorRole.ASSISTANT,
        items=[
            FunctionCallContent(
                name="OrderPizza-add_pizza_to_cart",
                id="call_abc123",
                arguments=str({"size": "Medium", "toppings": ["Cheese", "Pepperoni"]})
            )
        ]
    )
)

5) Invoque a função

Uma vez que o Kernel Semântico tenha os tipos corretos, ele pode finalmente invocar a add_pizza_to_cart função. Como o plugin usa injeção de dependência, a função pode interagir com serviços externos como pizzaService e userContext para adicionar a pizza ao carrinho do usuário.

No entanto, nem todas as funções terão êxito. Se a função falhar, o Semantic Kernel pode lidar com o erro e fornecer uma resposta padrão para o modelo. Isso permite que o modelo entenda o que deu errado e gere uma resposta para o usuário.

Gorjeta

Para garantir que um modelo possa se autocorrigir, é importante fornecer mensagens de erro que comuniquem claramente o que deu errado e como corrigi-lo. Isso pode ajudar o modelo a repetir a chamada de função com as informações corretas.

6) Retornar o resultado da função

Depois que a função for invocada, o resultado da função será enviado de volta ao modelo como parte do histórico de bate-papo. Isso permite que o modelo compreenda o contexto da conversa e gere uma resposta subsequente.

Nos bastidores, o Semantic Kernel adiciona uma nova mensagem ao histórico de bate-papo da função de ferramenta que se parece com isto:

chatHistory.Add(
    new() {
        Role = AuthorRole.Tool,
        Items = [
            new FunctionResultContent(
                functionName: "add_pizza_to_cart",
                pluginName: "OrderPizza",
                id: "0001",
                result: "{ \"new_items\": [ { \"id\": 1, \"size\": \"Medium\", \"toppings\": [\"Cheese\",\"Pepperoni\"] } ] }"
            )
        ]
    }
);
from semantic_kernel.contents import ChatMessageContent, FunctionResultContent
from semantic_kernel.contents.utils.author_role import AuthorRole

chat_history.add_message(
    ChatMessageContent(
        role=AuthorRole.TOOL,
        items=[
            FunctionResultContent(
                name="OrderPizza-add_pizza_to_cart",
                id="0001",
                result="{ \"new_items\": [ { \"id\": 1, \"size\": \"Medium\", \"toppings\": [\"Cheese\",\"Pepperoni\"] } ] }"
            )
        ]
    )
)

Observe que o resultado é uma cadeia de caracteres JSON que o modelo precisa processar. Como antes, o modelo precisará gastar tokens consumindo essas informações. É por isso que é importante manter os tipos de devolução o mais simples possível. Neste caso, a devolução inclui apenas os novos itens adicionados ao carrinho, não o carrinho inteiro.

Gorjeta

Seja o mais sucinto possível com as suas devoluções. Sempre que possível, retorne apenas as informações de que o modelo precisa ou resuma as informações usando outro prompt LLM antes de devolvê-las.

Repita os passos 2 a 6

Depois que o resultado é retornado ao modelo, o processo se repete. O modelo processa o histórico de bate-papo mais recente e gera uma resposta. Neste caso, o modelo pode perguntar ao usuário se ele deseja adicionar outra pizza ao carrinho ou se deseja fazer check-out.

Chamadas de função paralelas

No exemplo acima, demonstramos como um LLM pode chamar uma única função. Muitas vezes, isso pode ser lento se você precisar chamar várias funções em sequência. Para acelerar o processo, vários LLMs suportam chamadas de função paralelas. Isso permite que o LLM chame várias funções ao mesmo tempo, acelerando o processo.

Por exemplo, se um usuário quiser pedir várias pizzas, o LLM pode chamar a add_pizza_to_cart função para cada pizza ao mesmo tempo. Isso pode reduzir significativamente o número de viagens de ida e volta para o LLM e acelerar o processo de pedido.

Próximos passos

Agora que você entende como funciona a chamada de função, agora você pode aprender como realmente usar a chamada de função no Kernel Semântico consultando o artigo de planejamento