Plattformübergreifende C++-API für LiteRT-LM

Conversation ist eine API auf hoher Ebene, die eine einzelne, statusbehaftete Unterhaltung mit dem LLM darstellt und für die meisten Nutzer der empfohlene Einstiegspunkt ist. Intern wird ein Session verwaltet und komplexe Datenverarbeitungsaufgaben werden ausgeführt. Zu diesen Aufgaben gehören das Beibehalten des ursprünglichen Kontexts, das Verwalten von Tool-Definitionen, die Vorverarbeitung multimodaler Daten und das Anwenden von Jinja-Promptvorlagen mit rollenbasierten Nachrichtenformatierungen.

Conversation API-Workflow

Der typische Lebenszyklus für die Verwendung der Conversation API sieht so aus:

  1. Engine erstellen: Initialisieren Sie ein einzelnes Engine mit dem Modellpfad und der Konfiguration. Dies ist ein umfangreiches Objekt, das die Modellgewichte enthält.
  2. Conversation erstellen: Verwenden Sie Engine, um ein oder mehrere einfache Conversation-Objekte zu erstellen.
  3. Nachricht senden: Verwenden Sie die Methoden des Conversation-Objekts, um Nachrichten an das LLM zu senden und Antworten zu erhalten. So können Sie eine chatähnliche Interaktion ermöglichen.

Unten sehen Sie die einfachste Möglichkeit, eine Nachricht zu senden und eine Modellantwort zu erhalten. Für die meisten Anwendungsfälle empfohlen. Sie entspricht den Gemini Chat APIs.

  • SendMessage: Ein blockierender Aufruf, der Nutzereingaben entgegennimmt und die vollständige Modellantwort zurückgibt.
  • SendMessageAsync: Ein nicht blockierender Aufruf, der die Antwort des Modells Token für Token über Callbacks streamt.

Hier sehen Sie ein Beispiel für ein Code-Snippet:

Nur Textinhalte

#include "runtime/engine/engine.h"

// ...

// 1. Define model assets and engine settings.
auto model_assets = ModelAssets::Create(model_path);
CHECK_OK(model_assets);

auto engine_settings = EngineSettings::CreateDefault(
    model_assets,
    /*backend=*/litert::lm::Backend::CPU);

// 2. Create the main Engine object.
absl::StatusOr<std::unique_ptr<Engine>> engine = Engine::CreateEngine(engine_settings);
CHECK_OK(engine);

// 3. Create a Conversation
auto conversation_config = ConversationConfig::CreateDefault(**engine);
CHECK_OK(conversation_config)
absl::StatusOr<std::unique_ptr<Conversation>> conversation = Conversation::Create(**engine, *conversation_config);
CHECK_OK(conversation);

// 4. Send message to the LLM with blocking call.
absl::StatusOr<Message> model_message = (*conversation)->SendMessage(
    JsonMessage{
        {"role", "user"},
        {"content", "What is the tallest building in the world?"}
    });
CHECK_OK(model_message);

// 5. Print the model message.
std::cout << *model_message << std::endl;

// 6. Send message to the LLM with asynchronous call
// where CreatePrintMessageCallback is a users implemented callback that would
// process the message once a chunk of message output is received.
std::stringstream captured_output;
(*conversation)->SendMessageAsync(
    JsonMessage{
        {"role", "user"},
        {"content", "What is the tallest building in the world?"}
    },
    CreatePrintMessageCallback(std::stringstream& captured_output)
);
// Wait until asynchronous finish or timeout.
*engine->WaitUntilDone(absl::Seconds(10));

Beispiel CreatePrintMessageCallback

absl::AnyInvocable<void(absl::StatusOr<Message>)> CreatePrintMessageCallback(
    std::stringstream& captured_output) {
  return [&captured_output](absl::StatusOr<Message> message) {
    if (!message.ok()) {
      std::cout << message.status().message() << std::endl;
      return;
    }
    if (auto json_message = std::get_if<JsonMessage>(&(*message))) {
      if (json_message->is_null()) {
        std::cout << std::endl << std::flush;
        return;
      }
      ABSL_CHECK_OK(PrintJsonMessage(*json_message, captured_output,
                                     /*streaming=*/true));
    }
  };
}

