先说结论:这套方案能跑通,而且跑通之后你会觉得大模型接入Java服务这件事,没有想象中那么玄乎。Spring AI 通过 OpenAI 兼容协议对接 Xinference,相当于把“模型怎么部署”“接口怎么封装”全部交给 Xinference,让 Spring AI 只负责业务侧的编排和调用。最近我在一个内部问答系统里就是这么干的,私有化部署,模型跑在内网机器上,Java 服务通过 HTTP 调用,整个过程从搭建到调通大概花了一个下午。这篇就把落地过程、关键配置和几个坑完整记录下来,Java 后端同学想给自己的项目接本地大模型,可以直接照着抄。
1. 整体设计思路:一条链路和三件套
1.1 这题为什么值得解:Java应用接大模型没那么简单
Java 后端接大模型,最直接的方式是调在线平台接口,但实际业务里往往卡在三个问题上:数据不能出内网、单次调用成本不可控、模型的接入方式五花八门。Xinference 解决的是“模型服务化”问题,它把本地部署的模型统一封装成接口;Spring AI 解决的是“Java 业务怎么调”的问题,它把对话、提示词、结构化输出这些操作抽象成一套 API。中间的连接点就是 OpenAI 兼容协议。
所谓 OpenAI 兼容协议,就是一套大家都认的 HTTP 接口规范,包含chat/completions、embeddings等端点,请求和响应的 JSON 结构也是统一标准。Xinference 底层可以做模型加载、推理加速、并发调度,但对外暴露的接口长得很像 OpenAI 标准。Spring AI 的 OpenAI 模块本身就实现了这个协议的客户端,所以你只需要把base-url指向 Xinference 的地址,就能让 Spring AI 和本地模型对上话。
这套组合最实用的地方在于:哪天你想换个模型,不用改 Java 代码,只改配置里的模型名;哪天你想从本地切到在线平台,也只需要换掉base-url和一个 key。业务代码和底层模型彻底解耦,这是我自己最看重的好处。
1.2 为什么选 OpenAI 兼容协议来做中转
如果你调研过本地推理框架,会发现 Shim 这类方案也可以做模型网关,但 Xinference 的好处是开箱即用,UI 界面能管理模型加载,命令行动手也方便。更重要的是,它把 chat、embedding、rerank 都统一暴露成 OpenAI 风格的端点,Spring AI 一个 OpenAI 客户端就能全包了,不需要为每种模型写一套定制调用。
有人可能会问:那为什么不直接用 Xinference 的 Python SDK,或者用 HTTP 工具类自己写请求?当然可以,但那样你就要自己处理 SSE 流式格式、拼接请求体、维护令牌桶、做重试策略,工程量不小。Spring AI 把这些基础设施都做好了,你只需要关心业务问题“这个提示词该怎么构造”。
选型上还有一点值得注意:Spring AI 的 OpenAI 客户端不是只能连 Xinference,理论上任何实现了 OpenAI 兼容协议的服务都能连。这意味着你未来接任何平台,只用改配置,不用换代码。从架构角度看,这是标准的“面向接口编程”,只不过接口是 HTTP 级的协议。
2. 环境准备:先把 Xinference 服务跑起来
2.1 部署 Xinference:两种方式对比
我在两台机器上分别试过 pip 安装和 Docker 部署,结论是:如果你机器上已经有 Python 环境,直接用 pip 最省事;如果需要隔离环境、方便迁移,就用 Docker。下面是两种方式的具体操作。
方式一:pip 安装运行
pip install "xinference[all]" xinference-local --host 0.0.0.0 --port 9997[all]会把常见依赖都带上,缺点是安装体积大。如果你明确知道自己要跑哪些模型,可以只装xinference基础包,后续缺什么再补。--host 0.0.0.0是为了让局域网内的其他机器也能访问,不能漏,否则只能本机localhost访问。
方式二:Docker 运行
docker run -d --name xinference \ -p 9997:9997 \ -v /data/xinference:/root/.xinference \ -e XINFERENCE_MODEL_SRC=modelscope \ --gpus all \ xprobe/xinference:latest注意--gpus all需要你有 Docker 的 GPU 运行时支持,纯 CPU 跑小模型也可以不加。这里的XINFERENCE_MODEL_SRC我用了 modelscope,国内下载模型更稳定;如果你网络条件好,也可以改成huggingface。启动之后,打开http://服务器IP:9997就能看到 Web UI。
我个人的建议是:第一次实验用 pip 方式,出了问题好排查;正式环境用 Docker,依赖打包得干净。两个方式我都跑过,稳定性差别不大,主要看你服务器上现成的环境是什么样的。
2.2 加载模型并确认API可达
Xinference 启动之后只是个空壳,还没有模型在跑,需要先加载一个模型。Web UI 界面里选择“创建模型”,填写模型名、规格、量化方式,点创建就会开始下载并加载。也可以命令行操作:
xinference launch --model-name qwen2.5-instruct \ --size-in-billions 7 \ --model-format gguf \ --quantization q4_k_m这里我用的是通义千问系列模型做示例。--model-name是模型仓库里的名字,--size-in-billions是参数量版本,--quantization是量化等级。量化等级的选择直接影响显存占用和推理速度,q4 级别能在效果和资源占用之间取得平衡,要追求更高质量可以选 q8,但显存会明显上涨。
加载成功后,UI 或命令行会返回一个模型 UID,形如qwen2.5-instruct。这个 UID 必须记下来,后面 Spring AI 配置里的model参数就要填它。
然后先用 curl 验证接口是否通:
curl http://127.0.0.1:9997/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer xinference" \ -d '{ "model": "qwen2.5-instruct", "messages": [{"role": "user", "content": "你好"}] }'curl 能正常返回内容,说明 Xinference 的 OpenAI 兼容端点已经就绪。这里有个容易忽略的细节:路径里的/v1不能少,Spring AI 默认会在base-url后面拼接/chat/completions,所以你的base-url必须写到包含/v1的这一层。
3. Spring AI 接入:依赖、配置与参数策略
3.1 引入依赖:版本和仓库别搞错
Spring AI 目前还处在高频迭代阶段,版本选不好,后面全是坑。建议用当前稳定版本,我这次用的是1.0.0-M6,配合 Spring Boot 3.4 系列。如果是老项目,务必要确认 Spring Boot 版本和 Spring AI 版本兼容,不兼容会直接启动失败。
在pom.xml里加依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>由于里程碑版本不在 Maven 中央仓库,还需要在repositories里加 Spring 的里程碑仓库:
<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> </repository> </repositories>如果你选的版本是 0.8.1 之前的旧版,对应的配置项可能不叫spring.ai.openai.base-url,而是spring.ai.openai.base-url加spring.ai.openai.chat.options.model,整体结构类似,但细节有差异。最好以官方文档对应的版本文档为准。
这里还要提一个容易踩的坑:很多教程里用的是spring-ai-openai-spring-boot-starter,但也有老的spring-ai-azure-openai-spring-boot-starter,虽然也能连 OpenAI 兼容端点,但配置项完全不同。你只需要 OpenAI 标准协议,就用前者。
3.2 核心配置项:base-url、api-key、model
配置写在application.yml,下面是我实际能跑通的完整配置:
spring: ai: openai: base-url: http://192.168.1.100:9997/v1 api-key: xinference chat: options: model: qwen2.5-instruct temperature: 0.7 max-tokens: 2048逐个说下这些参数的含义。
base-url是 Xinference 的 HTTP 访问地址,必须带上/v1。因为 Spring AI 的 OpenAI 客户端会把这个地址当作 API 根路径,然后拼接chat/completions;如果漏掉/v1,请求会打到不存在的http://192.168.1.100:9997/chat/completions,直接 404。
api-key可以随便填一个非空字符串。Xinference 默认不校验 key,但 Spring AI 的客户端要求这个字段必须存在并写入请求头。我填的是xinference,纯粹为了占位。如果你给 Xinference 配了自定义 key,这里要对应填上。
model填的是模型 UID,而不是模型仓库里的名字。很多人加载模型时用--model-name qwen2.5-instruct,以为配置里也填它,其实有时候 UID 会带上模型规格后缀,比如qwen2.5-instruct:7b。不确定的话,在 Web UI 的模型列表里找到正在运行的那个 ID,填进去最保险。
temperature控制随机性。做知识问答、推理类任务建议调低到 0.2 到 0.4,让输出更稳定;做创意写作、头脑风暴可以调到 0.8 以上。max-tokens限制单次回复的最大长度,注意这个是 token 数不是汉字数,一个汉字大约占 1.5 到 2 个 token。
3.3 参数调优:超时、连接池与重试策略
除了上面这些基本参数,Spring AI 还暴露了 HTTP 客户端的配置项:
spring: ai: openai: base-url: http://192.168.1.100:9997/v1 api-key: xinference connect-timeout: 10s read-timeout: 300sconnect-timeout是建立连接的超时,10 秒足够;read-timeout是等待响应数据的超时,这个要设得大一些,因为大模型生成几百个 token 可能要好几十秒,设太短会频繁报超时。我一开始设了 60 秒,结果稍微长一点的回答就超时,后来改成 300 秒才消停。
如果你的服务并发请求量不小,还要关注底层连接池。Spring AI 用的是RestClient,默认连接池参数对高并发场景不够用。可以通过自定义RestClient.Builder来调整连接池大小和空闲回收时间。不过一般内部系统并发不高,默认配置也能扛住,等出现连接泄漏再调也不迟。
关于重试:模型推理失败和网络抖动不同,盲目重试可能放大负载。我的策略是:网络类错误重试 2 次,业务类错误直接抛出让上层处理。Spring AI 里可以配置RetryTemplate,但别把重试次数设太大,本地推理资源本来就有限。
4. 代码落地:从 ChatClient 到完整服务
4.1 注入 ChatClient:最少代码跑通对话
Spring AI 的自动配置会帮你创建好ChatClient,直接注入使用即可:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }就这么多,一个能跑的大模型问答服务就出来了。ChatClient.Builder是自动配置注入的,它内部已经读取了application.yml里的模型地址、key、model 名。你需要做的只是调用接口、组装提示词。
如果你想要更灵活的控制,也可以注入OpenAiChatModel:
@Configuration public class AiConfig { @Bean ChatClient chatClient(OpenAiChatModel chatModel) { return ChatClient.builder(chatModel).build(); } }这两种方式效果一样,区别在于自定义空间。用ChatClient.Builder是 Spring AI 官方推荐的最佳实践,它会带上一些默认的系统提示词和参数;直接注入OpenAiChatModel更贴近底层,适合要做深度定制的人。
我实际开发中还会封一层业务接口,比如内置系统角色、控制提示词模板,这样 Controller 层只关心用户输入,不会跟前端参数耦合。
4.2 流式输出与异步处理
真实产品里,用户很难忍受等十几秒没有任何反馈。流式输出是必须的,Spring AI 对这块支持得也很好,接口返回Flux<String>,底层自动处理 SSE 协议:
@GetMapping(value = "/chat/stream", produces = "text/event-stream") public Flux<String> chatStream(@RequestParam String question) { return chatClient.prompt() .user(question) .stream() .content(); }前端用fetch配合ReadableStream,或者直接用 EventSource 库,就能一段一段收到内容。这里有几个经验点:
- 接口返回值建议加
produces = "text/event-stream",虽然 Spring AI 底层会处理,但显式声明能让网关和前端更明确格式。 - 如果项目里有 Gateway 网关层,注意是否对响应做缓存。有些网关默认会缓冲整个响应,导致前端拿不到流式效果。我遇到过一次,排查半天发现是网关配置把 SSE 拦截了。
- 流式场景下,超时设置更要放宽。首 token 延迟可能不高,但整体生成时间可能很长,
read-timeout至少 120 秒起步。
如果你用的是 WebFlux 而非 Spring MVC,代码基本一样,Flux<String>天然适配。Spring MVC 环境下 Spring AI 也会通过SseEmitter机制兼容,但建议优先用 MVC 的异步接口,效果更稳定。
4.3 结构化输出与函数调用
这个部分属于进阶选项,但实际价值很大。大模型返回纯文本,业务系统往往需要 JSON 结构。Spring AI 提供了参数化的结构化输出方式:
public record QaResult(String answer, List<String> references) {} public QaResult askStructured(String question) { return chatClient.prompt() .user(question) .call() .entity(QaResult.class); }底层原理是让模型按 JSON Schema 格式化输出,再反序列化成对象。底层用的是大模型的 JSON mode 或 function calling 机制。对 Xinference 来说,只要你加载的模型支持 function calling(比如 qwen 系列),就能正常使用这个功能。注意不要要求模型输出它没见过的格式,字段名要简单明确。
我实际踩过一次坑:试图让模型返回一个嵌套很深的复杂对象,结果模型输出频繁缺字段。后来把结构压扁,让模型先返回一个 JSON 数组,再在 Java 侧做二次组装,失败率就低多了。结构化输出适合简单的两个字段,复杂结构还是靠多次调用或思维链设计来保证。
5. 实战排雷:五分钟定位连接问题
5.1 连接失败:八成是 base-url 少了 /v1
这是最高频的问题。Spring AI 报错信息通常有两种:一种是UnknownHostException,说明主机名解析不了;另一种是404 Not Found,这基本就是路径不对。我见过同事配了http://192.168.1.100:9997,Spring AI 去请求/chat/completions,Xinference 根本没有这个端点,直接 404。
排查思路很简单:先用 curl 敲一遍接口,把 curl 能通的 URL 原封不动填到base-url里。curl 能通而 Java 不通,优先查网络策略或防火墙;curl 不通,问题在 Xinference 启动或模型加载上,与 Spring AI 无关。
另外注意,如果 Xinference 跑在远程机器,别在配置里写localhost或127.0.0.1。必须写能被 Java 应用访问到的内网地址。我之前有个服务在容器里,Xinference 在宿主机上,localhost指向了容器自己,折腾了半天才反应过来。
5.2 模型找不到:要把模型 UID 配置进去
报错内容大致是Model not found或model xxx does not exist。原因基本是配置里的model和 Xinference 里模型 UID 不一致。注意,这个 UID 是创建模型成功后返回的那串标志,不是模型下载页面的名称。
举个例子:你可能在 Web UI 里看到模型叫qwen2.5-instruct,但实际 UID 可能是qwen2.5-instruct:7b或者带了量化后缀。一个稳妥的办法是调用 Xinference 的模型列表接口看看:
curl http://127.0.0.1:9997/v1/models返回结果里的id字段就是你要填的值。如果列表为空,说明模型没有加载成功,这时候去 Web UI 看日志,多半是显存不够或下载中断。
5.3 并发性能:显存溢出与线程数控制
本地部署大模型,并发能力远不如在线平台。我测过一个 7B 量化模型,单并发推理能到每秒二十个 token左右;但并发一上来,显存暴涨,速度快的话几秒钟就 OOM。而且 OOM 之后 Xinference 进程可能假死,请求全部超时,Java 侧表现为大量Read timed out。
应对策略:
- 控制并发:用信号量或线程池限制同时进行的模型调用数量,我这个项目限制为 4 个并发。
- 降级加载:如果只是测试,用更小的模型,比如 1.5B 或 3B 版本,先把流程跑通。
- 分批部署:生产环境可以让 Xinference 跑两个副本,每个副本加载不同的模型,通过负载均衡分发请求;但要注意显存规划。
在 Java 侧,建议抽象一层“模型调用隔离器”,用一个简单的线程池加队列来削峰。这不是 Spring AI 特有的问题,而是本地模型服务必然要面对的资源约束。
5.4 流式响应排错:别把 SSE 当普通 JSON
有朋友问为什么解析流式响应时只拿到半个 JSON。这通常不是因为 Spring AI,而是前端或网关在处理 SSE 时用了 JSON 解析。SSE 格式是“每行一个data:前缀”,一条消息可能分多段到达,直接 JSON.parse 必然失败。
正确做法是逐行读取,把data:前缀后的内容累积起来,直到遇到空行或流结束再整体解析。Spring AI 的Flux<String>已经处理了这块,你只需要把每个String片段直接推到前端即可。如果你自研了 HTTP 调用,这部分就要格外小心。
还有个经验:中文输出偶尔会出现末尾字符被截断的错觉,其实是因为max-tokens不够,模型生成到上限就被掐断了。排查时先看看返回的finish_reason是不是length,如果是,就把max-tokens调大。
6. 换模型的迁移实践
运行一段时间后,如果发现模型效果不够,想换成更大或更专业的模型,操作路径很清晰。先在 Xinference 上加载新模型拿到 UID,再改application.yml里的model字段,重启服务。业务代码一行不用动。
如果你要同时跑多个模型做对比,可以在同一个 Spring AI 配置里维护多个ChatClientBean,分别指定不同的模型参数。一种写法是先定义多个 options,再创建不同的OpenAiChatModel:
@Bean ChatClient chatClientForQwen(OpenAiChatModel model) { return ChatClient.builder(model).build(); } @Bean ChatClient chatClientForEmbedding(OpenAiChatModel model) { return ChatClient.builder(model).build(); }注意两个 Bean 共用的还是同一个base-url,但模型名可以不同。这种多 Bean 方式在 A/B 测试模型效果时特别有用,不用反复改配置文件重启服务。
我个人这段时间用下来的体会是:这套方案最值得称道的不是某个技术点有多深,而是把“模型服务化”和“Java 集成”这两个原本要写大量胶水代码的事,都收敛成了标准化操作。要踩的坑主要是路径、模型 UID、超时和资源限制这几类,提前知道就能省掉很多排查时间。
最后再分享一个小技巧:上线前一定要对 Xinference 做一次长时间稳定性测试,重点观察显存占用是否持续增长。有些模型框架会有轻微的内存泄漏,跑几天之后显存涨上去,响应速度就明显下降。这个坑是在真实项目里遇到的,当时凌晨接到告警,排查到一个循环调用场景下模型服务的显存被吃满,重启才恢复。后来在 Java 侧加了调用频率限制,同时给 Xinference 配了定时健康检查,问题才算彻底解决。