The Five-Layer Pipeline That Turns LLM Tokens Into Streaming Text
LLM Streaming Output Practical Deep Dive: From while Loops to try-catch, Every Line of Code Counts
1. Quick Review (5 Minutes to Regain Context)
Streaming Output: An LLM generates one token at a time, not all at once. The server generates one and sends one, and the client concatenates and displays them character by character—like a typewriter. The difference from the traditional "wait 8 seconds and then everything pops up at once" is that perceived wait time approaches zero.
Vue3 Composition API: ref() creates reactive variables. In <script>, you read and write with .value; in <template>, it's auto-unwrapped. v-model handles two-way binding. The core idea: group data and methods for the same feature together, rather than scattering them by type.
Binary Encoding/Decoding: Networks only understand bytes from 0-255. TextEncoder converts text to bytes, and TextDecoder converts bytes to text. One Chinese character occupies 3 bytes in UTF-8.
The Water Pipe System:
response.body (water pipe) → getReader() (faucet) → reader.read() (take a sip) → screen
2. Three Reactive States, Three Page Elements
const question = ref('Tell a story about a Chinese dragon') // Input box value
const content = ref('') // LLM's reply
const stream = ref(true) // Streaming/non-streaming toggle
Where does stream.value come from? The Streaming checkbox on the page:
<input type="checkbox" v-model="stream" />
Checked = true = go through the while loop, character by character. Unchecked = false = use response.json() to get everything at once.
3. Code Execution Panorama
Page loads → Initialize refs, declare update → Wait for user click
User clicks "Submit"
↓
update()
├─ Null check
├─ content = 'Thinking...' Give user immediate feedback
├─ fetch POST Send request to DeepSeek
├─ Get response
│
├─ stream === true ──────────────┐
│ while(!done) { │
│ reader.read() Take a sip │
│ decoder.decode() Decode │
│ buffer + text Stitch residue │
│ split + filter Slice lines, filter │
│ for each line { │
│ slice(6) Remove prefix │
│ [DONE]? → break │
│ JSON.parse Parse │
│ Get delta.content Extract value │
│ content += Append to screen │
│ catch → buffer Store residue │
│ } │
│ } │
│ │
└─ stream === false ────────────┘
response.json() Get all at once
content = message.content Assign once
4. Core Pipeline: while Loop Layer-by-Layer Breakdown
This is the heart of the entire application. From binary data to characters on the screen, there are five layers of transformation.
Layer 0: How stream.value is Determined
stream.value comes from the checkbox's v-model. It decides two things:
// ① Tell DeepSeek: What format to return
body: JSON.stringify({ stream: stream.value })
// ② Local decision: Which branch to enter
if (stream.value) { streaming } else { non-streaming }
Checked = true, streaming. Unchecked = false, get everything at once.
Layer 1: reader.read() — Take a Sip
const {value, done: doneReading} = await reader?.read()
done = doneReading
reader.read() is called once to fetch a chunk of data. It returns a Promise:
- Data arrived → resolve →
{value: Uint8Array[...], done: false} - Data not yet arrived → pending →
awaitwaits, doesn't block the page - Stream exhausted → resolve →
{value: undefined, done: true}, not an error
Why rename done to doneReading? Because there's already a let done = false controlling the while loop outside, causing a name conflict. After destructuring, done = doneReading syncs the exit flag.
value is a chunk of raw binary data. A chunk might contain 0, 1, or multiple lines of SSE data, or even half a line:
Sip 1: "data: {"cho" ← Half a line
Sip 2: "ices":[{"delta":{"content":"Hello"}}]}\n\n ← 1 complete line
Sip 3: "data: {"delta":{"content":"!"}}]}\n\ndata: [DONE]\n\n" ← 2 lines
Splitting depends entirely on network packet arrival timing, unrelated to SSE's \n boundaries. Hence the need for the three safety nets of split + filter + buffer later.
Layer 2: decoder.decode() — Binary → Text
const chunkValue = buffer + decoder.decode(value)
buffer = ''
decoder.decode(value) translates the Uint8Array into a text string. The {stream: true} parameter tells the decoder "multi-byte characters might span chunks," and it internally caches incomplete bytes for you.
The role of the buffer variable: Temporarily stores the incomplete line from the previous round's failed JSON parse. Most of the time, buffer is an empty string ''; it only stores a value when the try-catch catches truncated data:
// Normal case
chunkValue = '' + 'data: {...complete line...}\n\n' ← buffer is empty, no effect
// Truncated case
chunkValue = 'data: {"cho' + 'ices":[...]}\n\n' ← Previous residue + new data = complete!
buffer is initialized as let buffer = '' outside the while loop, so the first round it's an empty string, not affecting concatenation.
Layer 3: split + filter — Slice Lines + Filter
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data: '))
split('\n') : Splits by newline character. \n is the line delimiter of the SSE protocol and the standard for buffer to judge completeness—ending with \n = a complete line, not ending with it = truncated.
Why can one chunk have multiple lines? The speed at which an LLM generates tokens is variable. When fast, it generates several tokens at once, which are packaged into the same network packet. So one chunk after decoding might be:
data: {...Hello...}\n\ndata: {...!...}\n\n:ok\n\ndata: {...have...}\n\n
This is why you must split first and then process line by line; you cannot assume one chunk is one line.
.filter(line => line.startsWith('data: ')) : filter is an array method that iterates over each element, keeping those where the condition is true and discarding those where it's false. It doesn't change the original array but returns a new one. Here, only lines starting with data: are kept; empty lines (SSE message separators) and :ok (heartbeat keep-alive) are all filtered out:
After split: ["data: {...}", "", "data: {...}", ":ok", ""]
After filter: ["data: {...}", "data: {...}"]
filter doesn't look at the content inside the line, only whether the line starts with data:. It doesn't touch the JSON inside; that's left for later processing.
Layer 4: for Loop — Peel Line by Line
for (const line of lines) {
const incoming = line.slice(6)
slice(6) chops off the first 6 characters. "data: " is exactly 6 characters (d-a-t-a-:-space), so slice(6) starts from the 7th character, leaving the pure content:
line = 'data: {"choices":[{"delta":{"content":"Hello"}}]}'
incoming = '{"choices":[{"delta":{"content":"Hello"}}]}' ← after slice(6)
Note that slice(6) does not modify the original string; it returns a new string. The original line is unaffected.
Layer 5: Two Things — [DONE] Check + JSON Parsing
First: Stop when encountering [DONE]
if (incoming === '[DONE]') {
done = true
break
}
[DONE] is a termination flag defined by DeepSeek itself, just a plain text string (not JSON, no curly braces). It appears as an independent SSE line:
data: [DONE]
After slice(6), incoming is the complete '[DONE]', matched with === for exact comparison.
Why also set done = true? Because [DONE] is just a text message, not a stream closure at the TCP level. The doneReading returned by reader.read() might still be false—the water pipe isn't closed, there's just a note in the water saying "no more." You need to manually set done = true to make the while loop exit.
break only exits the for loop, not the while loop. So you need done = true + break together: done = true is responsible for making the while loop exit on its next condition check, and break is responsible for immediately exiting the current for loop (the remaining lines in this chunk don't need to be looked at).
DeepSeek has two ways to end:
reader.read()returnsdone: true—the faucet is physically closed.- The data contains the
data: [DONE]text—a note floats by in the water.
Your code handles both; it can exit normally regardless of which occurs.
Second: JSON.parse to get delta.content
try {
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
if (data && delta) {
content.value += delta
}
} catch(err) {
buffer = `data: ${incoming}`
}
JSON.parse(incoming) turns the JSON string into a real, operable JS object:
incoming = '{"choices":[{"delta":{"content":"Hello!"}}]}'
↓ JSON.parse
data = { choices: [{ delta: { content: "Hello!" } }] }
↓ Drill down
data.choices[0].delta.content → "Hello!"
if (data && delta) defensive check: data ensures JSON parsing succeeded (though try-catch already covers this), and delta ensures the content field is not empty. Some chunks' delta have no content (e.g., a final frame containing only finish_reason: "stop"). Without this check, content.value += undefined would display "undefined" on the screen.
Why use += for content.value += delta: Streaming is a step-by-step concatenation:
"" + "Hello" = "Hello"
"Hello" + "!" = "Hello!"
"Hello!" + "have" = "Hello!have"
Using = would overwrite the previous content each time, leaving only one character on the screen forever. Everything before would be lost.
Why must .value be used: content is a ref object; the actual string value is wrapped inside .value. In <script>, you must use .value to change it; in <template>, Vue auto-unwraps it.
Layer 6: catch — Safety Net for Incomplete JSON
catch(err) {
buffer = `data: ${incoming}`
}
Why is JSON truncated?
Network packets have size limits (MTU ~1500 bytes). If a JSON line exceeds the packet size, it gets cut in half:
Complete: data: {"choices":[{"delta":{"content":"Hello"}}]}\n
Packet 1: data: {"cho ← JSON incomplete, has { start but no } end
Packet 2: ices":[...]}\n ← The remaining half
Packet 1 arrives and goes to JSON.parse → SyntaxError: Unexpected end of JSON input → enters catch.
What to do in catch? Stuff this incomplete piece of data back into buffer:
buffer = `data: ${incoming}`
// ^^^^^^^ ^^^^^^^^
// Re-add prefix Incomplete JSON
Why must the data: prefix be re-added? incoming is what's left after slice(6); the data: has already been chopped off. In the next round, the concatenated data is in SSE format with the prefix—if the format is inconsistent, it won't stitch together. So it must be added back.
// Without re-adding prefix:
buffer = '{"choices":[...' ← No data:
// Next round:
chunkValue = '{"choices":[...' + 'data: {...}' ← First half doesn't start with data: → filtered out → permanently lost!
// With re-added prefix:
buffer = 'data: {"choices":[...' ← Has data:
// Next round:
chunkValue = 'data: {"choices":[...' + 'ices":[...]}\n\n' ← Complete line → filter keeps it
"Don't throw away on error" is the core principle: This piece of data is just temporarily incomplete, not garbage. Throwing it away means permanent loss, and characters will forever be missing from the screen. The purpose of try-catch is not "error tolerance," but "temporary storage, wait for the next round."
5. Non-Streaming Branch: Simple but Poor Experience
} else {
const data = await response.json()
content.value = data.choices[0].message.content
}
Two key differences from streaming:
| Streaming | Non-Streaming | |
|---|---|---|
| Fetch data | reader.read() loop sips |
response.json() gets all at once |
| Field | delta.content (incremental, += append) |
message.content (full amount, = assign) |
| while | Needed | Not needed |
| buffer/try-catch | Needed | Not needed |
Why are the fields different? Non-streaming returns only after everything is generated, so it returns the complete message. Streaming pushes one token at a time, so each return only contains the new delta. delta means "change, offset"—only the newly added few characters each time, not repeating from the beginning. This saves bandwidth.
6. CSS Document Flow
.container {
display: flex;
flex-direction: column;
height: 100vh;
font-size: 0.85rem; /* Mobile adaptation */
}
- Document Flow: The browser's default layout rules—block-level elements from top to bottom, inline elements from left to right.
display: flexinitiates a new formatting context;flex-direction: columnarranges items vertically.rem: A unit relative to the font size of the root<html>element, a core technique for proportional scaling on mobile.
7. Complete Data Morphology Change (Full Chain for User Input "Hello")
"Hello" (text, input box)
↓ JSON.stringify + UTF-8 encoding
Uint8Array [...] (binary, request body)
↓ POST to DeepSeek
═══════ Server Inference ═══════
↓
'data: {"choices":[{"delta":{"content":"Hello!"}}]}\n\n' (SSE format text)
↓ Network transmission encoding
Uint8Array [100,97,116,97,58,32,...] (binary byte stream)
↓ decoder.decode(value)
'data: {"choices":[{"delta":{"content":"Hello!"}}]}' (text)
↓ split('\n')
["data: {...Hello!...}", "", ""]
↓ filter(startsWith('data: '))
["data: {...Hello!...}"]
↓ slice(6)
'{"choices":[{"delta":{"content":"Hello!"}}]}'
↓ JSON.parse
{choices: [{delta: {content: "Hello!"}}]}
↓ .choices[0].delta.content
"Hello!"
↓ content.value +=
Screen displays: "Hello!"
↓ Next round
"have" → content += → Screen: "Hello!have"
↓ Round after
" something to help you" → Screen: "Hello!have something to help you"
8. Core Concept Quick Reference Table
| Concept | One-Liner Explanation |
|---|---|
response.body |
ReadableStream water pipe, a data container; cannot directly access data. |
getReader() |
Installs a faucet, locks the pipe (locked: true), exclusive read. |
reader.read() |
Takes a sip, returns {value: Uint8Array, done: boolean}. |
await |
Waits for Promise to resolve—data arrived = sip taken. |
{stream: true} |
Passed to decoder; multi-byte characters across chunks don't get garbled. |
buffer |
Temporarily stores the previous round's incomplete JSON; stitches it together for parsing in the next round. |
\n |
SSE line delimiter, also the marker for judging if a line is complete. |
split('\n') |
Splits text by newline; one chunk might contain 1 or multiple lines. |
filter(startsWith('data: ')) |
Keeps only data lines; discards empty lines and :ok heartbeats. Only checks the start, doesn't touch content. |
slice(6) |
Chops off "data: " (exactly 6 characters), leaving pure JSON. |
[DONE] |
DeepSeek's custom stream termination flag, plain text, not JSON. |
delta |
Increment; returns only the newly added few characters each time. |
message |
Full amount; non-streaming returns all content at once. |
content.value += |
Append concatenation; streaming character-by-character assembly. |
content.value = |
Direct assignment; non-streaming overwrites all at once. |
try-catch + buffer |
Don't discard truncated JSON; temporarily store and wait for the next round to complete it. |
if (data && delta) |
Defensive check: only append if JSON parsing succeeded and content has a value. |
ref() |
Vue3 reactivity; .value in script, auto-unwrapped in template. |
v-model |
Two-way binding; checkbox/input and variable are always in sync. |
9. One-Sentence Summary
The essence of streaming output is a five-layer data transformation pipeline—sip binary → decode text → split lines and filter → peel JSON skin → append to screen—with a safety net at every layer: buffer prevents truncation, try-catch catches JSON errors, and two assignment methods (+= vs =) distinguish streaming from non-streaming. Once you understand these clearly, you can connect to any LLM API.