Una respuesta que llega palabra a palabra
Una respuesta en streaming es una respuesta que un modelo de lenguaje envía en trozos pequeños mientras escribe. No llega de golpe al final. Las aplicaciones de chat lo usan para que las palabras aparezcan enseguida.
Es útil porque una respuesta larga puede tardar varios segundos. Con un flujo, quien lee ve el principio casi al instante. Tu programa tiene que leer los trozos y unirlos.
Al final de esta lección
Nota
Puedo leer una respuesta que llega en trozos y unirla en un solo texto.
Palabras clave
| Palabra | Qué significa | Ejemplo |
|---|---|---|
| Flujo | una respuesta enviada en trozos mientras se escribe | |
| Eventos del servidor | una forma de que un servidor empuje líneas a un cliente | |
| Línea de datos | una línea del flujo que empieza por data: y lleva JSON | |
| Delta | el trozo nuevo de texto de una línea de datos | |
| Marcador de fin | la última línea de datos, que dice que el flujo terminó | data: [DONE] |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Un flujo es una lista de líneas
Para pedir un flujo añades "stream": true al cuerpo de la petición. El servidor responde entonces con eventos enviados por el servidor. Es texto sencillo, una línea cada vez, con una línea vacía entre eventos.
Cada línea que empieza por data: lleva un trozo de JSON. El texto está en choices[0].delta.content. La última línea es data: [DONE].
Las líneas de un flujo
| La línea empieza por | Qué contiene |
|---|---|
| data: {"choices":[{"delta":{"role":"assistant"}}]} | La apertura: un rol y todavía sin texto |
| data: {"choices":[{"delta":{"content":"Uno"}}]} | Un trozo de texto que añadir |
| data: {"choices":[{"delta":{},"finish_reason":"stop"}]} | La razón por la que terminó la respuesta |
| data: [DONE] | El marcador de fin, que no es JSON |
| : keep-alive | Una línea de comentario que se ignora |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Mira los paquetes de bytes que llegan
docker exec -w /home/node/project agent node -e 'const lab = require("./lab.js"); const event = (text) => "data: " + JSON.stringify({ choices: [{ delta: { content: text } }] }) + "\n\n"; const whole = event("Uno") + event(", dos") + event(", tres.") + "data: [DONE]\n\n"; (async () => { let chunks = []; let label = "Flujo"; try { const response = await lab.send({ stream: true, max_tokens: 30, messages: [{ role: "user", content: "Cuenta del uno al cinco." }] }); if (!response.ok) throw new Error("HTTP " + response.status); const decoder = new TextDecoder(); for await (const bytes of response.body) chunks.push(decoder.decode(bytes, { stream: true })); } catch (error) { label = "Muestra grabada"; chunks = whole.match(/[^]{1,45}/g); } console.log(label + ": primeros paquetes de bytes"); chunks.slice(0, 4).forEach((one, i) => console.log((i + 1) + " " + JSON.stringify(one))); console.log("... " + chunks.length + " paquetes en total."); })();'Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Un paquete de red no es una línea
Los bytes llegan a tu programa en paquetes, y un paquete puede acabar a mitad de una línea. Por eso guarda un búfer. Añade cada paquete, corta cada línea completa y guarda el resto para el paquete siguiente.
Solo una línea completa se puede leer sin riesgo como JSON. Después añade cada trozo de texto a la respuesta.
Escribe stream.js, un lector de líneas completas
docker exec -w /home/node/project agent node -e 'require("fs").writeFileSync("stream.js", ["// stream.js: lee una respuesta en flujo como líneas completas, se corten como se corten los paquetes.", "async function* linesOf(response) {", " let held = \"\";", " const decoder = new TextDecoder();", " for await (const bytes of response.body) {", " held += decoder.decode(bytes, { stream: true });", " const parts = held.split(\"\\n\");", " held = parts.pop();", " yield* parts;", " }", " if (held !== \"\") yield held;", "}", "", "module.exports = { linesOf };"].join("\n") + "\n")'Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Añade piecesOf, que da el texto
docker exec -w /home/node/project agent node -e 'require("fs").appendFileSync("stream.js", ["", "// Parte 2: envía una llamada en flujo y devuelve los trozos de texto, uno a uno.", "async function* piecesOf(body) {", " const response = await require(\"./lab.js\").send({ ...body, stream: true });", " if (!response.ok) throw new Error(\"HTTP \" + response.status);", " for await (const line of linesOf(response)) {", " if (!line.startsWith(\"data: \") || line === \"data: [DONE]\") continue;", " const piece = JSON.parse(line.slice(6)).choices[0].delta.content;", " if (piece) yield piece;", " }", "}", "", "module.exports = { linesOf, piecesOf };"].join("\n") + "\n")'Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Un error frecuente
Advertencia
Mucha gente cree que cada paquete es un evento. No lo es. Un paquete puede traer tres eventos o la mitad de uno. Tu programa es correcto cuando da el mismo texto se corten como se corten los bytes. Primero guarda en el búfer, luego lee las líneas.
Imprime y une los trozos
docker exec -w /home/node/project agent node -e 'const { piecesOf } = require("./stream.js"); const sample = ["Uno", ", dos", ", tres", ", cuatro", ", cinco."]; (async () => { const started = Date.now(); let first = null, count = 0, text = "", label = "Respuesta del modelo"; try { const asking = { max_tokens: 40, messages: [{ role: "user", content: "Cuenta del uno al cinco." }] }; for await (const piece of piecesOf(asking)) { if (first === null) first = Date.now() - started; text += piece; count++; process.stdout.write(piece); } } catch (error) { label = "Muestra grabada"; text = sample.join(""); count = sample.length; first = null; process.stdout.write(text); } console.log("\n" + label + ": " + count + " trozos, " + text.length + " caracteres."); if (first !== null) console.log("El primer trozo llegó a los " + first + " ms y todo a los " + (Date.now() - started) + " ms."); })();'Se muestra aquí y no se ejecuta. Crea una cuenta gratuita para pulsar Ejecutar y escribirlo en el terminal de tu propio laboratorio.
Ejemplo resuelto: de las líneas al texto
| Líneas de datos, en orden | Trozo añadido | Texto hasta ahora |
|---|---|---|
| Apertura, solo el rol | Ninguno | Vacío |
| content "Uno" | Uno | Uno |
| content ", dos" | , dos | Uno, dos |
| finish_reason "stop" | Ninguno | Uno, dos |
| data: [DONE] | Ninguno | Uno, dos |
Las celdas que rellenan los estudiantes quedan en blanco en esta vista previa.
Para ir más lejos: mide el primer trozo
Ejecuta tres veces el script de unir y anota el tiempo hasta el primer trozo y hasta la respuesta entera.
Después di cuál de los dos nota más quien lee, y por qué un flujo ayuda sobre todo cuando la respuesta es larga.