DAY 41 / PHASE 4 · PRODUCTION

流式与中断工程

TTFT · Partial Parsing · Streaming Tool Use · Cancellation

2026-06-20 · BigCat

流式不是把答案早一点给你看,而是把"半截的、随时会变的、可能被掐断的"中间态变成可工程化的对象。

// WHY THIS MATTERS

几乎所有 LLM 产品都开了 streaming,但多数人只用了最浅的一层:把 token 一个个打到屏幕上。真正的难度在打字机效果之外——流进来的每一帧都是不完整的:半截的 Markdown 表格、断在引号中间的 JSON、还没收齐参数的 tool call、用户随时可能点的"停止"。这些都不是 prompt 能解决的,是 harness 层的状态管理问题。把 streaming 当成"早点显示"会埋三类线上事故:拿半截 JSON 去 parse 直接崩、把还没生成完的 tool input 提前执行、以及用户关了页面但服务端还在烧 token 和触发副作用。这一期讲清楚四件事:流式的本质是感知优化而非吞吐优化、增量解析必须容忍残缺、tool call 流式的 buffer 取舍、以及中途取消时的副作用回滚。

// 01

流式的本质:优化的是首 token 延迟,不是总时长

论断:streaming 一秒钟都不会缩短总生成时间;它把一段"无事发生的等待"换成"持续有反馈的等待",骗过的是感知。

背景与原理

一次 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 没有意义,反而徒增解析复杂度。

总时长相同,体感天差地别 —— 优化点在 TTFT 不在总长 非流式 [████████ 等待 8s 空白 ████████]→ 一次性吐出 用户感知: "卡死了?" 流式 [▌200ms]→ 字▌字▌字▌字▌字▌字▌字▌字 (持续 8s) 用户感知: "在动,挺快" —— 同样 8s

实战示例

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 救不了它
失败模式:把 streaming 当性能优化去汇报"延迟降低了"。总延迟一点没降,你优化的是体感;如果 TTFT 本身就高(prompt 太长、没开 prompt caching、模型在长 CoT),streaming 救不了——用户依然先盯一段空白。此时正解是先压 TTFT(缓存前缀、缩短系统提示、先流一个占位/骨架屏),而非寄希望于打字机。
进阶资源 · Redis Streaming LLM Responses: Make Your AI App Feel Fast, redis.io/blog/streaming-llm-responses · DeepInfra LLM Provider KPIs 101: TTFT, Throughput, E2E, deepinfra.com/blog
// 02

增量解析:每一帧都要当"半截"处理,标准 parser 会毁掉流式

论断:把流式 chunk 喂给一个要求完整输入的 parser,等于把 streaming 退化回 buffering——还多了崩溃风险。

背景与原理

