TECH ARTICLES
LLM API Design Backend

大模型 API 设计实战指南:从第一个 Endpoint 到生产级服务

Jackie Zhan 2026-03-25
目录
Endpoint 该怎么设计? 请求和响应长什么样? 流式输出到底怎么回事? 出错了怎么办? 认证和限流怎么做? 写在最后:你的挑战

上周,一个做后端的朋友给我发消息:"我们公司要把自己微调的模型包成 API 对外提供,老板让我设计接口。我看了一圈 OpenAI、Anthropic、Google 的文档……越看越懵。"

我问他:"你哪里懵?"

他说:"每家的接口都不一样,endpoint 路径不同、响应格式不同、流式输出的实现也不同。我到底该参考谁的?还是说,我自己随便设计一套?"

"随便设计"这四个字,让我打了个冷战。

因为我见过太多"随便设计"的 LLM API 了——endpoint 命名混乱、响应格式前后不一致、流式输出只测了 happy path、错误码全是 500。上线三个月后,对接的开发者骂娘,维护的工程师想跑路。

API 设计这件事,看起来简单——不就是定几个接口嘛?但实际上,大模型 API 和传统 REST API 有本质区别。传统 API 是"请求-响应",一问一答,干脆利落。大模型 API 是"请求-流",你问一句,它可能吐 2000 个 token,耗时 30 秒,中间还可能出错。

这篇文章,我把过去踩过的坑、看过的最佳实践,浓缩成 6 个核心问题。每个问题,我都会告诉你"业界怎么做"、"为什么这么做"、以及"你该怎么做"。


Endpoint 该怎么设计?

先聊最基础的:你的 API 路径长什么样?

很多人觉得这是个小事。不就是 /api/chat 或者 /v1/completions 嘛?随便写一个就行。

但如果你观察过 OpenAI、Anthropic、Google 这三家的 endpoint,你会发现一个有意思的现象——它们不约而同地遵守了同一套设计哲学,但在具体实现上各有取舍。

先看三家的核心 endpoint:

厂商Endpoint设计风格
OpenAI/v1/chat/completions动作导向(completions)
Anthropic/v1/messages资源导向(messages)
Google/v1/models/{model}:generateContentRPC 风格

看到了吗?三种完全不同的命名风格。

OpenAI 的 /chat/completions 暗示"你给我一段对话,我给你补全"。这是从 GPT-3 时代的 /completions 演化来的,加了 /chat 前缀来区分多轮对话场景。

Anthropic 的 /messages 更简洁,把交互抽象成"消息"。你发消息,我回消息。很符合 RESTful 的资源思维——message 是一个名词,POST 就是创建一条新消息。

Google 的 :generateContent 则是典型的 gRPC 风格——在资源路径后面用冒号加动词,表示"对这个 model 执行 generateContent 操作"。

那你该选哪种?

实战建议
如果你的 API 面向开发者,优先选资源导向(如 /v1/messages/v1/chat/completions)。原因很简单:绝大多数开发者最熟悉 REST 风格,学习成本最低。Google 的 RPC 风格虽然技术上没问题,但对不熟悉 gRPC 的人来说,看到 :generateContent 会愣一下。

但不管你选哪种风格,有三条铁律必须遵守:

第一,版本号放在路径里。/v1//v2/,不要用 header 传版本。原因是:开发者调试的时候,curl 一行命令就能看到版本,不用翻 header。三家巨头全部用了路径版本号,这不是巧合。

第二,endpoint 的命名要能"自解释"。看到路径就知道这个接口干什么。/v1/chat/completions 一看就知道是"对话补全",/v1/embeddings 一看就知道是"向量化"。如果你的 endpoint 叫 /v1/process,对不起,没人知道你在 process 什么。

第三,模型选择放在请求体里,不要放在路径里。OpenAI 和 Anthropic 都是在 body 里传 "model": "gpt-4o",而不是 /v1/models/gpt-4o/completions。为什么?因为当你有 50 个模型的时候,路径里放模型名会让 API 文档变成一场噩梦。

