Alors qu'OpenAI retarde la sortie des modes vocaux avancés pour ChatGPT, je souhaite partager comment nous avons construit notre application vocale LLM et l'avons intégrée dans un stand interactif.
Fin février, Bali a accueilli le festival Lampu , organisé selon les principes du célèbre Burning Man. Selon la tradition, les participants créent leurs propres installations et objets d'art.
Mes amis du Camp 19:19 et moi-même, inspirés par l'idée des confessionnaux catholiques et par les capacités des LLM actuels, avons eu l'idée de construire notre propre confessionnal IA, où n'importe qui pourrait parler à une intelligence artificielle.
Voici comment nous l’envisagions au tout début :
Pour tester le concept et commencer à expérimenter une invite pour le LLM, j'ai créé une implémentation naïve en une soirée :
Pour implémenter cette démo, je me suis entièrement appuyé sur les modèles cloud d'OpenAI : Whisper , GPT-4 et TTS . Grâce à l'excellente bibliothèque Speech_recognition , j'ai construit la démo en seulement quelques dizaines de lignes de code.
import os import asyncio from dotenv import load_dotenv from io import BytesIO from openai import AsyncOpenAI from soundfile import SoundFile import sounddevice as sd import speech_recognition as sr load_dotenv() aiclient = AsyncOpenAI( api_key=os.environ.get("OPENAI_API_KEY") ) SYSTEM_PROMPT = """ You are helpfull assistant. """ async def listen_mic(recognizer: sr.Recognizer, microphone: sr.Microphone): audio_data = recognizer.listen(microphone) wav_data = BytesIO(audio_data.get_wav_data()) wav_data.name = "SpeechRecognition_audio.wav" return wav_data async def say(text: str): res = await aiclient.audio.speech.create( model="tts-1", voice="alloy", response_format="opus", input=text ) buffer = BytesIO() for chunk in res.iter_bytes(chunk_size=4096): buffer.write(chunk) buffer.seek(0) with SoundFile(buffer, 'r') as sound_file: data = sound_file.read(dtype='int16') sd.play(data, sound_file.samplerate) sd.wait() async def respond(text: str, history): history.append({"role": "user", "content": text}) completion = await aiclient.chat.completions.create( model="gpt-4", temperature=0.5, messages=history, ) response = completion.choices[0].message.content await say(response) history.append({"role": "assistant", "content": response}) async def main() -> None: m = sr.Microphone() r = sr.Recognizer() messages = [{"role": "system", "content": SYSTEM_PROMPT}] with m as source: r.adjust_for_ambient_noise(source) while True: wav_data = await listen_mic(r, source) transcript = await aiclient.audio.transcriptions.create( model="whisper-1", temperature=0.5, file=wav_data, response_format="verbose_json", ) if transcript.text == '' or transcript.text is None: continue await respond(transcript.text, messages) if __name__ == '__main__': asyncio.run(main())
Les problèmes que nous avons dû résoudre sont immédiatement apparus après les premiers tests de cette démo :
Nous avions le choix entre la manière de résoudre ces problèmes : en recherchant une solution d'ingénierie ou de produit adaptée.
Avant même de commencer à coder, nous avons dû décider comment l'utilisateur interagirait avec le stand :
Pour détecter un nouvel utilisateur dans la cabine, nous avons envisagé plusieurs options : des capteurs d'ouverture de porte, des capteurs de poids au sol, des capteurs de distance et une caméra + modèle YOLO. Le capteur de distance derrière le dos nous a semblé le plus fiable, car il excluait les déclenchements accidentels, comme lorsque la porte n'est pas suffisamment fermée, et ne nécessitait pas d'installation compliquée, contrairement au capteur de poids.
Pour éviter le défi de reconnaître le début et la fin d'un dialogue, nous avons décidé d'ajouter un gros bouton rouge pour contrôler le microphone. Cette solution permettait également à l'utilisateur d'interrompre l'IA à tout moment.
Nous avons eu de nombreuses idées différentes sur la mise en œuvre du feedback sur le traitement d'une demande. Nous avons opté pour une option avec un écran qui montre ce que fait le système : écouter le microphone, traiter une question ou répondre.
Nous avons également envisagé une option plutôt intelligente avec un ancien téléphone fixe. La session démarre lorsque l'utilisateur décroche le téléphone et le système écoute l'utilisateur jusqu'à ce qu'il raccroche. Cependant, nous avons décidé qu'il est plus authentique lorsque l'utilisateur reçoit une « réponse » par la cabine plutôt que par une voix provenant du téléphone.
Au final, le flux utilisateur final s'est déroulé comme ceci :
Arduino surveille l'état du capteur de distance et du bouton rouge. Il envoie toutes les modifications à notre backend via l'API HTTP, ce qui permet au système de déterminer si l'utilisateur est entré ou sorti de la cabine et s'il est nécessaire d'activer l'écoute du microphone ou de commencer à générer une réponse.
L' interface utilisateur Web est simplement une page Web ouverte dans un navigateur qui reçoit en permanence l'état actuel du système du backend et l'affiche à l'utilisateur.
Le backend contrôle le microphone, interagit avec tous les modèles d'IA nécessaires et exprime les réponses LLM. Il contient la logique de base de l'application.
Comment coder un croquis pour Arduino, connecter correctement le capteur de distance et le bouton et assembler le tout dans la cabine est le sujet d'un article séparé. Passons brièvement en revue ce que nous avons obtenu sans entrer dans les détails techniques.
Nous avons utilisé un Arduino, plus précisément le modèle ESP32 avec un module Wi-Fi intégré. Le microcontrôleur était connecté au même réseau Wi-Fi que l’ordinateur portable, qui exécutait le backend.
Liste complète du matériel que nous avons utilisé :
Les principaux composants du pipeline sont Speech-To-Text (STT), LLM et Text-To-Speech (TTS). Pour chaque tâche, de nombreux modèles différents sont disponibles localement et via le cloud.
Comme nous ne disposions pas d'un GPU puissant, nous avons décidé d'opter pour des versions cloud des modèles. La faiblesse de cette approche est la nécessité d’une bonne connexion Internet. Néanmoins, la vitesse d'interaction après toutes les optimisations était acceptable, même avec l'Internet mobile dont nous disposions au festival.
Examinons maintenant de plus près chaque composant du pipeline.
De nombreux appareils modernes prennent depuis longtemps en charge la reconnaissance vocale. Par exemple, l'API Apple Speech est disponible pour iOS et macOS, et l'API Web Speech est destinée aux navigateurs.
Malheureusement, leur qualité est très inférieure à celle de Whisper ou Deepgram et ne peuvent pas détecter automatiquement la langue.
Pour réduire le temps de traitement, la meilleure option consiste à reconnaître la parole en temps réel pendant que l'utilisateur parle. Voici quelques projets avec des exemples de mise en œuvre :
Avec notre ordinateur portable, la vitesse de reconnaissance vocale utilisant cette approche s'est avérée loin d'être en temps réel. Après plusieurs expériences, nous avons opté pour le modèle Whisper basé sur le cloud d'OpenAI.
Le résultat du modèle Speech To Text de l'étape précédente est le texte que nous envoyons au LLM avec l'historique des dialogues.
Lors du choix d'un LLM, nous avons comparé GPT-3.5. GPT-4 et Claude. Il s'est avéré que le facteur clé n'était pas tant le modèle spécifique que sa configuration. Finalement, nous avons opté pour GPT-4, dont les réponses nous ont plus plu que les autres.
La personnalisation de l'invite pour les modèles LLM est devenue une forme d'art à part entière. Il existe de nombreux guides sur Internet expliquant comment régler votre modèle selon vos besoins :
Nous avons dû expérimenter de manière approfondie les paramètres d'invite et de température pour que le modèle réponde de manière engageante, concise et humoristique.
Nous exprimons la réponse reçue du LLM en utilisant le modèle Text-To-Speech et la lisons à l'utilisateur. Cette étape a été la principale source de retard dans notre démo.
Les LLM mettent beaucoup de temps à répondre. Cependant, ils prennent en charge la génération de réponses en mode streaming – jeton par jeton. Nous pouvons utiliser cette fonctionnalité pour optimiser le temps d'attente en exprimant des phrases individuelles au fur et à mesure de leur réception sans attendre une réponse complète du LLM.
Nous utilisons le temps pendant lequel l'utilisateur écoute le fragment initial pour masquer le retard dans le traitement des parties restantes de la réponse du LLM. Grâce à cette approche, le délai de réponse ne se produit qu'au début et est d'environ 3 secondes.
async generateResponse(history) { const completion = await this.ai.completion(history); const chunks = new DialogChunks(); for await (const chunk of completion) { const delta = chunk.choices[0]?.delta?.content; if (delta) { chunks.push(delta); if (chunks.hasCompleteSentence()) { const sentence = chunks.popSentence(); this.voice.ttsAndPlay(sentence); } } } const sentence = chunks.popSentence(); if (sentence) { this.voice.say(sentence); } return chunks.text; }
Même avec toutes nos optimisations, un délai de 3 à 4 secondes reste significatif. Nous avons décidé de nous occuper de l'interface utilisateur avec des commentaires pour éviter à l'utilisateur le sentiment que la réponse est bloquée. Nous avons examiné plusieurs approches :
Nous avons opté pour la dernière option avec une simple page Web qui interroge le backend et affiche des animations en fonction de l'état actuel.
Notre salle de confession IA a fonctionné pendant quatre jours et a attiré des centaines de participants. Nous avons dépensé environ 50 $ en API OpenAI. En retour, nous avons reçu des retours positifs substantiels et des impressions précieuses.
Cette petite expérience a montré qu'il est possible d'ajouter une interface vocale intuitive et efficace à un LLM même avec des ressources limitées et des conditions externes difficiles.
Au fait, les sources backend disponibles sur GitHub