纯文本流式很简单(追加上屏即可),但凡输出有结构就立刻变难。标准 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 才是"最终态",中途的都是预览
失败模式:(1)对每一帧 strict JSON.parse 然后 try/catch 吞掉——能跑但每帧都白解析,且容易把"恰好暂时合法"的中间态误当完整。(2)更危险:把部分解析的结果当最终态去触发副作用——比如流到 {"action":"delete" 就执行删除,结果完整意图是 {"action":"delete_draft","confirm":false}。中途态只能用于渲染预览,绝不能驱动决策或副作用。
进阶资源 · Vercel Streamdown — Markdown for AI streaming, streamdown.ai / github.com/vercel/streamdown · Vercel AI SDK streamObject / partial object, ai-sdk.dev/docs
// 03

Tool call 流式:input_json_delta 的累积,与 fine-grained 的取舍

论断:tool 参数的流式默认是"攒齐才给你合法 JSON";想更早拿到大参数,得开 fine-grained,但要自己承担残缺 JSON 的风险。

背景与原理

Anthropic 的 SSE 流里,一次响应由多个 content block 组成,每个 block 是 content_block_start → 多个 content_block_delta → content_block_stop。文本走 text_deltatool 的参数走 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。这是一个清晰的取舍:更低延迟 ⇄ 自己处理残缺

一次带 tool 的流式响应,事件如何拼出参数 message_start └ content_block_start (type=tool_use, name=edit_file) ├ delta: partial_json = '{"path":"a.' ├ delta: partial_json = 'py","content":"def ' ├ delta: partial_json = 'main():..."}' ← 片段不对齐 └ content_block_stop ← 默认在此校验+交付完整 JSON message_delta (stop_reason) message_stop 默认: 攒到 stop 才 parse → 安全, 慢 细粒度: 每个 delta 即用 → 快, 可能残缺(max_tokens 截断)

实战示例

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 不完整、不执行
失败模式:(1)在 content_block_stop 之前就拿累积的 partial_json 去 parse 并执行 tool——参数还没收齐,等于拿半个意图行动。(2)开了 fine-grained 却仍假设结尾一定是合法 JSON,不做残缺校验——一旦 max_tokens 截断,json.loads 直接抛,或更糟,容错 parser 补出一个语义被篡改的参数被执行。fine-grained 的前提是你有能力安全处理半截参数
进阶资源 · Anthropic Streaming Messages, docs.claude.com/.../streaming · Anthropic Fine-grained tool streaming, docs.claude.com/.../fine-grained-tool-streaming
// 04

中途取消与回滚:断开连接 ≠ 停止,已发出的副作用要补偿

论断:用户点"停止"或关页面,只断了你这端的接收;server 可能仍在生成、仍在计费、tool 可能已把副作用做出去了。

背景与原理

"取消"在流式里有个隐蔽的真相:客户端断开 ≠ 生成停止。要真正止损,你得用 AbortController(或对应 SDK 的 cancel)显式中止那条 HTTP/SSE 连接,让 server 收到断开信号停止生成——光关 UI、丢弃后续 chunk 是不够的,那只是你不看了,token 还在烧。这是取消的第一层:止血。

第二层更难,发生在 agent 流式里:取消的瞬间,某个 tool 可能已经执行了副作用(订单已建、邮件已发、文件已写一半)。流式让这个问题更尖锐,因为输出是边生成边落地的,取消点落在哪里不确定。三条处理原则:(a) 把已 streamed 的部分输出显式标记为 partial/aborted,绝不能当完整结果入库;(b) 副作用工具必须幂等,且对已发出的副作用走补偿/回滚(见 Day 39);(c) 长流配 checkpoint,让"取消"等价于"在某个干净的断点停下",而非"停在任意半截状态"。

点"停止"之后,真正要做的三件事 用户取消 │ ├─① abort 连接 → server 停生成 (否则仍计费/仍跑) ├─② 标记输出 → 已收 token = partial,不入库/不当真 └─③ 副作用善后 → 已执行的 tool 走补偿;未执行的丢弃 配合幂等键,避免"重发一次"的二次伤害

实战示例

// 前端:真正中止生成,而非只丢弃 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);                  // 标记残缺,触发副作用补偿
}
失败模式:(1)以为"关闭页面/丢弃 chunk"就停止了——server 端在 abort 信号缺失时往往把整段生成跑完,账单照付,订阅式 tool 的副作用照做。(2)取消后把已收到的半截输出当成最终答案存库或返回下游,污染数据。(3)取消发生在一个非幂等副作用工具执行后却没有补偿——用户以为"我点了停止所以没发生",实际邮件已经发出去了。
进阶资源 · MDN AbortController, developer.mozilla.org/.../AbortController · 本系列 Day 39 Agent 错误恢复与韧性(幂等 / 补偿 / checkpoint)

// 综合实战 · 给一个 streaming 接口做"残缺安全"加固

拿你正在用的任何 streaming 聊天/agent,按这张 checklist 逐项检查——每一项对应上面一个失败模式:

  1. 埋 TTFT 指标:在收到首个 delta 处打点,监控 TTFT 的 p50/p95,而不是只看总延迟。TTFT 高就先压前缀(缓存、缩系统提示),别指望打字机。
  2. 换容错渲染:Markdown 用 Streamdown 这类流式渲染器;结构化字段用 partial-json 解析,半截只用于预览。
  3. tool 参数只在 stop 后执行:累积 input_json_deltacontent_block_stop 才 parse + run;除非你确实需要、且能安全处理残缺,才开 fine-grained。
  4. 接一个真取消:停止按钮绑 AbortController 真断连,而非只停渲染;验证:点停止后 server 端日志确实停止生成。
  5. 给中途态贴标签:任何因取消/截断而不完整的输出标 partial,不入库、不当真、不驱动下游。
  6. 副作用兜底:流式 agent 里所有"写"类 tool 配幂等键 + 补偿,取消时逆序回滚已发出的副作用。

做完你会发现:让 streaming "看起来在动"只需十行;让它在半截、截断、被掐断三种残缺下都不出事,才是 demo 与 production 的真实分界。

// ENGLISH GLOSSARY

TTFT (Time To First Token)
从发请求到第一个 token 落地的延迟。流式体验真正要优化的指标,比总时长更影响体感。
Inter-Token Latency
相邻两个 token 之间的间隔,决定"打字"的流畅度。
Perceived Latency
用户主观感知的快慢;同样总时长,流式被感知为显著更快。
SSE (Server-Sent Events)
流式响应常用的单向事件流协议,承载 content_block_delta 等事件。
input_json_delta / partial_json
Anthropic 流式中 tool 参数的增量事件与字段;片段需按序拼接,不尊重 JSON 边界。
Fine-grained Tool Streaming
Anthropic beta:参数不 buffer 不校验直接流出,降低大参数延迟,代价是可能残缺 JSON。
Partial / Progressive Parsing
为不完整输入设计的解析,能从半截字符串得到部分对象/可渲染结构。
AbortController
显式中止 HTTP/SSE 连接的标准机制;真取消靠它,而非丢弃 chunk。
Cancellation vs Disconnect
客户端断开不等于服务端停止生成;不发 abort 信号,token 仍在烧、副作用仍在跑。

