第 01 篇:OpenAI 兼容 API 初探 —— 用 curl 跑通第一次对话

第 01 篇:OpenAI 兼容 API 初探 ------ 用 curl 跑通第一次对话

系列:《Java 大模型应用开发入门》第 01 篇。

源码范围:mock-llm-server。这一篇一行 Java 都不写。


一、背景

上一篇把项目搭起来了,很多人这时候会直接开写 WebClient 调用代码。

先别。

原因不复杂:如果你连「这个接口要什么、返回什么」都没亲眼看过,那你写出来的 Java 本质上是在猜。猜错了之后,你面前会同时出现两个未知数 ------ 是 Java 写错了,还是我对协议的理解就错了?排查成本翻倍。

所以这篇的做法是:先用 curl 把链路跑通,把请求和响应的每一个字节都看清楚。

curl 是最小可信实现,它不会帮你藏任何东西。


二、目标

  1. 搞清楚 OpenAI 兼容协议的三类核心端点,知道各自干什么;
  2. 能手工拼出一个正确的请求:URL、请求头、请求体;
  3. 能看懂返回的 JSON,知道去哪一层取正文、去哪一层取 Token 用量;
  4. 亲手制造 401 / 404 / 429 / 超时,记住它们长什么样;
  5. 搞清楚流式和流式响应的结构差异。

三、前置

启动本地模拟服务

这个系列不需要真实 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_reasonstop 正常,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:

  1. 字段从 message 变成了 delta。非流式是 choices[0].message.content,流式是 choices[0].delta.content
  2. 第一片只带 roledelta.contentundefined
  3. 最后一片只带 finish_reasondelta.content 同样是 undefined
  4. 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.contentusagefinish_reason
  • 亲手制造并记住了 401 / 404 / 429 / 超时;
  • 发现了流式响应的四个坑:deltamessage 之差、首尾空片、[DONE]

现在你对这条链路已经看得见了。但 curl 只是验证工具,进不了生产。

下一篇回答一个更基础的问题:当你写下 temperature: 0.7 的时候,你到底在对模型做什么?

搞不清 Token、上下文窗口和 Temperature,你会写出「有时对有时错」的代码,而且完全不知道为什么。

下一篇 → 第 02 篇:大模型核心概念 ------ Token、上下文窗口、Temperature、消息角色

跟着敲的建议:本文所有 curl 命令都亲手跑一遍,尤其是第五步的错误场景。

暂时接不上真实 Key 也没关系,用 mock 服务即可,命令完全一样。

相关推荐
梦帮科技1 小时前
RNS 代币架构:ERC20 五件套扩展与六钱包分配
人工智能·sql·区块链·database·合成复用原则·加密货币
曹牧1 小时前
PostMan:400 Bad Request‌
java
林澈在路上1 小时前
游戏场景背景音乐定制AI软件哪款好 2026对比推荐
大数据·人工智能·aigc·音视频
devpotato1 小时前
RPO与RTO:容灾的两个关键指标
java·后端
宁渡AI大模型1 小时前
AI 全栈面试新趋势:Vibe Coding、前端、Java 后端高频面试题深度解析|河南宁渡科技有限公司编程教程
java·javascript·人工智能·python·ai大模型
AI码农小姐姐2 小时前
AI漫剧推文短视频自动化生产:从文本分镜到视频合成的工程实现
人工智能·音视频·ai工具·ai漫剧·知漫剧
CallFay云起未来2 小时前
AI客服如何与人工客服协同?从任务路由到上下文交接的Agent架构实践
java·人工智能·文心一言
智购科技自动售货机工厂2 小时前
2026自动售货机端侧AI降本逻辑:从云端API到本地推理的成本重构~YH
人工智能·python·ui·面试·交互
cfm_29142 小时前
观察者模式
java