第 01 篇:OpenAI 兼容 API 初探 ------ 用 curl 跑通第一次对话
系列:《Java 大模型应用开发入门》第 01 篇。
源码范围:
mock-llm-server。这一篇一行 Java 都不写。
一、背景
上一篇把项目搭起来了,很多人这时候会直接开写 WebClient 调用代码。
先别。
原因不复杂:如果你连「这个接口要什么、返回什么」都没亲眼看过,那你写出来的 Java 本质上是在猜。猜错了之后,你面前会同时出现两个未知数 ------ 是 Java 写错了,还是我对协议的理解就错了?排查成本翻倍。
所以这篇的做法是:先用 curl 把链路跑通,把请求和响应的每一个字节都看清楚。
curl 是最小可信实现,它不会帮你藏任何东西。
二、目标
- 搞清楚 OpenAI 兼容协议的三类核心端点,知道各自干什么;
- 能手工拼出一个正确的请求:URL、请求头、请求体;
- 能看懂返回的 JSON,知道去哪一层取正文、去哪一层取 Token 用量;
- 亲手制造 401 / 404 / 429 / 超时,记住它们长什么样;
- 搞清楚流式和流式响应的结构差异。
三、前置
启动本地模拟服务
这个系列不需要真实 API Key。仓库里带了一个 OpenAI 兼容的模拟服务:
powershell
powershell -ExecutionPolicy Bypass -File tools\start-mock.ps1
启动成功的日志:
Tomcat started on port 8899 (http) with context path ''
Started MockLlmServerApplication in 2.307 seconds (process running for 2.785)
约定两个变量
后面所有命令都用到这两个变量。Linux / macOS 用 export:
bash
# Windows PowerShell
$env:AI_BASE_URL="http://localhost:8899/v1"
$env:AI_API_KEY="mock-key-123456"
# Linux / macOS
export AI_BASE_URL="http://localhost:8899/v1"
export AI_API_KEY="mock-key-123456"
将来你要接真实服务,改这两个值就行,后面的命令一个字都不用动。
四、核心概念
OpenAI 兼容协议是什么
OpenAI 的 HTTP 接口出得早,事实上成了行业标准。现在绝大多数厂商------通义千问、DeepSeek、智谱、Moonshot、本地的 Ollama 和 vLLM------都提供一套「兼容模式」接口,路径、请求体字段名、响应体结构跟 OpenAI 一模一样。
也就是说,你只要学会这一套,就能对接几乎所有模型服务商。这也是本系列不引入任何厂商 SDK 的底气。
三个最常用的端点
| 方法 | 路径 | 用途 |
|---|---|---|
GET |
/v1/models |
列出可用模型。用它验证 Key 和 baseUrl,最省 Token |
POST |
/v1/chat/completions |
对话补全。stream: true 时返回 SSE 流 |
POST |
/v1/embeddings |
文本向量化(第 07 篇细讲) |
请求头
Authorization: Bearer <API_KEY>
Content-Type: application/json
就两个。Authorization 的格式是固定的 Bearer 加 Key,中间有一个空格。这个空格漏掉是最蠢也最常见的 401 原因。
请求体
json
{
"model": "mock-gpt-4o-mini",
"messages": [
{"role": "system", "content": "你是一个严谨的技术助手。"},
{"role": "user", "content": "用两句话说明大模型 API 里的 Token 是什么"}
],
"temperature": 0.7,
"max_tokens": 2048,
"stream": false
}
messages 是个数组,这是它跟传统 REST 接口最大的不同:对话历史是一等公民。数组里的顺序就是对话顺序。
还有一点容易忽略------模型本身是无状态的。每次请求你都要把完整历史重新发一遍,它不会记得你上次说了什么。
响应体(非流式)
json
{
"id": "chatcmpl-mock-2f943e2cde83",
"object": "chat.completion",
"created": 1789201300,
"model": "mock-gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "......回复正文......" },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 36,
"completion_tokens": 94,
"total_tokens": 130
}
}
三个必须记住的位置:
| 你要的东西 | JSON 路径 |
|---|---|
| 回复正文 | choices[0].message.content |
| 结束原因 | choices[0].finish_reason(stop 正常,length 表示被 max_tokens 截断) |
| Token 用量 | usage.prompt_tokens / usage.completion_tokens / usage.total_tokens |
usage 是成本核算的唯一来源。做 AI 应用不看 usage,等于开公司不看账单。
五、代码实操
第一步:先探 Key 和 baseUrl
永远先调 /v1/models。它不消耗生成 Token,是验证「地址对不对、Key 对不对」最快的办法:
bash
curl -s "$AI_BASE_URL/models" -H "Authorization: Bearer $AI_API_KEY"
实测输出:
json
{
"object": "list",
"data": [
{ "id": "mock-gpt-4o-mini", "object": "model", "created": 1789201300, "owned_by": "mock" },
{ "id": "mock-gpt-4o", "object": "model", "created": 1789201300, "owned_by": "mock" },
{ "id": "mock-embedding-3-small", "object": "model", "created": 1789201300, "owned_by": "mock" },
{ "id": "mock-qwen-max", "object": "model", "created": 1789201300, "owned_by": "mock" }
]
}
看到这个列表,说明 baseUrl 和 Key 都是对的。接下来的失败只可能出在请求体上。
第二步:第一次对话
bash
curl -s -X POST "$AI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $AI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mock-gpt-4o-mini",
"messages": [
{"role": "system", "content": "你是一个严谨的中文技术助手。"},
{"role": "user", "content": "用两句话说明大模型 API 里的 Token 是什么"}
],
"temperature": 0.7
}'
实测返回:
json
{
"id": "chatcmpl-mock-2f943e2cde83",
"object": "chat.completion",
"created": 1789201300,
"model": "mock-gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "【本地模拟回复】我收到了你的问题:「用两句话说明大模型 API 里的 Token 是什么」。当前运行的是 mock-llm-server,用规则引擎代替真实模型,因此回复内容是确定性的、可复现的。把 ai.base-url 换成真实服务地址与 Key,即可获得真实模型的回答。"
},
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 36, "completion_tokens": 94, "total_tokens": 130 }
}
到这里,你已经「调通大模型」了。
后面 12 篇做的事,说到底就是把这段 curl 搬进 Java,再把沿途的工程问题一个个解决掉。
第三步:让它返回结构化 JSON
加一个 response_format:
bash
curl -s -X POST "$AI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $AI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mock-gpt-4o-mini",
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": "你是文本分类助手。只能从 咨询、投诉、退款、物流、其他 中选一个,只返回 JSON。"},
{"role": "user", "content": "【待分类文本】我上周买的东西到现在还没发货,快递信息一直停在「已揽收」"}
]
}'
返回的 content 是一个字符串形式的 JSON:
json
{"label":"物流","confidence":0.9,"reason":"文本关注包裹运输与配送进度。"}
这里有个关键认知:结构化输出返回的 content 仍然是字符串,只不过内容恰好是合法 JSON。你还需要在 Java 侧再反序列化一次。第 05 篇专门讲这个。
第四步:流式(SSE)
把 stream 改成 true,同时用 curl -N 关掉 curl 自己的缓冲,否则你看不到逐字效果:
bash
curl -N -s -X POST "$AI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $AI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"mock-gpt-4o-mini","stream":true,
"messages":[{"role":"user","content":"介绍一下你自己"}]}'
实测输出(前几行):
data:{"id":"chatcmpl-mock-4d0ad82271f5","object":"chat.completion.chunk","created":1789201300,"model":"mock-gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data:{"id":"chatcmpl-mock-4d0ad82271f5","object":"chat.completion.chunk","created":1789201300,"model":"mock-gpt-4o-mini","choices":[{"index":0,"delta":{"content":"【本"},"finish_reason":null}]}
data:{"id":"chatcmpl-mock-4d0ad82271f5","object":"chat.completion.chunk","created":1789201300,"model":"mock-gpt-4o-mini","choices":[{"index":0,"delta":{"content":"地模"},"finish_reason":null}]}
...
共 128 行,以 data: [DONE] 结束
流式响应有四个细节,每一个都会在 Java 里变成 bug:
- 字段从
message变成了delta。非流式是choices[0].message.content,流式是choices[0].delta.content; - 第一片只带
role,delta.content是undefined; - 最后一片只带
finish_reason,delta.content同样是undefined; data: [DONE]不是 JSON,它是纯文本终止标记,不能拿去反序列化。
第 3、4 两点,正是第 06 篇里两个真实踩过的坑。
第五步:故意犯错
这一步其实是整篇里最该认真做的。只有见过错误长什么样,你才认得出它。
① Key 写错 → 401
bash
curl -s -X POST "$AI_BASE_URL/chat/completions" \
-H "Authorization: Bearer wrong-key" -H "Content-Type: application/json" \
-d '{"model":"mock-gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
json
{
"error": {
"message": "Incorrect API key provided. (mock 服务期望的 Key 是 mock-key-123456)",
"type": "invalid_api_key",
"code": "invalid_api_key"
}
}
② 模型名写错 → 404
json
{
"error": {
"message": "The model `gpt-5-ultra` does not exist. 可用模型:[mock-gpt-4o-mini, mock-gpt-4o, mock-embedding-3-small, mock-qwen-max]",
"type": "model_not_found",
"code": "model_not_found"
}
}
③ 触发限流 → 429
bash
curl -i -s -X POST "$AI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $AI_API_KEY" -H "Content-Type: application/json" \
-H "X-Mock-Scenario: 429" \
-d '{"model":"mock-gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
json
{
"error": {
"message": "模拟场景:触发限流,请稍后重试",
"type": "rate_limit_exceeded",
"code": "rate_limit_exceeded"
}
}
注意响应头里还有一个 Retry-After: 2,意思是建议 2 秒后重试。真实的模型服务商在 429 时通常都会带这个头,重试逻辑应该优先读它,而不是自己拍一个退避时间。
X-Mock-Scenario是本项目 mock 服务提供的演练开关,真实服务没有这个头。它支持
401/429/500/timeout/empty。第 08 篇验证重试、熔断、降级时会大量用到它 ------因为你没法让真实 API 按需报错,这是本地 mock 最大的价值。
④ 上游卡死 → 超时
bash
curl -i -s -X POST "$BASE/v1/chat/completions" -H "X-Mock-Scenario: timeout" ...
mock 会睡 8 秒再返回,足以让你看到「客户端在等、上游没反应」这个状态。第 08 篇的超时验证就靠它。
六、验证
tools/probe_mock.py 会把上面所有场景跑一遍,落盘成报告:
bash
python tools/probe_mock.py
| # | 场景 | 期望 | 实测 |
|---|---|---|---|
| 1 | /v1/models |
返回 4 个模型 | 通过 |
| 2 | 普通对话 | choices[0].message.content 有内容,usage 完整 |
prompt=36 / completion=94 |
| 3 | JSON Mode + 分类 | content 是合法 JSON 字符串 |
{"label":"物流","confidence":0.9,...} |
| 4 | 流式 | 逐字分片,以 data: [DONE] 结束 |
共 128 行 |
| 5 | 错误 Key | HTTP 401 + invalid_api_key |
通过 |
| 6 | 错误模型名 | HTTP 404 + model_not_found |
通过 |
| 7 | 限流 | HTTP 429 + Retry-After: 2 |
通过 |
| 8 | 向量化 | 返回 384 维浮点数组 | 通过 |
完整报告在 tools/out/probe-mock.txt。
七、常见坑
| 坑 | 表现 | 原因与解法 |
|---|---|---|
/v1 重复 |
404,URL 变成 /v1/v1/chat/completions |
约定 baseUrl 已经含 /v1,拼路径时只加 /chat/completions |
/v1 缺失 |
404 | 有些厂商 baseUrl 不带 /v1(比如智谱是 /api/paas/v4),以文档为准 |
Bearer 少空格 |
401 | Bearer 加 Key,中间必须有一个空格 |
| 模型名照抄别家 | 404 model_not_found |
每个厂商的模型名都不同,先 /v1/models 查一下 |
流式解析了 [DONE] |
JSON 解析异常 | [DONE] 不是 JSON,见到就停 |
流式读 message 字段 |
内容永远为空 | 流式是 delta,不是 message |
用 POST 又不带 Content-Type |
400,或服务端看不懂 | 必须显式 -H "Content-Type: application/json" |
curl 不加 -N |
看不到流式效果,像一次性返回 | curl 自己有输出缓冲 |
| 只处理 200,不读错误体 | 排查两眼一抹黑 | 错误响应体里往往有最有价值的信息(上面 404 就直接列出了可用模型) |
最后一条值得多说两句。很多人写代码是这样:
java
if (response.statusCode() != 200) {
throw new RuntimeException("调用失败");
}
这一行把最有用的信息丢了。正确做法永远是:把状态码和响应体一起带进异常里。
八、小结与下一篇
这篇做完的事:
- 讲清了 OpenAI 兼容协议的三类端点;
- 用 curl 跑通第一次对话,看清了请求与响应的每一个字段;
- 认识了三条取数路径:
choices[0].message.content、usage、finish_reason; - 亲手制造并记住了 401 / 404 / 429 / 超时;
- 发现了流式响应的四个坑:
delta与message之差、首尾空片、[DONE]。
现在你对这条链路已经看得见了。但 curl 只是验证工具,进不了生产。
下一篇回答一个更基础的问题:当你写下 temperature: 0.7 的时候,你到底在对模型做什么?
搞不清 Token、上下文窗口和 Temperature,你会写出「有时对有时错」的代码,而且完全不知道为什么。
下一篇 → 第 02 篇:大模型核心概念 ------ Token、上下文窗口、Temperature、消息角色
跟着敲的建议:本文所有 curl 命令都亲手跑一遍,尤其是第五步的错误场景。
暂时接不上真实 Key 也没关系,用 mock 服务即可,命令完全一样。