流式不是把答案早一点给你看,而是把"半截的、随时会变的、可能被掐断的"中间态变成可工程化的对象。
几乎所有 LLM 产品都开了 streaming,但多数人只用了最浅的一层:把 token 一个个打到屏幕上。真正的难度在打字机效果之外——流进来的每一帧都是不完整的:半截的 Markdown 表格、断在引号中间的 JSON、还没收齐参数的 tool call、用户随时可能点的"停止"。这些都不是 prompt 能解决的,是 harness 层的状态管理问题。把 streaming 当成"早点显示"会埋三类线上事故:拿半截 JSON 去 parse 直接崩、把还没生成完的 tool input 提前执行、以及用户关了页面但服务端还在烧 token 和触发副作用。这一期讲清楚四件事:流式的本质是感知优化而非吞吐优化、增量解析必须容忍残缺、tool call 流式的 buffer 取舍、以及中途取消时的副作用回滚。
一次 LLM 调用的延迟拆成两段:TTFT(time to first token,从发请求到第一个 token 落地)和逐 token 生成(inter-token latency × token 数)。streaming 不改变这两者之和——总 wall-clock 几乎一样——它只是让用户在 TTFT 之后立刻开始看到字,而不是干等到全部生成完。研究与产品经验都指向同一结论:同样的总时长,流式界面被感知为快得多,因为它消除了"盯着空白屏幕"这段最难熬的认知空窗。
这条本质推出两个反直觉工程决策。其一:你该监控和优化的是 TTFT 的 p95,而不是总延迟——一个总耗时 10s 但 200ms 出首 token 的接口,体感远胜于 4s 总耗时却 3s 才动的接口。其二:streaming不提升吞吐,纯后台批处理任务开 streaming 没有意义,反而徒增解析复杂度。
import time, anthropic
client = anthropic.Anthropic()
t0 = time.time(); ttft = None
with client.messages.stream(model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role":"user","content":"写一段..."}]) as stream:
for text in stream.text_stream:
if ttft is None:
ttft = time.time() - t0 # ← 真正要盯的指标
print(f"TTFT={ttft*1000:.0f}ms")
render(text) # 增量上屏
total = time.time() - t0 # 总时长:streaming 救不了它
纯文本流式很简单(追加上屏即可),但凡输出有结构就立刻变难。标准 Markdown 渲染器、JSON.parse、XML parser 都假设输入是完整且合法的——而流式中你拿到的永远是中间态:一个还没闭合的代码块、一个写到一半的表格、一个断在 "add 处的字符串。直接丢给标准 parser 只有两种结局:抛异常崩掉,或者它干脆等到完整才渲染——后者把 streaming 的全部价值抹平了。
正解是为残缺而设计的渐进解析:见到代码块起始就先渲染一个打开的容器、表格来一行画一行、字符串没闭合就先补一个临时引号显示。Vercel 的 Streamdown 把这套封装成 react-markdown 的流式替代品——它假设输入永远不完整,对未闭合语法做"修补再渲染"。结构化数据同理:要用容错 JSON 解析器(partial JSON / trailing-strings 模式),它能把 {"city":"北 解析成 {"city":"北"} 这样的部分对象,让 UI 边流边填字段。
# 容错解析:用 partial-json 把半截 JSON 解析成部分对象
from partial_json_parser import loads
buf = ""
for chunk in stream: # chunk 不尊重 JSON 边界
buf += chunk
try:
obj = loads(buf) # 半截也能拿到部分对象
render_partial(obj) # 字段到一个渲染一个
except Exception:
continue # 这一帧还不够,等下一帧
# 关键:只有 stream 结束后的 obj 才是"最终态",中途的都是预览
strict JSON.parse 然后 try/catch 吞掉——能跑但每帧都白解析,且容易把"恰好暂时合法"的中间态误当完整。(2)更危险:把部分解析的结果当最终态去触发副作用——比如流到 {"action":"delete" 就执行删除,结果完整意图是 {"action":"delete_draft","confirm":false}。中途态只能用于渲染预览,绝不能驱动决策或副作用。
Anthropic 的 SSE 流里,一次响应由多个 content block 组成,每个 block 是 content_block_start → 多个 content_block_delta → content_block_stop。文本走 text_delta;tool 的参数走 input_json_delta,字段是 partial_json——你要把到达顺序的 partial_json 片段按序拼接才能还原参数 JSON,而且这些片段不尊重 JSON 边界(可能断在任意字符)。默认模式下,SDK 会帮你 buffer 到 content_block_stop,那时拿到的才是校验过的完整合法 JSON——安全但有延迟,大参数(比如让模型写一大段文件内容)要等它整段攒完。
想消除这个等待,Anthropic 提供 fine-grained tool streaming(beta header fine-grained-tool-streaming-2025-05-14):参数不 buffer、不做 JSON 校验直接流出,显著降低大参数的首字延迟。代价写在文档里:不保证流出的是合法 JSON——若 max_tokens 截断,流可能停在某个参数中间,给你一段永远补不全的残缺 JSON。这是一个清晰的取舍:更低延迟 ⇄ 自己处理残缺。
acc = ""
for ev in stream:
if ev.type == "content_block_delta" \
and ev.delta.type == "input_json_delta":
acc += ev.delta.partial_json # 按序拼接,别提前 parse
elif ev.type == "content_block_stop":
args = json.loads(acc) # 默认模式:此处一定合法
run_tool(name, args) # ← 唯一安全的执行点
# fine-grained beta 下,acc 在 stop 时也可能是残缺 JSON,
# 须用容错 parser + 校验,失败则判定 tool call 不完整、不执行
content_block_stop 之前就拿累积的 partial_json 去 parse 并执行 tool——参数还没收齐,等于拿半个意图行动。(2)开了 fine-grained 却仍假设结尾一定是合法 JSON,不做残缺校验——一旦 max_tokens 截断,json.loads 直接抛,或更糟,容错 parser 补出一个语义被篡改的参数被执行。fine-grained 的前提是你有能力安全处理半截参数。
"取消"在流式里有个隐蔽的真相:客户端断开 ≠ 生成停止。要真正止损,你得用 AbortController(或对应 SDK 的 cancel)显式中止那条 HTTP/SSE 连接,让 server 收到断开信号停止生成——光关 UI、丢弃后续 chunk 是不够的,那只是你不看了,token 还在烧。这是取消的第一层:止血。
第二层更难,发生在 agent 流式里:取消的瞬间,某个 tool 可能已经执行了副作用(订单已建、邮件已发、文件已写一半)。流式让这个问题更尖锐,因为输出是边生成边落地的,取消点落在哪里不确定。三条处理原则:(a) 把已 streamed 的部分输出显式标记为 partial/aborted,绝不能当完整结果入库;(b) 副作用工具必须幂等,且对已发出的副作用走补偿/回滚(见 Day 39);(c) 长流配 checkpoint,让"取消"等价于"在某个干净的断点停下",而非"停在任意半截状态"。
// 前端:真正中止生成,而非只丢弃 chunk
const ctrl = new AbortController();
const stream = await client.messages.stream(
{ model: "claude-opus-4-8", max_tokens: 2048, messages },
{ signal: ctrl.signal }); // ← 绑定取消信号
stopBtn.onclick = () => ctrl.abort(); // 断连,server 停生成
let acc = "";
try {
for await (const ev of stream) acc += ev.text ?? "";
commit(acc); // 正常结束才是完整态
} catch (e) {
if (e.name === "AbortError")
save_as_partial(acc); // 标记残缺,触发副作用补偿
}
拿你正在用的任何 streaming 聊天/agent,按这张 checklist 逐项检查——每一项对应上面一个失败模式:
input_json_delta,content_block_stop 才 parse + run;除非你确实需要、且能安全处理残缺,才开 fine-grained。AbortController 真断连,而非只停渲染;验证:点停止后 server 端日志确实停止生成。partial,不入库、不当真、不驱动下游。做完你会发现:让 streaming "看起来在动"只需十行;让它在半截、截断、被掐断三种残缺下都不出事,才是 demo 与 production 的真实分界。
content_block_stop 之前是不可信的半截,这一条不能破——拿半个参数行动是纯粹的 bug。但 agent 的"边想边做"发生在更高的粒度:一个 tool call 完整结束后,agent 立刻发起下一个,无需等整段回复生成完。所以正确的边界是:结构(单个 tool input)必须完整才能用,但序列(多个 tool call)可以流式推进。fine-grained streaming 试图把红线往里推一点——让超大参数能边流边用——但它明确要求你自证有能力安全处理残缺,本质是把"完整性校验"的责任从平台转移给你。max_tokens 和服务端超时作为硬上界,让"取消失败"的最坏代价有界;(3) 把取消语义建在幂等 + 补偿上而非"成功停止"上——即假设生成可能仍在跑,靠副作用层的可回滚来兜底。换句话说,取消是 best-effort 止血,正确性靠 Day 39 那套韧性,不靠断连一定生效。max_tokens 截断,前面已流出的部分仍可用(标记为未完成即可)。绝不该开的场景:参数是控制性的小结构,比如 {"action":..., "target_id":..., "confirm":...}——这种参数小、buffer 延迟可忽略,却字段间强耦合,残缺一个字段就语义崩坏甚至危险。判据:参数是"内容"(大、可增量消费、残缺可用)还是"指令"(小、强耦合、必须完整)?内容开,指令不开。