The Frontend Agent Experience: Streaming Rendering, Visualizing Tool Calls, Interrupt/Retry, SSE Hooks
Write a React chat frontend for mini-koda: render the reply as it streams in, visualize the tool-calling process, and support interrupting and retrying.
Today's Goals
- Implement a chat interface that consumes SSE streaming output and renders it character by character
- Visualize the tool-calling process — which tool was called, with what arguments, and what result
- Implement both interrupting the current reply and retrying on failure
As of yesterday the backend triages, splits tasks, reviews itself, reaches out proactively, finds things accurately, and holds off attacks — and you have not once looked at it through a browser. Today it goes in front of human eyes.
Plain-Language Walkthrough
Simultaneous interpretation: speaking while listening, and stoppable
A simultaneous interpreter at an international conference does exactly what a streaming chat frontend does.
They do not wait for the speaker to finish. They render sentence by sentence, three to five seconds behind, and the audience hears it near-live. That is streaming: the backend pushes each fragment as it emerges rather than holding everything to the end. How big is the difference? A reply taking ten-odd seconds to generate leaves a full-response interface showing a spinner for all of them; a streaming interface has characters within the first second. The same total duration, an entirely different perception.
They also do not start speaking after half a word. "We will, in the next quarter..." is unfinished with no predicate yet, and rushing produces a wrong rendering. So an interpreter always keeps a small buffer, speaking once a complete unit of meaning has arrived. The frontend is the same: SSE messages travel as frames, one frame may arrive split in two by the network, and you must keep a buffer holding the half until the next chunk — D1 covered this thoroughly, and today reuses the same technique on the frontend.
During the thirty seconds the speaker spends looking something up, the interpreter says "he is checking the figures." Dead silence makes the audience assume the equipment failed. An agent's few seconds of tool calling is the same predicament: with nothing displayed, users assume it hung. So tool calls must be seen — which tool, which arguments, what result — as a card rather than a black box.
And the last thing, today's most important: an audience member raising a hand to say "skip this part" is not satisfied by the interpreter falling silent. The interpreter stopped and the speaker is still talking — what actually has to happen is the chair signaling the speaker to stop. The frontend's "interrupt" is likewise two things, not one. That gets its own section below and it is this chapter's core.
Why not EventSource
Browsers have a native SSE client called EventSource, usable in two lines. In an agent setting it is basically unusable, for three hard reasons:
- It can only issue GET. A chat request carries a full message body, and stuffing that into a query string has both a length limit and no dignity.
- It cannot carry custom headers. Which means no
Authorization. You are reduced to putting the token in the URL — where it enters browser history, server access logs, and every proxy's logs. - It cannot carry a request body. Same root as the first and more damaging: idempotency keys, session ids, and attachment references all belong in the body.
So the correct approach is fetch plus ReadableStream parsed by hand: send the request however you like, take res.body, and split frames yourself. The price is handling buffering and reconnection yourself — EventSource's automatic reconnection goes too, and that reconnection was never usable with authentication anyway.
The parser's shape is simple: take bytes, decode, split frames on a blank line, and keep a possibly-half final segment for the next round.
// SSE is a text protocol: lines within a frame, and a blank line between frames.
// There is only one crucial point: the final segment may be a half and must wait for
// the next chunk.
export function createSseParser() {
let buffer = ''
return function push(chunk: string): string[] {
buffer += chunk
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? '' // the tail has seen no blank line yet, so it is incomplete
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")
# The tail has seen no blank line yet, so keep it for the next chunk
buffer = frames.pop()
return [f for f in frames if f.strip()]
return push// Dependencies: JDK 17+ standard library. Stateful, so a class rather than a static method.
final class SseParser {
private final StringBuilder buffer = new StringBuilder();
List<String> push(String chunk) {
buffer.append(chunk);
var frames = new ArrayList<String>();
int cut;
// An indexOf loop beats split: split also carves out the still-incomplete tail,
// which then has to be stitched back
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:) comes from Foundation, not the standard library
// Stateful, so a class rather than a struct - the caller holds one parser across chunks
final class SseParser {
private var buffer = ""
func push(_ chunk: String) -> [String] {
buffer += chunk
var frames: [String] = []
// Cut one frame per separator found; the tail remainder stays in the 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
}
}This code has a place that easily fools you: connecting locally, frames are almost never split, so it works with or without the buffer and only a real network exposes it. Today's lab's first self-check deliberately feeds shredded messages in so the bug appears locally too — starter/'s version, without a buffer, parses 0 events.
Visualizing tool calls: three events merged into one card
Tool-call event names need no reinvention; D5 fixed that set: run:start / run:end / run:error, model:delta, tool:proposed / approval:required, tool:start / tool:end / tool:error. What the frontend does is merge them.
One tool call sends three events in sequence (tool:proposed, tool:start, tool:end) sharing one callId. The frontend must not render them as three messages but merge them by callId into one card whose state advances with the events: proposed (the model proposed it, not yet executed), running, then done or error.
Why this deserves its own section: "three events, three cards" is the most common implementation error, and on a fast network it is nearly invisible — three events arrive within tens of milliseconds, the interface flickers into its final state, and you assume it was render jitter. Only a slow tool (a two-second database query) reveals three cards stacked together. Today's lab's second self-check tests exactly that: starter/'s version produces 3 cards stuck at proposed.
There is an incidental benefit: exposing the intermediate process makes waiting tolerable. A reply calling three tools may take ten-odd seconds, and with only a spinner a user starts suspecting a hang by the fifth second; three cards lighting up in turn make the same ten seconds read as "it is working." That is not a psychological consolation — it directly decides whether the user refreshes mid-run, and a refresh means the round's money was wasted.
What goes on a card is decided by "can the user judge whether to interrupt": the tool's name (in plain language, not a function name), the key arguments, elapsed time, and a result summary. approval:required must render as a genuine button — as D5 said, the gap between the model proposing and actual execution is the only place human confirmation can be inserted.
Interrupting is two things: the frontend goes quiet, and the backend must stop
This is the chapter's most important section.
The frontend's stop button instinctively calls controller.abort(). That line does something limited: it makes your end stop reading. The backend's run knows nothing — it is still looping, still calling the model, still writing to the database, still billing tokens.
Today's lab holds a comparison designed for this, with two self-checks running the same stream:
[4/7] two-step interrupt, the backend really stopped: 5 characters emitted at interrupt, stopping at 5/70 OK
[5/7] control group, abort only and the backend runs on: 5 characters at abort, backend still reached 70/70 OKThe two lines' left halves are identical (both stop reading at the fifth character) and their right halves differ by a factor of 14. In the abort-only group the user believes they saved 65 characters' worth of money and saved nothing — and those 65 characters also landed in the session history, to be resent as context next round and paid for a second time.
So interrupting must be two steps, neither optional:
controller.abort()— the frontend stops reading, the interface responds immediately, and the user does not wait.POST /runs/:id/cancel— make the backend genuinely stop this run.
Abort first and cancel second (interface responsiveness first), and neither step may be skipped. Connect this to D11's rule too: on receiving a cancel the backend does not hard-kill but transitions the run to cancelled and exits after the current step — a hard kill leaves half-written messages and mismatched sequence numbers.
// Interrupting is two steps: abort only makes you stop listening, and cancel makes the
// backend stop. Order: abort first (immediate interface response), then cancel in the
// background without blocking the UI.
export async function stopRun(baseUrl: string, runId: string, controller: AbortController) {
controller.abort()
try {
// cancel is idempotent: cancelling an already-finished run also returns 200
await fetch(`${baseUrl}/runs/${runId}/cancel`, { method: 'POST' })
} catch {
// When a network wobble stops cancel arriving, the backend's run times out on its own;
// this must not throw and interrupt the interface's state transition
}
}import httpx
async def stop_run(base_url: str, run_id: str, cancel_scope) -> None:
# Stop reading locally first so the interface responds immediately
cancel_scope.cancel()
try:
async with httpx.AsyncClient() as client:
# cancel is idempotent: cancelling a finished run also returns 200
await client.post(f"{base_url}/runs/{run_id}/cancel")
except httpx.HTTPError:
# Do not raise on non-delivery either: the backend's run times out on its own
pass// Dependencies: java.net.http (JDK 11+)
static void stopRun(HttpClient http, String baseUrl, String runId, CompletableFuture<?> reading) {
reading.cancel(true); // stop reading first so the interface responds immediately
var request = HttpRequest.newBuilder(URI.create(baseUrl + "/runs/" + runId + "/cancel"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
// sendAsync plus exceptionally: a failed cancel must neither block nor throw, and the
// backend times out on its own
http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
.exceptionally(e -> null);
}import Foundation
func stopRun(baseUrl: URL, runId: String, reading: Task<Void, Never>) {
reading.cancel() // stop reading first so the interface responds immediately
var request = URLRequest(url: baseUrl.appendingPathComponent("runs/\(runId)/cancel"))
request.httpMethod = "POST"
// A detached Task sends the cancel: not awaited, and its error never reaches the interface
Task { try? await URLSession.shared.data(for: request) }
}A retry must carry the same idempotency key, the move's fourth appearance
A request failed and the user clicks retry. If the retry generates a new request identifier, the backend treats it as an entirely new sentence — so the same sentence runs twice, at double the token cost and possibly two irreversible tool calls.
The approach has the client generate an idempotencyKey (a uuid) when the first request goes out and carry the same one on a retry. The backend uses it as a unique constraint: on a hit it creates no new run and instead reattaches to the stream of the run already in flight (or already finished). Today's lab's sixth self-check tests that — solution/'s retry gets the same run back (resumed=true) while starter/'s changes the key and gets a different one.
Draw the line about when to change the key clearly: a retry of the same sentence uses the same key; the user editing the content and resending is a new sentence and must use a new one. The criterion is not which button the user pressed, it is whether the content to send changed.
This is the idempotency key's fourth appearance in this course: D8's write deduplication, D13 preventing one cron tick being consumed twice, D19's cross-service duplicate delivery, and today's frontend retry. All four have the same shape — a deterministic key derived from a business fact, with a database's unique constraint as the final arbiter. The same move solving one class of problem at four completely different layers is itself an observation worth voicing in an interview.
Gathering it into one hook: state outside, React only subscribes
A streaming reply delivers dozens of tokens a second. setState per token means React runs dozens of full render passes a second — and the longer the message list, the more each costs, so a long reply visibly stutters towards the end.
The approach is batching: tokens accumulate into a ref (no render) and a timer commits the accumulation every 30 milliseconds. Thirty milliseconds is about 33 frames a second, so the typewriter still looks continuous to the eye while render count drops by one or two orders of magnitude. Today's lab's seventh self-check measures that ratio: 200 tokens commit only 8 renders, against starter/'s 200.
Three points:
- A forced flush when the stream ends is mandatory, or the final sub-30-millisecond fragment stays in the buffer forever and the user sees a reply missing half a sentence.
- Flush on interrupt too, so the user sees which character it stopped on rather than the previous batch's boundary.
- Keep the state in a ref rather than state, or the code you wrote to avoid rendering is itself triggering renders.
As for state management: this frontend pulls in neither a state library nor a UI library, using useSyncExternalStore plus a few dozen lines of hand-written store. The reason is not a lightweight slogan — it is that an interviewer cares how you manage streaming state, not which library you can use.
And that decides how the hook is cut. A common version stuffs everything into useState and useEffect: the connection opened in an effect, tokens appended with setMessages, and interruption via a controller kept in a useRef. It works, and has three unavoidable troubles — an effect running twice under strict mode opens two connections; unmount the component and the run in flight has no owner; and worst, that logic is entangled with React and cannot be tested alone.
So invert the division: the store lives outside React, and the hook only subscribes to it. Three layers concretely.
- The bottom is pure functions:
createSseParser(cross-chunk buffering),reduceEvent(one event plus the current turn producing the new turn state),createFlushScheduler(30-millisecond batching). They do not know React exists, their inputs and outputs are plain objects, and so they can be unit-tested directly. Today's lab's seven self-checks all test this layer. - The middle is the store: it holds the current session's message list and a set of subscribers, exposes
getSnapshot()andsubscribe(), and calls those pure functions internally. It does not know React exists either. - The top is the hook:
useSyncExternalStore(store.subscribe, store.getSnapshot)gets the state in one line, andsend/stop/retryare exposed. The hook's only React logic is cleanup on unmount —useEffect's returned function callsstop, so navigating away also cancels the backend rather than continuing to burn money.
The criterion for this layering is simple: can you verify, without a browser, which steps a token passes through before becoming a character on screen. If you can, your streaming logic does not depend on the framework; if you cannot, it can only be verified by clicking. Which is why today's lab can prove itself under SELFTEST=1 — the complete React components are in the lab's src/web/, and no JSX appears here: everything worth comparing across four languages today is the framework-independent layer.
Source Reading
Hands-On Lab
starter/ has 5 exercise points and runs fully offline under MOCK=1 with no real model or API key — the backend emits SSE from a fixed script containing one complete tool-call event sequence. A browser interface cannot self-test, so SSE parsing and state merging are extracted as React-independent pure functions that SELFTEST=1 tests directly; to see the interface, pnpm dev with the backend on 3025 and the frontend on 4025.
- Run
MOCK=1 SELFTEST=1 pnpm startas-is, confirm the baseline is 2/7, and study checks 4 and 5's comparison — both are currently 70/70, meaning interruption is not really wired up. - Add cross-chunk buffering to the parser (exercise 1), turning check 1 green: shredded messages parse into 4 events.
- Merge
tool:*bycallId(exercise 2), turning check 2 green: 3 cards become 1 advancing todone. - Complete the two-step interrupt — send
POST /runs/:id/cancelafter abort (exercise 3) — turning check 4 green with the backend dropping from 70/70 to 5/70; check 5 stays at 70/70 as the control group, so leave it alone. - Make retry reuse the same
idempotencyKey(exercise 4) and add 30-millisecond throttling to flush (exercise 5), turning checks 6 and 7 green; then open port 4025 withpnpm devand look at the real typewriter effect and tool cards.
Interview Questions
Today's four questions are in the bank below, weighted toward how a frontend consumes streaming, state management in streaming scenarios, and frontend-backend cooperation on interrupt and retry. Expand a question and read the analysis before the key points — question 2, on what the backend is doing after abort, is this chapter's crux and the day's likeliest place to be pressed to death, so do not skip it.
Checklist and Tomorrow
- Implement a chat interface that consumes SSE streaming output and renders it character by character
- Visualize the tool-calling process — which tool was called, with what arguments, and what result
- Implement both interrupting the current reply and retrying on failure
- Name
EventSource's three hard limitations in an agent setting - Explain why abort without cancel is wrong, and recite that 5/70 versus 70/70 comparison
- All 5 acceptance criteria of the lab pass
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D26) the course changes character: four weeks of technical content end today. From tomorrow there is no new technology, only practicing expression — reorganizing four weeks of parts into answers an interviewer can follow in 40 minutes, with one template each for four frequent system-design topics and a time box on every step. The order is deliberate: build the thing before practicing how to talk about it; done the other way, every sentence you say has nothing behind it, and an interviewer's second follow-up exists precisely to probe that.
Interview questions
How does a frontend consume SSE to render a typewriter effect, and why do people usually avoid the built-in EventSource?前端怎么消费 SSE 并实现打字机效果?为什么一般不用浏览器自带的 EventSource?
Common in ChinaCommon overseasBasic#sse#streaming#frontendHow 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.
分析过程 · 先想清楚再作答
- 这题是送分题,但送分点在后半句。只答「用 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。这一条能主动说出来会很加分。
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.
答题要点
- 用 fetch 读 res.body 这个 ReadableStream,TextDecoder 解码,按空行切帧,增量追加文本。
- EventSource 三个硬伤:只能 GET、不能带自定义头(放不进 Authorization)、不能带请求体。
- 代价是自动重连和 Last-Event-ID 续传要自己写——但带鉴权时那个自动重连本来也用不了。
- 必须处理跨块的半截帧:切完之后最后一段留到下一块再拼,本机直连测不出这个 bug。
- 不用 WebSocket 是因为 SSE 单向下行、走普通 HTTP、过代理和 CDN 更省事;需要频繁上行才换 WebSocket。
The user hits Stop and the frontend calls AbortController.abort(). What is the backend doing at that moment?用户点了「停止生成」,前端调用 AbortController.abort() 之后,后端在做什么?
Common in ChinaCommon overseasDeep dive#streaming#cancellation#costHow 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 了」当成既成事实,等你顺着说「那就停了」。答「停了」的直接出局。
- 正确答案一句话:**后端什么都不知道,它还在跑。** 还在调模型、还在往库里写消息、还在按 token 计费。`abort` 只是让你这一端不再读了,它顶多让 TCP 连接断开,而后端是否感知得到连接断开、感知到之后做不做事,是另一回事。
- 怎么拆:把「谁知道这件事」画出来。用户知道 → 前端知道 → **中间断了** → 后端不知道。断掉的这一环必须用一个显式的请求补上:`POST /runs/:id/cancel`。所以打断是两步,不是一步。
- 给一个量化的对照最有说服力:同一段 70 个字的回复,在第 5 个字打断——两步打断的后端停在 5/70,只 abort 的后端照跑到 70/70。差 14 倍的 token,而且那 65 个字还会落进会话历史,下一轮当上下文重新发一遍,付第二遍钱。
- 生产视角的补充:cancel 收到之后**不要硬杀**,把 run 迁到 cancelled 状态、让当前这一步跑完再退出——硬杀会留下半写的消息和对不上的序号。而且 cancel 本身必须幂等,因为网络抖动时你会重试它。
- 可预期的追问:那能不能靠后端检测连接断开来自动停?可以做,而且应该做(作为兜底),但不能只靠它——反向代理和负载均衡常常会把连接维持一段时间,后端感知到断开可能已经是十几秒之后;而且用户点停止之后如果自动重连,连接根本没断。**兜底归兜底,显式 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.
答题要点
- 后端完全不知情:还在调模型、还在写库、还在计费。abort 只让前端这一端停止读取。
- 打断必须两步:abort(界面立刻响应)+ POST /runs/:id/cancel(后端真的停)。
- 量化差别:同一段 70 字的回复在第 5 个字打断,两步是 5/70,只 abort 是 70/70。
- 后端收到 cancel 不要硬杀,迁到 cancelled 状态让当前步跑完;cancel 必须幂等。
- 靠后端检测连接断开只能当兜底:代理会维持连接、自动重连时连接根本没断。
What is different about frontend state management under streaming, and why not call setState on every token?流式场景下前端的状态管理要注意什么?为什么不能每个 token 都 setState?
Common in ChinaCommon overseasIntermediate#react#streaming#performanceHow 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.
分析过程 · 先想清楚再作答
- 这题考的是「你有没有在长回复下真的看过掉帧」。答「用 useState 存消息数组,收到 delta 就 setState」在功能上没错,但它暴露的是只在短回复上试过。
- 先算一笔账:流式一秒来几十个 token,每个 token 一次 setState 就是一秒几十轮完整渲染。而消息列表是越来越长的,每一轮的代价随对话轮数增长——所以卡顿在回复后半段和长会话里最明显,正好是最不该卡的时候。
- 做法是攒批:token 先追加进 ref(不触发渲染),一个定时器每 30 毫秒把攒下的一次性提交。30 毫秒约等于 33 帧每秒,肉眼仍是连续的打字机,渲染次数掉一到两个数量级——实测 200 个 token 只提交 8 次。
- 三个必须配套的细节:流结束时强制 flush 一次(否则最后不足一个批次的内容永远留在缓冲里,用户看到回复少半句);打断时也要 flush(让用户看到停在哪个字);缓冲状态必须放 ref 不放 state,否则你为了省渲染写的代码本身在触发渲染。
- 再往上一层是分层:**流式逻辑应该活在 React 外面。** 解析、事件归并、攒批都是纯函数,store 持有状态并暴露 subscribe 和 getSnapshot,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.
答题要点
- 每个 token 一次 setState 等于一秒几十轮全量渲染,而消息列表越长每轮越贵,长回复后半段必然掉帧。
- 做法是攒批:token 进 ref 不触发渲染,30 毫秒定时 flush 一次,实测 200 个 token 只提交 8 次。
- 必须配套:流结束和打断时强制 flush;缓冲放 ref 不放 state。
- 流式逻辑(解析、归并、攒批)应该是 React 之外的纯函数,React 只用 useSyncExternalStore 订阅。
- 这样分层的直接好处是能脱离浏览器做单元测试,而不是只能手点验证。
How do you design retry so it does not duplicate side effects, and should the tool-call process be visible to the user?失败重试怎么设计才不会产生重复副作用?工具调用过程要不要暴露给用户?
Common in ChinaCommon overseasIntermediate#idempotency#retry#uxHow 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.
分析过程 · 先想清楚再作答
- 这题把两件事绑在一起问,考的是你能不能看出它们的共同点:**都是「把不可见的中间状态变成可控的」**。分开答也行,但点出这层关系会显得成熟。
- 重试这一半的推导链:重试意味着同一句话可能被执行两遍 → 两倍 token,还可能两次不可逆的工具调用(比如退款打两次钱)→ 所以要幂等 → 幂等键必须由**客户端在第一次发送时生成**并在重试时原样带上 → 后端拿它做唯一约束,命中就把已有 run 的流接回来,而不是新建。
- 关键判据要说清:**什么时候该换新键?** 判据是「要发送的内容变没变」,不是「用户点了哪个按钮」。同一句话重试用同一个键;用户改了内容重新发,那是新的一句话,必须换新键。
- 顺带提一句这一招的复用面会很加分:落库去重、定时任务防止一个 tick 被消费两次、跨服务调用防重复投递、前端重试——同一个形状用在四个层面,最终裁判永远是数据库的唯一约束,不是应用层的先查后写。
- 工具可视化这一半:中间过程要暴露,理由有三条——用户能判断要不要打断(不然他只能盲等);等待变得可以忍受(十几秒的转圈会让人刷新页面,而刷新意味着这一轮的钱白花);出问题时用户能说清「卡在查订单那一步」,客服和你都省事。
- 可预期的追问:全都暴露会不会泄露内部实现?会,所以要过滤——工具名用人话不用函数名,参数里的用户标识、内部 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.
答题要点
- 重试要带客户端首次生成的幂等键,后端用唯一约束命中后把已有 run 的流接回来,不新建。
- 换不换键的判据是「内容变没变」:同一句话重试用同一个键,改了内容才换新键。
- 同一招在落库、定时任务、跨服务调用、前端重试四处复用,最终裁判永远是数据库的唯一约束。
- 工具调用要可视化:用户才能判断要不要打断、等待变得可忍受、出问题时说得清卡在哪一步。
- 但要过滤:工具名用人话、参数里的内部 id 与密钥不显示、错误显示归类原因而不是原始堆栈。