Packaging It as a Service: Fastify + SSE + Docker (dg P07); Week One Retrospective
Package the agent built over the previous six days into a Fastify service other frontends and services can call, streaming output over SSE and packaged with Docker, wrapping up week one.
今日目标
- 能用 Fastify 暴露一个支持 SSE 流式输出的聊天接口
- 能把 Agent 服务写成 Dockerfile 并本地构建、运行成功
- 能用一句话总结第一周从 LLM API 到 Pi SDK 学到的关键概念
前六天写的代码都是同一种跑法:在自己的终端里敲一行命令,看输出,按 Ctrl+C,只服务你一个人。今天把它变成别人能调用的东西——有地址、有接口约定、能被前端连上、能打包带到任何一台机器上跑。这是 W1 的最后一步,也是 W2 拆分架构的起点。读完回来勾掉上面三条。
小白版讲解
从脚本到服务:接口一公开,假设就全变了
在自己家厨房做饭,你不用考虑几点开门、同时来五桌、客人点了菜单上没有的东西。开门营业那一刻这些问题一个都躲不掉——不是你的菜变了,是使用它的方式变了。
CLI 脚本变成服务也是同一件事。前六天的代码里藏着三个没被挑战过的假设:只有一个用户,那份历史天然属于你,不用问「这条消息是谁的」;串行执行,不存在两件事同时改同一份状态;输入可信,参数是你自己敲的,不会是十兆字节的字符串或缺字段的 JSON。三条在服务里全部不成立,第一条翻车最快也最难查——它在本地单人测试时表现完美。
还有一条更隐蔽,而且昨天刚被你解决过一次:D6 你已经把会话写进了 .sessions/*.jsonl,另起一个进程也读得回来。但服务化之后这条路立刻失效——为了扛并发你会起两个实例,各写各的本地盘,用户第二次请求被负载均衡打到另一台,那台磁盘上根本没有他的历史。D6 解决的是「进程重启」,服务化引入的是「多实例」,不是同一个问题。 所以今天先退一步,把历史放回进程内存、按会话隔离,让服务先跑起来;挪到独立于任何一台实例的地方是 D8 的事,也正是下周要拆 Gateway 与 Worker 的动机。
// 脚本时代:全世界只有一个用户,一个模块级数组就够了
// const history = []
// 服务里两个人同时聊天,就会写进同一个数组,A 会看到 B 的对话
// 正确做法:按会话隔离
const histories = new Map()
function historyOf(sessionId) {
if (!histories.has(sessionId)) histories.set(sessionId, [])
return histories.get(sessionId)
}
// 注意:Map 只是退一步的临时方案——D6 的本地文件在多实例下失效,D8 换成 Postgresfrom collections import defaultdict
# defaultdict 省掉「不存在就先建一个」的样板代码
histories: defaultdict[str, list[dict]] = defaultdict(list)
def history_of(session_id: str) -> list[dict]:
return histories[session_id]
# 提醒:多 worker 部署时每个进程各有一份这样的字典,
# 同一个用户第二次请求落到另一个 worker 就查不到历史了// 服务端天生多线程:普通 HashMap 在并发写下会丢数据,
// 用 ConcurrentHashMap + computeIfAbsent 一步拿到或创建,整个操作是原子的
static final Map<String, List<Message>> HISTORIES = new ConcurrentHashMap<>();
static List<Message> historyOf(String sessionId) {
return HISTORIES.computeIfAbsent(sessionId,
key -> Collections.synchronizedList(new ArrayList<>()));
}// Swift 的地道做法是 actor:编译器保证同一时刻只有一个任务在改这份状态,
// 不需要你自己加锁,也不可能忘记加
actor SessionStore {
private var histories: [String: [Message]] = [:]
func history(for sessionID: String) -> [Message] {
histories[sessionID] ?? []
}
func append(_ message: Message, to sessionID: String) {
histories[sessionID, default: []].append(message)
}
}四份代码解决同一个问题,但各语言的默认危险程度不同:JavaScript 单线程让你侥幸躲过一部分并发问题,Java 必须显式选并发容器,Swift 用 actor 从类型层面禁止你写错。换语言做服务,第一件要问的是「并发安全由谁负责」。
会话隔离只是入场券。接口设计还有四个决定:接口形状(一次性返回完整 JSON 还是边生成边推)、会话标识(客户端带 sessionId 还是服务端发 cookie)、鉴权与限流、错误怎么表达。
最后一条最容易漏,也最要命:流式接口的错误没法用 HTTP 状态码表达。 200 和第一个字节一旦写出去,状态码就已经在网络上了;之后模型超时、余额不足、上游 500,都只能在流里补发一个约定好的 error 事件。所以接口文档必须写清楚正常、结束、出错三种事件各叫什么,并在推流之前把能校验的都校验完——那是你最后一次能用状态码好好说话的机会。
接口约定定下来之后,真正难的是那条一直开着的连接本身:响应头写错浏览器直接拒收,空闲两分钟被网关无声掐断,用户关掉页面而你还在为他一个字一个字地烧钱。下面从起服务开始,一件一件解决。
用 Fastify 起服务:流式接口为什么要绕开框架
电视台播一期录好的节目,流程是固定的:成片交上来、片长确定、排进节目表、到点播出。可一旦切直播,这套流程全用不上——现场什么时候结束没人知道,信号只能边发生边推出去,导播台能做的就是让开,把线路直接交给前方。
Web 框架就是那套演播室流程,SSE 就是那条直播线路。框架的正常路径是:处理函数返回一个对象,框架序列化成 JSON、算出 Content-Length、一次性写完、关连接——每一步都假设响应是一个长度确定的完整结果。SSE 恰恰相反:长度未知、要分很多次写、写完第一批连接还得开着。选 Fastify 不是因为跑分高,而是它把「让开」做成了明确的 API:reply.hijack() 之后它不再管这条响应,你拿 reply.raw 自己写;它还自带 JSON Schema 校验,「推流之前校验完」有地方放。
这个 /chat 后面接的不是新东西,就是 D3 用 Pi SDK 收编的那个 Agent 循环,加上 D4 的模型调用层和 D5 那批会自纠错的工具。变的只有入口和出口:从「读一行 stdin」变成「读一个 HTTP 请求体」,从「打印到终端」变成「写进一条 SSE 流」。服务化不改 Agent 内核,只给它换一个外壳。
import Fastify from 'fastify'
const app = Fastify()
app.get('/healthz', async () => ({ ok: true }))
app.post('/chat', async (request, reply) => {
const message = String(request.body?.message ?? '').trim()
// 推流之前是最后一次能用状态码说话的机会
if (!message) return reply.code(400).send({ error: 'message 不能为空' })
reply.hijack() // 交出控制权:接下来我自己写原始字节
const res = reply.raw
res.writeHead(200, SSE_HEADERS)
for await (const chunk of streamChat(message)) {
res.write(`event: delta\ndata: ${JSON.stringify({ text: chunk })}\n\n`)
}
res.write('event: done\ndata: {}\n\n')
res.end()
})
// 监听 0.0.0.0 而不是 127.0.0.1,进容器之后才连得上
await app.listen({ port: 3000, host: '0.0.0.0' })import json
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.post("/chat")
async def chat(body: ChatBody) -> StreamingResponse:
if not body.message.strip():
raise HTTPException(status_code=400, detail="message 不能为空")
async def frames():
async for chunk in stream_chat(body.message):
payload = json.dumps({"text": chunk}, ensure_ascii=False)
yield f"event: delta\ndata: {payload}\n\n"
yield "event: done\ndata: {}\n\n"
# StreamingResponse 就是 FastAPI 版的「自己写字节」:
# 传一个异步生成器进去,框架逐段转发,不再等一个完整结果
return StreamingResponse(frames(), media_type="text/event-stream")// Spring WebFlux 更进一步:返回 Flux<ServerSentEvent> 就行,
// 帧格式和响应头都由框架拼——四门语言里只有它把 SSE 做成了一等公民
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<String>> chat(@RequestBody ChatBody body) {
if (body.message() == null || body.message().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "message 不能为空");
}
return streamChat(body.message())
.map(chunk -> ServerSentEvent.<String>builder()
.event("delta")
.data(chunk)
.build());
}// Vapor 把响应体声明成一个异步写入流,帧要自己拼——和 Node 版最像
app.post("chat") { req async throws -> Response in
let body = try req.content.decode(ChatBody.self)
guard !body.message.isEmpty else { throw Abort(.badRequest, reason: "message 不能为空") }
var headers = HTTPHeaders()
headers.add(name: .contentType, value: "text/event-stream; charset=utf-8")
headers.add(name: .cacheControl, value: "no-cache, no-transform")
return Response(headers: headers, body: .init(asyncStream: { writer in
for try await chunk in streamChat(body.message) {
let payload = try String(data: JSONEncoder().encode(Delta(text: chunk)), encoding: .utf8) ?? "{}"
try await writer.write(.buffer(.init(string: "event: delta\ndata: \(payload)\n\n")))
}
try await writer.write(.end)
}))
}跨语言对照值得留意:Spring WebFlux 把 SSE 做成了一等公民,帧格式都不用碰;Node 和 Swift 要自己拼字符串。这直接影响排查方向——手拼帧的语言里,「客户端收不到」绝大多数是帧写错了;框架代拼的语言里,同样的故障多半出在代理层。
再留意 /healthz 那个两行的路由。它看着没用,但容器编排和负载均衡全靠它判断「这个实例能不能接流量」——没有健康检查接口的服务,在编排系统眼里就是个黑箱。
SSE 的服务端一侧:响应头、心跳、连接断开
想象一家餐厅的后厨出餐口:做好一道递出去一道,客人边吃边等。这个位置有三件事必须做对:窗口得开着、隔一会儿要有动静(不然外面以为后厨没人了)、客人中途走了要知道(别继续做那桌的菜)。
这一节和 D1 是同一件事的两面。 D1 站在取餐那一侧:怎么一块块地收、半行怎么拼、断了怎么接着要;今天站在窗口里面:怎么发、发之前声明什么、发的过程中怎么让连接活着。报文长什么样 D1 已经讲过,下面只讲产生它的一侧。
先是响应头。四个,一个都不能少:
| 响应头 | 不写会怎样 |
|---|---|
Content-Type: text/event-stream | 浏览器的 EventSource 直接判定连接失败,而且不重连 |
Cache-Control: no-cache, no-transform | 中间代理可能缓存整条响应,或者「好心」帮你压缩改写正文 |
Connection: keep-alive | 明确声明这条连接要一直开着 |
X-Accel-Buffering: no | Nginx 默认攒够一个缓冲区才转发,攒着攒着流式就变回一次性返回 |
最后一条是最典型的「本机好好的、上线就没了」:本地 curl 一切正常,一挂到反向代理后面,用户看到的还是转圈然后整段刷出来。流式功能的故障八成不在你的代码里,在中间那一层。
再看帧格式。一帧由若干行组成,event: 是事件名,data: 是内容,id: 是编号,冒号开头且没有字段名的是注释行。关键是:空行才代表一帧结束。 少写一个换行,客户端会一直等下去,现象是「服务端卡住了」,但服务端日志一切正常。
id: 1
event: delta
data: {"text":"你"}
: 这是一行注释,客户端会忽略它
event: done
data: {"chunks":103}顺带认领一下事件名的来历:delta / done / error 不是今天新发明的,对应的是 D5 那条内部事件流里的 model:delta 和 run:end / run:error——D5 里它们只打印在终端里;今天给这三种事件套上 SSE 的信封,搬到了网络那头。D5 里 tool:start 这类工具事件今天还没有对应的帧,SSE 化留到以后再展开。事件系统设计得好,服务化时只是给它加一层编码。
然后是心跳,定期往连接里写一行注释。负载均衡和网关普遍有空闲超时,常见值 60 到 120 秒,一段时间没有字节流动就关连接。而 Agent 恰恰有大量「静默期」——模型在思考、在调工具、在等一个慢接口,几十秒不吐一个字是常态。用注释行而不是自定义事件,是因为它会被所有客户端安静忽略。
最后是连接断开。用户关掉页面那一刻,服务端不会自动停——模型还在生成,token 还在计费,只是没人接收。这是流式服务里最贵的一个疏忽,而且在测试环境完全暴露不出来。
// 心跳:注释行,客户端忽略,但对网关来说这就是「还活着」的字节
const timer = setInterval(() => res.write(': ping\n\n'), 15000)
timer.unref()
const controller = new AbortController()
// 监听的是响应对象的 close,不是 request 的——
// Node 里 request 的 close 在请求体读完时就触发,会把正常请求误判成断线
res.on('close', () => {
if (!res.writableEnded) controller.abort() // 掐掉上游,别为已经走了的人烧钱
})
try {
for await (const chunk of streamChat(message, controller.signal)) {
res.write(`event: delta\ndata: ${JSON.stringify({ text: chunk })}\n\n`)
}
} finally {
clearInterval(timer)
if (!res.writableEnded) res.end()
}import asyncio
import json
import time
async def frames(request):
last_ping = time.monotonic()
async for chunk in stream_chat(request.state.message):
# Starlette 直接提供了「客户端还在不在」的查询,不用自己监听底层事件
if await request.is_disconnected():
break
if time.monotonic() - last_ping > 15:
yield ": ping\n\n"
last_ping = time.monotonic()
payload = json.dumps({"text": chunk}, ensure_ascii=False)
yield f"event: delta\ndata: {payload}\n\n"// Reactor 里「取消」是一等公民:客户端断开会把 cancel 信号一路传到上游,
// doOnCancel 就是 Java 版的「别再为已经走了的人烧 token」
Flux<ServerSentEvent<String>> deltas = streamChat(message)
.map(chunk -> ServerSentEvent.<String>builder().event("delta").data(chunk).build())
.concatWith(Mono.just(ServerSentEvent.<String>builder().event("done").data("").build()))
.doOnCancel(() -> log.warn("客户端断开,已取消上游生成"));
// 心跳是独立的一条流,comment() 发的正是以冒号开头的注释帧
Flux<ServerSentEvent<String>> ping = Flux.interval(Duration.ofSeconds(15))
.map(tick -> ServerSentEvent.<String>builder().comment("ping").build());
// 合流之后,正文发出 done 就整体结束,心跳跟着一起停
return Flux.merge(deltas, ping).takeUntil(event -> "done".equals(event.event()));// Swift 结构化并发:心跳单开一个 Task,defer 保证连接一断它也跟着停,
// checkCancellation 让生成循环在被取消时立刻退出
return Response(headers: headers, body: .init(asyncStream: { writer in
let heartbeat = Task {
while !Task.isCancelled {
try await Task.sleep(for: .seconds(15))
try await writer.write(.buffer(.init(string: ": ping\n\n")))
}
}
defer { heartbeat.cancel() }
for try await chunk in streamChat(message) {
try Task.checkCancellation()
try await writer.write(.buffer(.init(string: frame(for: chunk))))
}
try await writer.write(.end)
}))还剩一件今天只能做一半的事:给事件发号。帧里的 id: 是给客户端记的号,浏览器原生 EventSource 重连时会用 Last-Event-ID 带回来。但 D1 讲过:大模型接口必须 POST,EventSource 只能发 GET,真实前端都是手写解析,那套自动重连用不上。服务端能做的是把号发好、把推出去的内容另存一份——真正的断点续发需要一个独立于这条连接存在的地方存流,那是 W2 要搭的。
把服务装进集装箱:一个最小 Dockerfile
集装箱发明之前,货物形状五花八门:桶装、麻袋装、散装,换一次船就要人工重新码放。集装箱本身没有技术含量,全部价值在于把尺寸和吊具接口标准化了——之后所有船、卡车、吊车、港口都围绕同一个接口设计,装卸成本掉了一个数量级。
Docker 对软件做的是同一件事:把「怎么把这个程序跑起来」标准化成一个接口,不管里面是 Node、Python 还是 Java,外面都是「一个镜像 + 一条 docker run」。它解决的不是性能问题,是交接问题——你交出去的不再是一篇安装文档。
# 钉版本,别用 latest:镜像的价值就是「换台机器结果一样」
FROM node:22-alpine
RUN corepack enable
WORKDIR /app
# 关键:先只拷依赖清单,装完再拷业务代码。
# Docker 逐层缓存,改一行 src 只让最后两层失效,依赖那层照旧命中——
# 反过来一上来就 COPY . . 的话,改一个字都要把依赖重装一遍
COPY package.json pnpm-lock.yaml .npmrc ./
# 后面那个开关不是凑数的,理由见正文下一段
RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false
COPY tsconfig.json ./
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
EXPOSE 3000
USER node
CMD ["node", "--import", "tsx", "src/index.ts"]短短二十行藏着五个高频面试点。第一是分层缓存:镜像逐层叠出来,某层输入没变就复用,指令顺序直接决定构建要几秒还是几分钟。第二是钉版本:node:latest 等于让镜像某天悄悄升到下一个大版本,可复现性归零。第三是监听地址:容器里必须听 0.0.0.0,只听 127.0.0.1 就只在容器内可达,宿主机做了端口映射也连不上——头号新手坑,因为本机跑完全正常。第四是不用 root:容器和宿主机共用内核,用 root 跑业务等于把逃逸后的破坏面开到最大。
第五个和 SSE 直接相关,也最容易被忽略:PID 1 与信号。 容器里第一个进程就是 PID 1,docker stop 发的是 SIGTERM。启动命令写成 pnpm start,PID 1 就是包管理器,SIGTERM 未必传得到 node,优雅退出永远不执行,只能等十秒超时被 SIGKILL 强杀——对 SSE 服务那意味着所有在途的流被硬切,用户看到回复说了一半突然没了。所以要用数组形式直接起 node,收到 SIGTERM 先停止接受新连接、给在途的流留一点收尾时间再退出。
装依赖那行末尾的 --config.strictDepBuilds=false 得说一句:pnpm 11 遇到「有依赖的构建脚本被忽略」会以非 0 退出,本机跑只是多打一行提示,写进 Dockerfile 就是一条失败的 RUN,docker build 当场挂掉。容器构建会把所有非 0 退出都升级成致命错误,这是脚本搬进镜像最常见的一类翻车。
还有两条纪律:.dockerignore 必须排除 node_modules(宿主机的二进制在 Linux 容器里跑不起来)和 .env(key 打进镜像等于发给每个能拉到镜像的人,运行时用 --env-file 传才对)。
W1 复盘:七天其实是一条线
回头看这一周,它不是七个独立话题,而是一个 Agent 一步步长出来的过程,每天都被前一天逼出来:
- D1 搞清楚跟模型对话是怎么回事:
messages数组、三种角色、上下文窗口、流式输出、temperature。结论是模型没有记忆、没有手脚,只会续写。 - D2 于是手写循环和工具让它能动手——判断「停止原因」决定要不要继续,把工具结果变成一条消息喂回去。
- D3 手写之后发现这段循环在每个 Agent 里都长一样,于是用 Pi SDK 收编成三行 API,再对照看框架替你做了什么。
- D4 框架替你做的事里有两件不该由它决定:用哪家模型、人设是什么。于是拆出模型调用层做 fallback,并显式写了系统提示词。
- D5 工具会崩、参数会填错,于是有了参数校验和错误回传让模型自纠错,还有事件订阅把内部状态暴露出来。
- D6 工具结果和多轮对话把
messages撑爆了,于是有了上下文压缩与会话持久化——Agent 长出了记忆。 - D7 今天把这一切装进服务:有接口、有流式推送、有容器镜像,别人终于能调用它了。
如果只带走三句话,我建议这三句。第一,模型之外的一切都得你自己搭。 循环、工具、记忆、可靠性、可观测性,没一样是 API 自带的——这也正好回答了「Agent 工程师在干什么」。第二,每加一层抽象,都是为了把某一种变化关进一个房间。 模型调用层关厂商差异,工具 schema 关模型输出的不确定,上下文压缩关窗口上限,服务层关调用方差异;看不出一层在关什么变化,那层多半多余。第三,每个技术决定都对应一笔账单。 多轮成本随轮数增长、fallback 让同一段提示词付两次费、用户走了没停生成就是纯亏。
留一个问题给下周:会话历史现在躺在进程内存里,多开一台实例就散架,而 D6 的本地文件同样救不了它。单进程服务的天花板不是性能,是状态放错了地方。
源码导读
动手实验
这个实验和前几天不一样:它是常驻服务,pnpm start 起来不会自己退出,所以多了一个自检模式——SELFTEST=1 让进程启动后自己给自己发几个请求、打印结果再退出,一条命令就能验收。starter/ 挖的四个练习点正对应本章四个坑:响应头、帧格式、心跳、断开处理;自检前两项(健康检查、空参数返回 400)脚手架已写好,一开始就是 ✅,挖空点影响的是后两项。验证时记得给 curl 加 -N。
再说清实验的边界,免得你以为 W1 白做了:它聚焦服务层,Agent 内核用一个最小替身代替——只负责把回复一个字一个字吐出来的 streamChat,没有工具也没有历史回填。前六天做的完整 Agent 接进来,只是把这一个函数换掉,接口形状、帧格式、断开处理一行都不用改。服务层这四个坑各自都能独立翻车,混进 Agent 逻辑只会让你分不清是哪一层的问题。
- 先原样跑一次
MOCK=1 SELFTEST=1 pnpm start,看清第 3、4 项的 ❌ 长什么样(前两项脚手架已写好,一开始就是 ✅);然后补齐 SSE 响应头和帧格式,让第 3 项变 ✅。 - 另开一个终端
MOCK=1 pnpm start,用curl -N打POST /chat,肉眼确认回复是一帧一帧冒出来的,不是一次性刷屏。 - 加上心跳定时器和连接断开处理,重跑自检:第 4 项的「服务端一共生成了 N 个片段」要从 103 掉到个位数。
- 用
docker build打出镜像,docker run -p 3000:3000起起来,从宿主机 curl 一遍;再docker stop,确认它一秒内就退出了。 - 写一段 W1 复盘笔记:七天各用一句话,再回答「哪一天你现在还讲不清楚」——那条就是周末要补的。
面试题
今天 5 道题在下方题库区,覆盖 SSE 与 WebSocket 选型、脚本改服务的接口设计、Dockerfile 关键决定、长连接的心跳与优雅退出,最后一道是自我介绍。先看"分析过程"再看要点——第 5 题没有标准答案,但它的推导框架这两个月你会反复用到。
检查清单与明日预告
- 能用 Fastify 暴露一个支持 SSE 流式输出的聊天接口
- 能把 Agent 服务写成 Dockerfile 并本地构建、运行成功
- 能用一句话总结第一周从 LLM API 到 Pi SDK 学到的关键概念
- 能说清 SSE 服务端的四个响应头分别防的是什么,以及心跳为什么用注释行
- 能解释「流式接口一旦推流就没法用状态码报错」,并说出替代方案
- 实验的 5 条验收标准全部通过(Docker 那条没环境可跳过)
- 5 道面试题不看要点也能答出至少 3 道
明天(D8)进入第二周,第一件事就是把今天这个单进程服务拆开:接入层只做鉴权、限流和投递,执行层专心跑 Agent 循环,中间用队列解耦。为什么是这个顺序?因为只有亲手写过一个「什么都自己扛」的服务,才知道拆分要解决什么——今天那份进程内存里的会话 Map,明天会变成 Postgres 里的 sessions、runs、messages 三张表。
Interview questions
For streaming LLM responses, would you pick SSE or WebSocket, and why?流式返回大模型回复,你会选 SSE 还是 WebSocket?为什么?
Common in ChinaCommon overseasBasic#sse#streaming#api-designHow to reason about it · think before answering
- The hinge is 'how would you pick', not 'what is the difference'. Reciting 'SSE is one-way, WebSocket is two-way' scores nothing — that is the first paragraph of any doc.
- Ask one question that nearly decides it: does the client need frequent upstream messages on this connection? Chat completion is one request followed by a long push, which is exactly SSE's shape. Collaborative editing, realtime games and voice are what WebSocket is for.
- Give three practical wins for SSE: it is ordinary HTTP, so auth headers, cookies, rate limiting, logging, CDNs and reverse proxies all keep working; the server just writes bytes into a response, with no separate connection lifecycle to manage; and the wire format is plain text, so curl is your debugger. WebSocket runs an upgraded protocol where most of that tooling has to be rebuilt.
- Volunteer SSE's two real limits before they are raised. First, the browser's native EventSource can only issue GET, while model endpoints require POST, so real frontends hand-roll the parser with fetch and the spec's Last-Event-ID auto-reconnect never applies. Second, HTTP/1.1 caps concurrent connections per origin, so several tabs each holding a stream compete; HTTP/2 largely removes this.
- Land on a decision rule: one-way push means SSE, high-frequency bidirectional means WebSocket, and when unsure start with SSE — its escape hatch is adding one upstream endpoint, while WebSocket's escape hatch is rebuilding your infrastructure.
- Expect the follow-up: what about the 'stop generating' button? It does not need the same connection — send a plain POST carrying the run id, have the server abort upstream, and the SSE stream ends on its own. This one separates people who shipped it from people who read about it.
分析过程 · 先想清楚再作答
- 这题的题眼是「怎么选」,不是「有什么区别」。只背出「SSE 单向、WebSocket 双向」拿不到分,因为那是文档第一段。
- 先问自己一个问题,它几乎决定了答案:这条连接上客户端需不需要频繁上行?聊天补全是「一次请求、一路往回推」,上行只有最开始那一次,完全落在 SSE 的形状里;协同编辑、实时游戏、语音这种双向高频才轮到 WebSocket。
- 然后给 SSE 的三条实际好处:它就是普通 HTTP,鉴权头、Cookie、限流、日志、CDN、反向代理这一整套现成设施全部照用;服务端只是往响应里写字节,不需要额外的连接管理;协议是纯文本,出问题 curl 一下就能看。WebSocket 走的是升级后的独立协议,前面那套东西大多要重做一遍。
- 接着说 SSE 的两个真实限制,主动说破比被问出来强:一是浏览器原生的 EventSource 只能发 GET,而大模型接口必须 POST,所以真实前端都是 fetch 手写解析,规范里那套 Last-Event-ID 自动重连一行都用不上;二是 HTTP/1.1 下同域并发连接数有限制,多个标签页各开一条长连接会互相挤占,HTTP/2 之后这条基本消失。
- 结论要落到一句可判断的话:单向推送选 SSE,双向高频选 WebSocket;拿不准就先用 SSE,因为它的退路是加一个上行接口,而 WebSocket 的退路是重做整套基础设施。
- 可以预期的追问:那大模型产品里的「停止生成」按钮怎么办?答案是它根本不需要走同一条连接——另发一个普通的 POST 请求带上这次生成的 id,服务端收到就中止上游,SSE 那条连接自然结束。这个追问很能区分有没有真做过。
Key points
- Decide by upstream frequency: one request plus a long push (chat completion) fits SSE; high-frequency bidirectional traffic needs WebSocket
- SSE is plain HTTP, so auth, rate limiting, logging, proxies and CDNs all still apply, and curl is enough to debug it
- Name SSE's limits yourself: EventSource is GET-only while model endpoints need POST, so spec auto-reconnect does not apply; HTTP/1.1 also caps per-origin connections
- When unsure start with SSE — adding one upstream endpoint is cheaper than rebuilding infrastructure around WebSocket
- A stop button does not need the same connection: POST the run id and abort upstream, and the stream ends by itself
答题要点
- 先判断上行频率:一次请求、一路往回推的场景(聊天补全)用 SSE,双向高频(协同编辑、语音)用 WebSocket
- SSE 就是普通 HTTP,鉴权、限流、日志、代理、CDN 这套设施全部照用,排查时 curl 就够
- SSE 的限制要主动说:EventSource 只能 GET,而模型接口必须 POST,所以自动重连用不上;HTTP/1.1 下同域连接数有限
- 拿不准先选 SSE:加一个上行接口就能补足,而换 WebSocket 要重做整套基础设施
- 「停止生成」不用走同一条连接,另发一个 POST 带 run id 让服务端中止上游即可
When turning a local agent script into a production service, what does the interface layer have to get right?把一个本地跑的 Agent 脚本改造成生产服务,接口层要重点考虑哪些事?
Common in ChinaCommon overseasIntermediate#api-design#service-architecture#streamingHow to reason about it · think before answering
- This tests whether you can name the assumptions hidden in a script. A generic checklist (auth, logging, monitoring) scores nothing; name the assumptions that silently break.
- List them first: one user (so history can live in a module-level variable), serial execution (no two requests mutating the same state), trusted input (you typed the arguments yourself), and a process whose life equals the session's. All four break in a service, and the first is hardest to catch because single-user local testing looks perfect.
- Then give the four decisions: response shape (single JSON versus streamed events), session identity (client-supplied id versus server cookie, and where history is stored), authentication and rate limiting (who may call, how often, and the per-call token ceiling), and how errors are expressed.
- Expand the last one — it is where this question is actually won. Once a streaming endpoint has written 200 and the first byte, the status code is already on the wire, so a later timeout, out-of-credit or upstream 500 can only surface as an agreed error event inside the stream. Validate everything you can before the first byte, because that is your last chance to speak in status codes.
- Add a production note: ship a health endpoint. Without one, orchestrators and load balancers cannot tell whether an instance is ready, and rolling deploys send traffic to a process that has not finished booting.
- Expect the follow-up: why cap tokens per request at the interface layer? Because agent cost is triggered by the caller and paid by you — no cap means handing your wallet to the client. Rate limiting is about money per call, not just QPS.
分析过程 · 先想清楚再作答
- 这题考的是「你知不知道脚本里有哪些隐含假设」。答成一份笼统的清单(鉴权、日志、监控)拿不到分,要说出脚本时代默认成立、服务里立刻不成立的那几条。
- 先把假设列出来,这是最能体现工程视角的一步:只有一个用户(历史可以放模块级变量)、串行执行(不会有两个请求同时改一份状态)、输入可信(参数是自己敲的)、进程和会话同生共死(Ctrl+C 之后不用交代)。四条在服务里全部不成立,而第一条最难查,因为它在本地单人测试时表现完美。
- 然后给出四个必须做的决定:接口形状(一次性 JSON 还是流式推送)、会话标识(客户端带 sessionId 还是服务端发 cookie,以及历史存哪里)、鉴权与限流(谁能调、多久能调一次、单次 token 上限)、错误怎么表达。
- 第四条要单独展开,它是这题真正的区分点:流式接口一旦写出 200 和第一个字节,状态码就已经发出去了,之后模型超时、余额不足、上游 500,都只能在流里补发一个约定好的 error 事件。所以推流之前必须把能校验的全部校验完,那是你最后一次能用状态码好好说话的机会。
- 再补一条生产视角:服务要有健康检查接口。没有它,编排系统和负载均衡就没法判断这个实例能不能接流量,滚动发布时会把请求打给一个还没起好的进程。
- 可以预期的追问:单次请求的 token 上限为什么要在接口层限制?因为 Agent 的成本是请求方触发、你来买单,不设上限就等于把钱包交给调用方——限流限的不只是 QPS,还有每次调用能烧多少钱。
Key points
- A script's four assumptions all break in a service: single user, serial execution, trusted input, and a process that dies with the session
- Session state must be keyed by session id, and in-process storage means data is lost on restart and blocks horizontal scaling
- Four interface decisions: response shape, session identity, auth and rate limiting including a per-call token ceiling, and error semantics
- A streaming endpoint cannot report errors by status code after the first byte, so define an in-stream error event and move all validation ahead of it
- Expose a health endpoint, or orchestrators cannot tell whether the instance is ready for traffic
答题要点
- 脚本的四个隐含假设在服务里全部不成立:单用户、串行、输入可信、进程与会话同生共死
- 会话状态必须按 sessionId 隔离,且要意识到放进程内存意味着重启即丢、无法水平扩容
- 四个接口决定:响应形状、会话标识、鉴权与限流(含单次 token 上限)、错误表达方式
- 流式接口推流之后无法用状态码报错,必须约定一个流内的 error 事件,并把校验全部前置到第一个字节之前
- 提供健康检查接口,否则编排系统无法判断实例能不能接流量
What are the key decisions in a Dockerfile that packages a Node service?把一个 Node 服务打包成 Docker 镜像,Dockerfile 里有哪些关键决定?
Common in ChinaCommon overseasIntermediate#docker#deployment#nodejsHow to reason about it · think before answering
- It looks like a recipe question, but it tests whether you have ever traded off build speed against security. Reading FROM, COPY, RUN, CMD in order is the least differentiating answer.
- The first decision is instruction order, the only one with an immediately measurable payoff. Images are stacked layers and a layer whose inputs are unchanged is reused, so copy the manifest and lockfile first, install, then copy source. Editing one line of code then invalidates only the last two layers instead of forcing a full reinstall.
- Second, pin the base image. Using latest means the image silently jumps a major version some morning, which destroys the reproducibility that was the whole reason to containerize.
- Third, runtime configuration: bind to 0.0.0.0 inside a container. Binding 127.0.0.1 leaves the service reachable only from inside, so a published port still refuses connections — and it works perfectly on your laptop, which is why it is so common. Also note EXPOSE only documents intent; the port is actually published by docker run -p.
- Fourth, security: run as a non-root user, since containers share the host kernel and root widens the blast radius of an escape. Keep node_modules out via .dockerignore (host binaries will not run in a Linux container and the build context balloons) and keep .env out too, passing secrets at runtime with --env-file.
- Expect the follow-up, and it is the one a streaming service should volunteer: use the exec-form CMD to launch node directly so it becomes PID 1. With pnpm start, PID 1 is the package manager, SIGTERM from docker stop may never reach node, your graceful shutdown never runs, and the container is SIGKILLed after the timeout — cutting every in-flight SSE stream.
分析过程 · 先想清楚再作答
- 这题看着是背步骤,其实考的是「你有没有为构建速度和安全性做过取舍」。把 FROM、COPY、RUN、CMD 顺着念一遍是最没有区分度的答法。
- 第一个决定是指令顺序,也是唯一能立刻量化收益的:镜像是逐层叠出来的,某层的输入没变就复用缓存。所以先只拷 package.json 和 lockfile、装完依赖再拷源码——改一行业务代码只让最后两层失效,依赖那层照旧命中;反过来一上来就 COPY 全部,改一个字都要重装依赖。
- 第二个是基础镜像钉版本。写 latest 等于让镜像在某天悄悄升到下一个大版本,可复现性当场归零,而可复现正是用容器的全部理由。
- 第三个是运行时配置:容器里必须监听 0.0.0.0,只听 127.0.0.1 的话它只在容器内部可达,宿主机做了端口映射也连不上——这个坑在本机跑的时候完全正常,所以特别常见。另外 EXPOSE 只是声明意图,真正开端口的是 docker run 的 -p。
- 第四个是安全:用非 root 用户跑业务进程(容器和宿主机共用内核,逃逸后 root 的破坏面大得多),.dockerignore 排除 node_modules(宿主机的二进制在 Linux 容器里跑不起来,还会让构建上下文暴涨)和 .env(密钥打进镜像等于发给每个能拉到镜像的人,运行时用 --env-file 传)。
- 可以预期的追问,也是长连接服务最该主动说的一条:CMD 要用数组形式直接起 node,让它当 PID 1。写成 pnpm start 的话 PID 1 是包管理器,docker stop 的 SIGTERM 未必传得到 node,优雅退出代码永远不执行,只能等十秒超时被 SIGKILL——对 SSE 服务,那意味着所有在途的流被硬切。
Key points
- Instruction order drives cache hits: copy the manifest, install, then copy source, so code edits do not reinstall dependencies
- Pin the base image instead of latest — reproducibility is the entire point of containerizing
- Bind 0.0.0.0 inside the container; EXPOSE only documents intent while docker run -p publishes the port
- Run as a non-root user, and keep node_modules and .env out via .dockerignore, injecting secrets at runtime
- Use exec-form CMD to run node as PID 1 so SIGTERM reaches it and graceful shutdown actually executes
答题要点
- 指令顺序决定缓存命中:先拷依赖清单装依赖,再拷源码,改代码不会触发重装依赖
- 基础镜像钉版本不用 latest,可复现是用容器的全部理由
- 容器里监听 0.0.0.0;EXPOSE 只是声明,真正开端口靠 docker run -p
- 用非 root 用户运行;.dockerignore 排除 node_modules 与 .env,密钥运行时用 --env-file 注入
- CMD 用数组形式直接起 node 让它当 PID 1,SIGTERM 才能传到进程,优雅退出才有效
For a long-lived SSE service in production, what problems do heartbeats, disconnect handling and graceful shutdown each solve?一个 SSE 长连接服务上线,心跳、连接断开处理和优雅退出分别在解决什么问题?
Common in ChinaCommon overseasDeep dive#sse#reliability#deploymentHow to reason about it · think before answering
- The discriminator is that the three have completely different failure symptoms. Someone who can describe each symptom has shipped one; 'they all improve stability' is a non-answer.
- Heartbeats prevent middleboxes from killing you. Load balancers and gateways commonly close idle connections after 60 to 120 seconds, and agents are full of silent gaps while the model reasons, calls a tool or waits on a slow API. The symptom is a stream that dies halfway for no visible reason and never reproduces against a local server. Implement it as an SSE comment line, which clients silently ignore, so no client change is needed.
- Disconnect handling is about money. When a user closes the tab the server does not stop on its own: the model keeps generating and tokens keep billing with nobody receiving. It is the most expensive oversight in streaming services, and staging never reveals it because nobody closes tabs mid-run. Watch for the response closing, distinguish a premature close from a normal finish, and abort the upstream request.
- One detail must be right or it exposes you immediately: in Node listen on the response object's close, not the request's. The request emits close once its body has been read, so using it as a disconnect signal misfires on every normal request and you see streams stopping after one or two chunks.
- Graceful shutdown is about deploys cutting live requests. On SIGTERM the process should stop accepting new connections, give in-flight streams a short window, then exit; otherwise users watch a reply stop mid-sentence. This assumes the signal actually reaches the process — if the container's PID 1 is a package manager, SIGTERM never arrives and the runtime kills you on timeout.
- Expect: how long is the window? Shorter than the orchestrator's termination grace period (10s by default in Docker, 30s in Kubernetes), or you get SIGKILLed anyway; and refuse new connections immediately so the load balancer drains traffic away.
分析过程 · 先想清楚再作答
- 这题的区分度在于三件事各自的失败现象完全不同,能分别说出现象的人一定真上过线。答成「都是为了稳定性」等于没答。
- 心跳解决的是「被中间设施误杀」。负载均衡和网关普遍有空闲超时,常见 60 到 120 秒,一段时间没有字节流动就关连接;而 Agent 天生有大量静默期——模型在思考、在调工具、在等慢接口。现象是连接莫名其妙断在一半,且本地直连时完全复现不了。实现上用 SSE 的注释行(冒号开头)做心跳,客户端会安静忽略,不用改客户端代码。
- 连接断开处理解决的是「花钱」。用户关掉页面之后服务端不会自动停,模型继续生成、token 继续计费,只是没人接收。这是流式服务里最贵的疏忽,而且测试环境暴露不出来,因为没人会中途关页面。做法是监听响应对象的关闭事件,判定是被掐断而不是正常收尾,就把上游请求一起中止。
- 这里有个必须说对的细节,说错会当场暴露没写过:Node 里要监听的是响应对象的 close,不是 request 的——request 的 close 在请求体读完时就触发,拿它当断线信号会把每一条正常请求都误判成客户端跑了,现象是每次只推出一两个片段就停。
- 优雅退出解决的是「发布时切断在途请求」。容器收到 SIGTERM 后应当先停止接受新连接,给在途的流一点收尾时间再退出,否则用户看到的是回复说了一半突然没了。前提是信号真的能传到进程——CMD 写成包管理器的话 PID 1 不是 node,SIGTERM 传不到,只能等超时被强杀。
- 可以预期的追问:收尾时间给多久?答案是要小于编排系统的终止宽限期(Docker 默认十秒、K8s 默认三十秒),超过就会被 SIGKILL,等于白设计;同时新连接要立刻拒绝,让负载均衡把流量挪走。
Key points
- Heartbeats defeat idle timeouts in middleboxes, since agent silence often exceeds a gateway's 60 to 120 seconds; SSE comment lines do it transparently
- Disconnect handling stops waste: after a user closes the tab, an unaware server keeps burning tokens, and staging never shows it
- In Node listen on the response's close, not the request's — the latter fires when the body is read and misclassifies normal requests as disconnects
- Graceful shutdown stops deploys from cutting live streams: on SIGTERM refuse new connections and drain, within the orchestrator's grace period
- It only works if the signal reaches the process, so PID 1 must be node itself rather than a package manager
答题要点
- 心跳防的是中间设施的空闲超时,Agent 的静默期常常超过网关的 60 到 120 秒,用 SSE 注释行实现,客户端无感
- 断开处理防的是浪费:用户关页面后服务端不停就是纯烧 token,测试环境暴露不出来
- Node 里要监听响应对象的 close 而不是 request 的——后者在请求体读完时就触发,会把正常请求误判成断线
- 优雅退出防的是发布切断在途流:SIGTERM 后先停收新连接、给在途流收尾时间,收尾窗口要小于编排系统的终止宽限期
- 前提是信号能传到进程:容器的 PID 1 必须是 node 本身,不能是包管理器
In a two-minute self-introduction, how do you convey the value of an agent project?自我介绍时,怎么在两分钟里讲清楚一个 Agent 项目的价值?
Common in ChinaCommon overseasBasic#interview-prep#communicationHow to reason about it · think before answering
- There is no model answer, but there is a clear failure mode: opening with a tool list. Interviewers do not remember stacks; they remember problems and numbers.
- Use a fixed structure that fits two minutes: one line on who you are and where you are heading, one line on the business problem (who suffers, in what situation), three or four lines on your key technical decisions and what each bought you, and one closing line with a verifiable result.
- Choose decisions that involved a trade-off, not decisions that merely involved implementation. 'We stream over SSE rather than WebSocket because upstream traffic is a single request, which lets us keep existing auth, rate limiting and logging' shows you knew the alternative and priced it — far stronger than naming ten tools.
- Attach numbers wherever you can, even self-measured ones: time-to-first-token dropping from seconds to a few hundred milliseconds, tiered routing cutting daily spend by more than half, multi-provider fallback removing a single vendor from your availability ceiling. If the numbers are from a test environment, say so; inventing them collapses after two follow-ups.
- A common mistake is presenting a learning project as production. Position it yourself: a complete system built to understand production agent architecture, at self-test scale, where every decision was made against real constraints. Interviewers forgive honest scoping far more readily than inflated claims.
- Expect: what was the hardest part? Prepare one concrete story with a process — for example, discovering that a streaming endpoint cannot report errors by status code once it has started pushing, and redesigning around an in-stream error event plus front-loaded validation.
分析过程 · 先想清楚再作答
- 这题没有标准答案,但有明确的失败模式:从技术栈开始报菜名(我用了 Fastify、SSE、Docker、向量库……)。面试官记不住工具清单,他记得住的是问题和数字。
- 用一条固定结构去组织,两分钟正好够:一句话说你是谁和转型方向,一句话说项目解决的业务问题(谁在什么场景下受什么苦),三到四句说你的关键技术决定和它换来了什么,最后一句给可验证的结果。
- 关键技术决定要挑「有取舍的」讲,不要讲「有实现的」。比如「流式用 SSE 而不是 WebSocket,因为上行只有一次,这样鉴权限流日志这套现成设施全部照用」——这种句子同时展示了你知道有别的选项、也知道选它的代价,比列出十个工具有效得多。
- 结果要尽量带数字,哪怕是自测数据:首字延迟从几秒降到几百毫秒、分层路由把日成本从 300 元降到 125 元、多 provider 冗余让可用性不再取决于单家厂商。没有生产数据就诚实说明是自测环境,编数字是最危险的做法,追问两句就穿帮。
- 常见误区是把学习项目说成生产项目。正确姿势是主动定位:这是我为了搞懂生产级 Agent 架构而完整实现的一套系统,规模是自测级,但每个决定都对着真实约束做过取舍——面试官对诚实的自评远比对夸大的描述宽容。
- 可以预期的追问:这个项目最难的地方是什么?提前准备一个具体的、有过程的答案(比如流式接口推流之后没法用状态码报错,最后改成流内 error 事件加上把校验全部前置),比任何形容词都有说服力。
Key points
- Keep a fixed structure: positioning, the business problem, three or four traded-off decisions, and one verifiable result
- Do not recite a stack — interviewers retain problems, trade-offs and numbers, not tool lists
- Frame decisions as trade-offs, naming the alternative and why it lost
- Attach numbers even from self-testing, but label their source and never invent them
- Scope the project honestly as a complete build at self-test scale; honest framing survives follow-ups better than inflation
答题要点
- 结构固定:定位一句、业务问题一句、三到四个有取舍的技术决定、一句可验证的结果
- 不要报菜名:面试官记不住工具清单,记得住问题、取舍和数字
- 技术决定要讲取舍而不是讲实现,说清楚备选方案是什么、为什么没选它
- 结果尽量带数字,自测数据也可以,但必须标明来源,绝不编造
- 主动定位项目规模:为搞懂生产架构而完整实现、自测级规模,诚实自评比夸大更容易通过
Comments
Sign in to join the discussion
No comments yet — be the first.