一个接口从能用到稳定,中间差的到底是什么
我最近把一个"调用模型生成摘要"的接口重新做了一遍。
第一版很简单:收一个 text,调用模型,返回一段字符串。十几行代码就能跑,Postman 里看起来也没问题。
但只要把它放到真实环境,问题会一个接一个冒出来:请求体太大怎么办?模型偶发超时要不要重试?用户连续点击两次会不会扣两次额度?同一个幂等键被拿来提交另一段内容怎么办?出了 504,运维只能看到一句"生成失败",怎么定位?
这篇文章不讨论某个模型有多聪明,只做一件更朴素的事:把一个"能调用模型"的接口,改成一个有边界、可重试、可去重、能追踪的服务。
示例使用 Node.js 原生模块,不依赖 Express,也不需要真实模型 Key。模型层用一个确定性的本地模拟器代替,读者可以直接跑测试;接入真实模型时,只替换模型适配函数即可。

先把结果跑出来
进入示例目录:
bash
cd examples/stable-text-api
npm test
npm start
服务监听 8788 端口。发送一条请求:
bash
curl -sS http://127.0.0.1:8788/v1/summaries \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: demo-001' \
-d '{"text":"接口先要能跑,再要能重试、可追踪、可去重。","idempotency_key":"demo-001"}'
返回结果类似这样:
json
{
"request_id": "demo-001",
"replayed": false,
"data": {
"summary": "接口先要能跑,再要能重试、可追踪、可去重",
"attempts": 1
}
}
再次发送完全相同的请求,模型不会被再次调用:
json
{
"request_id": "另一个请求 ID",
"replayed": true,
"data": {
"summary": "接口先要能跑,再要能重试、可追踪、可去重",
"attempts": 1
}
}
replayed: true 不是装饰字段,它告诉调用方:这次拿到的是之前已经完成的结果。客户端可以据此决定是否展示"已恢复"提示,服务端也可以在监控里区分真实执行和结果重放。
1. "能跑"的版本,通常只有一个大问题
很多接口的第一版大概是这样:
js
async function summarize(request, response) {
const body = await request.json();
const result = await callModel(body.text);
response.json({ summary: result });
}
这段代码的问题不在于短,而在于它把四种完全不同的事情揉在了一起:
- 输入是否可信;
- 上游是否在规定时间内返回;
- 同一个业务请求是否已经执行过;
- 失败后调用方应该怎么处理。
一旦上游抖一下,所有问题都会变成同一句话:生成失败。调用方只能盲目重试,重试又可能制造重复任务。
我最后采用的分层很简单:
text
HTTP 请求
│
├─ 读取上限、JSON 解析、字段白名单
│
├─ 生成 request_id,记录基础指标
│
├─ 幂等键查找 / 参数指纹校验
│
├─ 超时控制 + 只对可重试错误做有限重试
│
└─ 统一成功和失败响应
每一层只解决一类问题。这样以后替换模型供应商,接口契约和稳定性策略不用跟着重写。

