Express 接入大模型 API 怎么做:鉴权代理、流式输出、超时重试与面试答法
面试速答(30 秒版 TL;DR)
- Express 接入大模型 API,本质不是“后端帮前端转发一下请求”这么简单,而是要补齐 密钥管理、请求编排、超时重试、限流、流式转发、内容安全、成本控制。
- 更稳的架构通常是:前端请求自己的 Express 服务,Express 再调用模型厂商 API,而不是前端直接把厂商密钥暴露出去。
- 真实项目里,Express 往往承担 4 个职责:统一鉴权入口、拼装提示词上下文、屏蔽多厂商差异、沉淀日志与监控。
- 如果面试官追问“难点在哪”,优先讲:流式输出、失败重试、429 限流、提示词注入、长上下文成本、响应结构约束。
- 一句话总结:Express 接大模型 API 的重点不是能不能调通,而是能不能把外部模型调用治理成一个稳定、可控、可审计的内部能力。
心智模型:把“大模型接入”拆成 5 层
很多人一上来就写:
app.post("/chat", async (req, res) => {
const result = await fetch("模型接口", { ... });
res.json(await result.json());
});
这只能算“打通了 HTTP”,还不能算工程接入。
更好的理解方式是拆成 5 层:
- 接口层:Express 暴露
/chat、/summary、/extract等业务接口 - 适配层:把内部请求转换成不同模型厂商要求的协议
- 治理层:鉴权、限流、超时、重试、熔断、日志、成本统计
- 策略层:Prompt 模板、模型选择、温度参数、上下文裁剪、结构化输出
- 厂商层:OpenAI-compatible API、闭源模型平台、自建网关或自部署模型
只要你按这 5 层来讲,答案就会从“会调用第三方接口”升级成“会设计 AI 能力接入层”。
一、为什么前端通常不直接调用模型厂商 API
最核心原因不是“能不能调用”,而是“该不该让它直接调用”。
1. 厂商密钥不能暴露给浏览器
- 前端代码和网络请求天然可被看到
- 一旦把真实 API Key 放到浏览器,就等于把计费权限交给任何人
2. 你需要统一收口权限和配额
例如:
- 哪个用户可以用哪些模型
- 免费用户每天能调用多少次
- 哪些接口必须带业务身份
这些逻辑更适合在 Express 层统一控制。
3. 你需要屏蔽多厂商差异
不同模型平台常见差异包括:
- 鉴权头命名不同
- 请求体字段不同
- 流式协议细节不同
- 错误码和限流语义不同
如果让前端直接对接,后面切换模型厂商时前端会被迫跟着大改。
4. 你需要补审计和安全治理
例如:
- 记录调用人、模型名、耗时、token 用量
- 对输入做敏感词和越权检查
- 对输出做内容审核或结构校验
这些能力放在 Express 侧更自然。
二、推荐的接入架构应该怎么讲
这张图的重点不是画复杂,而是说明:
- Express 不只是代理层,还是治理层。
- Prompt、模型选择、审计日志通常都应该在服务端收口。
- 未来如果你要切模型、做 AB 测试、做降级,都需要这一层存在。
三、最小可运行版本应该长什么样
下面的示例假设:
- 运行环境是
Node 20+ - 使用
Express 4.x/5.x - 通过原生
fetch调用“OpenAI-compatible”接口 - 不依赖具体 SDK,方便你看清通用接入思路
注意:
- 不同厂商的请求字段可能是
messages、input或其他名字 - 流式响应格式也可能是 SSE、JSON Lines 或自定义 chunk
- 真正落地时要以厂商官方文档为准
import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/api/ai/chat", async (req, res) => {
const userMessage = String(req.body?.message ?? "").trim();
if (!userMessage) {
return res.status(400).json({ message: "message 不能为空" });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20_000);
try {
const upstream = await fetch(process.env.LLM_BASE_URL + "/chat/completions", {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: "your-model-name",
temperature: 0.2,
messages: [
{
role: "system",
content: "你是一个面向中文用户的助手,回答要简洁准确。",
},
{
role: "user",
content: userMessage,
},
],
}),
});
if (!upstream.ok) {
const errorText = await upstream.text();
return res.status(502).json({
message: "上游模型服务调用失败",
detail: errorText.slice(0, 300),
});
}
const data = await upstream.json();
const content = data?.choices?.[0]?.message?.content ?? "";
return res.json({
reply: content,
requestId: crypto.randomUUID(),
});
} catch (error) {
const message =
error instanceof Error && error.name === "AbortError"
? "模型请求超时"
: "模型请求异常";
return res.status(504).json({ message });
} finally {
clearTimeout(timeout);
}
});
这个最小版本已经体现了 4 个关键点:
- 参数先校验,避免脏请求直接打到模型
- API Key 只放在服务端环境变量
- 调模型必须有超时控制
- 对前端暴露的是内部统一响应,而不是把厂商返回原样透传
四、为什么真实项目里通常还要再包一层 Service
如果你直接在路由里写模型调用代码,短期能跑,长期会很乱。
更推荐:
router -> controller -> ai service -> provider adapter -> model api
这样拆的原因是:
1. Controller 只处理 HTTP 协议
例如:
- 从
req.body取参数 - 写
res.json - 处理请求级鉴权信息
2. AI Service 处理业务语义
例如:
- 这是客服问答、摘要生成,还是结构化抽取
- 用哪个 Prompt 模板
- 用便宜模型还是高质量模型
- 是否要接知识库、缓存或历史消息
3. Provider Adapter 屏蔽厂商差异
例如统一成:
type ChatInput = {
model: string;
systemPrompt?: string;
userMessage: string;
stream?: boolean;
};
type ChatOutput = {
text: string;
usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
};
这样你后面换厂商时,改动主要集中在 adapter,而不是全项目跟着一起改。
五、流式输出为什么是接入里的高频考点
因为聊天类产品最常见的体验诉求就是“边生成边返回”。
如果不用流式:
- 用户要等整个回答生成完才看到结果
- 长回答的体感延迟会很差
如果用流式:
- 首 token 更早到达
- 用户更容易接受模型整体耗时
常见服务端转发思路
- Express 收到前端请求
- 请求上游模型接口时打开
stream: true - 把上游 chunk 按 SSE 或文本流方式继续写给前端
- 结束时主动关闭响应
一个最小示例:
app.post("/api/ai/chat-stream", async (req, res) => {
const upstream = await fetch(process.env.LLM_BASE_URL + "/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: "your-model-name",
stream: true,
messages: [{ role: "user", content: req.body.message }],
}),
});
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ message: "流式调用失败" });
}
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
} finally {
res.end();
}
});
面试里更重要的不是背代码,而是主动补充边界:
- 上游不一定返回标准 SSE,可能只是 chunked 文本流
- 前端断开连接后,服务端最好也取消上游请求
- 流式输出更容易遇到半包、乱码、代理缓冲、Nginx 超时等问题
六、接入大模型 API 时最容易被问的工程点
1. 超时为什么一定要配
因为模型调用比普通 CRUD 更容易慢:
- 推理耗时本身更长
- 上下文一大,延迟会继续抬高
- 厂商限流或排队时可能卡很久
所以超时要分层控制:
- 客户端超时
- Express 到上游的请求超时
- 网关或反向代理超时
2. 为什么要做重试,但又不能乱重试
可以重试的常见情况:
- 短暂网络抖动
- 5xx
- 某些可恢复的限流响应
不应该盲重试的情况:
- 参数本身非法
- 配额已用尽
- 业务要求强幂等但没有请求去重
因为重试会直接增加成本,也可能让同一请求被模型处理多次。
3. 为什么要做限流和配额
大模型接口通常是按 token 或请求次数计费。
如果不控:
- 容易被恶意刷接口
- 容易因为单个用户滥用造成整体账单失控
- 容易把上游限流问题放大成全站故障
4. 为什么要记录 usage
最常见的沉淀字段:
- 用户 ID
- 业务场景
- 模型名称
- 请求耗时
- 输入长度
- 输出长度
- token 用量
- 是否命中缓存
- 是否重试
这些数据后面会直接影响:
- 成本分析
- 模型选型
- Prompt 优化
- 故障排查
七、Prompt 为什么不应该散落在控制器里
很多项目早期会这样写:
messages: [
{ role: "system", content: "你是客服助手..." },
{ role: "user", content: question },
];
一开始没问题,但后面会越来越难维护。
更合理的做法是把 Prompt 模板单独管理,至少做到:
- 按业务能力拆模板,例如问答、摘要、改写、抽取
- 模板版本化,方便回滚和 AB 测试
- 变量显式注入,避免字符串拼接混乱
- 对系统提示词、开发者提示词、用户输入分层管理
这样做的价值是:
- 便于调优
- 便于审计
- 便于后续接知识库或函数调用
八、结构化输出为什么经常比“自由文本”更重要
真实业务里,很多场景并不是要一段聊天文本,而是要一份可消费的数据。
例如:
- 提取工单字段
- 识别意图分类
- 生成商品标签
- 返回固定格式的评分结果
这时更稳的方案不是让模型“自由发挥”,而是:
- 在 Prompt 里明确输出格式
- 尽量使用厂商支持的 JSON / schema 约束能力
- 服务端再做一次 schema 校验
例如可以用 zod 在 Express 侧做最后一道兜底:
import { z } from "zod";
const TicketSchema = z.object({
category: z.string(),
priority: z.enum(["low", "medium", "high"]),
summary: z.string(),
});
原因很简单:
- 模型输出再像 JSON,也不等于它一定合法
- 只要下游系统要继续消费结果,就必须做服务端校验
九、面试里很容易加分的安全点
1. 不要把用户原始输入无脑拼进系统 Prompt
否则容易出现:
- Prompt Injection
- 越权指令覆盖
- 敏感规则泄露
2. 不要把密钥写进代码仓库
正确做法通常是:
- 环境变量
- 密钥管理平台
- 网关统一注入
3. 不要把完整上下文和敏感数据长期明文落日志
尤其是:
- 身份证号
- 手机号
- 订单隐私信息
- 企业内部资料
日志要脱敏,必要时只留摘要和追踪 ID。
4. 不要把模型输出直接当成可信结果
模型可能:
- 幻觉
- 漏字段
- 格式错乱
- 产生越权建议
所以高风险场景一定要有人审或规则校验。
十、一个更像真实项目的目录结构
src/
routes/
ai.route.ts
controllers/
ai.controller.ts
services/
ai.service.ts
providers/
llm/
llm.types.ts
openai-compatible.provider.ts
provider.factory.ts
prompts/
chat.prompt.ts
summary.prompt.ts
extract.prompt.ts
middleware/
auth.middleware.ts
rate-limit.middleware.ts
utils/
retry.ts
timeout.ts
config/
env.ts
这套结构背后的意思是:
- HTTP 入口和模型调用解耦
- Prompt 和 provider 解耦
- 治理能力通过中间件和工具函数复用
它比“所有 AI 代码都堆在一个 chat.ts 文件里”更适合持续演进。
典型题与标准答法
1. 为什么大模型 API 通常要通过 Express 中转
- 为了隐藏厂商密钥
- 为了统一鉴权、限流、配额和审计
- 为了屏蔽多厂商接口差异
2. Express 接入大模型 API 的核心难点是什么
- 不是调用一次接口,而是稳定性和治理
- 重点包括超时、重试、流式输出、成本、内容安全和结构化输出
3. 流式输出为什么常见
- 因为聊天产品更看重首 token 延迟
- 流式可以明显改善用户体感
- 但服务端要处理连接中断、代理超时和 chunk 转发
4. 为什么不能完全信任模型输出
- 因为模型输出本质是概率生成
- 可能幻觉、漏字段、格式不合法
- 所以需要 schema 校验、规则校验和人工兜底
5. 如果以后要切换模型厂商,怎么降低改造成本
- 在服务端抽 provider adapter
- 对内统一输入输出协议
- 不让 controller 和前端依赖某个厂商的私有字段
常见追问
1. 大模型调用为什么比普通第三方 API 更需要成本治理
- 因为很多平台按 token 计费
- 同样一次请求,输入长度、输出长度、模型档位不同,成本差距会很大
2. 缓存能不能用于大模型接口
- 可以,但要分场景
- 固定问题、固定模板、低个性化请求比较适合缓存
- 强个性化对话、带实时上下文的请求缓存价值通常更低
3. Express 里适合直接用 SDK 还是手写 HTTP
- 两者都可以
- SDK 开发体验更好
- 手写 HTTP 更容易看清协议和做统一封装
- 如果团队要支持多厂商,通常会再包一层内部 adapter
4. 接知识库检索时,Express 层一般负责什么
- 收请求
- 做用户权限判断
- 先查知识库检索结果
- 再把检索上下文注入 Prompt
- 最后统一调用模型并返回结果
易错点 / 坑
- 把厂商 API Key 暴露到前端。
- 直接把厂商原始响应透传给前端,导致前端强耦合上游协议。
- 不设超时,导致请求长时间挂死。
- 遇到失败就无限重试,结果把成本和流量一起放大。
- 没做限流和配额,导致接口被刷爆。
- 把 Prompt 写死在控制器里,后续无法版本化管理。
- 把模型输出当成绝对可信结果,不做 schema 校验。
- 流式转发时忽略前端断开、代理缓冲和 SSE 兼容问题。
速记要点(可背诵)
- Express 接大模型 API,重点是 治理,不是“转发”。
- 服务端中转的核心价值:藏密钥、做权限、收口差异、留审计。
- 高频工程点:超时、重试、限流、流式输出、结构化校验、成本统计。
- Prompt 应该模板化,provider 应该适配化。
- 模型输出不是可信数据,必须做校验和兜底。
- 如果要一句话总结:把大模型能力封装成内部稳定服务,而不是把第三方接口裸露给业务。