流式输出与终端渲染:手写 SSE 解析、增量 Markdown 与可打断的打字机
把网关吐出的 SSE 字节流一层层剥开:先解析事件、再归并成增量事件、最后交给渲染器逐字打印,并处理流中断、光标控制与用户按键打断这三件真实工程里最容易做错的事。
今日目标
- 能手写一个不依赖任何库的 SSE 解析器,并说清它为什么必须按空行切分而不是按行
- 能区分网关层的流式分片与循环层的语义事件,并解释这层分界省掉了什么麻烦
- 能在终端里实现增量 Markdown 渲染与可打断的输出,处理流中途断开的情况
昨天那个「攒完再打」的黑屏,今天全部拆掉。读完回到页面顶部把三条目标勾掉。
小白版讲解
让新人边想边说,而不是憋一小时交一份报告
回到那个新人。你给他一个问题,他坐在工位上一声不吭,一小时后交来一份写得很好的报告。你满意吗?大概不。你会更希望他边想边说:「我先看看这个模块……嗯,这里的逻辑好像不对……我去翻一下测试。」你随时知道他在哪一步,方向偏了能立刻叫停。
模型也是这样,而且它的沉默时间比你想象的长。下面这组数字是 2026 年 9 月 7 日在一家 OpenAI 兼容网关(https://api.n1n.ai/v1)上实测的,同一段两百字左右的中文回答,每个模型跑三次,取区间:
| 模型 id | 首字延迟区间 | 分片数区间 |
|---|---|---|
deepseek-v3.2 | 1.2–1.9 秒 | 22–36 片 |
claude-haiku-4-5-20251001 | 1.8–2.4 秒 | 87–105 片 |
gpt-5-mini | 11.7–15.6 秒 | 151–160 片 |
先说这张表怎么读,免得读成排行榜:分片数只能看量级、首字延迟只能看相对关系,两列都不可复现——它们取决于网关实现、当时的负载和你的网络,换个时间跑就是另一组数字。这张表要传达的只有一件事:最后那一行的十几秒,在「攒完再打」的实现里是一段完全静默的黑屏,用户唯一能做的判断是「这东西是不是死了」。而它的总耗时并不比第一行长十倍,它只是在开口之前先想了很久。
所以:流式不是体验糖,它是首字延迟的唯一解法。 总耗时一点没变,但用户在第二秒就看到字在动。这也是为什么昨天那层网关接口只留了流式一个方法——在 Coding Agent 里,非流式没有任何用得上的地方。
顺带一个反直觉的好处,今天下半段会用到:流式让「中途停下来」变得可能。一轮几十个分片,就是几十次可以插进去的机会;攒完再打的实现里,你只有「等它结束」这一个选择。
SSE 报文长什么样:事件的边界是空行,不是换行
服务端推送事件(SSE,Server-Sent Events)是一个朴素到有点可爱的文本协议。网关吐给你的原始字节,解码之后大概是这样:
data: {"choices":[{"delta":{"content":"流式"}}]}
data: {"choices":[{"delta":{"content":"不是"}}]}
: 这是一条注释行,通常用来做心跳保活
data: {"choices":[],"usage":{"prompt_tokens":24,"completion_tokens":88}}
data: [DONE]三条规矩,每一条都对应一个真实事故:
- 事件之间由空行分隔,一条事件可以有多行
data:。 按规范,同一条事件里的多行data要用换行拼成一个载荷再解析。所以解析器的分帧依据是空行(也就是连续两个换行),不是单个换行。按行读在大多数网关上「碰巧能跑」,因为它们每条事件只发一行 data——直到某天有一家不是。 - 以冒号开头的是注释行,直接丢掉。 它一般是心跳,用来防止中间的代理把空闲连接掐掉。拿它去解析 JSON 会炸。
[DONE]不是 JSON。 把它一起丢进解析函数,你会得到一个语法错误。
还有一条比上面三条更容易踩:网络分块与事件边界毫无关系。 你从响应体里拿到的是一个个字节块,一条事件可能横跨两块,一块里也可能挤着五条事件,甚至一个汉字的三个字节被切在两块里。所以必须留一个缓冲区,把没凑齐的尾巴留到下一块再拼。
服务器想发的(完整报文)
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
实际到达的网络包
(还没开始收)
buffer 里留着的半行
(空)
已解析出的完整事件
const decoder = new TextDecoder()
let buffer = ''
let closed = false
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
// stream: true 让解码器自己处理被切开的多字节字符
buffer += decoder.decode(chunk, { stream: true })
buffer = buffer.replace(/\r\n/g, '\n') // 兼容 CRLF,归一之后只找空行
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')
}
}
// 字节流结束了,却没见过 finish 也没见过 [DONE],那就是断流
if (!closed) throw new StreamTruncatedError()
/** 一条事件里的多行 data 按规范用换行拼成一个载荷;冒号开头的注释行丢掉 */
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')] : []
}# Python 版直接在字节层分帧:httpx 也有 aiter_lines,但它按行切,
# 拿不到「空行」这个边界,所以这里用 aiter_bytes 自己留 buffer。
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]:
"""一条事件里的多行 data 拼成一个载荷;冒号开头的注释行丢掉"""
lines = [
line.removeprefix("data:").lstrip()
for line in raw_event.split("\n")
if line.startswith("data:")
]
return ["\n".join(lines)] if lines else []两版都在循环外面判断了一件事:见过结束标记吗。这是断流检测,也是很多自制客户端漏掉的一步——只靠「迭代自然结束」判断说完了,流被中间的代理掐断时,用户会看到半句话和一个正常的提示符,以为模型就是这么答的。
分片粒度是实现细节,不许拿它当契约
上面那张表里的分片数区间,其实还藏着一个坑。同一个模型的三次运行,分片数都不一样;不同模型之间差了五倍以上。分片粒度是网关与模型的实现细节:量级稳定,具体数字不稳定。
于是有一条纪律:渲染层不许依赖分片的形状。具体说,下面这些写法都是错的——
- 假设一片就是一个词、或者一个完整的 token
- 假设一片不会跨越换行符,于是按片当行处理
- 假设 Markdown 的标记(反引号、星号)不会被切开
- 用分片计数做进度条
正确的做法是把分片当成「又来了几个字符」,仅此而已。本课离线剧本里每片固定四个字符,就是为了把这件事逼到台面上:MOCK=1 下问一句「讲讲流式」,那段一百三十三个字符的回答会被切成三十四片——这个数字可复现,因为它是我们自己切的。真实网关给你什么,你就得接住什么。
两层事件模型:网关分片与循环事件为什么必须分开
昨天定了两套类型:网关吐出来的分片和循环对外发出的语义事件。今天它们第一次真的付了利息,值得把这层分界讲清楚。
Mermaid 源码
flowchart LR
A[SSE 字节块] -->|分帧| B[网关层:一条条事件]
B -->|翻译报文| C[分片:文本、工具、用量、结束]
C -->|加语义| D[循环层:语义事件]
D -->|订阅| E[渲染层:屏幕上的字]
D -->|订阅| F[会话层:落盘的日志]同一件事在两层的说法完全不同。网关层说的是「报文里有个 content 字段,值是这两个字」;循环层说的是「模型正在输出文本」。看起来只是换了个说法,差别在于谁会变:报文形状随网关变化,语义不变。
具体到今天的两种收尾,这层分界省掉的麻烦很明显。流被掐断是网关层的事(字节流结束了却没有结束标记),用户按下取消是键盘的事,两者来源完全不同;但在语义层,它们被统一成两条事件——一条 error 带着「可不可以重试」的判断,一条 done 带着结束原因。渲染层只认这两条,完全不需要知道它们从哪来。
for await (const delta of provider.stream({ messages, tools, signal })) {
// 打断检查放在每一片之间:一轮里有几十次可以停下来的机会
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 } // 报文形状到此为止,外面只看到语义
}
}async for delta in provider.stream(ChatRequest(messages, tools, signal)):
# 打断检查放在每一片之间:一轮里有几十次可以停下来的机会
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) # 报文形状到此为止,外面只看到语义注意那句 messages.push:打断也要把已经收到的文本写回消息数组。用户看过的内容不能凭空消失——他会以为程序把这一段吃掉了,下一轮追问「你刚才说的那个方案」,模型却完全不知道自己说过。断流同理:它不是「这一轮没发生」,而是「这一轮没说完」。
增量 Markdown 的难点:代码块和行内标记都会被切开
现在到渲染层。逐字打印本身很简单,难的是模型输出的是 Markdown,而 Markdown 的标记会被切在不同分片里。
举个最烦人的例子:模型要输出一个围栏代码块。三个反引号至少会被切成两片,于是有一帧屏幕上只有两个反引号。如果你的渲染器这时候按「行内代码」上色,用户会看到颜色闪一下再变回去。一整段回答里这种闪烁能出现十几次,看着像程序坏了。
解法分两步,都很朴素:
第一步,把「行」当成定型的最小单位。 换行符到达之前,这一行都可能变,所以只按原样显示;换行符一到,这一行就固定了,此时才套样式(标题加粗、行内代码上色、围栏行切换代码模式)。真终端上用「回到行首 + 清掉整行 + 重画」实现,用户看到的就是一行字在长。
第二步,给未定型的行加一个判据。 不是所有半截行都危险,只有三种情况要按原样显示:整行只有一两个反引号(可能长成围栏)、反引号总数是奇数(行内代码没闭合)、行尾停在星号上(加粗没闭合)。
/** 这一行还可能被后续字符改变含义吗?还可能就先按原样显示 */
export function looksUnsettled(line: string): boolean {
const trimmed = line.trimStart()
if (/^`{1,2}$/.test(trimmed)) return true // 可能正在长成围栏
if (((line.match(/`/g) ?? []).length) % 2 === 1) return true // 行内代码没闭合
if (/\*{1,2}$/.test(line)) return true // 加粗没闭合
return false
}
push(delta: string): void {
for (const ch of delta) {
if (ch === '\n') this.commitLine() // 换行符到达,这一行定型
else this.line += ch
}
if (this.tty) this.redraw() // 非 TTY 没有光标,退化成纯追加
}import re
UNCLOSED_EMPHASIS = re.compile(r"\*{1,2}$")
MAYBE_FENCE = re.compile(r"^`{1,2}$")
def looks_unsettled(line: str) -> bool:
"""这一行还可能被后续字符改变含义吗?还可能就先按原样显示"""
return bool(
MAYBE_FENCE.match(line.lstrip()) # 可能正在长成围栏
or line.count("`") % 2 # 行内代码没闭合
or UNCLOSED_EMPHASIS.search(line) # 加粗没闭合
)
def push(self, delta: str) -> None:
for ch in delta:
if ch == "\n":
self.commit_line() # 换行符到达,这一行定型
else:
self.line += ch
if self.tty:
self.redraw() # 非 TTY 没有光标,退化成纯追加还有一个只有真写过才会遇到的问题:管道里没有光标。 回到行首和清行这两个转义序列,在非交互终端(CI、管道、别的 Agent 的 shell)里是纯噪音,会把日志弄得没法看。所以渲染器要按标准输出是不是终端分两条路径:真终端重画当前行,管道纯追加、不上样式。代价是打字机效果在管道里看不出来——所以本实验的验收判据是自检里那几条断言(分片数、去掉转义后与拼接结果逐字一致、误上色的帧数为零),而不是「颜色好不好看」。
本实验的 MOCK=1 下问一句「解析器长什么样」,那段带围栏代码块的回答会被切成四十四片、触发五十一帧重画、误上色零帧、代码块识别一个。这四个数字都可复现,因为剧本和切片规则都是我们自己写的。
打断与收尾:断在中间和被按停,分别该留下什么
最后是收尾。两种非正常结束,处理方式不一样,但有一条共同的底线:已经显示给用户的内容,一个字都不能丢。
流断在中间。 判据前面说过了:字节流结束但没见过结束事件。这时候要做三件事——把已收到的文本定型(不要留一个半截行在屏幕上),补一句人话(「流在中途断开,已保留上面收到的部分」),把这一段写进消息数组。注意错误对象上要带一个「可不可以重试」的标记:断流和限流是可重试的,参数写错了重试一百次也是同一个结果。今天只做分类,怎么退避、重试几次是第六天的事。
用户按下取消键。 它不是异常,是一次正常的提前结束。实现上是一个取消控制器:一轮开始时新建一个,把它的信号一路传给网关(真实网关下会让请求本身断掉),循环在每片之间检查它。今天这条链路只到网关;等第四天有了子进程、第六天你会看到完整的取消链路——按键、信号、请求、子进程,任何一环断了,卡死的命令就杀不掉。
源码导读
动手实验
今天的实验会把网关层与离线剧本引擎定稿:这两个文件从今天起冻结,后面十九天原样复制。starter 挖了四个练习点,原样跑是三项通过三项亮叉,做完练习应该全绿。
- 把网关层的解析器换成按空行分帧的版本:处理多行 data、丢掉注释行、遇到结束标记就收工,并在字节流结束时判断有没有见过结束事件。
- 把分片翻译成语义事件,让渲染层拿不到任何报文字段。做完这一步,渲染层的代码里应该搜不到 content 或 delta 这类网关字段名。
- 实现增量渲染:行定型、未定型行按原样显示、围栏切换代码模式,并按标准输出是不是终端分成重画与纯追加两条路径。
- 跑
INJECT=truncated,确认屏幕上留着那半句话、下面是一句人话、没有堆栈。 - 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印6/6 通过,其中分片数、重画帧数、误上色帧数三项都是可复现的数字。
验收看五条勾:自检 6/6 通过;那段长回答被切成三十四片且去掉转义后与拼接结果逐字一致;带围栏的回答里误上色帧数为零;打断后事件是 done 加 aborted 且已收到的字符留在消息数组里;INJECT=truncated 时提示是人话。
面试题
今天三道题,考的是「你自己写过一个解析器和一个渲染器」,而不是「知不知道 SSE 是什么」:
- 手写 SSE 解析器有哪些容易踩的边界条件?为什么不能按行读?
- 流式响应中途断开,客户端该怎么处理?重试的代价是什么?
- 终端里做增量 Markdown 渲染,难点在哪?你会怎么权衡正确性与实时性?
完整的中英题干、分析过程与答题要点见本课面试题库的第二天。第二题最容易答漏——大多数人只答「重试」,答不出「重试要付多少 token」和「已经打出去的半段怎么办」这两个真实代价。
检查清单与明日预告
- 能手写按空行分帧的解析器,并说清多行 data、注释行、结束标记各自怎么处理
- 能说清网络分块与事件边界为什么无关,以及缓冲区解决的是什么问题
- 知道分片粒度是实现细节,能举出三种「依赖分片形状」的错误写法
- 能解释网关分片与语义事件这层分界,在今天的两种收尾里各省掉了什么
- 能说出未定型行的三个判据,以及为什么管道里要退化成纯追加
- 自检跑出
6/6 通过,并且断流与打断都保住了已经收到的内容
明天是 D3《工具协议与只读三件套:schema 设计、分片归并与结果截断》。今天我们只处理了文本分片,但同一条流里还会来另一种分片——工具调用。它的归并比文本难得多:参数是一段被切碎的 JSON,拼起来才合法,而且序号的起点不可假设(实测同一个模型两次运行给出了两种起点)。先把流式吃透再上工具,顺序不能倒:工具调用的归并错误在文本流上是看不出来的。
面试题库
手写一个 SSE 解析器,有哪些容易踩的边界条件?为什么不能直接按行读?What edge cases bite you when hand-writing an SSE parser, and why can't you just read line by line?
国内高频海外高频基础#sse#streaming分析过程 · 先想清楚再作答
- 这题在筛「用过库」和「写过解析器」。答「按换行切、取 data 后面的 JSON」的人,写的是一个在多数网关上碰巧能跑的版本;区分度在于你能不能说出它在什么情况下会静默出错。
- 怎么拆:把「字节到语义」这条路上的每一次切分都问一遍——谁保证这一刀切在正确的位置。第一刀是网络分块,它由 TCP 与网关的缓冲决定,和协议边界毫无关系;第二刀是事件边界,规范定的是空行;第三刀是事件内部的字段行。三刀的依据完全不同,混成一刀就会出错。
- 于是四个边界条件:一,网络分块可能把一条事件劈成两半,也可能一块里塞五条事件,所以必须留缓冲区;二,多字节字符会被切开,解码器要按流式模式解码,不能每块单独解;三,一条事件可以有多行 data,按规范要拼成一个载荷,按行读会把它当成两条事件,内容就丢了;四,冒号开头的注释行(通常是心跳)和结束标记都不是 JSON,扔进解析函数会抛异常。
- 第二问的答案就藏在第三个边界里:**按行读的错误不是崩,是丢内容**。它在只发单行 data 的网关上永远正确,直到你换一家、或者对方开始返回多行 data——这类 bug 上线以后极难定位,因为报文肉眼看着完全正常。
- 还要补一条工程判断:解析器必须区分「正常结束」与「字节流没了」。见过结束事件才算说完,否则是断流,要报成可重试的错误。只靠迭代自然结束来判断,用户会看到半句话加一个正常提示符。
- 可预期的追问:为什么不用 EventSource?因为它只支持 GET、不能自定义请求头、也拿不到非 2xx 的响应体,而模型接口是带鉴权头的 POST。所以服务端推送在浏览器里可以用现成的,在客户端与命令行里通常得自己解析。
How 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
- 分帧依据是空行不是换行,按行读的后果是静默丢内容而不是报错
- 解码要用流式模式,缓冲区留住没凑齐的尾巴
- 必须区分正常结束与断流:没见过结束事件就报成可重试错误
- EventSource 只支持 GET 且不能自定义头,所以命令行里通常自己解析
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
流式响应在中途断开,客户端该怎么处理?重试的代价是什么?A streaming response dies halfway through. How should the client handle it, and what does retrying actually cost?
国内高频海外高频深入#streaming#error-handling分析过程 · 先想清楚再作答
- 这题的题眼在后半句。前半句几乎所有人都能答「重试」,区分度全在「代价」上——说不出代价的人,通常没有真的在生产里重试过一次流式生成。
- 怎么拆:先把「断开」判出来,再决定做什么。判据是「见过结束事件吗」:字节流结束但没有结束标记就是断流。这一步做不到,后面全是空谈——很多客户端把断流当成正常收尾,用户看到半句话却没有任何提示。
- 然后是三个代价,一个都不能少。第一,钱和时间:文本生成不能续写,重试就是从第一个字重新生成,输入 token 重新算一遍,前面已经生成的部分白花。第二,屏幕上的半段:已经打出去的字不能凭空消失,也不能和重试的新内容拼在一起(模型第二次的措辞几乎肯定不同,拼起来会前后矛盾)。第三,副作用:如果这一轮里已经执行过工具,重试整轮就会把工具再执行一次——非幂等的写操作会被做两遍。
- 结论落到策略上:只对「还没产生任何副作用、且已生成内容很短」的情况自动重试一次;已经吐了一大段或已经动过文件,就停下来把半成品留给用户,让他决定继续还是重来。重试的正确单位是「一次网关调用」,不是「一轮 Agent 循环」。
- 生产视角再加一条:断流和限流要走同一个可重试通道,但退避策略不同——限流要按响应头等待并加抖动,断流通常是连接问题,立刻重试一次的成功率就不低。判断依据放在错误对象上,而不是靠字符串匹配错误信息。
- 可预期的追问:能不能像下载那样断点续传?文本生成不行,模型没有「从第 200 个 token 继续」这个语义;能做的是把已生成的部分作为上下文让它接着写,但那是新的一次生成,措辞会变,只适合长文写作类场景,不适合工具调用。
How 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 循环
- 断流与限流共用可重试通道但退避不同,判定依据放在错误对象上而不是错误字符串
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
在终端里做增量 Markdown 渲染,难点在哪?正确性与实时性冲突时你怎么权衡?What makes incremental Markdown rendering in a terminal hard, and how do you trade correctness against responsiveness?
国内高频海外高频进阶#terminal-ui#streaming分析过程 · 先想清楚再作答
- 这题在考「你有没有被闪烁折磨过」。答「用一个 Markdown 库渲染」的人没意识到问题:所有 Markdown 解析器都要求输入是完整文档,而流式输入天生不完整。
- 怎么拆:先问一句「Markdown 的哪些语法需要看到后面才能确定含义」。答案是几乎全部——反引号要配对才是行内代码,三个反引号才是围栏,星号要配对才是加粗,表格要看到分隔行才是表格。所以增量渲染的本质问题是:**在语法还没闭合的时候,这几个字符该按什么显示。**
- 两个可选策略,各有代价。一是每来一片就整段重渲染:正确性满分,但屏幕会大面积重画、光标乱跳,长回答还会越来越慢。二是选一个定型单位,单位内先按原样显示、定型后再上样式:这就是我选的做法,定型单位取「行」,因为终端本来就按行滚动,重画一行的代价是常数。
- 结论加一条判据:定型之前要判断这一行「还可能变吗」。只有三种情况需要按原样显示——整行只有一两个反引号(可能长成围栏)、反引号总数是奇数、行尾停在星号上。不加这个判据,收到两个反引号时会先按行内代码上色,第三个到达再改回围栏,一段回答里能闪十几次。
- 工程视角还有一条经常被漏掉的:管道里没有光标。回到行首与清行这两个转义在非交互终端里是纯噪音,会毁掉日志,所以渲染层要按标准输出是不是终端分两条路径——真终端重画,管道纯追加。代价是打字机效果在管道里看不见,于是自动化验收只能靠断言(分片拼接是否逐字一致、误上色帧数是否为零),不能靠看颜色。
- 可预期的追问:那表格和列表怎么办?超出「一行」这个定型单位的结构,正确做法是流式期间只按原样显示,整轮结束后再重排一次;或者干脆接受它在流式期间不成型。**不要为了让表格实时成型而把定型单位放大到整段**,那等于回到每片重渲染。
How 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 语法需要闭合才能确定含义,而流式输入天生不完整
- 两种策略:每片整段重渲染正确但会大面积重画且越来越慢;选定型单位则代价是常数
- 定型单位取「行」,未定型的行按原样显示,判据是反引号奇偶、是否只有一两个反引号、行尾是否停在星号
- 按标准输出是不是终端分两条路径:真终端重画当前行,管道纯追加不发光标控制符
- 大于一行的结构(表格、列表)流式期间不成型,整轮结束后重排,不要为它放大定型单位
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