Node给AI接口做SSE代理转发

5 阅读4分钟

先把结论撂这儿:前端别想着直连大模型,跨域过不去、key 还会裸奔在 Network 面板里。正经做法是在 Node 起个薄薄的代理,前端连自己后端,后端拿着 key 去转发流式响应。下面是我趟过的坑和能跑的代码。

事情起因挺日常的。我手头有个内部小工具,想加个 AI 问答框,流式吐字那种。脑子一热就在前端 fetch 大模型的接口,Authorization 头里直接塞了 key。本地跑得好好的,部署到测试域名,啪,CORS 拦了。我对着控制台那行红字看了半天,才反应过来——就算我把跨域绕过去了,key 也明晃晃写在打包后的 js 里,F12 一翻就有。等于把家门钥匙贴脑门上出门。

所以必须有个中间层。Node 来当二传手:

  • key 只活在服务端环境变量里,前端碰不到
  • 跨域我自己说了算,想给谁开给谁开
  • 顺手能限个流、记个日志、加个缓存

代理实现

核心就一件事:把上游的 SSE 流,原样拼着透传给浏览器。别想着等全部收完再返回,那样流式就没了,用户盯着空白等结果,体验稀碎。

// proxy.js
import express from 'express'

const app = express()
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  // SSE 三件套,少一个浏览器都不认
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')

  const upstream = await fetch('https://上游地址/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // key 在这儿,只在服务端
      Authorization: `Bearer ${process.env.MODEL_KEY}`,
    },
    body: JSON.stringify({ ...req.body, stream: true }),
  })

  // 关键:别 await upstream.json(),要拿可读流一块块 pipe
  const reader = upstream.body.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    res.write(decoder.decode(value, { stream: true }))
  }
  res.end()
})

app.listen(3000)

就这么点。我第一版还手贱去解析每条 data:、重新组装,结果把上游的分包边界搞乱了,前端解码出半个汉字的乱码。后来想通了——透传就透传,别自作聪明。上游怎么切的包,我原样吐出去,前端那边照常一行行读就行。

前端这头也简单,用原生 fetch 配 reader 就够,不用上 EventSource(POST 它不支持):

const resp = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ messages }),
})
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  buf += decoder.decode(value, { stream: true })
  // 按 \n\n 拆 SSE 事件,自己解析 data: 后面的 json
  // ...更新到界面上,逐字出
}

有两个坑提醒一下。一是上线后挂在 Nginx 后面,SSE 会被默认缓冲,得加 proxy_buffering off;,不然字是攒一坨一坨蹦出来的,流式效果全没。我当时排查了快一个小时,以为是 Node 写错了,最后发现是反代背锅。二是客户端中途关页面,记得监听 req.on('close') 把上游 reader 也 cancel 掉,不然 token 还在偷偷烧。

一个意外的延伸

代理跑通之后,我顺手把它接到了团队群里。本来想自己再写一坨意图识别、知识库检索的逻辑,光 RAG 那套切片、向量、召回我估了下得磨好几天。后来同事甩给我一个零代码搭智能体的平台,拖一拖配一配,挂个现成模型、传几篇产品文档当私有知识库,二十分钟出了个能答业务问题的小助手,直接发布成一个 API。

我那个 Node 代理压根没白写——前端还是连我自己的后端,后端转发的上游,从原来手搓的接口换成了那个小助手的 API,SSE 透传逻辑一行没改。说实话搭出来第一版挺干的,答得机械,我又回去把文档喂细了点、调了下提示词才像样。它也就干个杂活,真要复杂编排还得自己来。但"不用我写检索逻辑"这一下,是真省心。

回头看,前端直连大模型这事,大概率第一反应都是对着 CORS 抓狂,然后才意识到 key 那个更要命的问题。一个 Node 薄代理把两样一起解决了,几十行的事。你们接 AI 接口踩过啥奇葩坑?评论区聊聊,我那个 Nginx 缓冲的坑就是被人一句话点醒的。

(模型那头我直接走的讯飞 MaaS,现成 API 调,没自己部署算力,省得折腾 GPU。)