Streaming Output and Terminal Rendering: Hand-Writing an SSE Parser, Incremental Markdown, and an Interruptible Typewriter
Peel back the gateway's SSE byte stream one layer at a time: parse events, merge them into incremental events, then hand them to a renderer that prints character by character — and handle stream disconnects, cursor control, and user keypress interrupts, the three things real engineering gets wrong most often.
Today's Goals
- Hand-write an SSE parser with no library dependency, and explain why it must split on blank lines rather than on lines
- Distinguish the gateway layer's streaming chunks from the loop layer's semantic events, and explain what trouble this boundary saves
- Implement incremental Markdown rendering and interruptible output in the terminal, handling a stream that disconnects mid-way
Yesterday's buffer-then-print black screen comes apart today. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Let the new hire think out loud instead of vanishing for an hour
Back to that new hire. You give them a question; they sit silently at their desk and hand you a well-written report an hour later. Are you satisfied? Probably not. You would rather they thought out loud: "let me look at this module... hmm, the logic here seems wrong... let me check the tests." You know which step they are on at all times and can stop them the moment they drift.
Models are the same, and their silence lasts longer than you expect. The numbers below were measured on 7 September 2026 against an OpenAI-compatible gateway (https://api.n1n.ai/v1), on the same roughly two-hundred-character answer, three runs per model, reported as ranges:
| Model id | Time to first token | Delta count |
|---|---|---|
deepseek-v3.2 | 1.2-1.9 seconds | 22-36 |
claude-haiku-4-5-20251001 | 1.8-2.4 seconds | 87-105 |
gpt-5-mini | 11.7-15.6 seconds | 151-160 |
Read the table correctly, not as a leaderboard: the delta count shows only magnitude and the first-token latency only a relative relationship; neither column is reproducible — they depend on the gateway's implementation, the load at that moment and your network, and another run gives different numbers. The table says one thing only: in a buffer-then-print implementation, that last row's dozen-odd seconds is completely silent black screen, and the only judgment available to the user is "has this thing died?" Its total time is not ten times the first row's; it simply thought for a long while before speaking.
So: streaming is not a nicety, it is the only remedy for time to first token. The total time does not change at all, and the user sees characters moving in the second second. That is also why yesterday's gateway interface kept only a streaming method — nothing in a Coding Agent has any use for the non-streaming one.
A counterintuitive bonus, used in the second half of today: streaming makes stopping midway possible. Dozens of deltas per turn are dozens of places to cut in; the buffer-then-print implementation offers only "wait for it to finish."
What SSE looks like: an event's boundary is a blank line, not a newline
Server-Sent Events is a text protocol plain enough to be endearing. The raw bytes the gateway emits, decoded, look roughly like this:
data: {"choices":[{"delta":{"content":"Streaming"}}]}
data: {"choices":[{"delta":{"content":" is not"}}]}
: this is a comment line, usually a heartbeat keep-alive
data: {"choices":[],"usage":{"prompt_tokens":24,"completion_tokens":88}}
data: [DONE]Three rules, each corresponding to a real incident:
- Events are separated by blank lines, and one event may have several
data:lines. Per the specification, severaldatalines in one event are joined with newlines into a single payload before parsing. So the parser frames on blank lines (that is, two consecutive newlines), not on single newlines. Reading line by line "happens to work" on most gateways because they send one data line per event — until one day one does not. - A line starting with a colon is a comment; drop it. It is usually a heartbeat, preventing an intermediate proxy from killing an idle connection. Feeding it to a JSON parser blows up.
[DONE]is not JSON. Pass it into the parse function and you get a syntax error.
And one easier to step on than all three: network chunking has nothing to do with event boundaries. What you get from the response body is byte chunks; one event may span two of them, one chunk may hold five events, and even a multi-byte character's bytes can be split across two chunks. So you must keep a buffer and carry an incomplete tail into the next chunk.
What the server wants to send (the full message)
data: {"choices":[{"delta":{"content":"Hi"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
data: [DONE]
The network packets that actually arrive
(nothing received yet)
The half-line left in the buffer
(empty)
Complete events parsed so far
const decoder = new TextDecoder()
let buffer = ''
let closed = false
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
// stream: true lets the decoder handle multi-byte characters split across chunks
buffer += decoder.decode(chunk, { stream: true })
buffer = buffer.replace(/\r\n/g, '\n') // normalize CRLF, then look only for blank lines
let boundary = buffer.indexOf('\n\n')
while (boundary !== -1) {
const rawEvent = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
for (const payload of dataLines(rawEvent)) {
if (payload === '[DONE]') return
for (const delta of parseChunk(payload)) {
if (delta.type === 'finish') closed = true
yield delta
}
}
boundary = buffer.indexOf('\n\n')
}
}
// The byte stream ended without a finish and without [DONE]: that is a truncated stream
if (!closed) throw new StreamTruncatedError()
/** Several data lines in one event join with newlines; comment lines are dropped */
function dataLines(rawEvent: string): string[] {
const lines = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
return lines.length ? [lines.join('\n')] : []
}# The Python version frames at the byte level: httpx has aiter_lines, but it splits on
# lines and cannot give you the blank-line boundary, so we keep our own buffer here.
buffer = b""
closed = False
async for chunk in res.aiter_bytes():
buffer += chunk.replace(b"\r\n", b"\n")
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
for payload in data_lines(raw_event.decode("utf-8", "ignore")):
if payload == "[DONE]":
return
for delta in parse_chunk(payload):
if isinstance(delta, FinishDelta):
closed = True
yield delta
if not closed:
raise StreamTruncatedError()
def data_lines(raw_event: str) -> list[str]:
"""Several data lines in one event join into one payload; comment lines are dropped"""
lines = [
line.removeprefix("data:").lstrip()
for line in raw_event.split("\n")
if line.startswith("data:")
]
return ["\n".join(lines)] if lines else []Both versions check one thing outside the loop: did we see a terminator? That is truncation detection, and it is the step most home-made clients miss — relying on "the iteration ended naturally" means that when a proxy cuts the stream, the user sees half a sentence and a normal prompt and assumes that is what the model said.
Delta granularity is an implementation detail; never treat it as a contract
That table's delta ranges hide another trap. Three runs of the same model give three different counts, and different models differ by more than fivefold. Delta granularity is an implementation detail of the gateway and the model: the magnitude is stable, the exact number is not.
Hence a rule: the render layer must not depend on the shape of a delta. Concretely, all of the following are wrong:
- assuming one delta is one word, or one complete token
- assuming a delta never crosses a newline, and treating deltas as lines
- assuming Markdown markers (backticks, asterisks) are never split
- using delta counts as a progress bar
The right approach treats a delta as "some more characters arrived," and nothing else. This course's offline script uses a fixed four characters per delta precisely to force this into the open: offline, asking "tell me about streaming" splits a 133-character answer into 34 deltas — a reproducible number, because we did the splitting. A real gateway gives you whatever it gives you, and you have to catch it.
The two-layer event model: why gateway deltas and loop events must stay separate
Yesterday defined two type families: the deltas the gateway emits and the semantic events the loop emits. Today they pay interest for the first time, so the boundary deserves spelling out.
Mermaid source
flowchart LR
A[SSE byte chunks] -->|framing| B[gateway: one event at a time]
B -->|translate the wire format| C[deltas: text, tool, usage, finish]
C -->|add semantics| D[loop: semantic events]
D -->|subscribe| E[render: characters on screen]
D -->|subscribe| F[session: the persisted log]The same thing is described completely differently at each layer. The gateway layer says "the payload has a content field whose value is these two words"; the loop layer says "the model is producing text." That looks like rewording, and the difference is which one changes: wire shapes change with the gateway, semantics do not.
For today's two ways of ending, the boundary saves obvious trouble. A cut stream is a gateway-layer matter (the byte stream ended without a terminator); a user pressing cancel is a keyboard matter, and their origins are entirely different. At the semantic layer they unify into two events — an error carrying a "can this be retried" judgment, and a done carrying a finish reason. The render layer knows only those two and never needs to know where they came from.
for await (const delta of provider.stream({ messages, tools, signal })) {
// The interrupt check sits between deltas: dozens of chances to stop per turn
if (signal?.aborted) {
if (assistantText) messages.push({ role: 'assistant', content: assistantText })
yield { type: 'done', reason: 'aborted' }
return
}
if (delta.type === 'text') {
assistantText += delta.text
yield { type: 'text', delta: delta.text } // wire shape stops here; outside sees semantics
}
}async for delta in provider.stream(ChatRequest(messages, tools, signal)):
# The interrupt check sits between deltas: dozens of chances to stop per turn
if signal.aborted:
if assistant_text:
messages.append(Message(role="assistant", content=assistant_text))
yield DoneEvent(reason="aborted")
return
match delta:
case TextDelta(text=text):
assistant_text += text
yield TextEvent(delta=text) # wire shape stops here; outside sees semanticsNote that messages.push: an interrupt must still write the received text back into the message array. Content the user has seen must not vanish — they would think the program ate it, and when they ask about "that approach you just mentioned" the model has no idea it said anything. Truncation is the same: it is not "this turn did not happen," it is "this turn did not finish."
The hard part of incremental Markdown: code fences and inline markers get split
Now the render layer. Printing character by character is easy; the difficulty is that the model outputs Markdown and Markdown markers get split across deltas.
The most annoying example: the model outputs a fenced code block. Three backticks get split into at least two deltas, so for one frame the screen holds two backticks. If your renderer colors that as inline code, the user sees a flash of color that reverts. A dozen such flickers in one answer look like a broken program.
The fix has two plain steps.
One: treat the line as the smallest settled unit. Until the newline arrives, the line may still change, so display it verbatim; once the newline arrives, the line is fixed and only then do you apply styling (bold headings, color inline code, toggle code mode on a fence line). On a real terminal, implement it as "return to the line start, clear the line, redraw," and the user sees one line growing.
Two: give unsettled lines a criterion. Not every partial line is dangerous; only three cases must be shown verbatim: a line holding just one or two backticks (it may be growing into a fence), an odd total of backticks (unclosed inline code), and a line ending on an asterisk (unclosed emphasis).
/** Could later characters still change this line's meaning? If so, show it verbatim */
export function looksUnsettled(line: string): boolean {
const trimmed = line.trimStart()
if (/^`{1,2}$/.test(trimmed)) return true // may be growing into a fence
if (((line.match(/`/g) ?? []).length) % 2 === 1) return true // unclosed inline code
if (/\*{1,2}$/.test(line)) return true // unclosed emphasis
return false
}
push(delta: string): void {
for (const ch of delta) {
if (ch === '\n') this.commitLine() // newline arrived, this line is settled
else this.line += ch
}
if (this.tty) this.redraw() // no cursor outside a TTY: degrade to plain appending
}import re
UNCLOSED_EMPHASIS = re.compile(r"\*{1,2}$")
MAYBE_FENCE = re.compile(r"^`{1,2}$")
def looks_unsettled(line: str) -> bool:
"""Could later characters still change this line's meaning? If so, show it verbatim"""
return bool(
MAYBE_FENCE.match(line.lstrip()) # may be growing into a fence
or line.count("`") % 2 # unclosed inline code
or UNCLOSED_EMPHASIS.search(line) # unclosed emphasis
)
def push(self, delta: str) -> None:
for ch in delta:
if ch == "\n":
self.commit_line() # newline arrived, this line is settled
else:
self.line += ch
if self.tty:
self.redraw() # no cursor outside a TTY: plain appendingAnd a problem only writing it reveals: there is no cursor in a pipe. The escape sequences for returning to the line start and clearing the line are pure noise in a non-interactive terminal (CI, a pipe, another Agent's shell) and make logs unreadable. So the renderer branches on whether standard output is a terminal: a real terminal redraws the current line, a pipe appends plainly with no styling. The cost is that the typewriter effect is invisible in a pipe — which is why this lab's acceptance rests on the self-test's assertions (delta count, verbatim equality with the concatenated text after stripping escapes, zero mis-styled frames), not on how pretty the colors are.
Offline in this lab, asking "what does the parser look like" splits the fenced answer into 44 deltas, triggers 51 redraw frames, zero mis-styled frames and one recognized code block. All four numbers are reproducible, because we wrote both the script and the splitting rule.
Interrupting and finishing: what a cut stream and a cancel each must leave behind
Finally, finishing. The two abnormal endings are handled differently, with one shared floor: not one character already shown to the user may be lost.
The stream is cut mid-way. The criterion was given above: the byte stream ended without a finish event. Three things follow — settle the received text (do not leave half a line on screen), add a plain-language sentence ("the stream was cut mid-way; the part received above has been kept"), and write that segment into the message array. Note that the error object carries a "can this be retried" flag: truncation and rate limiting are retryable; a malformed parameter gives the same result on the hundredth retry. Today only classifies; how to back off and how many times is day six.
The user presses cancel. That is not an exception but a normal early ending. It is implemented with a cancellation controller: create one when a turn begins, pass its signal down to the gateway (which aborts the request itself against a real gateway), and have the loop check it between deltas. Today the chain reaches only the gateway; once day four adds child processes, day six shows you the full cancellation chain — key, signal, request, child process — where one broken link means a hung command that cannot be killed.
Source Reading
Hands-On Lab
Today's lab finalizes the gateway layer and the offline script engine: those two files freeze today and are copied verbatim for the next nineteen days. The starter leaves four exercises; unmodified it passes three checks and fails three, and completing the exercises should turn everything green.
- Replace the gateway parser with the blank-line-framing version: handle multi-line data, drop comment lines, stop at the terminator, and check on stream end whether a finish event was ever seen.
- Translate deltas into semantic events so the render layer can see no wire field at all. Afterwards, searching the render layer's code for gateway field names like content or delta should find nothing.
- Implement incremental rendering: settle lines, show unsettled lines verbatim, toggle code mode on fences, and branch on whether standard output is a terminal into redraw or plain appending.
- Run
INJECT=truncatedand confirm the half sentence stays on screen with a plain sentence beneath it and no stack trace. - Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 6/6 passed, with the delta count, redraw frames and mis-styled frames all reproducible numbers.
Acceptance is five ticks: the self-test prints 6/6 passed; the long answer splits into 34 deltas and matches the concatenated text verbatim after stripping escapes; the fenced answer produces zero mis-styled frames; an interrupt yields done with aborted and keeps the received characters in the message array; and INJECT=truncated produces a plain-language notice.
Interview Questions
Today's three questions test whether you have written a parser and a renderer yourself, not whether you know what SSE is:
- What edge cases trip up a hand-written SSE parser? Why can it not read line by line?
- When a streaming response is cut mid-way, how should the client handle it? What does a retry cost?
- What is hard about incremental Markdown rendering in a terminal? How would you weigh correctness against immediacy?
Full bilingual prompts, analyses and key points are in this course's day-two question bank. Question two is the one most often answered incompletely — most people say "retry" and cannot name the two real costs: how many tokens a retry spends, and what happens to the half segment already printed.
Checklist and Tomorrow
- I can hand-write a blank-line-framing parser and say how multi-line data, comment lines and the terminator are each handled
- I can explain why network chunking is unrelated to event boundaries, and what the buffer solves
- I know delta granularity is an implementation detail, and can name three wrong ways to depend on delta shape
- I can explain what the gateway-delta versus semantic-event boundary saves in each of today's two endings
- I can state the three criteria for an unsettled line, and why a pipe degrades to plain appending
- The self-test prints 6/6 passed, and both truncation and interruption preserve what was already received
Tomorrow is D3, "The Tool Protocol and the Read-Only Trio: Schema Design, Fragment Merging, and Result Truncation." Today handled only text deltas, but the same stream carries another kind — tool calls. Merging those is far harder: the arguments are a chopped-up JSON string that is only valid once joined, and the index's starting point cannot be assumed (we measured two different starting points from the same model across two runs). Master streaming before tools, in that order: a fragment-merging bug is invisible on a text stream.
Interview questions
What edge cases bite you when hand-writing an SSE parser, and why can't you just read line by line?手写一个 SSE 解析器,有哪些容易踩的边界条件?为什么不能直接按行读?
Common in ChinaCommon overseasBasic#sse#streamingHow to reason about it · think before answering
- This separates people who used a library from people who wrote a parser. Splitting on newlines and JSON-parsing whatever follows data works on most gateways by luck; the signal is knowing when it fails silently.
- How to break it down: question every split between bytes and semantics. The first split is network chunking, decided by TCP and gateway buffering and unrelated to protocol boundaries. The second is the event boundary, which the spec defines as a blank line. The third is the field lines inside one event. Three different rules, and collapsing them into one is the bug.
- So four edge cases: chunking can cut one event in half or pack five into one chunk, so you need a buffer; multi-byte characters get split, so decode in streaming mode rather than per chunk; one event may carry several data lines that must be joined into a single payload; comment lines starting with a colon (usually heartbeats) and the terminator are not JSON and will throw.
- The second half follows from the third case: reading line by line does not crash, it loses content. It is correct forever on single-data-line gateways, until you switch vendors — and that bug is brutal to find because the wire text looks perfectly normal.
- Add one engineering point: the parser must distinguish a clean finish from the byte stream simply ending. Only a terminator or finish event means done; otherwise it is truncation and should surface as a retryable error, not a silent close.
- Likely follow-up: why not use EventSource? It is GET-only, cannot set headers, and hides non-2xx bodies, while model endpoints are authenticated POSTs. Browsers get a built-in client; CLIs usually parse it themselves.
分析过程 · 先想清楚再作答
- 这题在筛「用过库」和「写过解析器」。答「按换行切、取 data 后面的 JSON」的人,写的是一个在多数网关上碰巧能跑的版本;区分度在于你能不能说出它在什么情况下会静默出错。
- 怎么拆:把「字节到语义」这条路上的每一次切分都问一遍——谁保证这一刀切在正确的位置。第一刀是网络分块,它由 TCP 与网关的缓冲决定,和协议边界毫无关系;第二刀是事件边界,规范定的是空行;第三刀是事件内部的字段行。三刀的依据完全不同,混成一刀就会出错。
- 于是四个边界条件:一,网络分块可能把一条事件劈成两半,也可能一块里塞五条事件,所以必须留缓冲区;二,多字节字符会被切开,解码器要按流式模式解码,不能每块单独解;三,一条事件可以有多行 data,按规范要拼成一个载荷,按行读会把它当成两条事件,内容就丢了;四,冒号开头的注释行(通常是心跳)和结束标记都不是 JSON,扔进解析函数会抛异常。
- 第二问的答案就藏在第三个边界里:**按行读的错误不是崩,是丢内容**。它在只发单行 data 的网关上永远正确,直到你换一家、或者对方开始返回多行 data——这类 bug 上线以后极难定位,因为报文肉眼看着完全正常。
- 还要补一条工程判断:解析器必须区分「正常结束」与「字节流没了」。见过结束事件才算说完,否则是断流,要报成可重试的错误。只靠迭代自然结束来判断,用户会看到半句话加一个正常提示符。
- 可预期的追问:为什么不用 EventSource?因为它只支持 GET、不能自定义请求头、也拿不到非 2xx 的响应体,而模型接口是带鉴权头的 POST。所以服务端推送在浏览器里可以用现成的,在客户端与命令行里通常得自己解析。
Key points
- Four edge cases: chunking versus event boundaries, split multi-byte characters, multi-line data payloads, and non-JSON comment lines and terminators
- Frame on blank lines, not newlines; line-based reading loses content silently instead of failing loudly
- Decode in streaming mode and keep a buffer for the unfinished tail
- Distinguish a clean finish from truncation, and surface truncation as a retryable error
- EventSource is GET-only with no custom headers, so CLIs usually hand-roll the parser
答题要点
- 四个边界:网络分块与事件边界无关、多字节字符被切开、多行 data 要拼成一个载荷、注释行与结束标记不是 JSON
- 分帧依据是空行不是换行,按行读的后果是静默丢内容而不是报错
- 解码要用流式模式,缓冲区留住没凑齐的尾巴
- 必须区分正常结束与断流:没见过结束事件就报成可重试错误
- EventSource 只支持 GET 且不能自定义头,所以命令行里通常自己解析
A streaming response dies halfway through. How should the client handle it, and what does retrying actually cost?流式响应在中途断开,客户端该怎么处理?重试的代价是什么?
Common in ChinaCommon overseasDeep dive#streaming#error-handlingHow to reason about it · think before answering
- The signal is in the second half. Almost everyone says retry; what separates candidates is naming the cost, which usually means they have actually retried a streaming generation in production.
- How to break it down: detect the truncation first, then decide. The test is whether you ever saw a finish event; a byte stream that just ends without one is truncation. Skip this and the rest is theory, because many clients treat truncation as a clean close and leave the user staring at half a sentence.
- Then three costs. Money and time: text generation cannot resume, so a retry regenerates from the first token and re-bills the whole prompt. The half-rendered output: what the user already saw cannot vanish, and it cannot be concatenated with the retry either, because the second wording will differ and the result reads as self-contradictory. Side effects: if tools already ran this turn, retrying the whole turn runs them again, and non-idempotent writes happen twice.
- Conclusion as policy: auto-retry once only when nothing has had a side effect and very little text was produced. Once a long answer is on screen or files have been touched, stop and hand the partial result to the user. The unit of retry is one gateway call, never one agent turn.
- Production angle: truncation and rate limiting share the retryable path but not the backoff. Rate limits should honor the response header and add jitter, while truncation is usually a connection issue where one immediate retry often succeeds. Put the decision on the error object, not on string-matching the message.
- Likely follow-up: can you resume like a file download? No — models have no resume-from-token-200 semantics. You can feed the partial text back as context and ask it to continue, but that is a new generation with different wording, acceptable for long-form writing and not for tool calls.
分析过程 · 先想清楚再作答
- 这题的题眼在后半句。前半句几乎所有人都能答「重试」,区分度全在「代价」上——说不出代价的人,通常没有真的在生产里重试过一次流式生成。
- 怎么拆:先把「断开」判出来,再决定做什么。判据是「见过结束事件吗」:字节流结束但没有结束标记就是断流。这一步做不到,后面全是空谈——很多客户端把断流当成正常收尾,用户看到半句话却没有任何提示。
- 然后是三个代价,一个都不能少。第一,钱和时间:文本生成不能续写,重试就是从第一个字重新生成,输入 token 重新算一遍,前面已经生成的部分白花。第二,屏幕上的半段:已经打出去的字不能凭空消失,也不能和重试的新内容拼在一起(模型第二次的措辞几乎肯定不同,拼起来会前后矛盾)。第三,副作用:如果这一轮里已经执行过工具,重试整轮就会把工具再执行一次——非幂等的写操作会被做两遍。
- 结论落到策略上:只对「还没产生任何副作用、且已生成内容很短」的情况自动重试一次;已经吐了一大段或已经动过文件,就停下来把半成品留给用户,让他决定继续还是重来。重试的正确单位是「一次网关调用」,不是「一轮 Agent 循环」。
- 生产视角再加一条:断流和限流要走同一个可重试通道,但退避策略不同——限流要按响应头等待并加抖动,断流通常是连接问题,立刻重试一次的成功率就不低。判断依据放在错误对象上,而不是靠字符串匹配错误信息。
- 可预期的追问:能不能像下载那样断点续传?文本生成不行,模型没有「从第 200 个 token 继续」这个语义;能做的是把已生成的部分作为上下文让它接着写,但那是新的一次生成,措辞会变,只适合长文写作类场景,不适合工具调用。
Key points
- Detect truncation first: without a finish event the turn is not done, so raise a retryable error
- Three costs: regeneration from scratch with the prompt re-billed, the half-rendered output that can neither vanish nor be concatenated, and already-executed tools running twice
- Auto-retry once only when there are no side effects and little output; otherwise hand the partial result to the user
- The unit of retry is a single gateway call, not a whole agent turn
- Truncation and rate limits share the retryable path but need different backoff, decided by a field on the error object
答题要点
- 先判断断流:见过结束事件才算说完,否则报成可重试错误
- 三个代价:重试是从头重新生成并重算输入 token、屏幕上的半段不能丢也不能拼、已执行的工具会被重复执行
- 只在无副作用且已生成内容很短时自动重试一次,否则把半成品留给用户决定
- 重试的单位是一次网关调用,不是一轮 Agent 循环
- 断流与限流共用可重试通道但退避不同,判定依据放在错误对象上而不是错误字符串
What makes incremental Markdown rendering in a terminal hard, and how do you trade correctness against responsiveness?在终端里做增量 Markdown 渲染,难点在哪?正确性与实时性冲突时你怎么权衡?
Common in ChinaCommon overseasIntermediate#terminal-ui#streamingHow to reason about it · think before answering
- This checks whether flicker has ever hurt you. Answering just use a Markdown library misses the problem: every Markdown parser expects a complete document, and streaming input is incomplete by definition.
- How to break it down: ask which Markdown constructs need lookahead to be meaningful. Nearly all of them — backticks must pair for inline code, three of them make a fence, asterisks must pair for bold, a table needs its delimiter row. So the real question is what to display while the syntax is still open.
- Two strategies with different costs. Re-render the whole answer on every chunk: perfectly correct, but it repaints large regions, jumps the cursor, and gets slower as the answer grows. Or pick a commit unit, show raw text inside it, and style it once it commits. I take the second with the line as the unit, because terminals scroll by lines and repainting one line is constant cost.
- Then add a test for whether a line can still change meaning. Only three cases need raw display: the line is just one or two backticks (it may become a fence), the backtick count is odd, or the line ends on asterisks. Without that test, two backticks get colored as inline code and flip back when the third arrives, which flickers a dozen times per answer.
- One production detail people miss: pipes have no cursor. Carriage returns and clear-line escapes are noise in non-interactive output and ruin logs, so the renderer branches on whether stdout is a TTY — repaint in a terminal, append-only in a pipe. The cost is that the typewriter effect is invisible in a pipe, so automated checks must assert on reassembled text and mis-styled frame counts rather than on colors.
- Likely follow-up: what about tables and lists? For structures larger than one line, show them raw while streaming and reflow once the turn ends, or simply accept that they do not form until then. Do not widen the commit unit to a whole block just to make tables live, because that is re-rendering everything again.
分析过程 · 先想清楚再作答
- 这题在考「你有没有被闪烁折磨过」。答「用一个 Markdown 库渲染」的人没意识到问题:所有 Markdown 解析器都要求输入是完整文档,而流式输入天生不完整。
- 怎么拆:先问一句「Markdown 的哪些语法需要看到后面才能确定含义」。答案是几乎全部——反引号要配对才是行内代码,三个反引号才是围栏,星号要配对才是加粗,表格要看到分隔行才是表格。所以增量渲染的本质问题是:**在语法还没闭合的时候,这几个字符该按什么显示。**
- 两个可选策略,各有代价。一是每来一片就整段重渲染:正确性满分,但屏幕会大面积重画、光标乱跳,长回答还会越来越慢。二是选一个定型单位,单位内先按原样显示、定型后再上样式:这就是我选的做法,定型单位取「行」,因为终端本来就按行滚动,重画一行的代价是常数。
- 结论加一条判据:定型之前要判断这一行「还可能变吗」。只有三种情况需要按原样显示——整行只有一两个反引号(可能长成围栏)、反引号总数是奇数、行尾停在星号上。不加这个判据,收到两个反引号时会先按行内代码上色,第三个到达再改回围栏,一段回答里能闪十几次。
- 工程视角还有一条经常被漏掉的:管道里没有光标。回到行首与清行这两个转义在非交互终端里是纯噪音,会毁掉日志,所以渲染层要按标准输出是不是终端分两条路径——真终端重画,管道纯追加。代价是打字机效果在管道里看不见,于是自动化验收只能靠断言(分片拼接是否逐字一致、误上色帧数是否为零),不能靠看颜色。
- 可预期的追问:那表格和列表怎么办?超出「一行」这个定型单位的结构,正确做法是流式期间只按原样显示,整轮结束后再重排一次;或者干脆接受它在流式期间不成型。**不要为了让表格实时成型而把定型单位放大到整段**,那等于回到每片重渲染。
Key points
- The core difficulty is that Markdown needs closed syntax to have meaning while streaming input is inherently incomplete
- Two strategies: full re-render is correct but repaints widely and degrades, while a commit unit keeps repaint cost constant
- Use the line as the commit unit and display uncommitted lines raw, tested by backtick parity, one-or-two-backtick prefixes, and trailing asterisks
- Branch on whether stdout is a TTY: repaint the current line in a terminal, append only in a pipe with no cursor escapes
- Structures larger than a line do not form while streaming; reflow after the turn instead of widening the commit unit
答题要点
- 根本难点是 Markdown 语法需要闭合才能确定含义,而流式输入天生不完整
- 两种策略:每片整段重渲染正确但会大面积重画且越来越慢;选定型单位则代价是常数
- 定型单位取「行」,未定型的行按原样显示,判据是反引号奇偶、是否只有一两个反引号、行尾是否停在星号
- 按标准输出是不是终端分两条路径:真终端重画当前行,管道纯追加不发光标控制符
- 大于一行的结构(表格、列表)流式期间不成型,整轮结束后重排,不要为它放大定型单位