一个接口从能用到稳定,中间差的到底是什么

一个接口从能用到稳定,中间差的到底是什么

我最近把一个"调用模型生成摘要"的接口重新做了一遍。

第一版很简单:收一个 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 };
}

这里有一个看起来有点"严格"的决定:额外字段直接拒绝

有人会觉得忽略额外字段更宽容,但在接口演进过程中,静默忽略往往更难排查。调用方以为自己传了 temperaturemodel,服务端却完全没用它,最后只能从结果反推参数没有生效。白名单把契约写死,反而能更早暴露版本不一致。

请求体也要设置大小上限。示例默认只读 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 个用例,全部通过:

  1. 成功请求返回结构化摘要和 request_id
  2. 空文本在调用模型前被拒绝;
  3. 未审核字段不会悄悄进入模型层;
  4. 两次临时失败后,第三次成功,且总调用次数为 3;
  5. 两个并发请求使用相同幂等键时只执行一次模型调用;
  6. 相同幂等键提交不同参数时返回 409;
  7. 上游超过 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,而是它在超时、重复点击、参数漂移和上游抖动时,仍然能给出可解释的结果。

相关推荐
Data_Journal29 分钟前
Scrapyd:分步教程
开发语言·python·microsoft·golang·编辑器·html·iphone
离陌在学C#1 小时前
C# 异步编程:从 async/await 到 Task 实战指南
开发语言·数据库·c#
念恒123061 小时前
传输层协议TCP
服务器·网络·tcp/ip
羚尔1 小时前
C语言数组
c语言·开发语言
码农大叔的博客1 小时前
Python:FastAPI的typing.Annotated参数校验示例
fastapi
比兔代理2 小时前
代理IP池性能瓶颈在哪?并发、带宽与IP质量的平衡优化策略
服务器·网络·tcp/ip·安全
女神下凡2 小时前
芯参谋(11): 各种存储芯片电路设计
开发语言·嵌入式硬件
hoho_122 小时前
麒麟V10升级nginx最新版本到1.31.4
java·服务器·nginx
冬夜戏雪2 小时前
笔试的Scanner in Scanner out
java·开发语言