SpringBoot 实现大模型统一调度网关|对接 OpenAI、Claude 协议差异与适配方案

前言

最近在做企业内部 LLM 统一调度网关项目,也就是大家常说的大模型 API 中转网关。很多业务系统需要接入不同厂商的大模型,比如 OpenAI、Claude、国内各类大模型。 如果每个业务服务都单独对接各家模型原生接口,会存在几个很头疼的问题:

  1. 每家模型接口协议、请求体、鉴权方式各不相同,耦合严重;
  2. 切换模型时,业务代码需要大规模修改;
  3. 无法统一做鉴权、额度管控、调用日志、成本统计;
  4. 上游模型服务不稳定,缺少统一的熔断、降级、重试能力。

所以我们自研了一套基于 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流式响应)

网关分层职责:

  1. 鉴权层:校验业务方传入的 apikey,Redis 校验额度;
  2. 路由适配层:根据 model 名称,选择对应的协议转换器;
  3. 转发层:HTTP 请求转发、超时控制、重试;
  4. 结果转换层:上游返回报文统一封装为 OpenAI 格式;
  5. 日志统计层:记录调用耗时、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);
    }
}

五、开发中踩过的坑

  1. 把 system 消息直接放进 Claude 的 messages 里:Claude 会直接报错,必须单独抽出来放到顶层 system 字段;
  2. 鉴权头混用 :Claude 不能用 Bearer,必须是x-api-key;
  3. 缺少 max_tokens 参数:Claude 强制要求,不传递直接返回 400;
  4. 不同模型返回的错误码、报错文案不一致,网关层需要统一异常封装,业务方不用适配各种报错;
  5. 非流式接口还好,流式 SSE 转发是难点,同步读取会阻塞,不能等全部返回再输出,这部分放到下一篇讲解。
相关推荐
雪芽蓝域zzs1 小时前
第 1 章:IDEA 全局配置 Maven + 新建 SpringBoot3 Maven 项目
java·maven·intellij-idea
代码什么用1 小时前
Maven&Nexus
java·maven
顧棟1 小时前
OpenAPI 网关:鉴权 + 流量治理 一体化设计方案与实现
java·spring boot·设计模式
Rain的Java大神之路1 小时前
「设计一个在线文档协同编辑系统」:从 WebSocket、OT/CRDT 到快照回放,一篇讲透这道面试顶流题
java·后端·面试
程序员梅雨2 小时前
【从零开始学习数据仓库 | 第三篇】: Flume——数据采集基石
java·大数据·数据仓库·后端·学习·flume
acd120092 小时前
域名解析缓存带来的隐性故障,很多排查都漏掉了这一层
java·运维·nginx
wuminyu2 小时前
JVM执行引擎学习总结:热点字节码编译成本地机器码
java·linux·c语言·jvm·c++
真上帝的左手2 小时前
2. 编程语言-Java-Spring Boot-JAR
java·spring boot·jar