2. 先收紧输入,别把模型当校验器
这个示例只接受两个字段:
json
{
"text": "需要总结的正文",
"idempotency_key": "客户端生成的唯一请求键"
}
服务端明确列出允许字段:
js
const ALLOWED_FIELDS = new Set(["text", "idempotency_key"]);
function validatePayload(payload, maxTextLength) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new HttpError(400, "INVALID_ARGUMENT", "请求体必须是 JSON 对象");
}
const unknownField = Object.keys(payload)
.find((field) => !ALLOWED_FIELDS.has(field));
if (unknownField) {
throw new HttpError(400, "INVALID_ARGUMENT", `不支持字段:${unknownField}`);
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const key = typeof payload.idempotency_key === "string"
? payload.idempotency_key.trim()
: "";
if (!text) throw new HttpError(400, "INVALID_ARGUMENT", "text 不能为空");
if (text.length > maxTextLength) {
throw new HttpError(400, "INVALID_ARGUMENT", "text 超过长度限制");
}
if (!key || key.length > 128) {
throw new HttpError(400, "INVALID_ARGUMENT", "idempotency_key 无效");
}
return { text, idempotency_key: key };
}
这里有一个看起来有点"严格"的决定:额外字段直接拒绝。
有人会觉得忽略额外字段更宽容,但在接口演进过程中,静默忽略往往更难排查。调用方以为自己传了 temperature 或 model,服务端却完全没用它,最后只能从结果反推参数没有生效。白名单把契约写死,反而能更早暴露版本不一致。
请求体也要设置大小上限。示例默认只读 64 KB,超过后返回 413 PAYLOAD_TOO_LARGE。这不是为了防住所有攻击,而是避免一个本来只需要几 KB 的摘要请求,把进程内存和模型上下文一起拖垮。
生产环境还应在网关层做限流、在应用层做用户配额;不要把所有防护都押在这一段 JavaScript 上。
3. 超时必须真正中断上游
只给 HTTP 客户端设置超时还不够。如果应用层已经把请求判定为超时,但上游模型调用还在后台继续跑,重试之后就可能出现两个模型请求同时消耗额度。
示例用 AbortController 把超时信号传给模型适配层:
js
async function withTimeout(operation, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await operation(controller.signal);
} catch (error) {
if (controller.signal.aborted) {
throw Object.assign(new Error("上游模型超时"), {
code: "UPSTREAM_TIMEOUT",
retryable: true,
});
}
throw error;
} finally {
clearTimeout(timer);
}
}
模型适配层必须接受 signal:
js
export async function summarizeText(text, { signal } = {}) {
await abortableDelay(mockState.delayMs, signal);
// 这里替换成真实模型 SDK 或 fetch 调用
return text.split(/[。!?.!?]/u)[0].trim();
}
真实模型 SDK 如果不支持 AbortSignal,就要查清楚它的取消 API;如果既不能取消,也不能设置服务端超时,至少要把这类请求从"可安全重试"列表里拿出来。否则你以为在重试,实际上是在叠加未完成请求。
4. 重试要有"预算",不能见错就重来
示例只对两类错误重试:
- 上游临时不可用;
- 上游超时或请求被取消。
参数错误、字段不支持、幂等冲突都不重试。重试这些错误只会把同一个确定性问题放大。
核心循环如下:
js
async function executeWithRetry(text, config) {
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
try {
const summary = await withTimeout(
(signal) => config.model(text, { signal }),
config.timeoutMs,
);
return { summary, attempts: attempt };
} catch (error) {
const canRetry = error?.retryable === true
|| error?.name === "AbortError";
if (!canRetry || attempt === config.maxAttempts) {
throw Object.assign(error, { attempts: attempt });
}
await sleep(config.retryBaseMs * 2 ** (attempt - 1));
}
}
throw new Error("retry loop exhausted");
}
默认参数是:最多 3 次,退避基数 80 毫秒,单次上游超时 1.5 秒。这里没有把重试次数做成无限,也没有把所有 HTTP 5xx 一律当成可重试。
实际项目里我会把错误分类表单独维护:
| 错误 | 是否重试 | 返回给调用方 |
|---|---|---|
| JSON 解析失败 | 否 | 400 INVALID_JSON |
| text 为空或超长 | 否 | 400 INVALID_ARGUMENT |
| 幂等键参数冲突 | 否 | 409 IDEMPOTENCY_CONFLICT |
| 上游短暂不可用 | 是 | 重试成功则 200,耗尽后 502 |
| 上游超时 | 是 | 重试成功则 200,耗尽后 504 |
重试间隔最好再加随机抖动,避免同一时间大量请求一起重试。本文示例为了让测试稳定,没有引入随机数。
5. 幂等不是"查到结果就返回"这么简单
如果用户因为网络卡顿连续点击两次,两个请求可能几乎同时抵达服务端。下面这种写法有竞态:
js
if (!cache.has(key)) {
const result = await callModel(text);
cache.set(key, result);
}
return cache.get(key);
两个请求都可能在 cache.has 之后、callModel 之前穿过去,最终调用两次模型。
示例把正在执行的 Promise 也放进缓存:
js
async function executeIdempotent(payload, state, config) {
const key = payload.idempotency_key;
const digest = fingerprint(payload);
const existing = state.entries.get(key);
if (existing) {
if (existing.digest !== digest) {
throw new HttpError(
409,
"IDEMPOTENCY_CONFLICT",
"同一个幂等键对应了不同参数",
);
}
const replay = await existing.promise;
return { ...replay, replayed: true };
}
const promise = executeWithRetry(payload.text, config)
.then((value) => ({ value, replayed: false }));
state.entries.set(key, {
digest,
createdAt: Date.now(),
promise,
});
try {
return await promise;
} catch (error) {
state.entries.delete(key);
throw error;
}
}
这里有两个容易漏掉的细节。
第一,幂等键必须和参数指纹一起校验。同一个键第二次提交另一段文本,不能把第一次的摘要"正确地"返回给它,应该明确返回 409。
第二,失败的 Promise 要从缓存删除。否则第一次上游失败后,后面所有相同请求都会复用一条已经 rejected 的 Promise,系统永远没有恢复机会。
示例把结果放在进程内存里,并设置 10 分钟保留时间,只适合单实例演示。生产环境至少要考虑:
- 多实例之间使用 Redis 或数据库共享幂等记录;
- 幂等键按用户或租户隔离,避免互相覆盖;
- 结果和状态一起持久化,避免进程重启后重复执行;
- 设置明确的过期策略,防止幂等表无限增长。
如果这个接口会触发扣款、发货、发消息等副作用,幂等记录应该放在能和业务状态同事务提交的位置,不能只靠应用内 Map。
6. 错误响应要让调用方知道下一步做什么
成功和失败都返回 request_id:
js
function publicError(error) {
const status = error.status
|| (error.code === "UPSTREAM_TIMEOUT" ? 504 : 502);
return {
status,
body: {
error: {
code: error.code || "UPSTREAM_ERROR",
message: error.message,
attempts: error.attempts,
},
},
};
}
一次超时耗尽重试后的响应:
json
{
"request_id": "req-8f2d...",
"error": {
"code": "UPSTREAM_TIMEOUT",
"message": "上游模型超时",
"attempts": 3
}
}
调用方可以据此决定:
- 400:修正参数后再发;
- 409:检查幂等键是否复用了错误的请求;
- 502/504:提示稍后重试,或进入降级流程。
不要把上游 SDK 的完整异常、Prompt 原文或内部 URL 原样返回给浏览器。对外错误要稳定,对内日志才记录更详细的原因,并注意脱敏。
X-Request-Id 支持由调用方传入,也可以由服务端生成:
js
const requestId = request.headers["x-request-id"] || randomUUID();
这让一次请求可以串起网关日志、应用日志和模型调用日志。日志中建议记录请求 ID、幂等键的哈希、尝试次数、上游耗时和最终错误码,不要直接记录完整文本。
7. 健康检查不能只返回"进程活着"
示例提供了 /health:
bash
curl -sS http://127.0.0.1:8788/health
响应包含最基础的计数:
json
{
"ok": true,
"metrics": {
"total": 12,
"success": 10,
"failed": 2
}
}
这不是完整监控,但比"端口能连通"有用。生产环境还应拆出:
- 参数错误率;
- 上游超时率;
- 重试后的成功率;
- P50/P95/P99 延迟;
- 幂等重放比例;
- 每个模型、租户和版本的错误分布。
健康检查最好再分成两类:
liveness:进程是否还活着;readiness:是否具备接收流量的条件,例如 Redis、配置中心和模型上游是否可用。
不要把一次模型调用塞进 liveness。模型暂时超时,不应该让编排系统把整个进程反复重启。
8. 模型适配层应该是唯一可替换的地方
示例的 model.js 用固定逻辑模拟摘要:取第一句话,并支持人为设置延迟和临时失败。它的价值不是模拟某个供应商,而是让测试可以稳定复现边界。
真实接入时,保留这个函数签名即可:
js
export async function summarizeText(text, { signal } = {}) {
const response = await fetch("模型服务地址", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MODEL_API_KEY}`,
},
body: JSON.stringify({ input: text }),
signal,
});
if (!response.ok) {
const error = new Error("模型服务返回失败");
error.retryable = response.status >= 500;
throw error;
}
const data = await response.json();
return data.summary;
}
密钥只存在服务端环境变量里,不能放到浏览器代码、公开构建变量、接口响应或日志中。示例文章没有写任何真实接口地址和凭证,接入时请按你使用的模型服务文档替换适配层。
如果未来从同步摘要改成流式输出,也只应该改模型适配和内部事件转换,不要把供应商原始响应格式直接泄漏到业务层。接口的幂等、追踪和错误边界应该保持稳定。
9. 测试要验证"不会发生什么"
进入示例目录执行:
bash
npm test
当前一共 7 个用例,全部通过:
- 成功请求返回结构化摘要和
request_id; - 空文本在调用模型前被拒绝;
- 未审核字段不会悄悄进入模型层;
- 两次临时失败后,第三次成功,且总调用次数为 3;
- 两个并发请求使用相同幂等键时只执行一次模型调用;
- 相同幂等键提交不同参数时返回 409;
- 上游超过 25 毫秒未返回,最终得到 504,且最多尝试 3 次。
其中第 5 个用例是最容易被漏掉的。串行发两次请求并不能证明没有竞态,测试必须让两个请求同时抵达:
js
test("coalesces concurrent requests with the same idempotency key", async () => {
const payload = {
text: "同一个任务只执行一次",
idempotency_key: "same-1",
};
const [first, second] = await Promise.all([
request("/v1/summaries", payload),
request("/v1/summaries", payload),
]);
assert.equal(first.status, 200);
assert.notEqual(first.body.replayed, second.body.replayed);
assert.equal(modelCalls, 1);
});
测试幂等时,不能只断言两次响应内容相同,还要断言模型调用次数确实是 1。否则缓存代码即使完全没生效,测试也可能通过。
10. 这套方案仍然不是"生产即用"
示例有意保持小,下面这些地方上线前必须补齐:
内存状态要换成共享存储
当前幂等记录和指标都在进程内存里,重启会丢失,多实例也互相看不见。生产环境可以用 Redis 保存"进行中 Promise"对应的状态,用数据库保存最终结果;注意分布式锁的租约、续期和异常释放。
重试要和上游计费规则对齐
不是所有模型请求都适合自动重试。有些上游虽然返回 500,但请求已经在后台完成;有些接口会按请求次数计费。上线前要确认超时语义、取消语义和计费时点,再决定重试预算。
需要真正的限流和熔断
本文只有请求体上限和有限重试,没有实现令牌桶、并发上限和熔断器。上游持续故障时,重试本身可能形成"重试风暴"。成熟服务通常会按租户、模型和区域分别限流,并在错误率超过阈值后短暂熔断。
需要版本化契约
白名单能防止未知字段混入,但不能替代接口版本。字段语义发生变化时,应该发布 /v2 或显式版本头,并为旧版本保留回滚路径。
最后总结一下
一个接口从"能用"到"稳定",中间并不是多写几个 try/catch,而是把失败当成协议的一部分:
- 输入有明确边界,额外字段不会静默生效;
- 上游有超时,超时会真正取消请求;
- 重试有预算,只处理可恢复错误;
- 幂等键绑定参数指纹,进行中的请求也能被复用;
- 错误有稳定的代码、状态码和
request_id; - 测试验证调用次数、重试次数和不会发生的副作用。
模型可以换,摘要规则可以换,甚至接口背后的供应商也可以换,但这些边界最好留在自己的服务里。
真正让人敢把接口交给用户的,不是它在演示环境里返回过一次 200,而是它在超时、重复点击、参数漂移和上游抖动时,仍然能给出可解释的结果。