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

Conversation ist eine API auf hoher Ebene, die eine einzelne, zustandsbehaftete Unterhaltung mit dem LLM darstellt. Sie ist der empfohlene Einstiegspunkt für die meisten Nutzer. Intern wird eine Session verwaltet und es werden komplexe Aufgaben zur Datenverarbeitung ausgeführt. Zu diesen Aufgaben gehören das Beibehalten des ursprünglichen Kontexts, das Verwalten von Tool-Definitionen, die Vorverarbeitung multimodaler Daten und die Anwendung von Jinja-Promptvorlagen mit rollenbasierter Nachrichtenformatierung.

Conversation API-Workflow

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

  1. Engine erstellen: Initialisieren Sie eine einzelne Engine mit dem Modellpfad und der Konfiguration. Dies ist ein schwergewichtiges Objekt, das die Modellgewichte enthält.
  2. Conversation erstellen: Verwenden Sie die Engine, um ein oder mehrere leichtgewichtige 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 wird eine chatähnliche Interaktion ermöglicht.

Im Folgenden wird die einfachste Möglichkeit beschrieben, eine Nachricht zu senden und eine Modellantwort zu erhalten. Sie wird für die meisten Anwendungsfälle empfohlen. Sie spiegelt die Gemini Chat APIs wider.

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

Hier sind Beispiele für Code-Snippets:

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 für 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();
}

Multimodale Dateninhalte

// 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 Conversation API finden Sie unter Erweiterte Verwendung.

Komponenten in der Unterhaltung

Conversation kann als Delegat für Nutzer betrachtet werden, um Session und komplizierte Datenverarbeitung zu verwalten, bevor die Daten an die Sitzung gesendet werden.

E/A-Typen

Das wichtigste Eingabe- und Ausgabeformat für die Conversation API ist Message. Derzeit wird dies als JsonMessage implementiert, ein Typalias für ordered_json, eine flexible verschachtelte Schlüssel/Wert-Datenstruktur.

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

Es gibt zwar keinen einheitlichen Standard, aber die meisten Promptvorlagen und Modelle erwarten Message, dass Konventionen folgen, die denen in der Gemini API Content oder der OpenAI Message-Struktur ähneln.

Message muss role enthalten, die angibt, von wem die Nachricht gesendet wurde. 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 multimodaler Dateneingabe ist content eine Liste von part. Auch hier ist part keine vordefinierte Datenstruktur, sondern ein geordneter Schlüssel/Wert-Datentyp. Die spezifischen Felder hängen davon ab, was die Promptvorlage und das Modell erwarten.

{
  "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 unterstützen wir das folgende Format, 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 verschiedene Modelle zu gewährleisten, wird PromptTemplate als dünner 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-Prompt vorlagen. 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 korrekte Modellnutzung zu gewährleisten.

Die vom Modell verwendete Jinja-Vorlage wird in den Metadaten der Modelldatei angegeben.

Hinweis:Eine geringfügige Änderung des Prompts aufgrund einer falschen Formatierung kann zu einer erheblichen Verschlechterung des Modells führen. Siehe Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design oder: How I learned to start worrying about prompt formatting

Vorwort

Preface legt den anfänglichen Kontext für die Unterhaltung fest. Es kann anfängliche Nachrichten, Tool-Definitionen und alle anderen Hintergrundinformationen enthalten, die das LLM benötigt, um die Interaktion zu starten. So wird eine ähnliche Funktionalität wie bei der Gemini API system instruction und Gemini API Tools erreicht.

`Preface` enthält die folgenden Felder:

  • messages: Die Nachrichten im Vorwort. Die Nachrichten liefern den anfänglichen Hintergrund für die Unterhaltung. Beispiele für Nachrichten sind der Unterhaltungsverlauf, Systemanweisungen für das Prompt-Engineering, Beispiele für wenige Aufnahmen usw.

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

  • extra_context : Der zusätzliche Kontext, der die Erweiterbarkeit für Modelle beibehält, um die erforderlichen Kontextinformationen für den Beginn einer Unterhaltung anzupassen. Beispiele:

Beispiel für ein Vorwort, um anfängliche 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

In der Unterhaltung wird eine Liste aller Nachrichtenaustausche innerhalb der Sitzung geführt. Dieser Verlauf ist entscheidend für das Rendern von Promptvorlagen, da die Jinja-Promptvorlage in der Regel den gesamten Unterhaltungsverlauf benötigt, um den korrekten Prompt für das LLM zu generieren.

Die LiteRT-LM Sitzung ist jedoch zustandsbehaftet, d. h., sie verarbeitet Eingaben inkrementell. Um diese Lücke zu schließen, generiert die Unterhaltung den erforderlichen inkrementellen Prompt, 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 können diese Konfiguration auf verschiedene Arten erstellen:

  1. Aus einer Engine: Bei dieser Methode wird die Standard- SessionConfig verwendet, die mit der Engine verknüpft ist.
  2. Aus einer bestimmten SessionConfig: So haben Sie eine genauere Kontrolle über die Sitzungseinstellungen.

Neben den Sitzungseinstellungen können Sie das Conversation Verhalten in der ConversationConfig weiter anpassen. Dazu gehören:

Diese Überschreibungen sind besonders nützlich für fein abgestimmte Modelle, die möglicherweise andere Konfigurationen oder Promptvorlagen als das Basismodell benötigen, 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 ist absl::AnyInvocable<void(absl::StatusOr<Message>)>. Diese Funktion wird unter den folgenden Bedingungen ausgelöst:

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

Ein Beispiel für die Implementierung finden Sie unter Schritt 6: Asynchroner Aufruf.

Hinweis: Die Message von der Callback-Funktion empfangene Nachricht enthält nur den letzten Chunk der Modellausgabe, nicht den gesamten Nachrichtenverlauf.

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

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

Der Callback in SendMessageAsync kann mehrmals aufgerufen werden, jeweils mit einem nachfolgenden Teil des Texts:

// 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 Verwendung {#advanced-usage}

Eingeschränkte Decodierung

LiteRT-LM unterstützt die eingeschränkte Decodierung, 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 dazu EnableConstrainedDecoding(true) in ConversationConfig und geben Sie eine ConstraintProviderConfig an (z.B. LlGuidanceConfig für die Unterstützung von regulären Ausdrücken, JSON und Grammatik). Übergeben Sie dann Einschränkungen über OptionalArgs in SendMessage.

Beispiel: Regulärer Ausdruck

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 der Unterstützung für JSON-Schemas und Lark-Grammatik, finden Sie in der Dokumentation zur eingeschränkten Decodierung.

Tool-Nutzung

Mit dem Tool-Aufruf kann das LLM die Ausführung von clientseitigen Funktionen anfordern. Sie definieren Tools im Preface der Unterhaltung und geben ihnen einen Namen. 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.

Ablauf auf hoher Ebene :

  1. Tools deklarieren:Definieren Sie Tools (Name, Beschreibung, Parameter) im Preface-JSON.
  2. Aufrufe 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" zurück an das Modell, die die Ausgabe des Tools enthält.

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