踩坑记录
有一个反例值得说。Google 把模型名放在了路径里:/v1/models/{model}:generateContent。这导致每次换模型都要改 URL,而不是改 body 里的一个字段。对于做模型 A/B 测试的团队来说,这种设计会让代码里充满字符串拼接。

关于 endpoint 的数量,我的建议是:一开始做少、做精,后面再加。一个最小可用的 LLM API,只需要三个 endpoint:

三个接口,覆盖 90% 的使用场景。其他的 embeddings、images、audio,等需求明确了再加。

好的 endpoint 设计就像门牌号:你看一眼就知道这扇门后面是什么,不需要敲门问。


请求和响应长什么样?

endpoint 定好了,下一个问题:请求体和响应体该怎么设计?

先看请求体。大模型 API 的请求体,核心只有两样东西:消息列表生成参数

打个比方:你去餐厅点菜。消息列表是你的点菜单——"先来个凉菜,再来个红烧肉,最后上汤"。生成参数是你的偏好——"少盐、微辣、快点上"。

一个典型的请求体长这样:

{
  "model": "your-model-v1",
  "messages": [
    {"role": "system", "content": "你是一个专业的技术顾问。"},
    {"role": "user", "content": "解释一下什么是 RAG?"}
  ],
  "max_tokens": 1024,
  "temperature": 0.7,
  "stream": false
}

这里的关键设计决策是 messages 数组的结构。每条消息有 rolecontent 两个字段。role 通常是 systemuserassistant 三选一。

为什么要用数组而不是单个字符串?因为大模型的对话是多轮的。你需要把整个对话历史塞进来,模型才能"记住"之前聊了什么。这不是传统 API 的"无状态请求",而是"带上下文的请求"。

关键区别
传统 API 是无状态的——每个请求独立,服务器不记得你是谁。大模型 API 虽然也是无状态的(服务器不保存会话),但它通过让客户端每次都带上完整对话历史来实现"有记忆"的效果。这意味着随着对话轮数增加,请求体会越来越大。这是你设计 API 时必须考虑的——你需要设置 max_tokens 上限,或者提供对话摘要能力。

再看响应体。这是最容易"设计出分歧"的地方。

一个好的非流式响应应该包含四层信息:

{
  "id": "msg_01XFDUDYJgAACzvnptvVoYEL",
  "type": "message",
  "model": "your-model-v1",
  "content": [
    {
      "type": "text",
      "text": "RAG 全称 Retrieval-Augmented Generation..."
    }
  ],
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 42,
    "output_tokens": 256
  }
}

第一层:身份信息——idmodel。每个响应必须有唯一 ID,方便后续排查问题。别小看这个 ID,当你的用户说"我昨天那个请求结果不对"时,有 ID 就能精确定位,没 ID 就只能大海捞针。

第二层:内容本体——content 数组。注意,这里是数组而不是字符串。为什么?因为大模型的输出不一定只有文本。它可能同时返回文本和工具调用(function call),用数组可以自然地承载多种内容类型。

第三层:停止原因——stop_reason。告诉调用者"模型为什么停了"。是自然结束(end_turn)?是达到了 token 上限(max_tokens)?还是触发了内容过滤(content_filter)?这个字段看似不起眼,但在生产环境里极其重要——如果用户发现回答被截断,你需要知道是因为模型说完了,还是因为 token 不够。

第四层:用量统计——usage。输入多少 token、输出多少 token。这是计费的基础,也是成本优化的抓手。

常见误解
很多人设计响应时只返回 {"text": "..."},一个字段搞定。这在 demo 阶段没问题,但一到生产环境就炸了。你会发现:运维问你"这个请求花了多少 token?"——没有 usage 字段,答不上来。客户说"回答被截了"——没有 stop_reason,判断不了原因。所以,响应体从第一天就该设计完整,不要等出了问题再补字段。

