跳到主要内容

Chat UI — 中断与安全工程

这一章讲的不是“功能”,而是把聊天产品跑在生产环境(多副本、对接外部服务器)时要解决的硬问题。它们最不显眼,工程含量却很高。

1. 跨 Pod 的“停止”:为什么不能只用内存

用户点“停止”,最朴素的做法是:在内存里记一个 AbortController,停止请求来了就 abort()。但生产是**多副本(Pod)**部署的:

  • 发起生成的请求可能落在 Pod A,生成正在 A 上跑;
  • “停止”请求经过负载均衡,可能落到 Pod B——B 上根本没有那个 AbortController

所以 Chat UI 用 MongoDB 当跨 Pod 的停止信道:stop-generating 端点往 abortedGenerations 集合 upsert 一个“停止标记”;正在生成的 Pod 轮询这个集合(+server.ts:410,每 300ms 一次),看到标记就 ctrl.abort()

Pod B: 收到 /stop ──upsert──▶ [MongoDB abortedGenerations]
▲ 每 300ms 轮询
Pod A: 正在生成 ──────────────────────┘ 看到标记 → abort 本地请求

为什么要轮询而不只查内存缓存?注释说得很清楚(+server.ts:404):内存里的 AbortedGenerations map 只在 token 之间检查,而推理模型可能很久才吐第一个 token——那段空窗期里只有直接轮询数据库才能及时停下。

2. 停止标记的生命周期(竞态处理)

标记是按对话的,处理不好会串台。代码用了几个细节防竞态:

  • 预清理:新生成开始时,删掉本对话超过 5s 的陈旧标记(STOP_MARKER_GRACE_MS,+server.ts:94)。为什么留 5s 而不全删?因为可能有另一个标签页刚发的、还在被某个 Pod 观察中的标记——删早了就丢了那次停止。
  • 观察后才消费:停止被真正执行、中断状态存库之后,才删标记(+server.ts:751)。

这套“预清理 + 延迟消费”保证:之后观察到的任何标记,一定是给当前这次生成的,无需跨 Pod 比对挂钟时间。

3. 停止点精确截断:让历史等于“你看到的”

微妙的问题:你点“停止”那一刻,前端 UI 冻在某处;但服务端的 token 可能还在飞,直到轮询观察到标记才真停。如果直接把“服务端多收的那些 token”也存进历史,你刷新页面会发现回答“自己长回来了”——很怪。

解决:emitInterruptedFinalAnswer(+server.ts:617)在收尾时,用 clampStoppedContent 把内容截回到停止点——前端在停止请求里上报了它冻结时的 generationId 和停止位置,服务端据此把消息裁剪成“用户最后看到的样子”再存库。

4. SSRF 防护:对接外部 MCP 服务器的代价

MCP 工具服务器的 URL 可能由用户/配置提供。如果不设防,攻击者可以让服务端去打内网地址(http://169.254.169.254 云元数据、127.0.0.1 本机服务),这就是 SSRF。

urlSafety.ts 是两道防线:

第一道——语法校验 isValidUrl(urlSafety.ts:39):只允许 https:、拒绝 localhost、是 IP 字面量就查是否落在私有/保留段(urlSafety.ts:6UNSAFE_IPV4_SUBNETS,含 127/8169.254/1610/8192.168/16 等,IPv6 也处理了 ::ffff: 映射)。

第二道——连接时校验 ssrfSafeFetch(urlSafety.ts:108):这道更狠,防的是 DNS 重绑定(TOCTOU) ——“检查时”域名解析到公网 IP、“使用时”又解析到内网 IP。做法是给 undici 装一个自定义 lookup(urlSafety.ts:76),在真正建连的那一刻校验解析出的 IP 安不安全(assertSafeIp)。同时逐跳校验重定向(urlSafety.ts:144),防止用 302 跳到内网。

教学示意——连接时(而非校验时)拦截内网 IP:

// 示意,非源码:在 DNS 解析回调里,建连前拦截内网 IP
const agent = new Agent({
connect: {
lookup: (hostname, options, cb) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return cb(err, "", 4);
if (isUnsafeIp(address)) // 落在私有/保留段?
return cb(new Error("internal IP"), "", 4); // 直接拒绝建连
cb(null, address, family);
});
},
},
});

所有 MCP 客户端的 transport 都用这个 ssrfSafeFetch(mcp/tools.ts:160clientPool.ts:84)。

5. MCP 连接池:别每次都重连

MCP 调用可能很频繁,每次都新建连接很贵。clientPool.ts 维护一个 按 (url+headers) 复用的客户端池:

  • 复用:getClient(clientPool.ts:55)拿到最近用过的就直接返回;空闲超 30s 先 ping 探活,挂了就重连(代理常静默掐掉空闲连接)。
  • 保活计数:retainClient/releaseClient(clientPool.ts:114)记录“有几个调用正在用”,清扫器(每分钟跑)绝不关有在途调用的连接(clientPool.ts:38)。
  • 关键约束:池里的客户端是跨并发请求共享的,所以工具流结束后绝不能关它——注释警告关了会让别人的在途调用收到 "-32000 Connection closed"(runMcpFlow.ts:773)。空闲连接交给清扫器按 TTL(10 分钟)回收。
  • 断连重试:callMcpTool(httpClient.ts:52)对“连接关闭”/“会话过期(404)”会换新客户端重试,长任务还支持进度令牌不断延长超时(httpClient.ts:80)。

6. 巧妙之处

  • MongoDB 当跨 Pod 中断信道:DB 轮询补上“内存 abort 只在 Pod 内、只在 token 间”的两个盲区(+server.ts:410)。
  • 预清理 + 延迟消费标记:用 5s 宽限窗口区分“给我的停止”和“别人的停止”,免去跨 Pod 时钟比对(+server.ts:94/:751)。
  • 停止点截断:历史严格等于用户停下时看到的画面,不“长回来”(+server.ts:617)。
  • 连接时 IP 校验防 DNS 重绑定:在 undici lookup 里拦,而非只在 URL 字符串上拦(urlSafety.ts:76)。
  • 共享连接池 + 保活计数:既复用连接,又保证清扫器不误杀在途调用(clientPool.ts:38)。

7. 边界与局限

  • 跨 Pod 中断靠 300ms 轮询,停止有最多几百毫秒延迟,期间 token 仍在生成(只是不会进最终历史)。
  • SSRF 防护只覆盖走 ssrfSafeFetch 的路径;校验只拒 IPv4 私有段 + 部分 IPv6,自建奇异网络拓扑仍需谨慎。
  • 连接池是进程内的;多 Pod 各有各的池,不共享。

代码地图

主题文件符号
跨 Pod 中断 + 截断src/routes/conversation/[id]/+server.tsPOST, emitInterruptedFinalAnswer, STOP_MARKER_GRACE_MS
停止点截断src/lib/server/stopTruncation.tsclampStoppedContent
SSRF 防护src/lib/server/urlSafety.tsssrfSafeFetch, assertSafeIp, isValidUrl, isUnsafeIp
MCP 连接池src/lib/server/mcp/clientPool.tsgetClient, retainClient, releaseClient, drainPool
单次工具调用/重试src/lib/server/mcp/httpClient.tscallMcpTool, isConnectionClosedError, isSessionExpiredError