前端侧 Agent 体验:流式渲染、工具调用可视化、打断/重试、SSE hooks
给 mini-koda 写一个 React 聊天前端:流式渲染回复、可视化展示工具调用过程,并支持打断和重试。
今日目标
- 能实现一个消费 SSE 流式输出并逐字渲染的聊天界面
- 能把工具调用过程(调用了什么工具、参数、结果)可视化展示出来
- 能实现打断当前回复和失败重试两个交互
后端到昨天为止已经会分诊、会拆任务、会自我评审、会主动关怀、找得准、还挡得住攻击——但你从头到尾没有从浏览器里看过它一眼。今天把它接到人眼前。读完回到页面顶部把三条目标勾掉。
小白版讲解
同声传译:边听边说,还得能被叫停
国际会议上的同声传译,做的事和一个流式聊天前端一模一样。
他不会等发言人讲完再翻。 讲一句译一句,落后三五秒,听众几乎是实时听到的。这就是流式:后端每吐出一小段就往前端推一段,而不是憋到最后一次性返回。区别有多大?一次回复要生成十几秒,等全量返回的界面在这十几秒里是一个转圈的图标;流式的界面在第一秒就有字出来。同样的总耗时,感知完全不同。
但他也不会听到半个词就开口。 「我们将在下个季度……」这句话没说完,谓语还没出现,急着译只会译错。所以译员心里始终留着一小段缓冲,攒够一个完整意群才出口。前端也一样:SSE 报文是按帧走的,一帧可能被网络切成两半到达,你必须留一个缓冲区把半截的部分留到下一块再拼——D1 讲透了这件事,今天只在前端复用同一个手法。
发言人去查资料的那几十秒,译员会说一句「他正在查数据」。 干沉默会让听众以为设备坏了。Agent 调工具的那几秒也是同样的处境:如果界面什么都不显示,用户会以为卡死了。所以工具调用要被看见——调了什么工具、传了什么参数、结果是什么,做成一张卡片,而不是一段黑盒。
最后一件事,也是今天最重要的一件事:听众举手说「这段跳过」,译员停下来是不够的。 译员闭嘴了,发言人还在讲——真正要做的是让主持人示意发言人停。前端的「打断」也是两件事,不是一件。 这一点后面会单独展开,它是本章的核心。
为什么不用 EventSource
浏览器有一个原生的 SSE 客户端叫 EventSource,两行就能用。但在 Agent 场景里它基本用不了,三个硬伤:
- 只能发 GET。 一次聊天请求要带完整的消息体,塞进 URL 查询串既有长度上限也不体面。
- 不能带自定义请求头。 也就是放不进
Authorization。你只能退而求其次把令牌塞进 URL——那它会进浏览器历史、进服务端访问日志、进各级代理的日志。 - 不能带请求体。 与第一条同源,但更致命:幂等键、会话 id、附件引用这些都得走 body。
所以正确做法是 fetch 加 ReadableStream 手动解析:请求想怎么发怎么发,响应拿到 res.body 之后自己按帧切。代价是你得自己处理缓冲、自己处理重连——EventSource 自带的自动重连也一并失去了,但那个自动重连在带鉴权的场景里本来也不好用。
解析器的形状很简单:收字节 → 解码 → 按空行切帧 → 最后一段可能是半截,留到下一轮。
// SSE 是文本协议:一帧内部按行,帧与帧之间用一个空行分隔。
// 关键只有一句:最后一段可能是半截,必须留到下一块再拼。
export function createSseParser() {
let buffer = ''
return function push(chunk: string): string[] {
buffer += chunk
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? '' // 末尾那段没见到空行,还不完整
return frames.filter((f) => f.trim() !== '')
}
}from collections.abc import Callable
def create_sse_parser() -> Callable[[str], list[str]]:
buffer = ""
def push(chunk: str) -> list[str]:
nonlocal buffer
buffer += chunk
frames = buffer.split("\n\n")
# 末尾那段没见到空行,还不完整,留到下一块
buffer = frames.pop()
return [f for f in frames if f.strip()]
return push// 依赖:JDK 17+ 标准库。有状态,所以做成类而不是静态方法。
final class SseParser {
private final StringBuilder buffer = new StringBuilder();
List<String> push(String chunk) {
buffer.append(chunk);
var frames = new ArrayList<String>();
int cut;
// indexOf 循环比 split 好:split 会把还不完整的尾巴也切出来,得再拼回去
while ((cut = buffer.indexOf("\n\n")) >= 0) {
var frame = buffer.substring(0, cut);
buffer.delete(0, cut + 2);
if (!frame.isBlank()) frames.add(frame);
}
return frames;
}
}import Foundation // range(of:) 来自 Foundation,不是标准库
// 有状态,所以是 class 不是 struct——调用方持有同一个解析器跨多个数据块
final class SseParser {
private var buffer = ""
func push(_ chunk: String) -> [String] {
buffer += chunk
var frames: [String] = []
// 每找到一个分隔符就切一帧;末尾残段留在 buffer 里等下一块
while let sep = buffer.range(of: "\n\n") {
let frame = String(buffer[buffer.startIndex ..< sep.lowerBound])
buffer.removeSubrange(buffer.startIndex ..< sep.upperBound)
if !frame.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
frames.append(frame)
}
}
return frames
}
}这段代码有个很容易骗过自己的地方:本机直连时帧几乎不会被切开,所以缓冲写不写都能跑通,只有真实网络下才露馅。今天实验的第 1 项自检故意把报文切碎了喂进去,就是为了让这个 bug 在本地也现形——starter/ 那版没有缓冲,解出 0 个事件。
工具调用可视化:三条事件归并成一张卡片
工具调用的事件名不需要重新发明,D5 已经定死了那一套:run:start / run:end / run:error、model:delta、tool:proposed / approval:required、tool:start / tool:end / tool:error。前端要做的是把它们归并。
同一次工具调用会依次发来三条事件(tool:proposed → tool:start → tool:end),它们共享一个 callId。前端不能把它们渲染成三条消息,而要按 callId 归并成一张卡片,卡片的状态随事件推进:proposed(模型提议了,还没执行)→ running(执行中)→ done(有结果了)或 error。
为什么这件事值得单独说:「三条事件三张卡片」是最常见的实现错误,而且它在快网下几乎看不出来——三条事件在几十毫秒内到齐,界面闪一下就变成最终态,你以为是渲染抖动。工具执行慢的时候(比如查库两秒)才会看到三张卡片堆在一起。今天实验的第 2 项自检就专门验这个:starter/ 那版出 3 张卡、状态停在 proposed。
还有一个顺带的收益:把中间过程露出来,等待就变得可以忍受。 一次要调三个工具的回复可能要跑十几秒,如果界面只有一个转圈图标,用户在第五秒就开始怀疑是不是卡了;换成三张依次亮起的卡片,同样的十几秒会被读成「它在干活」。这不是心理安慰——它直接决定用户会不会在中途刷新页面,而刷新意味着这一轮的钱白花了。
卡片上要显示什么,判据是「用户看了能判断要不要打断」:工具名(用人话,不是函数名)、关键参数、耗时、结果摘要。approval:required 那一条要渲染成一个真的按钮——D5 说过,模型提议和实际执行之间那道缝,是人工确认唯一能插进去的位置。
打断是两件事:前端闭嘴,还得让后端停下
这是本章最重要的一节。
前端点「停止」,直觉写法是 controller.abort()。这行代码做的事情很有限:它让你这一端不再读了。 后端那个 run 完全不知情——它还在跑循环、还在调模型、还在往库里写、还在按 token 计费。
今天实验里有一组专门为此设计的对照,两项自检跑的是同一段流:
[4/7] 打断两步:后端真的停了:打断时吐了 5 个字,最终停在 5/70 ✅
[5/7] 对照组:只 abort 时后端照跑不误:abort 时吐了 5 个字,后端仍跑到 70/70 ✅两行的左半边一模一样(都是在第 5 个字时中止读取),右半边差了 14 倍。 只 abort 的那一组,用户以为自己省下了 65 个字的钱,实际一分没省——而且那 65 个字还落进了会话历史,下一轮会被当成上下文重新发出去,付第二遍钱。
所以打断必须是两步,缺一不可:
controller.abort()—— 前端停止读取,界面立刻响应,用户不用等。POST /runs/:id/cancel—— 让后端把这个 run 真的停掉。
顺序上先 abort 再发 cancel(界面响应优先),但这两步都不能省。这里还要接上 D11 的那条规则:后端收到 cancel 之后不是硬杀,而是把 run 迁到 cancelled 状态、让当前这一步跑完就退出——硬杀会留下半写的消息和对不上的序号。
// 打断是两步:abort 只让自己不听了,cancel 才让后端停。
// 顺序:先 abort(界面立刻响应),再发 cancel(后台进行,不阻塞 UI)。
export async function stopRun(baseUrl: string, runId: string, controller: AbortController) {
controller.abort()
try {
// cancel 是幂等的:重复取消一个已结束的 run 也返回 200
await fetch(`${baseUrl}/runs/${runId}/cancel`, { method: 'POST' })
} catch {
// 网络抖动导致 cancel 没送达时,后端的 run 会自己超时结束;
// 这里不能抛出去打断界面状态的切换
}
}import httpx
async def stop_run(base_url: str, run_id: str, cancel_scope) -> None:
# 先让本地这一端停止读取,界面立刻响应
cancel_scope.cancel()
try:
async with httpx.AsyncClient() as client:
# cancel 幂等:重复取消已结束的 run 也返回 200
await client.post(f"{base_url}/runs/{run_id}/cancel")
except httpx.HTTPError:
# 没送达也不要往上抛:后端的 run 会自己超时结束
pass// 依赖:java.net.http(JDK 11+)
static void stopRun(HttpClient http, String baseUrl, String runId, CompletableFuture<?> reading) {
reading.cancel(true); // 先停止读取,界面立刻响应
var request = HttpRequest.newBuilder(URI.create(baseUrl + "/runs/" + runId + "/cancel"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
// sendAsync + exceptionally:cancel 失败不能阻塞也不能抛,后端会自己超时结束
http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
.exceptionally(e -> null);
}import Foundation
func stopRun(baseUrl: URL, runId: String, reading: Task<Void, Never>) {
reading.cancel() // 先停止读取,界面立刻响应
var request = URLRequest(url: baseUrl.appendingPathComponent("runs/\(runId)/cancel"))
request.httpMethod = "POST"
// 起一个游离的 Task 发 cancel:不 await,也不让它的错误冒泡到界面
Task { try? await URLSession.shared.data(for: request) }
}重试要带同一个幂等键,这是这一招的第四次出现
请求失败了,用户点「重试」。如果重试时生成一个新的请求标识,后端会把它当成一句全新的话——于是同一句话跑了两遍,两倍的 token 钱,可能还有两次不可逆的工具调用。
做法是客户端在发出第一次请求时就生成一个 idempotencyKey(一个 uuid),重试时原样带上同一个。后端拿它做唯一约束:命中已有记录就不新建 run,而是把已经在跑(或已经跑完)的那个 run 的流接回来。今天实验第 6 项自检验的就是这个——solution/ 重试拿回的是同一个 run(resumed=true),starter/ 那版换了新键,拿回的是另一个 run。
注意「什么时候换新键」这条线要划清楚:同一句话的重试用同一个键;用户改了内容重新发,那是新的一句话,必须换新键。 判据不是「用户点了哪个按钮」,是「要发送的内容变没变」。
这是本课幂等键的第四次出现:D8 落库去重、D13 定时任务防止一个 tick 被消费两次、D19 跨服务调用防止重复投递、今天前端重试。四次的形状完全一样——由业务事实算出一个确定性的键,最终裁判是数据库的唯一约束。同一招在四个完全不同的层面上解决同一类问题,这本身就是一个值得在面试里说出来的观察。
把这一切收进一个 hook:状态放外面,React 只负责订阅
流式回复一秒能来几十个 token。如果每来一个就 setState 一次,React 一秒就要走几十轮完整的渲染流程——消息列表越长,每一轮越贵,长回复到后半段会肉眼可见地卡。
做法是攒批:token 先追加进一个 ref(不触发渲染),用一个定时器每 30 毫秒把攒下的内容一次性提交。30 毫秒对应约 33 帧每秒,人眼看起来仍然是连续的打字机效果,但渲染次数下降一到两个数量级。今天实验第 7 项自检量的就是这个比值:200 个 token 只提交了 8 次渲染,而 starter/ 那版是 200 次。
要点有三个:
- 流结束时必须强制 flush 一次,否则最后不足 30 毫秒的那一小段永远留在缓冲里,用户会看到回复少了半句。
- 打断时同样要 flush,让用户看到「停在哪个字」,而不是停在上一个批次的边界。
- 状态放 ref 不放 state,否则你为了避免渲染而写的代码本身在触发渲染。
至于状态怎么管:这个前端不引状态库,也不引 UI 库,用 useSyncExternalStore 加一个几十行的手写 store 就够了。理由不是「轻量」这种口号——是面试官关心的是你怎么管流式状态,不是你会用哪个库。
而这恰好决定了 hook 该怎么切。一个常见的写法是把所有东西塞进 useState 和 useEffect:连接在 effect 里建、token 用 setMessages 追加、打断靠一个 useRef 存着的 controller。它能跑,但有三个躲不掉的麻烦——严格模式下 effect 跑两遍会开两条连接;组件一卸载,正在流的那个 run 就没人管了;而最要命的是这套逻辑和 React 长在一起,没法单独测。
所以分工反过来:store 活在 React 外面,hook 只负责订阅它。 具体是三层——
- 最底层是纯函数:
createSseParser(跨块缓冲)、reduceEvent(一条事件加当前回合,算出新的回合状态)、createFlushScheduler(30 毫秒攒批)。它们不知道 React 存在,输入输出都是普通对象,所以可以直接跑单元测试。今天实验的七项自检,测的全是这一层。 - 中间是 store:持有当前会话的消息列表和一个订阅者集合,暴露
getSnapshot()和subscribe(),内部调上面那些纯函数。它也不知道 React 存在。 - 最上层才是 hook:
useSyncExternalStore(store.subscribe, store.getSnapshot)一行拿到状态,再把send/stop/retry三个方法透出去。hook 里唯一的 React 逻辑是卸载时的清理——useEffect的返回函数里调stop,这样用户切走页面时后端也会被 cancel,而不是继续烧钱。
这个分层的判据很简单:能不能在没有浏览器的环境里验证「一个 token 到达之后经过了哪几步才变成屏幕上的一个字」。 能,就说明你的流式逻辑不依赖框架;不能,就说明它只能靠手点来验。这也是为什么今天的实验能在 SELFTEST=1 下自证——完整的 React 组件在实验的 src/web/ 里,正文这里不贴 JSX:今天所有值得四语言对照的东西,都是与框架无关的那一层。
源码导读
动手实验
starter/ 挖了 5 个练习点,MOCK=1 下完全离线跑通,不需要真模型和 API key——后端吐的是一段固定脚本的 SSE,里面包含一次完整的工具调用事件序列。浏览器界面没法自测,所以 SSE 解析和状态归并都抽成了不依赖 React 的纯函数,SELFTEST=1 直接测它们;想看界面就 pnpm dev,后端在 3025、前端在 4025。
- 原样跑一次
MOCK=1 SELFTEST=1 pnpm start,确认基线是 2/7,并看清第 4、5 项那组对照——现在两组都是 70/70,说明打断还没真的接上。 - 给解析器加跨块缓冲(练习 1),第 1 项变 ✅:切碎的报文也能解出 4 个事件。
- 把
tool:*按callId归并(练习 2),第 2 项变 ✅:3 张卡合成 1 张,状态推进到done。 - 把打断补成两步——abort 之后再发
POST /runs/:id/cancel(练习 3),第 4 项变 ✅,后端从 70/70 掉到 5/70;第 5 项作为对照组仍然是 70/70,别去改它。 - 让重试复用同一个
idempotencyKey(练习 4)、给 flush 加 30 毫秒节流(练习 5),第 6、7 项变 ✅;顺手用pnpm dev打开 4025 端口看一眼真实的打字机效果和工具卡片。
面试题
今天 4 道题在下方题库区,侧重前端怎么消费流式、流式场景的状态管理、打断与重试的前后端配合。展开后先看"分析过程"再看要点——第 2 题「abort 之后后端在干什么」是本章题眼,也是这一天最容易被追死的地方,别跳过。
检查清单与明日预告
- 能实现一个消费 SSE 流式输出并逐字渲染的聊天界面
- 能把工具调用过程(调用了什么工具、参数、结果)可视化展示出来
- 能实现打断当前回复和失败重试两个交互
- 能说出
EventSource在 Agent 场景里的三个硬伤 - 能解释为什么只 abort 不发 cancel 是错的,并说得出那组 5/70 与 70/70 的对照
- 实验的 5 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D26)课程性质会切换一次:四周的技术内容到今天全部结束了。 从明天起没有新技术,练的是表达——把这四周攒下的零件重新组织成面试官在 40 分钟里听得懂的答案,四类高频系统设计题各一份模板,每一步都有时间盒。顺序是有意的:先把东西真的做出来,再练怎么讲;反过来练,你讲的每一句话背后都是空的,而面试官的第二个追问就是专门用来探这件事的。
面试题库
前端怎么消费 SSE 并实现打字机效果?为什么一般不用浏览器自带的 EventSource?How does a frontend consume SSE to render a typewriter effect, and why do people usually avoid the built-in EventSource?
国内高频海外高频基础#sse#streaming#frontend分析过程 · 先想清楚再作答
- 这题是送分题,但送分点在后半句。只答「用 EventSource 监听 message 事件」的,面试官会立刻追问鉴权怎么办——答不上来就说明没在真项目里接过。
- 先给正面答案的骨架:`fetch` 拿到响应后读 `res.body` 这个 ReadableStream,`TextDecoder` 解码成文本,按空行切帧,逐帧解析出 `event` 与 `data`,把文本增量追加到当前这条消息上。
- 为什么不用 `EventSource`,三个硬伤要一口气说全:只能发 GET、不能带自定义请求头(也就是放不进 Authorization)、不能带请求体。Agent 场景里消息体、幂等键、会话 id 都得走 body,三条全撞上。
- 紧接着说代价,这是区分「用过」和「读过」的地方:手写解析意味着 `EventSource` 自带的自动重连、`Last-Event-ID` 续传都要自己实现。不过带鉴权的场景里那个自动重连本来就不好用(它重连时同样带不了头),所以损失没听起来那么大。
- 可预期的追问一:帧被网络切成两半怎么办?答缓冲——按空行切完之后,最后一段可能是半截,`pop` 出来留到下一块再拼。**这个 bug 在本机直连时几乎不出现**,所以要专门构造切碎的报文来测。
- 可预期的追问二:为什么不用 WebSocket?答:SSE 是单向下行、走普通 HTTP、天然过代理和 CDN、实现和运维都更轻;只有需要频繁上行(协同编辑、语音)才值得上 WebSocket。这一条能主动说出来会很加分。
How to reason about it · think before answering
- This is a warm-up question, but the second half is where it bites. Answering only 'use EventSource and listen for message events' invites an immediate follow-up about auth, and not having one shows you never wired it in a real project.
- Sketch the positive answer first: fetch the response, read res.body as a ReadableStream, decode with TextDecoder, split on blank lines into frames, parse event and data per frame, and append the text delta onto the current message.
- Then the three hard blockers on EventSource, stated together: GET only, no custom request headers (so no Authorization), and no request body. Agent requests need all of a message payload, an idempotency key and a session id in the body, so all three bite at once.
- Name the cost next — this separates having used it from having read about it. Hand-rolling means you also reimplement EventSource's auto-reconnect and Last-Event-ID resume. That said, its auto-reconnect is already unusable under auth because reconnects cannot carry headers either, so the loss is smaller than it sounds.
- Expected follow-up 1: what if a frame is split across chunks? Buffer it — after splitting on blank lines, pop the trailing partial segment and prepend it to the next chunk. This bug almost never reproduces on localhost, so you must feed deliberately fragmented payloads to test it.
- Expected follow-up 2: why not WebSocket? SSE is one-way downstream over plain HTTP, passes proxies and CDNs, and is far lighter to run. WebSocket earns its keep only when you need frequent upstream traffic such as collaborative editing or voice. Volunteering this scores well.
答题要点
- 用 fetch 读 res.body 这个 ReadableStream,TextDecoder 解码,按空行切帧,增量追加文本。
- EventSource 三个硬伤:只能 GET、不能带自定义头(放不进 Authorization)、不能带请求体。
- 代价是自动重连和 Last-Event-ID 续传要自己写——但带鉴权时那个自动重连本来也用不了。
- 必须处理跨块的半截帧:切完之后最后一段留到下一块再拼,本机直连测不出这个 bug。
- 不用 WebSocket 是因为 SSE 单向下行、走普通 HTTP、过代理和 CDN 更省事;需要频繁上行才换 WebSocket。
Key points
- Use fetch, read res.body as a ReadableStream, decode with TextDecoder, split frames on blank lines, append deltas.
- EventSource has three blockers: GET only, no custom headers (no Authorization), no request body.
- The cost is reimplementing auto-reconnect and Last-Event-ID resume — though auto-reconnect is unusable under auth anyway.
- You must buffer partial frames across chunks; localhost testing will not surface this bug.
- SSE beats WebSocket here: one-way, plain HTTP, proxy and CDN friendly. Switch only when you need frequent upstream messages.
用户点了「停止生成」,前端调用 AbortController.abort() 之后,后端在做什么?The user hits Stop and the frontend calls AbortController.abort(). What is the backend doing at that moment?
国内高频海外高频深入#streaming#cancellation#cost分析过程 · 先想清楚再作答
- 这题是本章题眼,也是一道**陷阱题**:题干里已经把「前端 abort 了」当成既成事实,等你顺着说「那就停了」。答「停了」的直接出局。
- 正确答案一句话:**后端什么都不知道,它还在跑。** 还在调模型、还在往库里写消息、还在按 token 计费。`abort` 只是让你这一端不再读了,它顶多让 TCP 连接断开,而后端是否感知得到连接断开、感知到之后做不做事,是另一回事。
- 怎么拆:把「谁知道这件事」画出来。用户知道 → 前端知道 → **中间断了** → 后端不知道。断掉的这一环必须用一个显式的请求补上:`POST /runs/:id/cancel`。所以打断是两步,不是一步。
- 给一个量化的对照最有说服力:同一段 70 个字的回复,在第 5 个字打断——两步打断的后端停在 5/70,只 abort 的后端照跑到 70/70。差 14 倍的 token,而且那 65 个字还会落进会话历史,下一轮当上下文重新发一遍,付第二遍钱。
- 生产视角的补充:cancel 收到之后**不要硬杀**,把 run 迁到 cancelled 状态、让当前这一步跑完再退出——硬杀会留下半写的消息和对不上的序号。而且 cancel 本身必须幂等,因为网络抖动时你会重试它。
- 可预期的追问:那能不能靠后端检测连接断开来自动停?可以做,而且应该做(作为兜底),但不能只靠它——反向代理和负载均衡常常会把连接维持一段时间,后端感知到断开可能已经是十几秒之后;而且用户点停止之后如果自动重连,连接根本没断。**兜底归兜底,显式 cancel 才是主路径。**
How to reason about it · think before answering
- This is the core question of the chapter and a deliberate trap: the prompt states the abort as a given and waits for you to say 'so it stopped'. Saying that ends the conversation.
- The correct answer in one line: the backend knows nothing and is still running — still calling the model, still writing messages, still billing tokens. abort only stops your end from reading; at most it drops the TCP connection, and whether the backend notices, or acts on noticing, is a separate matter.
- Decompose by drawing who knows what: the user knows, the frontend knows, the chain breaks, the backend does not know. That broken link must be closed with an explicit request: POST /runs/:id/cancel. So stopping is two steps, not one.
- A quantified contrast lands best: on the same 70-character reply interrupted at character 5, the two-step version stops the backend at 5/70 while abort-only runs to 70/70. That is 14x the tokens, and those 65 characters also land in conversation history and get resent as context next turn, billing you twice.
- Production addendum: on cancel, do not hard-kill. Move the run to a cancelled state and let the current step finish, or you leave half-written messages and gaps in the sequence numbers. Also make cancel idempotent, because you will retry it when the network flakes.
- Expected follow-up: can the backend just detect the dropped connection and stop by itself? It can and should, as a safety net, but not as the only mechanism. Proxies and load balancers often hold connections open, so detection can lag by tens of seconds, and if the client auto-reconnects the connection never drops at all. The net is a net; the explicit cancel is the main path.
答题要点
- 后端完全不知情:还在调模型、还在写库、还在计费。abort 只让前端这一端停止读取。
- 打断必须两步:abort(界面立刻响应)+ POST /runs/:id/cancel(后端真的停)。
- 量化差别:同一段 70 字的回复在第 5 个字打断,两步是 5/70,只 abort 是 70/70。
- 后端收到 cancel 不要硬杀,迁到 cancelled 状态让当前步跑完;cancel 必须幂等。
- 靠后端检测连接断开只能当兜底:代理会维持连接、自动重连时连接根本没断。
Key points
- The backend has no idea: still calling the model, still writing, still billing. abort only stops your side reading.
- Stopping is two steps: abort for instant UI response, plus POST /runs/:id/cancel to actually halt the run.
- Quantified: interrupting the same 70-character reply at character 5 gives 5/70 with both steps versus 70/70 with abort alone.
- On cancel, transition the run to cancelled and let the current step finish rather than hard-killing; make cancel idempotent.
- Backend disconnect detection is only a safety net — proxies hold connections open and auto-reconnect means no disconnect at all.
流式场景下前端的状态管理要注意什么?为什么不能每个 token 都 setState?What is different about frontend state management under streaming, and why not call setState on every token?
国内高频海外高频进阶#react#streaming#performance分析过程 · 先想清楚再作答
- 这题考的是「你有没有在长回复下真的看过掉帧」。答「用 useState 存消息数组,收到 delta 就 setState」在功能上没错,但它暴露的是只在短回复上试过。
- 先算一笔账:流式一秒来几十个 token,每个 token 一次 setState 就是一秒几十轮完整渲染。而消息列表是越来越长的,每一轮的代价随对话轮数增长——所以卡顿在回复后半段和长会话里最明显,正好是最不该卡的时候。
- 做法是攒批:token 先追加进 ref(不触发渲染),一个定时器每 30 毫秒把攒下的一次性提交。30 毫秒约等于 33 帧每秒,肉眼仍是连续的打字机,渲染次数掉一到两个数量级——实测 200 个 token 只提交 8 次。
- 三个必须配套的细节:流结束时强制 flush 一次(否则最后不足一个批次的内容永远留在缓冲里,用户看到回复少半句);打断时也要 flush(让用户看到停在哪个字);缓冲状态必须放 ref 不放 state,否则你为了省渲染写的代码本身在触发渲染。
- 再往上一层是分层:**流式逻辑应该活在 React 外面。** 解析、事件归并、攒批都是纯函数,store 持有状态并暴露 subscribe 和 getSnapshot,React 侧只用 useSyncExternalStore 订阅。这样做的直接好处是**这套逻辑可以在没有浏览器的环境里跑单元测试**,而不是只能靠手点。
- 可预期的追问:为什么不直接用某个状态库?答:状态库解决的是跨组件共享和更新粒度,而流式的难点在生命周期(连接、取消、卸载清理)和批处理频率——这两件事没有哪个库替你做。面试官问这题想听的是你怎么想,不是你会用哪个库。
How to reason about it · think before answering
- This question probes whether you have watched a long reply drop frames. 'Keep messages in useState and setState on each delta' is functionally correct but reveals you only tried short replies.
- Do the arithmetic first: streaming delivers tens of tokens per second, so one setState per token means tens of full render passes per second. The message list keeps growing, so each pass gets more expensive as the conversation goes — the jank peaks late in long replies and long sessions, exactly when it hurts most.
- The fix is batching: append tokens into a ref without rendering, and flush the accumulated text on a 30 ms timer. Thirty milliseconds is roughly 33 fps, still a smooth typewriter, while render count drops by one to two orders of magnitude — measured, 200 tokens produced 8 commits.
- Three details that must ship with it: force a final flush when the stream ends, or the last sub-batch stays in the buffer and the user sees a truncated reply; flush on interrupt too, so the user sees exactly where it stopped; and keep the buffer in a ref, not state, or the code you wrote to avoid renders is itself causing them.
- One level up is layering: streaming logic should live outside React. Parsing, event reduction and batching are pure functions; a store holds state and exposes subscribe and getSnapshot; React only calls useSyncExternalStore. The concrete payoff is that this logic can be unit tested with no browser instead of being click-tested.
- Expected follow-up: why not just use a state library? Libraries solve cross-component sharing and update granularity, while the hard parts here are lifecycle (connect, cancel, cleanup on unmount) and flush cadence — no library does those for you. The interviewer wants your reasoning, not your library list.
答题要点
- 每个 token 一次 setState 等于一秒几十轮全量渲染,而消息列表越长每轮越贵,长回复后半段必然掉帧。
- 做法是攒批:token 进 ref 不触发渲染,30 毫秒定时 flush 一次,实测 200 个 token 只提交 8 次。
- 必须配套:流结束和打断时强制 flush;缓冲放 ref 不放 state。
- 流式逻辑(解析、归并、攒批)应该是 React 之外的纯函数,React 只用 useSyncExternalStore 订阅。
- 这样分层的直接好处是能脱离浏览器做单元测试,而不是只能手点验证。
Key points
- One setState per token means tens of full renders per second, and each render costs more as the list grows — long replies jank at the end.
- Batch instead: accumulate tokens in a ref and flush every 30 ms; measured, 200 tokens produced only 8 commits.
- Ship the details with it: force a flush on stream end and on interrupt, and keep the buffer in a ref rather than state.
- Keep parsing, event reduction and batching as pure functions outside React; subscribe via useSyncExternalStore.
- The payoff of that split is unit-testable streaming logic with no browser in the loop.
失败重试怎么设计才不会产生重复副作用?工具调用过程要不要暴露给用户?How do you design retry so it does not duplicate side effects, and should the tool-call process be visible to the user?
国内高频海外高频进阶#idempotency#retry#ux分析过程 · 先想清楚再作答
- 这题把两件事绑在一起问,考的是你能不能看出它们的共同点:**都是「把不可见的中间状态变成可控的」**。分开答也行,但点出这层关系会显得成熟。
- 重试这一半的推导链:重试意味着同一句话可能被执行两遍 → 两倍 token,还可能两次不可逆的工具调用(比如退款打两次钱)→ 所以要幂等 → 幂等键必须由**客户端在第一次发送时生成**并在重试时原样带上 → 后端拿它做唯一约束,命中就把已有 run 的流接回来,而不是新建。
- 关键判据要说清:**什么时候该换新键?** 判据是「要发送的内容变没变」,不是「用户点了哪个按钮」。同一句话重试用同一个键;用户改了内容重新发,那是新的一句话,必须换新键。
- 顺带提一句这一招的复用面会很加分:落库去重、定时任务防止一个 tick 被消费两次、跨服务调用防重复投递、前端重试——同一个形状用在四个层面,最终裁判永远是数据库的唯一约束,不是应用层的先查后写。
- 工具可视化这一半:中间过程要暴露,理由有三条——用户能判断要不要打断(不然他只能盲等);等待变得可以忍受(十几秒的转圈会让人刷新页面,而刷新意味着这一轮的钱白花);出问题时用户能说清「卡在查订单那一步」,客服和你都省事。
- 可预期的追问:全都暴露会不会泄露内部实现?会,所以要过滤——工具名用人话不用函数名,参数里的用户标识、内部 id、密钥一律不显示,错误显示归类后的原因而不是原始堆栈。**可视化的是过程,不是内部结构。**
How to reason about it · think before answering
- The question bundles two topics, and the test is whether you see what they share: both turn invisible intermediate state into something the user can act on. Answering them separately is fine, but naming the link reads as senior.
- Chain for retry: retrying means the same message may execute twice, costing double tokens and possibly duplicating irreversible tool calls such as issuing a refund twice. Hence idempotency. The key must be generated by the client on the first attempt and resent unchanged on retry, and the backend enforces it with a unique constraint, reattaching to the existing run instead of creating a new one.
- State the decision rule clearly: when do you mint a new key? The rule is whether the content being sent changed, not which button the user pressed. Same message retried keeps the key; edited content is a new message and needs a new key.
- Mentioning how far this pattern reaches scores well: write deduplication, cron ticks consumed exactly once, cross-service delivery, and frontend retry — the same shape at four layers, with the database's unique constraint always the final arbiter rather than an application-level check-then-write.
- For tool visibility: expose the process, for three reasons. The user can decide whether to interrupt instead of waiting blind; waiting becomes tolerable, since a spinner for fifteen seconds invites a page refresh that wastes the whole turn; and when something breaks the user can say 'it hung on looking up my order', which saves everyone time.
- Expected follow-up: does exposing everything leak internals? It can, so filter. Show human-readable tool names rather than function names, hide user identifiers, internal ids and secrets from the arguments, and show classified error reasons rather than raw stack traces. You are surfacing the process, not the internal structure.
答题要点
- 重试要带客户端首次生成的幂等键,后端用唯一约束命中后把已有 run 的流接回来,不新建。
- 换不换键的判据是「内容变没变」:同一句话重试用同一个键,改了内容才换新键。
- 同一招在落库、定时任务、跨服务调用、前端重试四处复用,最终裁判永远是数据库的唯一约束。
- 工具调用要可视化:用户才能判断要不要打断、等待变得可忍受、出问题时说得清卡在哪一步。
- 但要过滤:工具名用人话、参数里的内部 id 与密钥不显示、错误显示归类原因而不是原始堆栈。
Key points
- Retry carries the idempotency key minted on the first attempt; the backend hits a unique constraint and reattaches to the existing run.
- The rule for minting a new key is whether the content changed — same message keeps the key, edited content gets a new one.
- The same pattern recurs in write dedup, cron ticks, cross-service delivery and frontend retry, always arbitrated by a database unique constraint.
- Make tool calls visible so users can decide whether to interrupt, tolerate the wait, and describe where it hung.
- But filter: human-readable tool names, no internal ids or secrets in the arguments, classified error reasons instead of raw stack traces.
评论
登录后即可参与讨论
还没有评论,来说第一句。