资讯详情

Spring Boot 接入阿里百炼与火山引擎:统一 OpenAI 兼容协议实战

📅 2026/10/11 4:23:47 | 华诺云谱 👁 阅读
Spring Boot 接入阿里百炼与火山引擎:统一 OpenAI 兼容协议实战
上周我把两套国内大模型服务接进了同一个 Spring Boot 工程一套是阿里百炼的 qwen-plus另一套是火山引擎的豆包系列模型。两边没有写各自的专用 SDK而是统一走了 Spring AI 的 OpenAI 兼容协议用同一套 Java 代码完成对话、流式返回和函数调用。跑通之后最大的感受是国内主流大模型平台已经普遍提供兼容端点之后Java 后端接大模型的成本和接一个普通 HTTP 接口已经差不太多了。这篇文章就是这次双平台接入过程的完整记录内容包括为什么用 Spring AI 而不是每家 SDK、两个平台的密钥和端点怎么配、自定义多 ChatModel Bean 的写法、请求级模型路由与灰度方案以及我在实际测试中踩过的一系列坑。适合手里有 Spring Boot 服务、准备接入大模型能力或者已经在用 OpenAI 相关客户端但想切换到国内平台的团队参考。1. 为什么不等各家 SDK用兼容协议统一接1.1 直接写 HTTP 调大模型的问题在哪在没有 Spring AI 这类框架之前Java 后端接大模型通常会经历这么几步先引入一个 HTTP 客户端封装请求头、鉴权信息然后把业务参数拼成 JSON 请求体调用模型接口获取响应最后把返回结果一层层解析成自己的业务对象。这还只是同步调用。如果要支持流式输出还得自己处理 SSEServer-Sent Events数据流逐行解析data:前缀的 chunk再考虑连接异常、半包、重连之类的边界问题。每次换一个平台这套流程都要重写一遍。阿里百炼的请求格式和火山方舟的请求格式细节不同模型名称不同鉴权方式也不同。业务代码里就会逐渐出现一个QwenClient、一个DoubaoClient两边各写各的消息构造逻辑和解析逻辑上层业务想换个模型就得改一大片代码。更麻烦的是这种直连方式很难和 Spring 生态融合。项目里已有的配置中心、监控、链路追踪、统一异常处理都没法和这些散落的 HTTP 调用打通。早期我做过一段时间这种直连方案说实话能用但维护成本很高尤其是模型一多、需求一变样板代码比业务代码多好几倍。1.2 Spring AI 给你补齐了什么Spring AI 做的事情其实很像 JDBC 之于数据库、Spring Cloud OpenFeign 之于 HTTP 调用它把“调大模型”抽象成一组通用的 Java 接口和组件。核心就是ChatModel接口。不管底层是 OpenAI、阿里百炼还是火山引擎你拿到的都是一个能接收Prompt、返回ChatResponse的对象。上层业务不需要关心 HTTP 细节、鉴权细节和 JSON 解析这些都被封装进OpenAiApi和自动配置里了。再往上它还提供了ChatClient链式 API写起来比直接操作ChatModel舒服很多ChatClient chatClient ChatClient.builder(chatModel).build(); String answer chatClient.prompt() .system(你是一个耐心的客服助手) .user(用户说{question}) .call() .content();除了对话Spring AI 还提供了一系列企业集成里非常关键的能力PromptTemplate做提示词模板化渲染、BeanOutputConverter把模型返回的 JSON 自动绑定到 Java POJO、Function Calling让模型可以调用你注册的业务方法、ChatMemory做会话上下文管理。这些能力正好是 Java 后端接大模型时最常用、最头疼的部分。我当时选择 Spring AI 还有一个现实原因团队后端是清一色 Spring Boot 技术栈让业务开发去维护自己的一套 LLM 调用层不如让框架层解决 80% 的通用问题业务只关心提示词和输出结构。1.3 百炼和火山为什么都能被 OpenAI 协议覆盖这里有个关键背景阿里百炼和火山引擎都提供了兼容 OpenAI 协议的 REST 端点。也就是说Spring AI 的spring-ai-starter-model-openai不需要做任何定制只要把base-url指到对应平台的兼容端点再把 API Key 填进去就能正常发起请求。阿里百炼的兼容端点地址是https://dashscope.aliyuncs.com/compatible-mode/v1它把 qwen 系列模型包装成了 OpenAI 风格的接口。火山引擎方舟的兼容地址是https://ark.cn-beijing.volces.com/api/v3豆包系列模型走这套接口。两个平台都支持流式输出、工具调用、Embedding 等常用能力。这个现状带来的价值是巨大的同一个 Spring AI 工程可以像配置数据源多环境一样用一组代码同时对接多家模型平台然后通过配置或路由逻辑决定这个请求发给哪个模型。这也是我在标题里把“阿里百炼”和“火山引擎”放在一起的原因——这两家放在一起覆盖了文本生成、长上下文、图片理解等不同方向的模型选择业务侧可以根据场景任意切换。2. 前置工作两个平台的密钥、依赖与自定义 ChatModel Bean2.1 阿里百炼与火山引擎控制台的密钥准备先说阿里百炼。登录阿里云百炼控制台开通百炼服务之后在 API-KEY 管理页面创建一个 Key字符串以sk-开头。要特别注意百炼平台上不同模型可能需要在模型广场单独开通或授权尤其是新发布的模型。如果请求报“没有权限使用该模型”多半是模型没开通而不是 Key 写错了。火山引擎这边进入火山方舟控制台在“API Key 管理”里创建一个 Key。这里有个容易混淆的点如果你直接使用模型 ID比如豆包系列的doubao-seed-1-6-250615作为请求参数那只需要 API Key 就够了。如果你创建的是“推理接入点”那请求参数里要填接入点 ID通常是ep-开头。两者选其一即可同时传可能会被平台拒绝。两个平台的关键信息整理成一张表方便对照项目阿里百炼火山引擎方舟API Key 名称API-KEYsk- 开头API Key平台生成字符串OpenAI 兼容端点https://dashscope.aliyuncs.com/compatible-mode/v1https://ark.cn-beijing.volces.com/api/v3常用模型名示例qwen-plus、qwen-max、qwen-turbo豆包系列模型 ID或 ep- 开头的接入点 ID模型开通方式模型广场单独开通按模型/接入点维度计费流式输出支持支持函数调用支持支持2.2 Maven 依赖与 Spring AI 版本选型Spring AI 从 1.0 开始发布了正式版目前用下来已经很稳。我这边工程基础是 Spring Boot 3.3.xJDK 17引入以下依赖dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-tool-function-calling/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency /dependencies其中spring-ai-tool-function-calling是函数调用和结构化输出相关的支持建议直接带上spring-boot-starter-webflux是因为后面要用FluxString做流式输出如果只是同步调用普通 Web MVC 也可以。提示Spring AI 的具体小版本建议以官方兼容矩阵为准不要盲目追最新。1.0.x 系列在 API 稳定性上已经比较友好升级到 1.x 之后ChatClient的链式调用风格基本没大变化。2.3 自定义 OpenAiApi 与 ChatModel绕开单品自动装配的限制如果你只接一个平台直接配置spring.ai.openai.api-key和spring.ai.openai.base-url让自动配置生效就行。但这次是同时接百炼和火山两个平台它们共用一个OpenAiApi配置命名空间写死在 yml 里就会冲突。我的做法是放弃自动装配直接用Configuration自定义两个OpenAiApi和两个ChatModelBean。这样每个模型平台都有自己独立的客户端和配置互不干扰Configuration public class LlmConfig { Bean public OpenAiApi qwenOpenAiApi() { return new OpenAiApi( https://dashscope.aliyuncs.com/compatible-mode/v1, System.getenv(QWEN_API_KEY)); } Bean public ChatModel qwenChatModel(OpenAiApi qwenOpenAiApi) { return new OpenAiChatModel(qwenOpenAiApi, OpenAiChatOptions.builder() .withModel(qwen-plus) .withTemperature(0.7) .build()); } Bean public OpenAiApi doubaoOpenAiApi() { return new OpenAiApi( https://ark.cn-beijing.volces.com/api/v3, System.getenv(ARK_API_KEY)); } Bean public ChatModel doubaoChatModel(OpenAiApi doubaoOpenAiApi) { return new OpenAiChatModel(doubaoOpenAiApi, OpenAiChatOptions.builder() .withModel(doubao-seed-1-6-250615) .build()); } }这段代码里API Key 建议走环境变量或密钥管理系统别硬编码进 yml。OpenAiChatOptions里设置的model是默认模型后面还可以在请求级别覆盖这个第 5 章会详细说。有一点要注意定义了两个ChatModelBean 之后任何地方直接注入ChatModel都会因为类型不唯一而报错必须配合Qualifier指定名称。这个名字默认就是方法名也就是qwenChatModel和doubaoChatModel。3. 阿里百炼接入qwen-plus 跑通一个真实客服对话示例3.1 配置端点与模型参数百炼的兼容端点在配置上几乎和 OpenAI 官方地址一样唯一要记牢的是路径必须带/compatible-mode/v1。这个环节最容易犯的错是把地址写成https://dashscope.aliyuncs.com或者https://dashscope.aliyuncs.com/api/v1那样会直接 404 或者路由错误。我在自己的LlmConfig里给qwenOpenAiApi用的是qwen-plus作为默认模型。qwen-plus 是通义千问的通用模型响应速度和效果比较平衡适合大多数业务场景。如果对复杂推理要求更高可以换qwen-max如果追求低成本高吞吐qwen-turbo也可以。一个工程里其实可以把这些模型做成下拉配置后续随时切。3.2 用 ChatClient 做多轮客服会话模型接好之后业务层我习惯用一个ChatClient封装。Spring AI 的消息体系里UserMessage对应人类输入SystemMessage对应系统提示词多轮上下文就是把历史消息按顺序放进Prompt里。先看最基础的同步调用Service public class CustomerService { private final ChatClient chatClient; public CustomerService(Qualifier(qwenChatModel) ChatModel chatModel) { this.chatClient ChatClient.builder(chatModel) .defaultSystem(你是售后客服助手回答要简洁涉及订单问题时需要向用户索要订单号。) .build(); } public String answer(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }这里把系统提示词放到defaultSystem业务方法只需要关心用户输入。如果要做多轮对话可以把历史记录也传进去public String answerWithContext(String userMessage, ListMessage history) { return chatClient.prompt() .messages(history) .user(userMessage) .call() .content(); }history里的消息类型可以是UserMessage或AssistantMessage按时间顺序排列即可。实际项目里如果不想每次把全部历史都发给模型可以只保留最近几轮或者用ChatMemory做窗口管理。3.3 百炼兼容接口的几个习惯实测下来百炼的 OpenAI 兼容接口和官方 OpenAI 格式的匹配度相当高。temperature、max_tokens、top_p这些常规参数都能用。但有几个细节值得注意max_tokens在百炼平台有自己的上限限制不同模型不一样。传超出模型限制的值会被校验拒绝建议先查阅对应模型的文档再填。如果请求里带了seed这类细粒度参数部分 qwen 模型会忽略或者直接报未知参数。兼容接口不等于参数完全一致前端传参时要做白名单过滤。百炼的返回 content 字段和 OpenAI 格式一致基本可以用 Spring AI 的默认解析直接转成业务对象不需要额外写解析代码。但我也遇到过一次比较隐蔽的问题package 扫描到多个RestClient.Builder时OpenAiApi内部的 HTTP 客户端配置可能被全局拦截器影响导致加了错误的自定义 Header。如果发现百炼请求始终鉴权失败检查一下项目里是否有全局RestClient自定义配置必要时给OpenAiApi单独指定RestClient.Builder。4. 火山引擎接入豆包模型与流式输出的两种选择4.1 API Key 直连和方舟签名协议选哪个火山方舟平台历史上提供过两套调用方式一套是老式的 v2 签名协议需要对 Access Key 和 Secret Key 做 HMAC 签名另一套是新的 v3 OpenAI 兼容协议只需要 API Key。对 Spring AI 来说默认走的就是 OpenAI 客户端风格所以最佳选择是直接用 v3 兼容协议。我的doubaoOpenAiApi里配置的https://ark.cn-beijing.volces.com/api/v3就是这套。这样配置以后豆包模型在 Java 层的使用方式和 qwen 完全一致只是 Bean 不同。那什么时候要考虑 v2 签名协议呢主要是公司内部网关强制要求方舟 v2 签名或者需要对请求做细粒度审计的场景。这时候你没法直接把 Spring AI 的OpenAiApi指向 v2 端点需要在请求出口加一层自定义签名逻辑复杂度会高不少。我这次的架构里没有强制签名要求所以走了 API Key 直连这也是我推荐大部分团队优先考虑的方式。4.2 将豆包接入到 FlowFlux 流式返回豆包模型对流式输出的支持很完整。在 Spring AI 的ChatClient里流式调用非常简单RestController public class ChatController { private final ChatClient chatClient; public ChatController(Qualifier(doubaoChatModel) ChatModel chatModel) { this.chatClient ChatClient.builder(chatModel).build(); } GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(String question) { return chatClient.prompt() .user(question) .stream() .content(); } }返回类型是FluxStringSpring 会把流式内容按text/event-stream格式输出。前端用EventSource或fetch读取即可不用后端自己解析 SSE 了。我做测试的时候有一个阶段总想自己解析ChatResponse里的delta字段来做流式拼接后来发现 Spring AI 的.stream().content()已经把这块封装掉了。除非你有自定义反馈需求否则直接消费FluxString就够了。需要给前端追加一句话级别的回调时可以用.doOnNext(text - ...)做监听return chatClient.prompt() .user(question) .stream() .content() .doOnNext(text - log.info(模型输出片段: {}, text));4.3 流式输出在业务层怎么封装流式接口上线前一般还要考虑几个问题是否要做超时中断、客户端断开后是否取消模型请求、异常后是重试还是直接返回错误。我的做法是给流式接口加一个timeout(Duration.ofSeconds(60))防止模型长时间不返回。客户端断开时Spring AI 的Flux消费端取消底层连接会随之关闭这个行为在方舟 v3 接口上表现正常不需要额外处理。另外流式输出和日志链路不要混为一谈。模型生成的片段会非常多全量打日志会淹没业务日志。我一般只记录开始和结束时间以及完成的整句文本不记录每个 chunk。5. 双模型统一路由、灰度与请求级模型切换5.1 用 Qualifier 管理多个 ChatModel同时存在两个ChatModelBean 之后直接Autowired ChatModel会启动失败因为 Spring 不知道注入哪个。我在所有业务注入点都显式指定了QualifierService public class AssistantService { private final ChatModel qwenChatModel; private final ChatModel doubaoChatModel; public AssistantService(Qualifier(qwenChatModel) ChatModel qwenChatModel, Qualifier(doubaoChatModel) ChatModel doubaoChatModel) { this.qwenChatModel qwenChatModel; this.doubaoChatModel doubaoChatModel; } }到这里代码已经是“一个服务里同时握着两套模型”的状态了。业务选择模型的过程其实就是一次路由判断。5.2 ChatOptions 实现请求级模型切换Spring AI 里可以在构造Prompt时动态设置模型参数。比如默认 Bean 里配的是qwen-plus但某个请求想走qwen-max可以这样ChatResponse response qwenChatModel.call(new Prompt( userMessage, OpenAiChatOptions.builder() .withModel(qwen-max) .withTemperature(0.3) .build() ));请求级的ChatOptions会覆盖 Bean 里设置的默认值这个优先级关系是确定的。我用这个机制做了一个简单的“高精度模式”和“低成本模式”切换普通对话走 qwen-plus复杂分析任务动态切到 qwen-max。5.3 针对租户/业务线的 YAML 路由表如果只是内部手动切换写死在代码里也能接受。但真实场景里更常见的是不同租户、不同业务线要固定使用不同模型还要支持快速灰度切换。比如 A 租户默认用 qwenB 租户默认用豆包或者这周 10% 流量切到豆包观察效果。我设计了一个很轻量的路由配置放在 YAML 里集中维护llm: route: default: qwen tenants: tenant-a: qwen tenant-b: doubao gray: model: doubao percent: 20配合一个路由服务Component ConfigurationProperties(prefix llm.route) public class LlmRouteProperties { private String defaultModel; private MapString, String tenants new HashMap(); private GrayRule gray; // getter/setter 省略 } Service public class LlmRouter { private final MapString, ChatModel modelMap; private final LlmRouteProperties routeProperties; private final AtomicInteger counter new AtomicInteger(); public LlmRouter(Qualifier(qwenChatModel) ChatModel qwenChatModel, Qualifier(doubaoChatModel) ChatModel doubaoChatModel, LlmRouteProperties routeProperties) { this.modelMap Map.of(qwen, qwenChatModel, doubao, doubaoChatModel); this.routeProperties routeProperties; } public ChatModel select(String tenant) { String modelKey routeProperties.getTenants().getOrDefault(tenant, routeProperties.getDefaultModel()); if (routeProperties.getGray() ! null) { int v counter.incrementAndGet() % 100; if (v routeProperties.getGray().getPercent()) { modelKey routeProperties.getGray().getModel(); } } return modelMap.get(modelKey); } }这里用了取模做简单灰度生产环境可以换成按用户 ID hash保证同一个用户在灰度期间始终走同一个模型避免会话体验忽高忽低。有了这个路由上层业务完全不用感知具体模型和平台只告诉它租户 ID 或业务线即可。5.3 多模型的请求级 options 覆盖除了路由有时候同一路由下也想临时改参数比如提高豆包模型的temperature、给某个请求关闭函数调用。Spring AI 的请求级 options 覆盖策略在双模型下一样适用。只要你传入的ChatOptions里设置的字段就会覆盖 Bean 默认值。这里注意一个常见误区ChatOptions只是“按字段覆盖”不是整体替换没有设置的字段仍然会用 Bean 默认值。我把这块封装成了一个小工具public Prompt buildPrompt(String text, String model, Double temperature) { OpenAiChatOptions options OpenAiChatOptions.builder() .withModel(model) .withTemperature(temperature null ? 0.7 : temperature) .build(); return new Prompt(new UserMessage(text), options); }然后配合LlmRouter使用ChatModel model llmRouter.select(tenant-a); String result model.call(buildPrompt(帮忙总结这份需求文档, qwen-plus, 0.2)) .getResult() .getOutput() .getText();这样从路由到参数调整都是配置化、代码化的换模型不需要改业务逻辑让我后续接入第三个模型平台时轻松很多。6. 实测踩坑从 404 到限流再到 Function Calling 差异6.1 端点路径写错引发的 404 与 403第一次配置百炼时我把base-url写成了https://dashscope.aliyuncs.com/compatible-mode少了一截/v1结果所有请求都返回 404。后来意识到Spring AI 的 OpenAiApi 内部会在请求路径上追加/chat/completions也就是最终请求其实是{base-url}/chat/completions。如果你把 base-url 写成.../v1最终路径就是.../v1/chat/completions才对得上平台提供的接口。火山引擎这边类似base-url 必须写到/api/v3而不是/api。我把这块直接写进了配置代码注释里防止后面的同事再踩。403 也是一个高频问题。百炼平台如果模型没有开通接口返回的权限错误在 Spring AI 的异常信息里会出现 “permission” 相关字样。查根因的顺序应该是先确认模型开通状态再检查 API Key 是否对应正确的账号最后检查环境变量有没有加载成功。千万不要一看到 403 就去怀疑网络、去换 Key那样会浪费很长时间。6.2 429 与限流重试与退避策略两个平台对并发和 QPS 都有一定限制。我在压测的时候豆包接口连续高频请求会返回 429有时还会包含Retry-After头。Spring AI 内部已经有简单的重试支持但默认配置不一定适合国内平台的限流节奏。我的经验是不要依赖过短间隔的快速重试那只会加重限流。实际操作中我在客户端这一层做了两件事一是给下游模型调用加了一个轻量信号量控制并发不超过平台配额二是对 429 和 5xx 使用指数退避重试初始间隔 1 秒最多重试 3 次。Bean public RetryTemplate modelCallRetryTemplate() { RetryTemplate retryTemplate new RetryTemplate(); retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); ExponentialBackOffPolicy backOff new ExponentialBackOffPolicy(); backOff.setInitialInterval(1000); backOff.setMultiplier(2.0); retryTemplate.setBackOffPolicy(backOff); return retryTemplate; }在业务代码里把RetryTemplate包在模型调用外层而不是让每个请求都盲目地“卡死等重试”。如果三次重试后仍然失败直接返回降级提示比一直阻塞要稳妥。6.3 Function Calling 和结构化输出的兼容差异这次双平台接入里Function Calling 是最让我觉得“框架香”的部分。Spring AI 里定义一个工具方法非常简单Bean Description(根据订单号查询订单状态) public FunctionOrderQueryRequest, OrderQueryResult queryOrder() { return new OrderQueryService(); }然后在ChatClient里通过.functions(queryOrder)注册模型在需要的时候会自动生成工具调用参数框架负责把调用结果传回模型。这个机制在百炼和豆包上都能跑通说明两家兼容层做得很到位。但我遇到两个差异点值得记录一下一是工具调用的结果消息角色。OpenAI 规范里工具执行结果应该用tool角色消息返回并且必须带上tool_call_id。Spring AI 已经封装了这个逻辑但如果你在 customize 代码里手动拼消息很容易漏掉tool_call_id。豆包平台对这个字段的校验很严格漏了会直接报参数错误。二是结构化输出的 JSON 稳定性。我用BeanOutputConverter让模型返回固定结构的 POJO 时百炼和豆包基本都能完成但如果 POJO 字段含义不明确豆包偶尔会多输出字段或者跳过空字段。解决办法是给字段加上JsonFieldDescription做语义约束让模型更准确地理解 JSON 结构。这也算是接任何大模型都通用的经验输出结构越清晰模型越稳定。最后再说一个我在双平台并行期感受到的习惯性建议如果从头开始接先单独用百炼跑通同步对话再单独用豆包跑通流式输出最后再合并成双模型路由。直接把两个平台一步到位配好虽然也能成功但出了问题排查范围会一下子扩大不少人容易懵。我这次是倒过来的路线中间花了不少时间在区分到底哪个环节影响了请求下次再让我做一定先从单平台起步再逐层叠加。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