跳到主要内容

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 层:

  1. 接口层:Express 暴露 /chat/summary/extract 等业务接口
  2. 适配层:把内部请求转换成不同模型厂商要求的协议
  3. 治理层:鉴权、限流、超时、重试、熔断、日志、成本统计
  4. 策略层:Prompt 模板、模型选择、温度参数、上下文裁剪、结构化输出
  5. 厂商层:OpenAI-compatible API、闭源模型平台、自建网关或自部署模型

只要你按这 5 层来讲,答案就会从“会调用第三方接口”升级成“会设计 AI 能力接入层”。


一、为什么前端通常不直接调用模型厂商 API

最核心原因不是“能不能调用”,而是“该不该让它直接调用”。

1. 厂商密钥不能暴露给浏览器

  • 前端代码和网络请求天然可被看到
  • 一旦把真实 API Key 放到浏览器,就等于把计费权限交给任何人

2. 你需要统一收口权限和配额

例如:

  • 哪个用户可以用哪些模型
  • 免费用户每天能调用多少次
  • 哪些接口必须带业务身份

这些逻辑更适合在 Express 层统一控制。

3. 你需要屏蔽多厂商差异

不同模型平台常见差异包括:

  • 鉴权头命名不同
  • 请求体字段不同
  • 流式协议细节不同
  • 错误码和限流语义不同

如果让前端直接对接,后面切换模型厂商时前端会被迫跟着大改。

4. 你需要补审计和安全治理

例如:

  • 记录调用人、模型名、耗时、token 用量
  • 对输入做敏感词和越权检查
  • 对输出做内容审核或结构校验

这些能力放在 Express 侧更自然。


二、推荐的接入架构应该怎么讲

这张图的重点不是画复杂,而是说明:

  1. Express 不只是代理层,还是治理层。
  2. Prompt、模型选择、审计日志通常都应该在服务端收口。
  3. 未来如果你要切模型、做 AB 测试、做降级,都需要这一层存在。

三、最小可运行版本应该长什么样

下面的示例假设:

  • 运行环境是 Node 20+
  • 使用 Express 4.x/5.x
  • 通过原生 fetch 调用“OpenAI-compatible”接口
  • 不依赖具体 SDK,方便你看清通用接入思路

注意:

  • 不同厂商的请求字段可能是 messagesinput 或其他名字
  • 流式响应格式也可能是 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 更早到达
  • 用户更容易接受模型整体耗时

常见服务端转发思路

  1. Express 收到前端请求
  2. 请求上游模型接口时打开 stream: true
  3. 把上游 chunk 按 SSE 或文本流方式继续写给前端
  4. 结束时主动关闭响应

一个最小示例:

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 模板单独管理,至少做到:

  1. 按业务能力拆模板,例如问答、摘要、改写、抽取
  2. 模板版本化,方便回滚和 AB 测试
  3. 变量显式注入,避免字符串拼接混乱
  4. 对系统提示词、开发者提示词、用户输入分层管理

这样做的价值是:

  • 便于调优
  • 便于审计
  • 便于后续接知识库或函数调用

八、结构化输出为什么经常比“自由文本”更重要

真实业务里,很多场景并不是要一段聊天文本,而是要一份可消费的数据。

例如:

  • 提取工单字段
  • 识别意图分类
  • 生成商品标签
  • 返回固定格式的评分结果

这时更稳的方案不是让模型“自由发挥”,而是:

  1. 在 Prompt 里明确输出格式
  2. 尽量使用厂商支持的 JSON / schema 约束能力
  3. 服务端再做一次 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 应该适配化。
  • 模型输出不是可信数据,必须做校验和兜底。
  • 如果要一句话总结:把大模型能力封装成内部稳定服务,而不是把第三方接口裸露给业务。