跪拜 Guibai
← Back to the summary

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:

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:

  1. reader.read() returns done: true—the faucet is physically closed.
  2. 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.parseSyntaxError: 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 */
}

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.