大模型 API 设计实战指南:从第一个 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) |
/v1/models/{model}:generateContent | RPC 风格 |
看到了吗?三种完全不同的命名风格。
OpenAI 的 /chat/completions 暗示"你给我一段对话,我给你补全"。这是从 GPT-3 时代的 /completions 演化来的,加了 /chat 前缀来区分多轮对话场景。
Anthropic 的 /messages 更简洁,把交互抽象成"消息"。你发消息,我回消息。很符合 RESTful 的资源思维——message 是一个名词,POST 就是创建一条新消息。
Google 的 :generateContent 则是典型的 gRPC 风格——在资源路径后面用冒号加动词,表示"对这个 model 执行 generateContent 操作"。
那你该选哪种?
/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 文档变成一场噩梦。
/v1/models/{model}:generateContent。这导致每次换模型都要改 URL,而不是改 body 里的一个字段。对于做模型 A/B 测试的团队来说,这种设计会让代码里充满字符串拼接。
关于 endpoint 的数量,我的建议是:一开始做少、做精,后面再加。一个最小可用的 LLM API,只需要三个 endpoint:
POST /v1/messages— 核心对话接口(支持流式和非流式)GET /v1/models— 列出可用模型GET /v1/models/{id}— 查看单个模型详情
三个接口,覆盖 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 数组的结构。每条消息有 role 和 content 两个字段。role 通常是 system、user、assistant 三选一。
为什么要用数组而不是单个字符串?因为大模型的对话是多轮的。你需要把整个对话历史塞进来,模型才能"记住"之前聊了什么。这不是传统 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
}
}
第一层:身份信息——id 和 model。每个响应必须有唯一 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 分隔。
为什么选 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_start、content_block_delta、message_stop。还会穿插 ping 事件保持连接。
关于客户端怎么消费 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 复杂两倍。为什么?因为你有两种完全不同的错误场景:
- 非流式错误:请求打过来,还没开始生成,就出错了(参数不对、模型不存在、余额不足)
- 流中错误:请求已经在流式返回了,生成到一半,突然出错了(GPU OOM、推理超时、内容安全拦截)
第一种好处理,跟传统 API 一样——返回一个错误 JSON 和对应的 HTTP 状态码就行。
第二种才是真正头疼的。因为 HTTP 状态码(200 OK)在流开始时已经发了,你不可能回头改状态码。这就好比餐厅服务员已经开始上菜了,上到第三道发现后厨着火了——你总不能假装前三道菜没上过吧?
怎么处理流中错误?答案是:在流里发一个 error 事件。
event: content_block_delta
data: {"delta":{"text":"RAG 的核心原理是"}}
event: error
data: {"type":"error","error":{"type":"server_error","message":"Internal inference timeout"}}
客户端收到 error 事件后,需要做三件事:
- 保存已接收的部分内容——用户已经看到一部分回答了,直接丢掉体验很差
- 展示错误提示——告诉用户"生成中断了",而不是默默停住
- 决定是否重试——如果是超时,可以重试;如果是内容过滤,重试也没用
说到错误码设计,我给你一套经过生产验证的错误分类体系:
| HTTP 状态码 | 错误类型 | 含义 | 客户端应对 |
|---|---|---|---|
| 400 | invalid_request | 参数有误 | 检查请求体 |
| 401 | authentication_error | 认证失败 | 检查 API Key |
| 403 | permission_error | 无权限访问该模型 | 联系管理员 |
| 429 | rate_limit_error | 请求太频繁 | 指数退避重试 |
| 500 | server_error | 服务器内部错误 | 稍后重试 |
| 529 | overloaded | 服务过载 | 等待后重试 |
每个错误响应的 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,都能拿到重试间隔。
错误处理见真功夫。用户不会因为你的 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 背后做一层权限映射:
- 哪些模型可以调用?
- 每天 / 每月的 token 配额是多少?
- 是否允许使用流式输出?
- 是否允许使用某些高级功能(如 function calling)?
这些信息存在你的后端,Key 只是查找这些信息的"钥匙"。
限流:不是惩罚,是保护
大模型推理的成本极高——一次请求可能占用 GPU 好几秒。如果不做限流,一个不小心写了死循环的客户端就能把你的服务打挂。
限流设计的关键是:不要只看请求数,要看 token 数。
为什么?因为大模型 API 不像传统 API——一个请求的成本差异巨大。一个"你好"的请求消耗 10 token,一个带 20 轮对话历史的请求可能消耗 10 万 token。如果你只限制"每分钟 100 个请求",那一个恶意用户可以用 100 个超长请求把你的 GPU 吃光。
业界最佳实践是双维度限流:
- 请求数限流:每分钟不超过 N 次请求(RPM,Requests Per Minute)
- Token 数限流:每分钟不超过 M 个 token(TPM,Tokens Per Minute)
两个维度取更严的那个。
限流信息应该通过 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。
X-RateLimit-Warning: approaching_limit,让客户端有时间优雅降级。等真正超限了再返回 429。这就像高速公路——先用信号灯提醒"前方拥堵",而不是直接拉栏杆不让走。
最后一个经常被忽略的问题:超时设计。
大模型生成的时间不确定——可能 1 秒就完成,也可能 60 秒还没完。你需要设置合理的超时:
- 连接超时:5-10 秒。如果服务器 10 秒内没有返回第一个字节,大概率是服务挂了
- 首 token 超时(TTFT):生产级 API 的 P95 TTFT 应该控制在 500ms 以内
- 总超时:根据
max_tokens动态计算。一般每个 token 的生成时间在 20-50ms,所以 1024 token 的总超时可以设为 60 秒
限流不是在惩罚用户,而是在保护所有用户。一个好的限流系统,让 99% 的用户完全无感,只在那 1% 异常流量出现时才亮红灯。
写在最后:你的挑战
回到开头我朋友问的那个问题:"我该参考谁的设计?"
现在你应该有答案了。不是"参考谁的",而是理解每个设计决策背后的为什么。
Endpoint 用资源导向命名,因为开发者最熟悉。响应体分四层设计,因为每层都有不同的消费者。流式用 SSE 而不是 WebSocket,因为大模型的流是单向的。错误处理要区分流前和流中,因为 HTTP 状态码一旦发出就不能修改。限流要看 token 不是请求数,因为一个请求的成本差距可以达到万倍。
每一条看起来都不复杂,但组合在一起,就是"专业"和"随便"的分水岭。
现在,给你一个挑战:
今天回去,找一个你正在用的 LLM API(OpenAI、Anthropic、任何一个都行),做三件事:
- 发一个流式请求,打印出原始的 SSE 事件流,仔细看每个 chunk 的格式
- 故意发一个错误请求(比如传一个不存在的模型名),看看错误响应长什么样
- 把响应 header 全部打印出来,找到限流相关的 header,算算你还有多少配额
做完这三件事,你会对 LLM API 的设计有一个全新的体感。
纸上学来终觉浅。看十篇文章,不如自己抓一次包。
参考资料
- OpenAI - Streaming API Responses
- Simon Willison - How Streaming LLM APIs Work
- Anthropic - OpenAI SDK Compatibility
- From Waiting to Streaming: How to Handle LLM Responses Like a Pro
- Rate Limiting Best Practices in REST API Design
- Designing APIs for LLM Apps: Build Scalable and AI-Ready Interfaces
- How to Stream LLM Responses Using Server-Sent Events (SSE)
- OpenAI - Rate Limits Guide