目标岗位:Agent Harness 研发/工程方向(参考 ~/agent/jb/jd.md) 配套讲义:讲义 02 · Streaming 流式输出 用法:先自己口头答一遍,再对照"参考回答";重点看"考察点"和"坑"。 原则:所有回答结论先行 → 项目证据 → 原理 → 边界与改进,不要背概念。


0. 面试官视角:流式题到底在考什么

流式是"理论简单、工程全是坑"的题目,面试官按三层递进:

  1. 概念层:SSE 是什么、TTFB 是什么;
  2. 实践层:你的网关/前端怎么实现的,能不能现场演示;
  3. 判断层:两段流式缺一段会怎样?取消怎么贯穿三层?流中出错为什么不能 重试?长文本断了怎么办?——这一层才是拉开差距的地方。

回答公式:一句话结论 → 你项目里的实现/例子 → 背后的原理 → 边界与可改进点。 主动说"这里我做得不彻底,我的方案是 X",比被追问出来强得多。


1. 按讲义章节的题目与参考回答

1.1 为什么需要 Streaming

Q1:流式和非流式在用户体验上差在哪?你项目里有数据吗?

参考回答:LLM 生成要几十秒,非流式是"等全部生成完才出现第一个字",用户 看到一片空白;流式是"边生成边显示",用户感知延迟从"总耗时"降到"第一个 token 的时间"。我首页有个对比面板:同一消息 × 两种模式并排跑,流式栏会 显示首字节耗时、总耗时、分片数和分片表。改流式链路之前实测过一次: 非流式总耗时 20662ms,流式首字节 20655ms——首字节几乎等于总耗时,这恰恰 暴露了"上游整体缓冲后回放"的问题,说明只传 stream: true 而不打通传输层, 用户体验没有任何提升。

考察点:是否真的理解"感知延迟";能不能用自己项目里的指标讲,而不是背概念。


Q2:首字节耗时(TTFB)和总耗时有什么区别?

参考回答:TTFB 是请求发出到收到第一块内容的时间,总耗时是收到最后一块的 时间。非流式里两者基本相等(必须等完整结果);真流式里 TTFB 约等于"模型 生成第一个 token 的时间",总耗时不变但感知变快。工程上这是流式最重要的 监控指标——如果首字节≈总耗时,说明链路里某一段被缓冲了(浏览器、反向代理、 或者上游 HTTP 客户端整体读 body)。

加分点:能说出"把 TTFB 单独监控"这个想法(proxy buffering、X-Accel-Buffering)。


1.2 Server-Sent Events

Q3:SSE 协议长什么样?为什么 OpenAI 兼容流式用 SSE 而不是 WebSocket?

参考回答:SSE 是"基于 HTTP 长连接的文本行协议":Content-Type: text/event-stream,每个事件是 data: <JSON>\n\n,OpenAI 兼容约定结束标记 data: [DONE]。我的网关 openai_format.py 负责组装,base.py_iter_sse 逐行解析。为什么用 SSE:模型→客户端是单向流,SSE 走普通 HTTP,任何语言/代理/负载均衡都天然支持,不用单独处理握手和二进制帧; WebSocket 是双向的,适合需要客户端随时回消息的场景,但对"消费一个流式 回复"是杀鸡用牛刀。还有个细节:SSE 客户端(EventSource)自带自动重连, 这是 WebSocket 要自己实现的。

考察点:协议格式 + 选型判断(单向 vs 双向),不是只会说"SSE 是流式"。


Q4:中文内容流式传输有什么坑?

参考回答:SSE 按行分隔,但 TCP 分片不保证 UTF-8 字符边界——“流"字的 多字节编码可能被切到两个分片里。消费端不能按字节切字符串,要用带 stream: trueTextDecoder 解码,并且"最后一行不完整就留到下一轮”。 我前端就是 buf += decoder.decode(value, {stream:true}) 然后 lines.pop() 把不完整的尾部留在缓冲区。

:如果只说"用 JSON.parse",面试官追问"中文乱码怎么办"就答不上来了。


1.3 FastAPI 流式接口:两段式流式

Q5:FastAPI 怎么做流式接口?StreamingResponse 的原理是什么?