absl::Status PrintJsonMessage(const JsonMessage& message,
                              std::stringstream& captured_output,
                              bool streaming = false) {
  if (message["content"].is_array()) {
    for (const auto& content : message["content"]) {
      if (content["type"] == "text") {
        captured_output << content["text"].get<std::string>();
        std::cout << content["text"].get<std::string>();
      }
    }
    if (!streaming) {
      captured_output << std::endl << std::flush;
      std::cout << std::endl << std::flush;
    } else {
      captured_output << std::flush;
      std::cout << std::flush;
    }
  } else if (message["content"]["text"].is_string()) {
    if (!streaming) {
      captured_output << message["content"]["text"].get<std::string>()
                      << std::endl
                      << std::flush;
      std::cout << message["content"]["text"].get<std::string>() << std::endl
                << std::flush;
    } else {
      captured_output << message["content"]["text"].get<std::string>()
                      << std::flush;
      std::cout << message["content"]["text"].get<std::string>() << std::flush;
    }
  } else {
    return absl::InvalidArgumentError("Invalid message: " + message.dump());
  }
  return absl::OkStatus();
}

Inhalte multimodaler Daten

// To use multimodality, the engine must be created with vision and audio
// backend depending on the multimodality to be used
auto engine_settings = EngineSettings::CreateDefault(
    model_assets,
    /*backend=*/litert::lm::Backend::CPU,
    /*vision_backend*/litert::lm::Backend::GPU,
    /*audio_backend*/litert::lm::Backend::CPU,
);

// The same steps to create Engine and Conversation as above...

// Send message to the LLM with image data.
absl::StatusOr<Message> model_message = (*conversation)->SendMessage(
    JsonMessage{
        {"role", "user"},
        {"content", { // Now content must be an array.
          {
            {"type", "text"}, {"text", "Describe the following image: "}
          },
          {
            {"type", "image"}, {"path", "/file/path/to/image.jpg"}
          }
        }},
    });
CHECK_OK(model_message);

// Print the model message.
std::cout << *model_message << std::endl;

// Send message to the LLM with audio data.
model_message = (*conversation)->SendMessage(
    JsonMessage{
        {"role", "user"},
        {"content", { // Now content must be an array.
          {
            {"type", "text"}, {"text", "Transcribe the audio: "}
          },
          {
            {"type", "audio"}, {"path", "/file/path/to/audio.wav"}
          }
        }},
    });
CHECK_OK(model_message);

// Print the model message.
std::cout << *model_message << std::endl;

// The content can include multiple image or audio data.
model_message = (*conversation)->SendMessage(
    JsonMessage{
        {"role", "user"},
        {"content", { // Now content must be an array.
          {
            {"type", "text"}, {"text", "First briefly describe the two images "}
          },
          {
            {"type", "image"}, {"path", "/file/path/to/image1.jpg"}
          },
          {
            {"type", "text"}, {"text", "and "}
          },
          {
            {"type", "image"}, {"path", "/file/path/to/image2.jpg"}
          },
          {
            {"type", "text"}, {"text", " then transcribe the content in the audio"}
          },
          {
            {"type", "audio"}, {"path", "/file/path/to/audio.wav"}
          }
        }},
    });
CHECK_OK(model_message);

// Print the model message.
std::cout << *model_message << std::endl;

Unterhaltung mit Tools verwenden

Weitere Informationen zur Tool-Nutzung mit der Conversation API finden Sie unter Erweiterte Nutzung.

Komponenten in Unterhaltungen

Conversation kann als Stellvertreter für Nutzer betrachtet werden, um Session und eine komplizierte Datenverarbeitung aufrechtzuerhalten, bevor die Daten an Session gesendet werden.

