Protocoles d’appel LLM : synchrone vs streaming
Ce document décrit les deux modes d’appel utilisés par les modèles LLM dans l’architecture v200 :
- appel synchrone (
generate) - appel en streaming (
stream_generate)
Les deux modes utilisent HTTP(S), mais pas le même protocole.
Dans la v200, les InternalLLM sont les seules classes responsables :
- de la configuration backend (client, modèle, URL si HTTP)
- de l’appel synchrone
- de l’appel streaming
- de la normalisation des réponses
1. Appel synchrone : HTTP POST classique
L’appel synchrone (generate) utilise un POST HTTP standard.
Caractéristiques
- Une seule requête
- Une seule réponse
- Format JSON complet
- Pas de flux
- Pas de tokens intermédiaires
Exemple (OpenAI/Mistral-like)
POST /v1/chat/completions
Content-Type: application/json
{
"model": "mistral-large",
"messages": [...]
}
Réponse :
200 OK
Content-Type: application/json
{
"id": "...",
"choices": [
{ "message": { "content": "réponse complète" } }
]
}
2. Appel streaming : HTTP POST + protocole de flux
L’appel stream_generate utilise également un POST HTTP, mais la réponse n’est pas un JSON unique.
Le serveur envoie un flux d’événements ou de chunks.
Deux protocoles sont utilisés selon les backends :
2.1. SSE (Server-Sent Events)
C’est le protocole utilisé par :
- OpenAI
- Mistral
- Anthropic
- Groq
- LM Studio (mode OpenAI)
Caractéristiques
Content-Type: text/event-streamTransfer-Encoding: chunked- Chaque ligne commence par
data: - Chaque événement contient un fragment (
delta) - Le flux se termine par
data: [DONE]
Exemple
data: {"choices":[{"delta":{"content":"Bon"}}]}
data: {"choices":[{"delta":{"content":"jour"}}]}
data: {"choices":[{"delta":{"content":" !"}}]}
data: [DONE]
2.2. Chunked Transfer Encoding (Ollama)
Ollama n’utilise pas SSE mais un flux de JSON successifs, un par chunk.
Exemple
{"response": "Bon"}
{"response": "jour"}
{"response": " Bertrand"}
{"done": true}
Caractéristiques
- Pas de
data: - Pas de SSE
- Chaque chunk est un JSON indépendant
- Le client doit lire le flux chunk par chunk
3. Pourquoi deux protocoles différents ?
Parce que :
- un appel synchrone renvoie une réponse complète
- un streaming renvoie un flux de tokens
HTTP classique ne permet pas d’envoyer plusieurs réponses successives.
Les serveurs utilisent donc :
- SSE → flux d’événements textuels
- chunked transfer → flux de JSON partiels
- WebSocket (rare) → flux bidirectionnel
4. Conséquences pour l’architecture v200
4.1. generate()
- utilise un appel HTTP simple
- renvoie un JSON complet
- normalisation effectuée dans la classe LLM
4.2. stream_generate()
- utilise SSE ou chunked transfer
- lit les fragments au fur et à mesure
- normalise chaque fragment en format v200 :
{ "delta": "texte" }
...
{ "delta": "", "meta": {...} }
Retourne un flux de chunks représentant la réponse en streaming. Contrat de l'interface :
- La méthode doit produire un itérable de chunks.
Convention v200 (obligatoire pour toutes les implémentations) :
- Les chunks intermédiaires sont des dicts contenant :
{ "delta": "<texte incrémental>" }
- Le dernier chunk contient en plus des métadonnées :
{ "delta": "", "meta": {...} }
Cette convention garantit un format standardisé pour toutes les
implémentations InternalLLM, indépendamment du backend utilisé.
5. Résumé
| Mode | Protocole | Format | Usage |
| generate() | HTTP POST classique | JSON complet | réponse unique |
| stream_generate | SSE ou chunked transfer | flux de tokens | streaming |
6. Exemple
- from commons.models.internal.base import BaseGenerativeModel
- from commons.models.response import Response
- class MistralAPIInternalLLM(BaseGenerativeModel):
- """
- Implémentation Mistral API pour v200.
- Cette classe contient :
- - la configuration backend (client, modèle)
- - generate() : appel synchrone
- - stream_generate() : appel SSE
- - normalisation des réponses
- """
- def __init__(self, client, model_name: str):
- self.client = client
- self.model_name = model_name
- def generate(self, prompt: str, **kwargs) -> Response:
- """
- Appel synchrone : requête HTTP complète, pas de streaming.
- Le format exact de la réponse dépend du backend.
- """
- raw = self.client.chat.completions.create(
- model=self.model_name,
- messages=[{"role": "user", "content": prompt}],
- stream=False,
- **kwargs,
- )
- try:
- text = raw["choices"][0]["message"]["content"]
- except Exception:
- text = str(raw)
- return Response(
- response=text,
- raw_response=raw,
- source_nodes=None,
- metadata={},
- )
- def stream_generate(self, prompt: str, **kwargs):
- """
- Retourne un flux de chunks.
- Le format exact dépend de l'implémentation.
- Convention interne v200 :
- - chunks intermédiaires : {"delta": "..."}
- - chunk final : {"delta": "", "meta": {...}}
- """
- stream = self.client.chat.completions.create(
- model=self.model_name,
- messages=[{"role": "user", "content": prompt}],
- stream=True,
- **kwargs,
- )
- final_message = None
- for chunk in stream:
- delta = ""
- if chunk.choices:
- c = chunk.choices[0]
- if getattr(c, "delta", None) and getattr(c.delta, "content", None):
- delta = c.delta.content or ""
- if getattr(c, "message", None):
- final_message = c.message
- yield {"delta": delta}
- yield {
- "delta": "",
- "meta": {
- "raw_response": final_message,
- "nodes": None,
- "metadata": {},
- },
- }