参考回答:核心是 StreamingResponse——给它一个生成器,它逐块写回客户端, 而不是等生成器结束再一次性返回。我的 _stream_response 里,生成器先发一个 带 role 的空块(OpenAI 兼容格式要求),然后 for delta in provider.stream(...) 逐块 yield sse_chunk(...),最后发 finish_reason[DONE]。两个头很关键: Cache-Control: no-cache 防浏览器/代理缓冲,X-Accel-Buffering: no 告诉 nginx 别缓冲 SSE。

考察点:生成器逐块 flush 的本质;反向代理缓冲这个真实坑。


Q6:为什么说"StreamingResponse 只是流式的一半"?你们怎么把上游也做成流式?

参考回答:网关→浏览器用 StreamingResponse,但上游→网关如果还是整体缓冲, 首字节照样等上游全部生成完。我之前就是用 httpx.Client.request()——它会 等完整响应体才返回,首页实测"首字节 20655ms ≈ 总耗时 20662ms"就是这么来的。 后来我把 provider 的 stream 改成 httpx.Client.stream + iter_lines() 逐行 转发(base.py_stream_sse),第一个分片一到就 yield。我用 MockTransport 做过量化对比:5 个分片每个间隔 0.2s,request() 要 1.02s 才返回, stream() 首行 0.00s。测试在 test_streaming.py::StreamTransportTest

加分点:能说"两段式流式"(上游→网关、网关→客户端)并给出实测数据, 说明不是抄概念而是真踩过坑。


Q7:流式请求的重试边界是什么?你们怎么实现?

参考回答:首字节前可重试,首字节后不能重试——重试会让客户端看到重复 内容。_stream_sse 里对 429/5xx 做指数退避,但用一个 yielded 标记: 一旦 yield 过任何分片,后续连接中断直接抛 ProviderError("流式中断"), 不再重试。测试专门覆盖了"已流出内容不重试"(连接先给一个分片再断,请求 只发了一次)。

考察点:对"幂等/内容不可重复"的理解;能不能把边界用代码精确表达。


1.4 前端 / CLI 消费

Q8:前端消费 SSE 为什么用 fetch + ReadableStream,而不是 EventSource?

参考回答:EventSource 有三个限制——只能 GET、不能带自定义请求头、不能带 请求体;聊天请求要 POST JSON,所以只能用 fetch + response.body.getReader() 逐块读。EventSource 适合"服务端主动推送"的场景(通知、状态广播),而且自带 自动重连。我前端消费的循环是:reader.read()TextDecoder(stream:true) 解码 → 按行切分 → 只处理 data: 行 → JSON.parsedelta.content

考察点:协议能力边界(GET-only)+ 实际消费流程的细节。


Q9:打字机效果是怎么做的?数据明明到了为什么还要排队逐片渲染?

参考回答:速度快时直接 textContent += delta 看起来还是"一次性输出",而且 浏览器可能把整段缓冲了。我实现了一个 createTypewriter:收到的分片先入队, 每 40ms 从队头取一片渲染,这样无论分片是实时到达还是整段缓冲,视觉上都是 逐字出现的。刻意不 flush 尾部,让"正在逐片回放…“的提示可见,用户能明确 感知这是流式。

加分点:主动说"这是为了演示效果做的队列”,并知道生产上应该直接渲染。


1.5 中断与取消

Q10:用户点"停止",完整链路发生了什么?

