ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端 Vite 接大模型接口:从 dev server 到上线,4 个最容易被忽略的工程坑

前端 Vite 接大模型接口:从 dev server 到上线,4 个最容易被忽略的工程坑 授权与合规声明本文为技术实践笔记示例均基于公开文档与自建环境中的实验不涉及任何未获授权的系统。文中结论仅代表个人实践小结与所涉厂商无利益关系。转载请注明出处。1. 背景前端为什么容易在「调大模型接口」上踩坑1.1 浏览器直连大模型接口的真实约束很多团队在做「前端 大模型」功能时第一反应是拿到一个 API Key在浏览器里直接fetch厂商的/v1/chat/completions。本地一跑能出字就以为万事大吉。但浏览器并不是「一个会发 HTTP 请求的客户端」那么简单它背后同时被三件事约束同源策略CORS、密钥可见性、流式响应的消费方式。这三点恰好是大模型接口和「普通 REST 接口」差异最大的地方。普通后端接口大多一次请求一次返回且服务端之间调用不受浏览器同源策略限制而大模型接口几乎默认开启流式SSE并且对请求来源Origin和鉴权头Authorization有严格要求。前端工程师如果没有后端协作经验很容易把「本地能跑」误判为「线上能跑」。1.2 三种调用路径对比前端要接大模型能力本质上只有三种网络拓扑。先把路径摆清楚后文的四个坑才能对号入座。路径描述是否暴露 Key跨域处理适用阶段前端直连厂商浏览器直接请求api.xxx.com必然暴露依赖厂商是否放行浏览器 Origin仅本地调试前端 → 自有后端代理 → 厂商前端请求自家服务服务转发不暴露同域无跨域推荐生产方案前端 → BFF/网关 → 厂商在代理层加鉴权、限流、日志不暴露同域无跨域中大型项目结论先行生产环境不要走第一条路。后面四个坑前三个都源于「误把第一条路当生产方案」第四个源于对构建工具环境变量的误解。2. 坑一开发期能跑通上线就 403 / 跨域2.1 为什么npm run dev时一切正常在 Vite 开发服务器下我们通常会在vite.config.ts里配置server.proxy把/api这样的前缀代理到真实的后端或厂商地址。由于代理发生在Node 进程内部开发服务器本身就是个 Node 服务请求是从服务端发出的浏览器只和localhost:5173通信因此不存在跨域。问题在于这个代理是「开发服务器」的能力不是「构建产物」的能力。2.2 vite.config 里的 proxy 写法下面是一段典型的开发期代理配置把/llm转发到厂商接口并改写掉不需要的路径前缀。⚠️代码待验证// vite.config.tsimport{defineConfig}fromviteexportdefaultdefineConfig({server:{proxy:{/llm:{target:https://api.openai.com,changeOrigin:true,rewrite:(path)path.replace(/^\/llm/,),// 若厂商要求特定头可在此用 configure 注入headers:{// 注意此处仅适合开发期临时注入切勿提交真实 Key},},},},})关键点changeOrigin: true会让代理请求里的Host头改成目标域名绝大多数厂商据此放行rewrite负责把本地前缀抹掉使转发后的路径等于厂商真实路径。2.3 生产环境 proxy 不会自动跟着走vite build产出的是静态文件HTML/JS/CSS它们被丢到 Nginx、对象存储或 CDN 上由浏览器直接加载。那里没有 Node 进程自然也没有server.proxy。于是线上会出现两类典型错误浏览器直接请求厂商域名 → 触发 CORS控制台报blocked by CORS policy即使厂商放行了浏览器 OriginAPI Key 也随请求明文暴露见坑三。维度开发期vite dev生产期静态产物代理执行方Vite Node 进程无需外部反向代理跨域是否存在否同域 localhost是除非同域代理Key 注入位置配置文件 / 环境变量必须放后端适用做生产吗否需补反向代理或后端2.4 生产怎么补用反向代理承接生产环境要把「前端 → 厂商」变成「前端 → 同域反向代理 → 厂商」。以 Nginx 为例把/llm反代到厂商并在服务端注入 Key浏览器始终只访问自己的域名。⚠️代码待验证# 仅示意路径与 upstream 按实际调整 location /llm/ { proxy_pass https://api.openai.com/; proxy_set_header Host api.openai.com; proxy_set_header Authorization Bearer $llm_api_key; proxy_set_header Content-Type application/json; proxy_read_timeout 300s; }proxy_read_timeout要调大因为流式响应是长连接默认 60s 可能在中途被 Nginx 断开。3. 坑二流式响应在浏览器里「读不出来」3.1 为什么大模型接口默认要流式大模型按 token 逐步生成端到端首字延迟往往数秒。若等全部生成完再一次性返回用户会面对长时间的「转圈」。流式SSE让服务端边生成边推送前端边收边渲染首字时间TTFT和体感都更好。因此厂商接口在请求体里带stream: true时返回的是text/event-stream而不是普通 JSON。3.2 用 fetch ReadableStream 消费 SSEEventSource只能发 GET 且不能自定义请求头而大模型接口需要 POST Authorization所以用fetch配合response.body.getReader()更通用。下面是一段浏览器侧消费流式数据的写法。⚠️代码待验证// 浏览器侧消费 SSE 流式响应asyncfunctionstreamChat(messages:{role:string;content:string}[]){constrespawaitfetch(/llm/v1/chat/completions,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({model:gpt-4o-mini,messages,stream:true}),})if(!resp.body)thrownewError(无响应流)constreaderresp.body.getReader()constdecodernewTextDecoder()letbufferwhile(true){const{value,done}awaitreader.read()if(done)breakbufferdecoder.decode(value,{stream:true})// SSE 以空行\n\n分隔事件constpartsbuffer.split(\n\n)bufferparts.pop()??for(constpartofparts){constlinepart.replace(/^data:\s*/,).trim()if(!line||line[DONE])continueconstjsonJSON.parse(line)constdeltajson.choices?.[0]?.delta?.content??process.stdout.write(delta)// 实际项目里更新 UI 状态}}}注意点厂商的 SSE 每行以data:开头结束事件是data: [DONE]TextDecoder的stream: true保证多字节字符如中文不会被截断在 chunk 边界。3.3 EventSource 的边界与坑如果坚持用EventSource它只能 GET、不能带自定义头所以服务端必须支持把鉴权放到 query 或 cookie。这意味着 Key 或 token 会出现在 URL 里存在被日志、 Referer 泄露的风险。综合来看POST fetch 流式在前端接大模型场景下更稳妥。方案方向自定义头适合大模型流式轮询短轮询双向可控支持不推荐延迟高、请求多EventSource (SSE)服务端 → 客户端不支持受限需改鉴权方式fetch ReadableStream服务端 → 客户端支持推荐灵活可控WebSocket全双工支持可用但需服务端额外实现4. 坑三API Key 一旦进前端就等于公开4.1 把 Key 写进前端到底会发生什么前端代码最终会打包成 JS 下发到浏览器。无论你写在import.meta.env、写死在源码还是藏在某个看起来「加密」的变量里只要浏览器能发起带 Key 的请求用户就能在 DevTools 的 Network 面板或打包产物里看到它。恶意者拿到 Key 后可以任意调用、盗刷额度而 Key 绑定的是你的账户。这也是坑一里强调「生产必须走同域代理」的根本原因Key 只能存在于服务端。4.2 一个最小后端代理在自有后端这里是 Node/Express 风格伪代码里持有 Key前端只调自己的接口。这样浏览器永远看不到Authorization头里的真实密钥。⚠️代码待验证// 服务端代理Node Express 风格仅示意importexpressfromexpressimport{ProxyAgent}fromundici// 或原生 fetchNode 18constappexpress()app.use(express.json())app.post(/llm/v1/chat/completions,async(req,res){constupstreamawaitfetch(https://api.openai.com/v1/chat/completions,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${process.env.LLM_API_KEY},// 仅服务端可见},body:JSON.stringify({...req.body,stream:true}),})// 透传流式res.setHeader(Content-Type,text/event-stream)if(upstream.body)upstream.body.pipe(res)})app.listen(3000)注意process.env.LLM_API_KEY只在服务端进程里前端请求/llm/...时完全接触不到它。4.3 代理层该承担的额外职责代理层不只是「转发」它还应该是安全与稳定的边界超时与取消上游挂起时及时断开避免占用连接重试与降级单次请求失败可有限重试或返回兜底文案限流按用户/IP 限制 QPS防止 Key 被刷审计日志记录调用量、耗时但不记录请求正文里的敏感内容。风险点前端直连后端代理Key 暴露必然不暴露跨域受厂商策略限制同域无跨域限流/审计做不到可做请求内容可控用户可篡改服务端可校验5. 坑四环境变量「构建时就定死」与「运行时可改」的差别5.1 import.meta.env 是构建时注入Vite 里只有以VITE_开头的变量会被注入到import.meta.env并且是在构建阶段被替换进打包产物的。换句话说VITE_API_BASE/llm在vite build那一刻就写死进了 JS 文件。线上想改这个值必须重新构建不能只改个配置就生效。5.2 运行时配置怎么解决如果希望「不改构建产物、只改部署配置」就能切换接口地址常见做法是前端在启动时fetch(/config.json)拿到运行时配置或后端在 HTML 里注入一段window.__APP_CONFIG__。这样同一份构建产物可以部署到不同环境。⚠️代码待验证// 运行时读取配置部署后可改无需重新构建letruntimeConfig:{apiBase:string}exportasyncfunctionloadConfig(){constrespawaitfetch(/config.json,{cache:no-store})runtimeConfigawaitresp.json()returnruntimeConfig}// 使用时awaitfetch(${runtimeConfig.apiBase}/v1/chat/completions,{/* ... */})注意config.json本身不要包含任何密钥它只放「非敏感的地址/开关」。5.3 .env 文件命名与优先级Vite 的 env 文件按模式加载.env是基础.env.local本地覆盖.env.[mode]如.env.production按vite --mode生效且.env.local一般不进版本库。理解这套优先级才能避免「本地好用、CI 上不对」的尴尬。维度构建时配置import.meta.env运行时配置config.json / 注入修改后是否需重新构建需要不需要适合放什么非敏感地址、功能开关非敏感地址、环境差异项能否放密钥绝不能绝不能多环境切换成本高重构建低改部署配套实战配置片段上面这套「构建时 / 运行时」拆分与 Nginx 反代片段我已经整理成可直接对照抄的模板。放在资料包里扫码即可获取6. 一个最小可落地的架构dev 与 prod 统一思路6.1 组件划分把前面四个坑的应对收敛成一张图浏览器 → 同域入口dev 用 Vite proxyprod 用 Nginx→ 后端代理持有 Key→ 厂商。开发和生产走不同的「同域入口」但前端代码完全一致只是apiBase指向/llm。6.2 dev / prod 配置切换开发时靠vite.config.ts的server.proxy把/llm指到厂商生产时靠 Nginx 把/llm反代到后端代理服务后端代理再去厂商。两端对前端的「接口形状」完全一致前端无需if (isProd)这类分支。6.3 仍要补的能力四个坑解决的是「能跑通且基本安全」但离生产可用还差几块用户级鉴权、按用户的配额与限流、上游故障的降级文案、以及流式中断的重连。这些建议作为后续迭代项而不是第一天就全上。组件职责解决哪个坑前端调/llm、消费 SSE统一入口Vite proxy / Nginx同域承接消除跨域坑一后端代理持有 Key、转发、限流坑三运行时配置地址可切换坑四流式消费层解析 SSE、更新 UI坑二7. 上线前的工程检查清单7.1 网络层检查/llm在生产是否由同域反代承接浏览器请求是否只发往自家域名反代proxy_read_timeout是否已调大以支持长流式确认没有把请求直接打到厂商域名CORS 与暴露双重风险。7.2 安全层检查全局搜索构建产物与前端源码确认不存在sk-之类明文 Key后端代理是否校验了请求体、是否做了限流config.json/ 注入配置里不含任何密钥。7.3 配置层检查VITE_变量是否只放了非敏感项是否需要「运行时可改」能力如需要是否已接config.json多环境test / prod的 env 文件与 Nginx 配置是否一一对应。检查项通过标准对应坑跨域浏览器仅访问同域/llm坑一流式能逐字渲染、长连接不断坑二密钥前端产物搜不到 Key坑三配置非敏感项支持环境切换坑四配套检查清单与可运行 Demo上面这张清单和对应的前端/代理最小 Demo我整理成了可直接跑的仓库结构说明。放在资料包里扫码即可获取附表 A本文引用事实与出处对照表事实出处本文位置Vite 提供server.proxy用于开发期代理构建产物为静态文件Vite 官方文档vite.devserver.proxy 章节第 2 章浏览器同源策略限制跨域请求CORS 由浏览器执行MDN Web DocsCORS / 同源策略第 2 章SSE 使用text/event-stream以data:行与空行分隔事件MDN Web DocsServer-Sent Events第 3 章fetch的Response.body为ReadableStream可用getReader()消费WHATWG Fetch / Streams 标准、MDN第 3 章EventSource仅支持 GET、不支持自定义请求头MDN Web DocsEventSource第 3 章Vite 仅将以VITE_前缀的变量注入import.meta.env且为构建时替换Vite 官方文档env 变量章节第 5 章兼容 OpenAI 的接口在stream: true时返回 SSE 流结束事件为data: [DONE]OpenAI API 文档streaming 章节待核实最新表述第 3 章现代 Node.js18 及以上已内置全局fetchNode.js 官方发布说明具体 LTS 版本号截至 2026-10-07 待核实第 4 章附表 B术语速查表术语含义CORS跨域资源共享浏览器基于同源策略对跨域请求做的限制机制SSEServer-Sent Events服务端向客户端单向推送事件的流式协议ReadableStreamWeb Streams 标准中的可读流用于逐块消费响应数据dev proxy开发服务器把特定路径代理到另一地址绕过浏览器跨域反向代理服务端把外部请求转发到内部服务对客户端隐藏真实后端BFFBackend For Frontend为前端定制的聚合/代理层import.meta.envVite 注入的环境变量命名空间构建时确定TTFTTime To First Token首 token 延迟衡量流式体感的关键指标写在最后这篇用到的资料写这篇文章时我把「前端接大模型」最容易翻车的四个点都按真实工程链路走了一遍顺手也整理了几份配套的东西《Vite 接大模型dev/prod 双配置模板》把开发期 proxy 与生产 Nginx 反代的对应写法整理成可直接抄的对照表。《SSE 流式消费最小 Demo》包含前端fetch ReadableStream解析与后端代理透传的可运行骨架。《上线前检查清单含密钥排查》一份按本文四个坑提炼的复查表避免把 Key 带进产物。资料是我自己整理的放在下面这个码上扫码即可获取资料较多建议先看「全套 AGI 大模型学习路线」再挑一个实战项目跟练。
返回列表