E/A-Typen

Das primäre Ein- und Ausgabeformat für die Conversation API ist Message. Derzeit wird dies als JsonMessage implementiert, einem Typalias für ordered_json, einer flexiblen verschachtelten Schlüssel/Wert-Datenstruktur.

Die Conversation API funktioniert nach dem Prinzip „Nachricht rein, Nachricht raus“ und ahmt so eine typische Chatoberfläche nach. Die Flexibilität von Message ermöglicht es Nutzern, beliebige Felder einzufügen, die für bestimmte Prompt-Vorlagen oder LLM-Modelle erforderlich sind. So kann LiteRT-LM eine Vielzahl von Modellen unterstützen.

Es gibt zwar keinen einzelnen starren Standard, aber die meisten Prompt-Vorlagen und Modelle erwarten, dass Message Konventionen folgt, die denen in den Gemini API Content oder der OpenAI Message structure ähneln.

Message muss role enthalten, das angibt, von wem die Nachricht gesendet wird. content kann so einfach wie ein Textstring sein.

{
  "role": "model", // Represent who the message is sent from.
  "content": "Hello World!" // Naive text only content.
}

Bei der multimodalen Dateneingabe ist content eine Liste von part. Auch hier ist part keine vordefinierte Datenstruktur, sondern ein Datentyp für geordnete Schlüssel/Wert-Paare. Die spezifischen Felder hängen davon ab, was in der Promptvorlage und im Modell erwartet wird.

{
  "role": "user",
  "content": [  // Multimodal content.
    // Now the content is composed of parts
    {
      "type": "text",
      "text": "Describe the image in details: "
    },
    {
      "type": "image",
      "path": "/path/to/image.jpg"
    }
  ]
}

Für multimodale part wird das folgende Format unterstützt, das von data_utils.h verarbeitet wird.

{
  "type": "text",
  "text": "this is a text"
}

{
  "type": "image",
  "path": "/path/to/image.jpg"
}

{
  "type": "image",
  "blob": "base64 encoded image bytes as string",
}

{
  "type": "audio",
  "path": "/path/to/audio.wav"
}

{
  "type": "audio",
  "blob": "base64 encoded audio bytes as string",
}

Eingabeaufforderungsvorlage

Um die Flexibilität für Variantenmodelle zu wahren, wird PromptTemplate als einfacher Wrapper um Minja implementiert. Minja ist eine C++-Implementierung der Jinja-Vorlagen-Engine, die JSON-Eingaben verarbeitet, um formatierte Prompts zu generieren.

Die Jinja-Vorlagen-Engine ist ein weit verbreitetes Format für LLM-Promptvorlagen. Hier einige Beispiele:

Das Format der Jinja-Vorlagen-Engine muss genau der Struktur entsprechen, die vom auf Anweisungen abgestimmten Modell erwartet wird. In der Regel enthalten Modellversionen die Standard-Jinja-Vorlage, um eine ordnungsgemäße Verwendung des Modells zu gewährleisten.

Die vom Modell verwendete Jinja-Vorlage wird von den Metadaten der Modelldatei bereitgestellt.

[!NOTE] Eine geringfügige Änderung des Prompts aufgrund einer falschen Formatierung kann zu einer erheblichen Beeinträchtigung des Modells führen. Wie im Artikel Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design or: How I learned to start worrying about prompt formatting beschrieben

Vorwort

Preface legt den anfänglichen Kontext für die Unterhaltung fest. Sie kann erste Nachrichten, Tool-Definitionen und alle anderen Hintergrundinformationen enthalten, die das LLM benötigt, um die Interaktion zu starten. Dies entspricht der Funktionalität von Gemini API system instruction und Gemini API Tools.