参考回答:三层。前端:AbortController.abort() 让 fetch 断开(首页有 “⏹ 停止生成"按钮);网关:Starlette 的 StreamingResponse 检测到客户端断开, 会关闭我的 async 生成器——生成器收到 GeneratorExit(或 CancelledError), 我在异常处理里调用 stream.close();上游:provider 的 stream 用 client.streamwith 块管理连接,生成器被关闭时 with 退出、HTTP 连接 关闭,模型端收到连接关闭才会真正停止生成。一句话:前端 abort → 网关 detect → 上游 close,缺一环 token 就白付。

考察点:能不能把取消讲成"贯穿全链路"而不是只讲前端按钮。


Q11:为什么取消必须关上游连接,只断前端不行?

参考回答:LLM 按 token 计费。只断前端,模型还在继续生成、继续计费。而且 如果上游用的是整体缓冲的 client.request,取消只能断"网关→浏览器”, “模型→网关"这段早就全额生成了——这也是必须改用 client.stream 的另一个 理由:它让"关闭生成器 = 关闭上游连接"变成同一件事。

加分点:把"取消"和"计费"挂钩,说明理解 token 成本。


Q12:你的流式生成器为什么用 asyncio.to_thread 逐块取?有什么边界?

参考回答:provider 目前是同步 httpx,如果直接在事件循环里迭代,读上游会 阻塞整个循环,取消(CancelledError)没法及时送达。所以我每取一个分片都用 asyncio.to_thread(_next_delta, stream) 放到 worker 线程,事件循环保持可 取消。已知边界:如果取消时 worker 线程正好在阻塞读上游,stream.close() 会抛"generator already executing”——我捕获吞掉,最多再多一个分片(或等 读取超时)连接才关闭。严格的即时中断需要把 provider 也改成 async 客户端, 这是我标注的下一步。

加分点:主动说出边界和演进方案,这是判断层回答。 :别吹"我们取消是即时的"——实现里明确有边界,面试官一追问细节就露馅。


1.6 流式异常处理

Q13:流式响应中途出错,为什么 HTTP 状态码还是 200?错误怎么传给客户端?

参考回答:流式响应头已经发出、状态码已经定了,没法改成 4xx/5xx,所以错误 只能作为流中的事件:网关捕获 ProviderError 后写一个 SSE 错误块 ({"error": {...}}),再补 [DONE] 结束,同时记录一条 status=error 的用量。前端解析时看到 obj.error 就显示错误并停止追加内容。首页"模拟 流式错误"按钮(demo:boom)就是演示这条链路:先出"前半段",再出错误块。

考察点:理解"流式错误无法用状态码表达"这个协议约束;能否讲清错误事件流。


Q14:流式场景为什么不能在中途切 fallback?你们实现的边界是什么?

参考回答:非流式失败可以整段换模型重试,客户端无感知;流式不行——用户已经 看到一半内容,中途换模型会导致前后文风断裂。我实现的边界是:FallbackProvider.streamyielded 标记,首字节前失败可切换下一个 provider,一旦流出过内容只抛错。 主 provider 连续失败还会先熔断(冷却期内直接跳过)。测试在 test_streaming.py::FallbackStreamTest,专门覆盖"流出后不切换"。

考察点:对"内容不可回滚"的理解;能不能把安全边界用代码精确表达。


Q15:流式请求的容错有几层?分别解决什么问题?

参考回答:两层,都收敛在"首字节前"这个边界里。第一层同一上游:_stream_sse 对 429/5xx 指数退避重试,解决偶发抖动;第二层跨模型:重试耗尽后 FallbackProvider 换下一个候选模型,解决"这个模型/端点坏了"。首字节后两层 都失效,只能报错让客户端决定是否重发(所以客户端请求要设计成幂等的)。

加分点:能把"重试 vs 回退"两层讲清楚,并关联客户端幂等。


1.7 长文本输出的状态保存

Q16:长文本生成为什么会失败?你们怎么把它做成可恢复的 job?

参考回答:长文本生成可能因为超时、断线、超出 context window 中途失败, 从头重试成本太高。我把"一次生成"建模成 GenerationJob:记录模型、消息、 已生成 partial、断点字符数、状态;后台线程逐块消费 provider.stream 并 追加落盘(ANNA_GENERATIONS_LOG 可选 JSONL,最多 1s 一次节流),断线后 通过 GET /v1/generations/{id} 随时可查已生成内容;cancel 关闭上游流、 continue 从断点续写。首页有"长文本断点"面板可以直接演示:开始 → 取消 → 查看 partial → 继续。

考察点:能不能把"重试成本高"这个工程判断讲出来;job 状态机是否清晰。


Q17:你们 continue 的语义是什么?有什么已知问题?生产怎么演进?

参考回答:v1 的 continue 是"保留已生成内容、重新跑一遍并追加"——它保证 断线后内容不丢,但会重复生成,这是诚实标注的已知问题。生产上的正确做法是 章节规划:先生成大纲,按章节逐个调用模型、每节落盘,continue 从下一节开始, 而不是重跑整篇。面试时能说出"v1 是进度可恢复,演进方向是生成可恢复",说明 你区分了"持久化"和"续写语义"两个层次。

加分点:主动暴露 v1 缺陷并给出演进方案,比藏着强。


Q18:断点落盘怎么做?为什么节流?

参考回答:内存 dict + JSONL 快照重写:每次落盘写临时文件再 replace(原子 替换),启动时逐行恢复。append_partial 在锁内最多 1s flush 一次(每个分片 都写文件 I/O 太大),mark() 在终态兜底 flush,保证 done/error/cancelled 一定落盘。cancel 还会 join 后台线程,确保 partial 稳定后再响应,避免 continue 并发追加。已知边界:这是单进程内存态方案,多实例部署要换成 数据库/对象存储。

考察点:对"写放大"和"原子替换"这类工程细节的理解;单机方案的边界是否清楚。


2. 通用回答技巧(怎么答能拿 offer)

2.1 用 PREP 结构

  • Point:一句话结论(“流式要两段都打通才是真流式”);
  • Reason:原理(TTFB、缓冲、连接关闭与计费的关系);
  • Example:你项目里的实现/演示(对比面板数据、停止按钮、模拟错误、断点面板);
  • Point:回到结论 + 边界与改进(取消边界、continue 演进、多实例存储)。

2.2 准备"弹药库"(每个都能现场演示/指路)

  • 首页"流式 vs 非流式"对比面板(首字节/总耗时/分片表);
  • “⏹ 停止生成"按钮(取消三层链路)与"模拟流式错误"按钮(SSE 错误块);
  • “长文本断点"面板(开始 → 取消 → 查看 partial → 继续);
  • test_streaming.py:真流式重试边界、流中回退边界、取消关闭上游、断点持久化;
  • 讲义 02 §3.1 的 request() vs stream() 实测数据(1.02s vs 0.00s)。

2.3 诚实边界清单(主动说,别等被问)

  • 取消不是即时:取消瞬间上游正在产出时,最多再多一个分片(或等读取超时); provider 还是同步 httpx,严格方案是 async 客户端;
  • 长文本 continue v1 会重复生成,演进方向是章节规划;
  • 生成任务/模板/用量默认内存态(可用 JSONL 落盘),多实例需换存储;
  • 首页"模拟流式错误"用的是内置 demo provider,不是真实厂商;
  • pacing_ms 是教学演示参数,生产不需要;
  • 用量成本是估算,以厂商账单为准;
  • Anthropic 流式 + 结构化输出暂不支持(有 input_json_delta 方案)。

面试官不扣"没做”,扣"没做却吹做了"和"没做也不说下一步”。

2.4 反问环节(展示工程品味)

  • “贵司流式网关怎么监控首字节延迟?代理层缓冲踩过什么坑?”
  • “长文本/流式场景下,取消和 token 计费是怎么对齐的?”
  • “你们的生成任务持久化用什么存储?多实例下怎么保证断点一致?”

3. 一分钟项目介绍(开场自述模板 · 流式版)

“我最近做的 Anna 项目里,有一个阶段成果是 LLM 统一模型调用服务,流式输出 是我做得最扎实的一块。我的理解是流式要打通两段:上游→网关用 httpx.Client.stream 逐行转发,网关→浏览器用 async StreamingResponse 输出 SSE,中间还处理了多字节截断、代理缓冲、首字节前重试这些坑。取消也做了 三层——前端 AbortController、网关关闭生成器、上游连接关闭,避免 token 白付。 长文本我做成可断点的 job,取消后 partial 不丢、能继续。所有行为都有测试 覆盖,首页有对比面板和演示按钮可以直接看。我最想强调的是,这些不是概念, 是能用数据验证的——比如首字节从’≈总耗时’改到’远小于总耗时’,是我实测过的。 当然也有诚实标注的边界,比如取消不是即时、continue 会重复生成,我知道下一步 怎么改。”