逐日AI

面试题库

共 328 题,当前筛选 4 题。

30 天从前端工程师到 Agent 工程师

D25 前端侧 Agent 体验:流式渲染、工具调用可视化、打断/重试、SSE hooks

  • 前端怎么消费 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

    分析过程 · 先想清楚再作答

    1. 这题是送分题,但送分点在后半句。只答「用 EventSource 监听 message 事件」的,面试官会立刻追问鉴权怎么办——答不上来就说明没在真项目里接过。
    2. 先给正面答案的骨架:`fetch` 拿到响应后读 `res.body` 这个 ReadableStream,`TextDecoder` 解码成文本,按空行切帧,逐帧解析出 `event` 与 `data`,把文本增量追加到当前这条消息上。
    3. 为什么不用 `EventSource`,三个硬伤要一口气说全:只能发 GET、不能带自定义请求头(也就是放不进 Authorization)、不能带请求体。Agent 场景里消息体、幂等键、会话 id 都得走 body,三条全撞上。
    4. 紧接着说代价,这是区分「用过」和「读过」的地方:手写解析意味着 `EventSource` 自带的自动重连、`Last-Event-ID` 续传都要自己实现。不过带鉴权的场景里那个自动重连本来就不好用(它重连时同样带不了头),所以损失没听起来那么大。
    5. 可预期的追问一:帧被网络切成两半怎么办?答缓冲——按空行切完之后,最后一段可能是半截,`pop` 出来留到下一块再拼。**这个 bug 在本机直连时几乎不出现**,所以要专门构造切碎的报文来测。
    6. 可预期的追问二:为什么不用 WebSocket?答:SSE 是单向下行、走普通 HTTP、天然过代理和 CDN、实现和运维都更轻;只有需要频繁上行(协同编辑、语音)才值得上 WebSocket。这一条能主动说出来会很加分。

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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

    分析过程 · 先想清楚再作答

    1. 这题是本章题眼,也是一道**陷阱题**:题干里已经把「前端 abort 了」当成既成事实,等你顺着说「那就停了」。答「停了」的直接出局。
    2. 正确答案一句话:**后端什么都不知道,它还在跑。** 还在调模型、还在往库里写消息、还在按 token 计费。`abort` 只是让你这一端不再读了,它顶多让 TCP 连接断开,而后端是否感知得到连接断开、感知到之后做不做事,是另一回事。
    3. 怎么拆:把「谁知道这件事」画出来。用户知道 → 前端知道 → **中间断了** → 后端不知道。断掉的这一环必须用一个显式的请求补上:`POST /runs/:id/cancel`。所以打断是两步,不是一步。
    4. 给一个量化的对照最有说服力:同一段 70 个字的回复,在第 5 个字打断——两步打断的后端停在 5/70,只 abort 的后端照跑到 70/70。差 14 倍的 token,而且那 65 个字还会落进会话历史,下一轮当上下文重新发一遍,付第二遍钱。
    5. 生产视角的补充:cancel 收到之后**不要硬杀**,把 run 迁到 cancelled 状态、让当前这一步跑完再退出——硬杀会留下半写的消息和对不上的序号。而且 cancel 本身必须幂等,因为网络抖动时你会重试它。
    6. 可预期的追问:那能不能靠后端检测连接断开来自动停?可以做,而且应该做(作为兜底),但不能只靠它——反向代理和负载均衡常常会把连接维持一段时间,后端感知到断开可能已经是十几秒之后;而且用户点停止之后如果自动重连,连接根本没断。**兜底归兜底,显式 cancel 才是主路径。**

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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

    分析过程 · 先想清楚再作答

    1. 这题考的是「你有没有在长回复下真的看过掉帧」。答「用 useState 存消息数组,收到 delta 就 setState」在功能上没错,但它暴露的是只在短回复上试过。
    2. 先算一笔账:流式一秒来几十个 token,每个 token 一次 setState 就是一秒几十轮完整渲染。而消息列表是越来越长的,每一轮的代价随对话轮数增长——所以卡顿在回复后半段和长会话里最明显,正好是最不该卡的时候。
    3. 做法是攒批:token 先追加进 ref(不触发渲染),一个定时器每 30 毫秒把攒下的一次性提交。30 毫秒约等于 33 帧每秒,肉眼仍是连续的打字机,渲染次数掉一到两个数量级——实测 200 个 token 只提交 8 次。
    4. 三个必须配套的细节:流结束时强制 flush 一次(否则最后不足一个批次的内容永远留在缓冲里,用户看到回复少半句);打断时也要 flush(让用户看到停在哪个字);缓冲状态必须放 ref 不放 state,否则你为了省渲染写的代码本身在触发渲染。
    5. 再往上一层是分层:**流式逻辑应该活在 React 外面。** 解析、事件归并、攒批都是纯函数,store 持有状态并暴露 subscribe 和 getSnapshot,React 侧只用 useSyncExternalStore 订阅。这样做的直接好处是**这套逻辑可以在没有浏览器的环境里跑单元测试**,而不是只能靠手点。
    6. 可预期的追问:为什么不直接用某个状态库?答:状态库解决的是跨组件共享和更新粒度,而流式的难点在生命周期(连接、取消、卸载清理)和批处理频率——这两件事没有哪个库替你做。面试官问这题想听的是你怎么想,不是你会用哪个库。

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.

