Saltar al contenido principal

Proveedores de LLM personalizados

Cómo integrarse con otras características de Flutter.

El protocolo que conecta un LLM y el LlmChatView se expresa en la interfaz LlmProvider:

dart
abstract class LlmProvider implements Listenable {
  Stream<String> generateStream(String prompt, {Iterable<Attachment> attachments});
  Stream<String> sendMessageStream(String prompt, {Iterable<Attachment> attachments});
  Iterable<ChatMessage> get history;
  set history(Iterable<ChatMessage> history);
}

El LLM podría estar en la nube o ser local, podría estar alojado en Google Cloud Platform o en algún otro proveedor de nube, podría ser un LLM propietario o de código abierto. Cualquier LLM o endpoint similar a un LLM que pueda usarse para implementar esta interfaz se puede conectar en la vista de chat como un proveedor de LLM. El AI Toolkit viene con dos proveedores listos para usar, los cuales implementan la interfaz LlmProvider que se requiere para conectar el proveedor en lo siguiente:

Implementación

#

Para crear tu propio proveedor, necesitas implementar la interfaz LlmProvider con estas cosas en mente:

  1. Proporcionando soporte de configuración completo

  2. Manejo del historial

  3. Traduciendo mensajes y archivos adjuntos al LLM subyacente

  4. Llamando al LLM subyacente

  5. Configuración Para soportar la configurabilidad completa en tu proveedor personalizado, deberías permitir al usuario crear el modelo subyacente y pasarlo como un parámetro, como lo hace MyLlmProvider:

dart
class MyLlmProvider extends LlmProvider ... {
  @immutable
  MyLlmProvider({
    required GenerativeModel model,
    ...
  })  : _model = model,
        ...

  final GenerativeModel _model;
  ...
}

De esta manera, sin importar qué cambios ocurran en el modelo subyacente en el futuro, todos los controles de configuración estarán disponibles para el usuario de tu proveedor personalizado.

  1. Historial El historial es una parte importante de cualquier proveedor; no solo el proveedor debe permitir que el historial se manipule directamente, sino que también tiene que notificar a los oyentes cuando cambie. Además, para soportar la serialización y el cambio de parámetros del proveedor, también debe admitir guardar el historial como parte del proceso de construcción.

El proveedor de Firebase maneja esto como se muestra:

dart
class MyLlmProvider extends LlmProvider with ChangeNotifier {
  @immutable
  MyLlmProvider({
    required GenerativeModel model,
    Iterable<ChatMessage>? history,
    ...
  })  : _model = model,
        _history = history?.toList() ?? [],
        ... { ... }

  final GenerativeModel _model;
  final List<ChatMessage> _history;
  ...

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }

  ...
}

Notarás varias cosas en este código:

  • El uso de ChangeNotifier para implementar los requisitos del método Listenable de la interfaz LlmProvider
  • La capacidad de pasar el historial inicial como parámetro del constructor
  • Notificar a los oyentes cuando hay un nuevo par de prompt de usuario/respuesta de LLM
  • Notificar a los oyentes cuando el historial se cambia manualmente
  • Crear un nuevo chat cuando el historial cambia, usando el nuevo historial

Esencialmente, un proveedor personalizado gestiona el historial para una sesión de chat única con el LLM subyacente. A medida que cambia el historial, el chat subyacente debe mantenerse actualizado automáticamente (como lo hace el proveedor de Firebase cuando llamas a los métodos específicos de chat subyacentes) o recrearse manualmente (como lo hace el proveedor de Firebase cada vez que el historial se establece manualmente).

  1. Mensajes y archivos adjuntos

Los archivos adjuntos deben mapearse desde la clase estándar ChatMessage expuesta por el tipo LlmProvider a lo que sea manejado por el LLM subyacente. Por ejemplo, el proveedor de Firebase mapea desde la clase ChatMessage del AI Toolkit al tipo Content proporcionado por el Firebase Logic AI SDK, como se muestra en el siguiente ejemplo:

dart
import 'package:firebase_ai/firebase_ai.dart';
...

class MyLlmProvider extends LlmProvider with ChangeNotifier {
  ...
  static Part _partFrom(Attachment attachment) => switch (attachment) {
        (final FileAttachment a) => DataPart(a.mimeType, a.bytes),
        (final LinkAttachment a) => FilePart(a.url),
      };

  static Content _contentFrom(ChatMessage message) => Content(
        message.origin.isUser ? 'user' : 'model',
        [
          TextPart(message.text ?? ''),
          ...message.attachments.map(_partFrom),
        ],
      );
}

El método _contentFrom se llama cada vez que se necesita enviar un prompt del usuario al LLM subyacente. Cada proveedor debe encargarse de su propio mapeo.

  1. Llamando al LLM

Cómo llamas al LLM subyacente para implementar los métodos generateStream y sendMessageStream depende del protocolo que este exponga. El proveedor de Firebase en el AI Toolkit maneja la configuración y el historial, pero las llamadas a generateStream y sendMessageStream terminan cada una en una llamada a una API de Firebase Logic AI SDK:

dart
class MyLlmProvider extends LlmProvider with ChangeNotifier {
  ...

  @override
  Stream<String> generateStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) =>
      _generateStream(
        prompt: prompt,
        attachments: attachments,
        contentStreamGenerator: (c) => _model.generateContentStream([c]),
      );

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  Stream<String> _generateStream({
    required String prompt,
    required Iterable<Attachment> attachments,
    required Stream<GenerateContentResponse> Function(Content)
        contentStreamGenerator,
  }) async* {
    final content = Content('user', [
      TextPart(prompt),
      ...attachments.map(_partFrom),
    ]);

    final response = contentStreamGenerator(content);
    yield* response
        .map((chunk) => chunk.text)
        .where((text) => text != null)
        .cast<String>();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }
}

Ejemplos

#

La implementación del proveedor de Firebase proporciona un buen punto de partida para tu propio proveedor personalizado. Si quieres ver un ejemplo de implementación de proveedor con todas las llamadas al LLM subyacente eliminadas, echa un vistazo a la aplicación de ejemplo Echo, que simplemente formatea el prompt y los archivos adjuntos del usuario como Markdown para enviárselos de vuelta al usuario como respuesta.