前言
最近在做企业内部 LLM 统一调度网关项目,也就是大家常说的大模型 API 中转网关。很多业务系统需要接入不同厂商的大模型,比如 OpenAI、Claude、国内各类大模型。 如果每个业务服务都单独对接各家模型原生接口,会存在几个很头疼的问题:
- 每家模型接口协议、请求体、鉴权方式各不相同,耦合严重;
- 切换模型时,业务代码需要大规模修改;
- 无法统一做鉴权、额度管控、调用日志、成本统计;
- 上游模型服务不稳定,缺少统一的熔断、降级、重试能力。
所以我们自研了一套基于 SpringBoot 的 LLM 网关,对外统一暴露 OpenAI 兼容接口,内部做协议适配器,把统一请求翻译成 Claude 等模型原生请求,屏蔽底层差异。
提示:本文是后端工程落地实践,不涉及模型训练,聚焦接口适配、请求转发,适合后端开发学习。
一、OpenAI 与 Claude 原生协议核心差异
这是整个网关适配器最核心的知识点,也是开发中踩坑最多的地方。
1. OpenAI 接口规范
-
请求端点:
POST /v1/chat/completions -
鉴权方式:请求头
Authorization: Bearer sk-xxxx -
消息结构:system、user、assistant 全部放在
messages数组内{
"model": "gpt-3.5-turbo",
"messages": [
{"role":"system","content":"你是助手"},
{"role":"user","content":"你好"}
],
"temperature": 0.7,
"stream": true
} -
流式输出:SSE 协议,事件固定为
data:,结束标记data: [DONE] -
流式增量字段:
delta
2. Claude(Anthropic)原生接口规范
-
请求端点:
POST /v1/messages -
鉴权方式:请求头
x-api-key: sk-ant-xxxx,不是 Bearer -
重点区别:
system是顶层独立字段,不能放在 messages 数组里,messages 只支持 user、assistant 角色{
"model": "claude-3-haiku-20240307",
"system": "你是助手",
"messages": [
{"role":"user","content":"你好"}
],
"temperature": 0.7,
"stream": true,
"max_tokens": 1024
} -
流式:同样是 SSE,但是事件类型和 OpenAI 完全不一样,有
message_start、content_block_delta、message_stop等事件,没有[DONE]标记 -
强制要求:Claude 必须传入
max_tokens参数,不传递会报错
一句话总结:OpenAI 协议是现在行业通用兼容标准;Claude 原生协议不兼容 OpenAI,必须手动做参数映射转换。
二、整体架构设计
前端/业务服务 → LLM网关统一入口(OpenAI兼容格式)
↓
【适配器层】根据model名称路由到对应模型转换器
↓
转换为目标模型原生请求(OpenAI / Claude)
↓
【转发层】OkHttp发送http请求到上游模型厂商
↓
拿到上游返回 → 适配器反向转换为OpenAI标准返回
↓
返回给业务方(普通响应 / SSE流式响应)
网关分层职责:
- 鉴权层:校验业务方传入的 apikey,Redis 校验额度;
- 路由适配层:根据 model 名称,选择对应的协议转换器;
- 转发层:HTTP 请求转发、超时控制、重试;
- 结果转换层:上游返回报文统一封装为 OpenAI 格式;
- 日志统计层:记录调用耗时、token、模型、成本,写入数据库。
三、项目依赖
XML
<!-- SpringBoot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OkHttp 用于转发上游请求 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- JSON序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Redis 后续用来做鉴权、额度,本篇先不展开 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
四、核心代码实现:非流式请求转发 + 协议适配
说明:本篇先实现非流式对话转发,流式 SSE 转发会放在第二篇博客单独讲解。
4.1 统一入参 DTO(对外 OpenAI 兼容)
java
import lombok.Data;
import java.util.List;
@Data
public class ChatRequestDTO {
private String model;
private List<ChatMessage> messages;
private Double temperature;
private Boolean stream;
private Integer max_tokens;
@Data
public static class ChatMessage {
private String role;
private String content;
}
}
4.2 适配器接口 + Claude 适配器实现
java
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class ClaudeAdapter implements ModelAdapter {
private final OkHttpClient okHttpClient;
private final ObjectMapper objectMapper;
private static final String CLAUDE_URL = "https://api.anthropic.com/v1/messages";
private static final String CLAUDE_KEY = "你的claude key";
public ClaudeAdapter(OkHttpClient okHttpClient, ObjectMapper objectMapper) {
this.okHttpClient = okHttpClient;
this.objectMapper = objectMapper;
}
@Override
public String convertAndForward(ChatRequestDTO dto) throws Exception {
// 1. 把OpenAI格式的入参,转为Claude原生结构
Map<String,Object> claudeReq = new HashMap<>();
claudeReq.put("model", dto.getModel());
claudeReq.put("temperature", dto.getTemperature());
claudeReq.put("max_tokens", dto.getMax_tokens());
claudeReq.put("stream", dto.getStream());
// 提取system消息,放到顶层字段,剩余messages只保留user/assistant
StringBuilder systemSb = new StringBuilder();
List<Map<String,Object>> msgList = new java.util.ArrayList<>();
for (ChatRequestDTO.ChatMessage message : dto.getMessages()) {
if("system".equals(message.getRole())){
systemSb.append(message.getContent());
}else{
Map<String,Object> m = new HashMap<>();
m.put("role", message.getRole());
m.put("content", message.getContent());
msgList.add(m);
}
}
if(systemSb.length() > 0){
claudeReq.put("system", systemSb.toString());
}
claudeReq.put("messages", msgList);
// 2. 构造请求
String jsonBody = objectMapper.writeValueAsString(claudeReq);
RequestBody body = RequestBody.create(jsonBody, MediaType.get("application/json"));
Request request = new Request.Builder()
.url(CLAUDE_URL)
.header("x-api-key", CLAUDE_KEY)
.header("anthropic-version", "2023-06-01")
.post(body)
.build();
// 3. 调用上游
try(okhttp3.Response resp = okHttpClient.newCall(request).execute()){
if(!resp.isSuccessful()){
throw new RuntimeException("Claude调用失败:"+resp.code());
}
String rawResp = resp.body().string();
// 4. 【重点】把Claude返回结果,包装成OpenAI格式返回给前端
return convertClaudeRespToOpenAI(rawResp);
}
}
/**
* Claude返回报文转OpenAI统一返回格式
*/
private String convertClaudeRespToOpenAI(String raw) {
// 这里简化,实际项目做json解析、字段映射
// 示例:Claude返回content[0].text -> OpenAI choices[0].message.content
return raw;
}
}
4.3 Controller 统一入口
java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/v1/chat/completions")
public class LlmGatewayController {
private final ModelAdapterFactory adapterFactory;
public LlmGatewayController(ModelAdapterFactory adapterFactory) {
this.adapterFactory = adapterFactory;
}
@PostMapping
public String chat(@RequestBody ChatRequestDTO dto) throws Exception {
ModelAdapter adapter = adapterFactory.getAdapter(dto.getModel());
return adapter.convertAndForward(dto);
}
}
五、开发中踩过的坑
- 把 system 消息直接放进 Claude 的 messages 里:Claude 会直接报错,必须单独抽出来放到顶层 system 字段;
- 鉴权头混用 :Claude 不能用 Bearer,必须是
x-api-key; - 缺少 max_tokens 参数:Claude 强制要求,不传递直接返回 400;
- 不同模型返回的错误码、报错文案不一致,网关层需要统一异常封装,业务方不用适配各种报错;
- 非流式接口还好,流式 SSE 转发是难点,同步读取会阻塞,不能等全部返回再输出,这部分放到下一篇讲解。