D29 弱项补强 + 编码热身:rate limiter、LRU、并发控制、流式 JSON 解析

  • 为什么流式场景下不能直接用 JSON.parse?你会怎么做增量解析?Why can't you just call JSON.parse in a streaming response, and how would you parse incrementally?
    国内高频海外高频深入#streaming#json-parsing

    分析过程 · 先想清楚再作答

    1. 这题的区分度几乎全在你开口的第一句话。听到「流式 JSON 解析」就动手写状态机的人,会花二十多分钟写一个大概率有 bug 的东西;先反问一句「是一行一个完整 JSON,还是一个大对象被切成很多片」的人,已经赢了一半。
    2. 先回答为什么不能直接解析:网络分包不认语法边界,一次读取拿到的很可能是半个 JSON。直接扔给解析器只会抛异常,而且这个异常没有任何可恢复的信息。
    3. 然后做那个关键区分。情况 a 是 SSE:每条事件是一行以 data 开头的文本,行内是完整 JSON,LLM 场景 99% 是这一种,解法是行缓冲加逐行解析,二十行代码——把切分出来的最后一段(可能是半行)留在缓冲区里,等下一次读到更多数据再拼。情况 b 是单个大对象跨分片到达,才需要真正的增量解析。
    4. 情况 b 的核心是三个状态变量:括号深度(深度归零说明一个完整对象结束)、是否在字符串内部(字符串里的括号不能计入深度)、前一个字符是不是反斜杠(转义中的引号不切换字符串状态)。三者缺一不可,少一个遇到含括号的文本就算错深度。
    5. 还有一条几乎没人主动说、但一说就加分的坑:分片是按字节切的,一个汉字在 UTF-8 里占三个字节,边界可能落在中间。必须用流式解码器(TextDecoder 的 stream 选项、Python 的增量解码器、Java 的 InputStreamReader),否则会拿到一个永远补不回来的乱码字符。这是「半行缓冲」在字节层的同款问题。
    6. 可以预期的追问:那结尾那个终止标记怎么办?答案是它不是 JSON,必须在解析前先判断并直接返回,拿它去解析必然抛异常——这是这道题里最常见的一行 bug。

    How to reason about it · think before answering

    1. Almost all the signal is in your first sentence. Whoever starts writing a state machine will spend twenty-plus minutes on something probably buggy; whoever first asks 'is it one complete JSON per line, or one big object split across chunks?' has already won half the question.
    2. Answer the why first: network chunking ignores syntax boundaries, so a single read often holds half a JSON document. Handing that to a parser only throws, and the exception carries nothing you can recover from.
    3. Then draw the distinction. Case (a) is SSE: each event is one line prefixed with data, holding one complete JSON object. This covers 99% of LLM work, and the fix is line buffering plus per-line parsing — keep the trailing fragment in the buffer and stitch it onto the next chunk.
    4. Case (b) — one large object arriving in pieces — is the only case needing real incremental parsing, and it rests on three state variables: bracket depth (back to zero means a complete object), whether you are inside a string (brackets in text must not count), and whether the previous character was a backslash (an escaped quote must not toggle string state). Drop any one and text containing brackets breaks the depth count.
    5. One trap almost nobody volunteers: chunks are split on bytes, and a CJK character takes three bytes in UTF-8, so a boundary can land mid-character. Use a streaming decoder — TextDecoder with the stream option, an incremental decoder in Python, InputStreamReader in Java — or you get a replacement character you can never recover. It is the half-line problem one layer down.
    6. Expect the follow-up: what about the terminator line? It is not JSON, so check for it and return before parsing. Feeding it to the parser is the single most common one-line bug in this question.

    答题要点

    • 网络分包不认语法边界,一次读取可能拿到半个 JSON,直接解析必然抛异常且不可恢复
    • 先问清是哪一种:一行一个完整 JSON(SSE,占 LLM 场景的绝大多数)还是一个大对象跨分片到达
    • 前者只需行缓冲加逐行解析:把最后一段可能的半行留在缓冲区,等下一次读到更多数据再拼
    • 后者才需要状态机,核心是括号深度、是否在字符串内部、前一个字符是否为转义反斜杠三个状态
    • 字节层还有一个同款坑:UTF-8 多字节字符可能被分片切开,必须用流式解码器;结尾的终止标记不是 JSON,解析前要先判断

    Key points

    • Network chunking ignores syntax boundaries, so a read can hold half a document; parsing it throws an unrecoverable error
    • Ask which case it is first: one complete JSON per line (SSE, the overwhelming majority of LLM work) or one large object split across chunks
    • The first case only needs line buffering plus per-line parsing, keeping the trailing partial line for the next chunk
    • Only the second case needs a state machine, tracking bracket depth, inside-string, and escaped-previous-character
    • One layer down, a multi-byte UTF-8 character can be split across chunks, so use a streaming decoder; and the terminator line is not JSON, so check for it before parsing