响应格式是 API 的脸面。用户每天看最多的,不是你的文档,而是你返回的 JSON。


流式输出到底怎么回事?

如果说前两个问题是"基本功",那流式输出就是大模型 API 设计的"灵魂考验"。

为什么这么说?因为传统 API 几乎不需要流式输出——一个 GET 请求,几毫秒就返回了,没必要"一点一点吐"。但大模型不一样。一次生成可能花 10-30 秒。如果让用户干等 30 秒再看到结果,体验会极其糟糕。

打个比方:非流式输出就像从水龙头接了一桶水再端给你,你要等到桶装满;流式输出就像直接把水龙头对着你的杯子,水一出来你就能喝上第一口。

在技术上,业界的共识是:用 Server-Sent Events(SSE)实现流式输出

SSE 的本质很简单:服务器返回一个 Content-Type: text/event-stream 的响应,然后在这个长连接里不断地发送"事件"。每个事件以 data: 开头,事件之间用两个换行符 \n\n 分隔。

Client POST /v1/messages stream: true Server text/event-stream data: {"delta":"RAG"} data: {"delta":"是"} data: {"delta":"一种"} data: {"delta":"..."} data: [DONE]
SSE 流式输出:客户端发起请求后,服务器通过长连接逐块推送生成结果

为什么选 SSE 而不是 WebSocket?

因为 LLM 的流式输出是单向的——服务器往客户端推数据,客户端不需要在流的过程中往回发消息。SSE 天然就是单向的,比 WebSocket 简单得多。而且 SSE 走的是标准 HTTP,能穿透几乎所有的代理、CDN 和负载均衡器。WebSocket 在企业网络里经常被防火墙拦截。

现在,来看看三家巨头的流式输出格式有什么不同:

OpenAI:简洁的 data-only 风格

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"你"},"index":0}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"好"},"index":0}]}

data: [DONE]

每行一个 data:,里面是 JSON,增量内容在 delta.content 里。结束时发一个 data: [DONE]。简单粗暴,好解析。

Anthropic:语义化的 event + data 风格

event: message_start
data: {"type":"message_start","message":{"id":"msg_01...","model":"claude-opus-4-6"}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你"}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"好"}}

event: message_stop
data: {"type":"message_stop"}

Anthropic 多了 event: 行,每个事件都有明确的语义类型:message_startcontent_block_deltamessage_stop。还会穿插 ping 事件保持连接。

对比一下
OpenAI 的风格是"你解析 JSON,自己判断是不是结束";Anthropic 的风格是"我用事件类型告诉你现在到哪个阶段了"。前者更简洁,后者更易读。如果你是从零设计,我推荐 Anthropic 的方式——显式优于隐式。当你的流包含文本、工具调用、思考过程等多种内容类型时,语义化事件会让客户端的解析逻辑清晰很多。

关于客户端怎么消费 SSE 流,有一个关键的坑要提醒你:浏览器原生的 EventSource API 用不了

为什么?因为 EventSource 只支持 GET 请求,而 LLM API 全部用的是 POST(因为请求体太大了,放不进 URL 参数)。所以你需要用 fetch + ReadableStream 来手动解析:

const response = await fetch('/v1/messages', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  // 解析 SSE 格式,提取 data: 行
  for (const line of chunk.split('\n')) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      const json = JSON.parse(line.slice(6));
      process(json); // 处理增量内容
    }
  }
}

关键在第 10 行:decoder.decode(value, { stream: true })stream: true 参数告诉 TextDecoder 不要在多字节字符的边界处截断——中文字符是 3 个字节的 UTF-8,如果 chunk 恰好在中间切开,不加这个参数就会出乱码。