// 深入思考

既然 streaming 是感知优化、不省总时长,为什么不干脆隐藏中间过程、用一个高质量的加载动画"假装在思考",等结果好了一次性呈现?
因为流式提供的不只是"有反馈",还有可证伪的进展提前介入的机会。加载动画是不可证伪的——它转十秒和转一秒传达的信息一样多,用户无法判断系统是否真在工作、方向对不对。流式则让用户边生成边判断:第一句话跑偏了就立刻点停止,省下后面 9 秒的无效生成和 token。这正是第 4 点"取消"的价值前提——streaming 把"等待"变成"可中断的协作"。对长 agent 任务尤其关键:流式的中间步骤让用户能在第 3 步就发现 agent 误解了意图,而非等 40 步全跑完。隐藏过程等于放弃这层人机协同。
第 2 点说中途态"只能渲染、不能驱动副作用"。但流式 agent 的全部价值不就是边想边做吗?这条红线会不会把 agent 阉割成"想完再做"?
红线划在"同一个未完成的结构内部",不是划在 agent 的整个生命周期。一次 tool call 的参数 JSON 在 content_block_stop 之前是不可信的半截,这一条不能破——拿半个参数行动是纯粹的 bug。但 agent 的"边想边做"发生在更高的粒度:一个 tool call 完整结束后,agent 立刻发起下一个,无需等整段回复生成完。所以正确的边界是:结构(单个 tool input)必须完整才能用,但序列(多个 tool call)可以流式推进。fine-grained streaming 试图把红线往里推一点——让超大参数能边流边用——但它明确要求你自证有能力安全处理残缺,本质是把"完整性校验"的责任从平台转移给你。
取消要靠 AbortController 真断连。但很多 LLM 网关/代理在中间,客户端的 abort 真能一路传到最上游的推理引擎、让它停止解码吗?如果传不到呢?
这是个真实的架构漏洞。abort 信号要止住计费和生成,必须逐跳传递:浏览器→你的后端→网关→模型提供商→推理引擎,任何一跳吞掉断开信号,上游就会把整段解码跑完。现实里多级代理、连接池、缓冲常常吃掉这个信号。务实的做法是不把止损全押在断连上:(1) 在你能控制的最上游一跳确保透传 abort;(2) 设较紧的 max_tokens 和服务端超时作为硬上界,让"取消失败"的最坏代价有界;(3) 把取消语义建在幂等 + 补偿上而非"成功停止"上——即假设生成可能仍在跑,靠副作用层的可回滚来兜底。换句话说,取消是 best-effort 止血,正确性靠 Day 39 那套韧性,不靠断连一定生效。
fine-grained tool streaming 用"更低延迟"换"可能残缺的 JSON"。在什么具体场景下这个交换是划算的?什么场景绝不该开?
划算的场景有一个共同特征:参数极大、且半截内容本身就有展示价值。典型是让模型流式写一大段文件内容、长文档、大段代码——用户想看着它一行行出现,而不是等整个文件攒完才一次性蹦出来;此时即便末尾被 max_tokens 截断,前面已流出的部分仍可用(标记为未完成即可)。绝不该开的场景:参数是控制性的小结构,比如 {"action":..., "target_id":..., "confirm":...}——这种参数小、buffer 延迟可忽略,却字段间强耦合,残缺一个字段就语义崩坏甚至危险。判据:参数是"内容"(大、可增量消费、残缺可用)还是"指令"(小、强耦合、必须完整)?内容开,指令不开。
这一期的四件事(感知/解析/tool/取消)本质都在对抗"中间态"。这暗示了一个更普遍的工程原则吗?
暗示的原则是:一旦把"结果"变成"过程",你就必须为过程的每个瞬间定义合法状态。非流式只有两个状态:没结果、有结果。流式把它劈成无穷多个中间态,每帧都要回答"此刻这半截能拿来做什么"。这和分布式系统从"事务"走向"最终一致性"同构:用体感收益,换来必须显式处理一大堆原本不存在的中间状态。深层启示:任何"实时化"改造的真正成本,不在让它流起来,而在为每个残缺瞬间补齐语义与安全边界。streaming 只是这个权衡在 LLM 接口上的一个投影。

// 延伸阅读