Vorwort enthält die folgenden Felder:

  • messages Die Nachrichten im Vorwort. Die Nachrichten lieferten den ursprünglichen Hintergrund für die Unterhaltung. Dazu können beispielsweise der Konversationsverlauf, Systemanweisungen für das Prompt-Engineering oder Beispiele für Few-Shot-Learning gehören.

  • tools Die Tools, die das Modell in der Unterhaltung verwenden kann. Das Format von Tools ist wieder nicht festgelegt, folgt aber meistens Gemini API FunctionDeclaration.

  • extra_context Der zusätzliche Kontext, der die Erweiterbarkeit von Modellen ermöglicht, um die erforderlichen Kontextinformationen zum Starten einer Unterhaltung anzupassen. Beispiele:

Beispiel für eine Einleitung, um erste Systemanweisungen und Tools bereitzustellen und den Denkmodus zu deaktivieren.

Preface preface = JsonPreface({
  .messages = {
      {"role", "system"},
      {"content", {"You are a model that can do function calling."}}
    },
  .tools = {
    {
      {"name", "get_weather"},
      {"description", "Returns the weather for a given location."},
      {"parameters", {
        {"type", "object"},
        {"properties", {
          {"location", {
            {"type", "string"},
            {"description", "The location to get the weather for."}
          }}
        }},
        {"required", {"location"}}
      }}
    },
    {
      {"name", "get_stock_price"},
      {"description", "Returns the stock price for a given stock symbol."},
      {"parameters", {
        {"type", "object"},
        {"properties", {
          {"stock_symbol", {
            {"type", "string"},
            {"description", "The stock symbol to get the price for."}
          }}
        }},
        {"required", {"stock_symbol"}}
      }}
    }
  },
  .extra_context = {
    {"enable_thinking": false}
  }
});

Verlauf

Conversation enthält eine Liste aller Message-Austausche innerhalb der Sitzung. Dieser Verlauf ist für das Rendern von Promptvorlagen von entscheidender Bedeutung, da für die Generierung des richtigen Prompts für das LLM in der Regel der gesamte Konversationsverlauf erforderlich ist.

Die LiteRT-LM-Sitzung ist jedoch zustandsbehaftet, d. h., sie verarbeitet Eingaben inkrementell. Um diese Lücke zu schließen, wird mit Conversation der erforderliche inkrementelle Prompt generiert, indem die Promptvorlage zweimal gerendert wird: einmal mit dem Verlauf bis zum vorherigen Zug und einmal mit der aktuellen Nachricht. Durch den Vergleich dieser beiden gerenderten Prompts wird der neue Teil extrahiert, der an die Sitzung gesendet werden soll.

ConversationConfig

ConversationConfig wird verwendet, um eine Conversation-Instanz zu initialisieren. Sie haben mehrere Möglichkeiten, diese Konfiguration zu erstellen:

  1. Über eine Engine:Bei dieser Methode wird die Standard-SessionConfig verwendet, die der Engine zugeordnet ist.
  2. Von einer bestimmten SessionConfig:Dies ermöglicht eine genauere Steuerung der Sitzungseinstellungen.

Neben den Sitzungseinstellungen können Sie das Verhalten von Conversation in der ConversationConfig weiter anpassen. Dazu zählen:

Diese Überschreibungen sind besonders nützlich für feinabgestimmte Modelle, die möglicherweise andere Konfigurationen oder Prompt-Vorlagen als das Basismodell erfordern, von dem sie abgeleitet wurden.

MessageCallback

MessageCallback ist die Callback-Funktion, die Nutzer implementieren sollten, wenn sie die asynchrone Methode SendMessageAsync verwenden.

Die Callback-Signatur lautet absl::AnyInvocable<void(absl::StatusOr<Message>)>. Diese Funktion wird unter den folgenden Bedingungen ausgelöst:

  • Wenn ein neuer Chunk des Message vom Modell empfangen wird.
  • Wenn bei der Nachrichtenverarbeitung durch LiteRT-LM ein Fehler auftritt.
  • Nach Abschluss der Inferenz des LLM wird der Callback mit einem leeren Message ausgelöst (z.B. JsonMessage()), um das Ende der Antwort zu signalisieren.