还有一个容易忽略的问题:网络 chunk 和 SSE 消息不是一一对应的。一个网络 chunk 可能包含半条 SSE 消息,也可能包含好几条。所以你必须做缓冲——把 chunk 累积起来,遇到 \n\n 才处理一条完整消息。

流式输出是大模型 API 的灵魂。做对了,用户感觉你的模型"反应飞快";做错了,用户觉得你的服务"不稳定"。差别只在毫秒之间。


出错了怎么办?

如果你觉得前面的内容都是"设计时就能想好的",那错误处理就是那种"上线后才知道有多重要的"。

我先说一个很多人没想到的问题:大模型 API 的错误处理比传统 API 复杂两倍。为什么?因为你有两种完全不同的错误场景:

第一种好处理,跟传统 API 一样——返回一个错误 JSON 和对应的 HTTP 状态码就行。

第二种才是真正头疼的。因为 HTTP 状态码(200 OK)在流开始时已经发了,你不可能回头改状态码。这就好比餐厅服务员已经开始上菜了,上到第三道发现后厨着火了——你总不能假装前三道菜没上过吧?

非流式错误(标准处理) 请求 HTTP 400/429/500 流中错误(特殊处理) 请求 HTTP 200 chunk 1, 2, 3... 出错! error event 关闭流
两种错误场景:非流式错误可以用 HTTP 状态码,流中错误只能通过事件通知

怎么处理流中错误?答案是:在流里发一个 error 事件

event: content_block_delta
data: {"delta":{"text":"RAG 的核心原理是"}}

event: error
data: {"type":"error","error":{"type":"server_error","message":"Internal inference timeout"}}

客户端收到 error 事件后,需要做三件事:

  1. 保存已接收的部分内容——用户已经看到一部分回答了,直接丢掉体验很差
  2. 展示错误提示——告诉用户"生成中断了",而不是默默停住
  3. 决定是否重试——如果是超时,可以重试;如果是内容过滤,重试也没用

说到错误码设计,我给你一套经过生产验证的错误分类体系:

HTTP 状态码错误类型含义客户端应对
400invalid_request参数有误检查请求体
401authentication_error认证失败检查 API Key
403permission_error无权限访问该模型联系管理员
429rate_limit_error请求太频繁指数退避重试
500server_error服务器内部错误稍后重试
529overloaded服务过载等待后重试

每个错误响应的 JSON 结构应该是统一的:

{
  "type": "error",
  "error": {
    "type": "rate_limit_error",
    "message": "Request rate limit exceeded. Please retry after 30 seconds.",
    "retry_after": 30
  }
}

注意两个关键设计:

一是 message 字段必须人话化。不要写 "ERR_RATE_LIMIT_EXCEEDED",要写 "Request rate limit exceeded. Please retry after 30 seconds."。开发者调试的时候,看到前者要去查文档,看到后者直接就知道该怎么做。

二是对于 429 错误,最好在 HTTP header 里返回 Retry-After 字段,并在 body 里也给出 retry_after。这样不管客户端解析 header 还是 body,都能拿到重试间隔。

实战案例
Anthropic 的 API 在 429 响应里不仅告诉你"被限流了",还告诉你具体是哪个维度被限了(请求数 / 输入 token 数 / 输出 token 数)。这个设计非常贴心——开发者可以据此决定是减少请求频率,还是缩短 prompt 长度。如果你的 API 也有多维限流,强烈建议把具体的限流维度写进错误信息里。

错误处理见真功夫。用户不会因为你的 API 从不出错而感激你,但会因为你的 API 出错时"说人话"而信任你。


认证和限流怎么做?

聊完了接口本身,最后聊聊"护城河"——认证和限流。

这部分看起来不性感,但如果搞砸了,后果比前面所有问题加起来都严重。认证出问题,数据泄漏;限流出问题,服务雪崩。

认证:API Key 就够了吗?

短回答:对于大多数场景,够了。

长回答:看场景。

OpenAI、Anthropic、Google 这三家,全部用的是 Bearer Token 方式的 API Key 认证:

curl https://api.example.com/v1/messages \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"your-model","messages":[...]}'

API Key 放在 Authorization header 里,不要放在 URL 参数里。为什么?因为 URL 会被记录在浏览器历史、代理日志、CDN 缓存里,而 header 通常不会。

但 API Key 只解决了"你是谁"的问题,没解决"你能干什么"。如果你的 API 面向多租户(不同客户有不同模型访问权限、不同配额),你需要在 Key 背后做一层权限映射:

这些信息存在你的后端,Key 只是查找这些信息的"钥匙"。

限流:不是惩罚,是保护

大模型推理的成本极高——一次请求可能占用 GPU 好几秒。如果不做限流,一个不小心写了死循环的客户端就能把你的服务打挂。

限流设计的关键是:不要只看请求数,要看 token 数

为什么?因为大模型 API 不像传统 API——一个请求的成本差异巨大。一个"你好"的请求消耗 10 token,一个带 20 轮对话历史的请求可能消耗 10 万 token。如果你只限制"每分钟 100 个请求",那一个恶意用户可以用 100 个超长请求把你的 GPU 吃光。

业界最佳实践是双维度限流

两个维度取更严的那个。

限流信息应该通过 HTTP header 透传给客户端:

HTTP/1.1 200 OK
X-RateLimit-Limit-Requests: 100
X-RateLimit-Limit-Tokens: 100000
X-RateLimit-Remaining-Requests: 87
X-RateLimit-Remaining-Tokens: 64250
X-RateLimit-Reset-Requests: 2026-03-25T10:00:30Z
X-RateLimit-Reset-Tokens: 2026-03-25T10:00:15Z

这六个 header 告诉客户端:你的配额是多少、还剩多少、什么时候重置。客户端拿到这些信息后,可以在本地做"预判"——如果剩余配额不足,主动降速或排队,而不是硬怼到 429。

insider 视角
一个被严重低估的设计技巧:渐进式限流。不要在客户端一碰到限额就直接返回 429。可以在接近限额时(比如剩余 10%),先在响应 header 里加一个 X-RateLimit-Warning: approaching_limit,让客户端有时间优雅降级。等真正超限了再返回 429。这就像高速公路——先用信号灯提醒"前方拥堵",而不是直接拉栏杆不让走。

最后一个经常被忽略的问题:超时设计

大模型生成的时间不确定——可能 1 秒就完成,也可能 60 秒还没完。你需要设置合理的超时:

限流不是在惩罚用户,而是在保护所有用户。一个好的限流系统,让 99% 的用户完全无感,只在那 1% 异常流量出现时才亮红灯。


写在最后:你的挑战

回到开头我朋友问的那个问题:"我该参考谁的设计?"

现在你应该有答案了。不是"参考谁的",而是理解每个设计决策背后的为什么

Endpoint 用资源导向命名,因为开发者最熟悉。响应体分四层设计,因为每层都有不同的消费者。流式用 SSE 而不是 WebSocket,因为大模型的流是单向的。错误处理要区分流前和流中,因为 HTTP 状态码一旦发出就不能修改。限流要看 token 不是请求数,因为一个请求的成本差距可以达到万倍。

每一条看起来都不复杂,但组合在一起,就是"专业"和"随便"的分水岭。

现在,给你一个挑战:

今天回去,找一个你正在用的 LLM API(OpenAI、Anthropic、任何一个都行),做三件事:

  1. 发一个流式请求,打印出原始的 SSE 事件流,仔细看每个 chunk 的格式
  2. 故意发一个错误请求(比如传一个不存在的模型名),看看错误响应长什么样
  3. 把响应 header 全部打印出来,找到限流相关的 header,算算你还有多少配额

做完这三件事,你会对 LLM API 的设计有一个全新的体感。

纸上学来终觉浅。看十篇文章,不如自己抓一次包。