Lesson 10.3 · 16 min
Streaming the real model
After lesson 10.2 the pretend model types its answer a word at a time, but the real one still sits silent until its whole answer is ready, then drops it on the screen in one go. A real model writes word by word too; it just needs to be asked to send each piece as soon as it exists. Reading those pieces is where the network shows its rough edges: it can cut a message in the middle of a line, and even in the middle of a letter.
Adds to ChatterboxThe real model’s answer appears as it is written.
The real model still makes you wait
In lesson 10.2, Model::reply started a task, and the task called ask, which waited for the whole answer and then sent it down the channel as a single piece. Ask the real model for a long story and the screen sits still for as long as the whole answer takes to write, then fills all at once. Meanwhile the model was writing the answer a word at a time, and you saw none of it.
The channel is already built for pieces: answer() in main.rs prints each one the moment it arrives. So nothing in main.rs changes in this lesson. All the work is in replier.rs, in how the task reads the reply.
Asking for pieces
The request gains one field, stream: bool, and Chatterbox sets it to true. That tells the service: do not wait until you are finished, send each piece as you write it. The reply then stops being one JSON object. It becomes server-sent events: plain lines of text that keep arriving over the same connection, one event at a time. A short answer looks like this (spaces matter; each piece of text starts with its own space):
data: {"choices":[{"delta":{"role":"assistant"}}]}
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
: keep-alive
data: [DONE]
Four facts cover all Chatterbox needs:
- An event is a line starting with
data:, followed by some JSON. A blank line separates one event from the next. - Instead of
message, each piece has adelta(the word means "the change"): just the new text since the last piece. - Some pieces carry no text at all. The first one often only says who is speaking. So
contentis anOption<String>in the newDeltastruct. - A line starting with
:is a comment, which servers send to keep a quiet connection open.data: [DONE], which is not JSON at all, means the answer is complete. That one is the chat API's own convention, not a rule of server-sent events.
One line, one meaning
Every line of the stream means one of three things, so Chatterbox names them with an enum (lesson 5.3):
Event::Piece(String): more of the answer, to print and to send down the channel.Event::Done: the answer is complete.Event::Other: anything else. A blank line, a comment, a piece with no text, or a line that is not valid JSON. Chatterbox skips it.
The function that decides is parse_event(line: &str) -> Event. It takes off the data: at the front with strip_prefix (which gives back an Option: Some of the rest, or None if the line does not start that way), trims the space after it, checks for [DONE], then lets serde read the JSON into the new structs Chunk, ChunkChoice and Delta. You write it yourself at the end of this lesson.
The network does not deliver lines
Here is the catch: the stream does not arrive line by line. response.chunk().await? gives you the next chunk of the reply: whatever bytes have arrived so far, as Some(bytes), or None once the reply is over. A chunk can be any size. It can hold three events, or half of one. It can end in the middle of a line.
Worse, it can end in the middle of a letter. Remember from lesson 2.5 that text is stored as UTF-8 bytes, and that a letter outside plain English takes more than one: é is 2 bytes, ☕ is 3. The network knows nothing about letters: it happily delivers the first byte of é in one chunk and the second byte in the next. What happens if you try to read the first chunk as text on its own?
The first chunk ends after the first byte of é. What happens when you turn it into a String?
- It compiles and runs
- It compiles, then panics
- Compile error E0308
Reveal the answer
It compiles, then panics at run time. String::from_utf8 checks that the bytes are valid UTF-8 and returns a Result. A lone first byte of é is not a letter, so it is an Err, and unwrap() on an Err stops the program (lessons 5.4 and 8.2). It compiles fine: the compiler cannot know where the network will cut.
The gentler cousin, String::from_utf8_lossy, never fails: it swaps any bytes that are not a whole letter for the replacement character �. That sounds safer and is worse, because the damage is silent. Read each chunk on its own and the reader sees caf� then another �, where the model wrote café:
$ cargo run
4 letters, 5 bytes
first chunk alone: caf�
second chunk alone: �
both, together: caféKeep the bytes until a line is whole
The fix is a waiting room. Chatterbox keeps everything that has arrived in a Vec<u8> called pending: bytes, not text, so half a letter can sit there safely. After each chunk it asks: is there a whole line in here yet? A line ends with a newline byte, written b'\n' (the b makes it a single byte, a u8, rather than a char).
pending.iter().position(|&b| b == b'\n')finds the first newline and gives back its index as an Option. The&in|&b|is a pattern (lesson 5.5) that copies the byte out of the reference the iterator hands over.pending.drain(..=end)takes the bytes from the start up to and includingendout of the Vec (..=is a range that includes its last number), andcollect()gathers them into a newVec<u8>. Whatever came after the newline stays behind for next time.- Only now, with a whole line in hand, does
from_utf8_lossyturn it into text. A whole line always holds whole letters. In UTF-8, every byte of a multi-byte letter is 0x80 or above, and the newline byte is 0x0A, so a newline never appears inside a letter and splitting at one can never cut a letter in half.
The inner while let (lesson 5.5) matters: one chunk may hold several lines, and they should all be read before waiting for more.
Three chunks arrive, cut in awkward places. What does this print?
3 lines; still waiting:2 lines; still waiting: data: [DO5 lines; still waiting:2 lines; still waiting:
Reveal the answer
The program prints: 2 lines; still waiting: data: [DO Only two newlines ever arrive, so there are exactly two lines, data: Hello and data: world, however the chunks were cut. The first chunk alone has no newline, so nothing is read after it. The last piece, data: [DO, has no newline yet, so it waits in pending for the rest of its line to arrive. Counting chunks instead of newlines is exactly the mistake this loop exists to avoid.
A tempting shortcut is to collect the drained bytes straight into a String. The compiler refuses:
This is the exact output of the real compiler. Toggle the English, then pick a fix and see what actually happens.
error[E0277]: a value of type `String` cannot be built from an iterator over elements of type `u8` --> src/main.rs:5:50 | 5 | let line: String = pending.drain(..=end).collect(); | ^^^^^^^ value of type `String` cannot be built from `std::iter::Iterator<Item=u8>` | = help: the trait `FromIterator<u8>` is not implemented for `String` = help: `String` implements trait `FromIterator<A>`: FromIterator<&char> FromIterator<&std::ascii::Char> FromIterator<&str> FromIterator<Box<str, A>> FromIterator<Cow<'_, str>> FromIterator<String> FromIterator<char> FromIterator<std::ascii::Char> note: the method call chain might not have had the expected associated types --> src/main.rs:5:36 | 2 | let mut pending: Vec<u8> = Vec::new(); | ---------- this expression has type `Vec<u8>` ... 5 | let line: String = pending.drain(..=end).collect(); | ^^^^^^^^^^^^^ `Iterator::Item` is `u8` here note: required by a bound in `collect` --> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/iter/traits/iterator.rs:2073:4
E0277 in the compiler error atlas →
a value of type `String` cannot be built from an iterator over elements of type `u8`:drainhands out bytes one at a time, andcollectcannot make text out of loose bytes.- This is a promise check (a trait, lesson 8.4):
Stringknows how to be collected fromchars and from pieces of text, and nothing in the standard library promises it can be collected from bytes. - That refusal is on purpose. Bytes are only text once someone has decided what to do about bytes that do not form a letter, and
collecthas no way to ask you. - So collect what you actually have, a
Vec<u8>, and then choose how to read it. - The list of types under
`String` implements trait `FromIterator<A>`can be skipped: the first line says it all.
Fixes that were compiled for real
- Collect bytes, then read them as text — compiles. This is what Chatterbox does. The bytes go into a Vec<u8>, and from_utf8_lossy turns the whole line into text in one step.
- Collect into a &str instead — still E0277. Same refusal. A &str only borrows text that already exists somewhere; there is no text yet to borrow.
- Turn every byte into a char — compiles. It compiles, and it is wrong. <code>b as char</code> turns each byte into a letter on its own, so the two bytes of é become two different letters, and this prints <code>data: café</code>. Compiling is not the same as being right.
Chatterbox v1.1: the real model streams
In replier.rs, ask becomes stream. It still sends the request with .send().await? and checks the status with .error_for_status()?, but then, instead of reading the whole body as JSON, it loops: while let Some(bytes) = response.chunk().await?. Each chunk goes onto the end of pending; each whole line goes through parse_event. A Piece goes straight down the channel with tx.send(Ok(text)).await, and if that send fails, nobody is listening any more, so the function stops. Done ends the answer.
The channel now has room for 16 waiting pieces instead of 1. Reading the network pauses only if 16 pieces sit unread, the pause from lesson 10.2 that stops a fast writer drowning a slow reader.
One small rule closes it: finished(sent_any). A stream that ends, with [DONE] or by simply closing, without a single piece of text is not an answer. It is ChatError::Empty, the same error a reply with no choices was in lesson 9.2, and main.rs already prints it and carries on.
The run below is the proof. The course build starts a stand-in server that streams the way a real service does, one word per event, and it is deliberately unkind: it cuts every event into two chunks, right after the first byte of a multi-byte letter when there is one. The message has three such letters. Every one survived.
Sam café ☕ naïve /quit
Chatterbox v1.1 (replies by: real) What should I call you? Hi Sam! I'm Chatterbox. Ask me anything. bot: mock model saw 1 message; last: café ☕ naïve bot: bye, Sam! messages so far: 1 saved 2 messages to chatterbox.json
Show the whole program (open it after you have tried the exercise below: it contains the answer)
src/main.rsunchanged
src/command.rsunchanged
src/message.rsunchanged
src/store.rsunchanged
Download this version (zip)Unzip, then cargo run inside the folder.
Going deeper Where this run was recorded
The stream needs the network, so the run above was recorded when the course was built, against that stand-in server on the same computer, with real bytes over a real connection. To see it word by word against a real service, download the project and set the same three environment variables as in lesson 9.2. Nothing else changes: any service that speaks chat completions streams in this format when asked.
This is Chatterbox v1.1 with the body of parse_event replaced by todo!(). Write it: a line starting with data: (with or without a space after it) whose JSON carries some text is a Piece; data: [DONE] is Done; everything else, including a piece whose text is missing or empty and a line that is not valid JSON, is Other. The structs Chunk, ChunkChoice and Delta are already there for serde. (In this copy, parse_event and Event are marked pub, because the hidden tests sit outside replier, and without pub code outside a module cannot reach what is inside it, as in lesson 8.1.)
This is Chatterbox v1.1 with the body of parse_event replaced by todo!(). Write it: a line starting with data: (with or without a space after it) whose JSON carries some text is a Piece; data: [DONE] is Done; everything else, including a piece whose text is missing or empty and a line that is not valid JSON, is Other. The structs Chunk, ChunkChoice and Delta are already there for serde. (In this copy, parse_event and Event are marked pub, because the hidden tests sit outside replier, and without pub code outside a module cannot reach what is inside it, as in lesson 8.1.)
Hidden tests (6): an_empty_piece_is_skipped a_data_line_is_a_piece broken_json_and_no_choices_are_skipped done_ends_the_answer lines_without_text_are_skipped the_space_after_data_is_optional
This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.
Say it out loud, then reveal
What does a chat API send back when the request says <code>"stream": true</code>?Reveal
data: {json}, one per piece of the answer, with blank lines between them and data: [DONE] at the end.Why is <code>content</code> an <code>Option<String></code> in <code>Delta</code>?Reveal
content if the field were a plain String.Why does Chatterbox keep incoming bytes in a <code>Vec<u8></code> instead of turning each chunk into text?Reveal
What does <code>pending.drain(..=end)</code> do?Reveal
end (the newline) from pending and hands them out; anything after the newline stays for later.When is a streamed answer an error even though nothing failed on the network?Reveal
finished(false) returns ChatError::Empty.That is lesson 10.3. Tick it off so the course remembers where you are.
Go deeper
Questions people ask
What are server-sent events?
A simple format for a server to keep sending messages over one HTTP connection. Each message is a line starting with data: and messages are separated by blank lines. Chat APIs use it to stream an answer piece by piece.
How do I stream a chat completion in Rust?
Send the request with stream set to true, then call chunk() on the reqwest response in a loop. Keep the bytes in a Vec of u8, split off each complete line at the newline, and parse every line that starts with data: as JSON until data: [DONE] arrives.
Why does my streamed text show a replacement character in the middle of a word?
A network chunk ended in the middle of a letter that takes more than one byte in UTF-8, and each chunk was turned into text on its own. Keep the bytes until a whole line has arrived and only then convert them.
Does reqwest need the stream feature to read a response in pieces?
No. Response::chunk is available in reqwest without any extra feature. The stream feature adds a different interface, bytes_stream, which this course does not use.
What is the difference between String::from_utf8 and String::from_utf8_lossy?
from_utf8 returns a Result and gives an error if the bytes are not valid UTF-8. from_utf8_lossy never fails: it replaces any invalid bytes with the replacement character.