Eine Beispielimplementierung finden Sie unter Schritt 6: Asynchroner Aufruf.

[!IMPORTANT] Die von der Rückruffunktion empfangene Message enthält nur den letzten Teil der Ausgabe des Modells, nicht den gesamten Nachrichtenverlauf.

Wenn beispielsweise die vollständige Modellantwort, die von einem blockierenden SendMessage-Aufruf erwartet wird, so aussieht:

{
  "role": "model",
  "content": [
    "type": "text",
    "text": "Hello World!"
  ]
}

Der Callback in SendMessageAsync kann mehrmals aufgerufen werden, jedes Mal mit einem nachfolgenden Textabschnitt:

// 1st Message
{
  "role": "model",
  "content": [
    "type": "text",
    "text": "He"
  ]
}

// 2nd Message
{
  "role": "model",
  "content": [
    "type": "text",
    "text": "llo"
  ]
}

// 3rd Message
{
  "role": "model",
  "content": [
    "type": "text",
    "text": " Wo"
  ]
}

// 4th Message
{
  "role": "model",
  "content": [
    "type": "text",
    "text": "rl"
  ]
}

// 5th Message
{
  "role": "model",
  "content": [
    "type": "text",
    "text": "d!"
  ]
}

Der Implementierer ist dafür verantwortlich, diese Chunks zu sammeln, wenn die vollständige Antwort während des asynchronen Streams benötigt wird. Alternativ ist die vollständige Antwort als letzter Eintrag in der History verfügbar, sobald der asynchrone Aufruf abgeschlossen ist.

Erweiterte Nutzung

Eingeschränkte Decodierung

LiteRT-LM unterstützt die eingeschränkte Dekodierung, mit der Sie bestimmte Strukturen für die Ausgabe des Modells erzwingen können, z. B. JSON-Schemas, reguläre Ausdrücke oder Grammatikregeln.

Setzen Sie zur Aktivierung EnableConstrainedDecoding(true) in ConversationConfig und geben Sie einen ConstraintProviderConfig an (z.B. LlGuidanceConfig für Unterstützung von regulären Ausdrücken, JSON und Grammatik). Übergeben Sie dann Einschränkungen über OptionalArgs in SendMessage.

Beispiel: Regex-Einschränkung

LlGuidanceConstraintArg constraint_arg;
constraint_arg.constraint_type = LlgConstraintType::kRegex;
constraint_arg.constraint_string = "a+b+"; // Force output to match this regex

auto response = conversation->SendMessage(
    user_message,
    {.decoding_constraint = constraint_arg}
);

Ausführliche Informationen, einschließlich Unterstützung für JSON-Schema und Lark-Grammatik, finden Sie in der Dokumentation zur eingeschränkten Dekodierung.

Tool-Nutzung

Mit Tool-Aufrufen kann das LLM die Ausführung clientseitiger Funktionen anfordern. Sie definieren Tools im Preface der Unterhaltung und weisen ihnen Namen zu. Wenn das Modell einen Tool-Aufruf ausgibt, erfassen Sie ihn, führen die entsprechende Funktion in Ihrer Anwendung aus und geben das Ergebnis an das Modell zurück.

Allgemeiner Ablauf:1. Tools deklarieren:Definieren Sie Tools (Name, Beschreibung, Parameter) im Preface-JSON. 2. Anrufe erkennen:Prüfen Sie model_message["tool_calls"] in der Antwort. 3. Ausführen:Führen Sie die Anwendungslogik für das angeforderte Tool aus. 4. Antworten:Senden Sie eine Nachricht mit role: "tool", die die Ausgabe des Tools enthält, zurück an das Modell.

Ausführliche Informationen und ein vollständiges Beispiel für einen Chat-Loop finden Sie in der Dokumentation zur Tool-Nutzung.