Spring AI自定义模型接入:把任意LLM接入Spring AI生态
Spring AI自定义模型接入:把任意LLM接入Spring AI生态
适读人群:想用Spring AI统一管理多个LLM、或需要接入私有化模型的Java工程师 阅读时长:约18分钟
老陈的"多模型地狱"
老陈所在的公司做政务系统,有合规要求:敏感业务必须用国内大模型,还不能走公网。他们同时跑着三套东西:
- 内网部署的文心一言私有化版本(负责处理敏感数据)
- 阿里云通义千问(负责日常问答)
- 公司自己微调的小模型(负责专业术语识别)
三套模型,三套SDK,三种调用方式,写出来的代码到处是if-else判断用哪个模型,后续维护噩梦。
他找到我的时候,说了一句话让我印象深刻:"我现在一看到模型切换的代码就头疼,感觉每接一个新模型,代码就要重写一遍。"
我告诉他:Spring AI的扩展机制专门解决这个问题,核心是实现ChatModel接口,然后统一用ChatClient调用。接好之后,切换模型就像换个Bean一样简单。
今天把这套接入方案完整拆给你看。
Spring AI模型接入架构
关键点:你只需要实现ChatModel接口,其他所有Spring AI能力(Tool Calling、RAG、流式输出)都自动获得。
核心接口说明
Spring AI的ChatModel接口很简洁,只有两个核心方法:
完整实现:接入私有化文心一言
第一步:定义配置属性
@ConfigurationProperties(prefix = "spring.ai.wenxin")
@Data
@Component
public class WenxinProperties {
/** API基础地址,私有化部署时改成内网地址 */
private String baseUrl = "https://aip.baidubce.com";
/** API Key */
private String apiKey;
/** Secret Key */
private String secretKey;
/** 默认模型名称 */
private String defaultModel = "ernie-4.0-8k";
/** 请求超时(秒) */
private int timeoutSeconds = 60;
/** 连接池大小 */
private int maxConnections = 20;
@Data
public static class ChatDefaults {
private Double temperature = 0.8;
private Integer maxTokens = 2000;
private Double topP = 0.8;
}
private ChatDefaults chatDefaults = new ChatDefaults();
}第二步:实现ChatModel核心逻辑
@Slf4j
public class WenxinChatModel implements ChatModel, StreamingChatModel {
private final WenxinProperties properties;
private final RestClient restClient;
private final ObjectMapper objectMapper;
// Token缓存,避免频繁刷新
private volatile String accessToken;
private volatile LocalDateTime tokenExpireTime;
public WenxinChatModel(WenxinProperties properties) {
this.properties = properties;
this.objectMapper = new ObjectMapper();
// 构建RestClient
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.requestFactory(new HttpComponentsClientHttpRequestFactory(
HttpClients.custom()
.setMaxConnTotal(properties.getMaxConnections())
.setConnectionTimeToLive(30, TimeUnit.SECONDS)
.build()
))
.build();
}
@Override
public ChatResponse call(Prompt prompt) {
WenxinRequest request = buildRequest(prompt, false);
String token = getAccessToken();
log.debug("调用文心一言: model={}, messages={}",
request.getModel(), request.getMessages().size());
try {
WenxinResponse wenxinResponse = restClient.post()
.uri("/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/{model}?access_token={token}",
request.getModel(), token)
.body(request)
.retrieve()
.body(WenxinResponse.class);
if (wenxinResponse == null) {
throw new RuntimeException("文心一言返回空响应");
}
if (wenxinResponse.getErrorCode() != null) {
throw new RuntimeException("文心API错误: " + wenxinResponse.getErrorMsg());
}
return convertToSpringAiResponse(wenxinResponse);
} catch (Exception e) {
log.error("文心一言调用失败", e);
throw new RuntimeException("文心一言调用失败: " + e.getMessage(), e);
}
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
WenxinRequest request = buildRequest(prompt, true);
String token = getAccessToken();
return WebClient.create(properties.getBaseUrl())
.post()
.uri("/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/{model}?access_token={token}",
request.getModel(), token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class)
.filter(line -> line.startsWith("data: "))
.map(line -> line.substring(6)) // 去掉"data: "前缀
.filter(data -> !data.equals("[DONE]"))
.map(data -> {
try {
WenxinStreamChunk chunk = objectMapper.readValue(data, WenxinStreamChunk.class);
return convertChunkToSpringAiResponse(chunk);
} catch (Exception e) {
log.error("解析文心流式响应失败: {}", data, e);
throw new RuntimeException("解析失败", e);
}
})
.doOnError(e -> log.error("文心流式请求失败", e));
}
@Override
public ChatOptions getDefaultOptions() {
return WenxinChatOptions.builder()
.withModel(properties.getDefaultModel())
.withTemperature(properties.getChatDefaults().getTemperature())
.withMaxTokens(properties.getChatDefaults().getMaxTokens())
.build();
}
/**
* 构建文心API请求体
* 把Spring AI的Prompt格式转换成文心的格式
*/
private WenxinRequest buildRequest(Prompt prompt, boolean stream) {
List<WenxinMessage> messages = new ArrayList<>();
String systemMessage = null;
for (Message message : prompt.getInstructions()) {
if (message instanceof SystemMessage) {
// 文心的system不在messages里,单独传
systemMessage = message.getContent();
} else if (message instanceof UserMessage) {
messages.add(new WenxinMessage("user", message.getContent()));
} else if (message instanceof AssistantMessage) {
messages.add(new WenxinMessage("assistant", message.getContent()));
}
}
// 获取ChatOptions(可能被调用方覆盖)
WenxinChatOptions options = (WenxinChatOptions) prompt.getOptions();
if (options == null) {
options = (WenxinChatOptions) getDefaultOptions();
}
return WenxinRequest.builder()
.messages(messages)
.system(systemMessage)
.model(options.getModel())
.temperature(options.getTemperature())
.maxOutputTokens(options.getMaxTokens())
.topP(options.getTopP())
.stream(stream)
.build();
}
/**
* 把文心响应格式转换成Spring AI标准格式
*/
private ChatResponse convertToSpringAiResponse(WenxinResponse wenxinResponse) {
AssistantMessage assistantMessage = new AssistantMessage(wenxinResponse.getResult());
ChatGenerationMetadata metadata = ChatGenerationMetadata.builder()
.finishReason(wenxinResponse.getFinishReason())
.build();
Generation generation = new Generation(assistantMessage, metadata);
// 封装Token使用量
DefaultUsage usage = new DefaultUsage(
(long) wenxinResponse.getUsage().getPromptTokens(),
(long) wenxinResponse.getUsage().getCompletionTokens()
);
ChatResponseMetadata responseMetadata = ChatResponseMetadata.builder()
.usage(usage)
.model(properties.getDefaultModel())
.id(wenxinResponse.getId())
.build();
return new ChatResponse(List.of(generation), responseMetadata);
}
/**
* 获取Access Token,带缓存
*/
private synchronized String getAccessToken() {
if (accessToken != null && LocalDateTime.now().isBefore(tokenExpireTime)) {
return accessToken;
}
log.info("刷新文心Access Token");
Map<String, String> response = restClient.post()
.uri("/oauth/2.0/token?grant_type=client_credentials&client_id={ak}&client_secret={sk}",
properties.getApiKey(), properties.getSecretKey())
.retrieve()
.body(new ParameterizedTypeReference<Map<String, String>>() {});
accessToken = response.get("access_token");
// Token有效期一般30天,提前1小时刷新
long expiresIn = Long.parseLong(response.getOrDefault("expires_in", "2592000"));
tokenExpireTime = LocalDateTime.now().plusSeconds(expiresIn - 3600);
return accessToken;
}
}第三步:自动配置类
@Configuration
@ConditionalOnProperty(prefix = "spring.ai.wenxin", name = "api-key")
@EnableConfigurationProperties(WenxinProperties.class)
public class WenxinAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public WenxinChatModel wenxinChatModel(WenxinProperties properties) {
return new WenxinChatModel(properties);
}
/**
* 注册为Spring AI标准ChatClient
* 这样业务代码可以直接注入ChatClient,不感知底层是文心还是GPT
*/
@Bean("wenxinChatClient")
@ConditionalOnMissingBean(name = "wenxinChatClient")
public ChatClient wenxinChatClient(WenxinChatModel wenxinChatModel) {
return ChatClient.builder(wenxinChatModel)
.defaultSystem("你是一个专业的政务助手,请用规范、准确的语言回答问题。")
.build();
}
}第四步:多模型路由(解决老陈的问题)
/**
* 多模型路由服务
* 根据业务场景自动选择合适的模型
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MultiModelRouter {
// 注入多个ChatClient
@Qualifier("wenxinChatClient")
private final ChatClient wenxinClient; // 敏感业务用文心私有化
@Qualifier("qwenChatClient")
private final ChatClient qwenClient; // 日常问答用通义
@Qualifier("localModelChatClient")
private final ChatClient localModelClient; // 专业词识别用本地模型
/**
* 根据场景路由到对应模型
*/
public String route(String message, BusinessScene scene) {
ChatClient client = selectClient(scene);
log.info("路由到模型: scene={}, client={}", scene, client.getClass().getSimpleName());
return client.prompt()
.user(message)
.call()
.content();
}
/**
* 流式路由
*/
public Flux<String> streamRoute(String message, BusinessScene scene) {
ChatClient client = selectClient(scene);
return client.prompt()
.user(message)
.stream()
.content();
}
private ChatClient selectClient(BusinessScene scene) {
return switch (scene) {
case SENSITIVE_DATA, // 敏感数据处理 → 文心私有化
COMPLIANCE_CHECK, // 合规检查
CITIZEN_INQUIRY -> // 公民查询(涉及个人信息)
wenxinClient;
case PROFESSIONAL_TERM, // 专业术语识别 → 本地微调模型
DOCUMENT_CLASSIFY ->
localModelClient;
default -> // 其他日常业务 → 通义千问
qwenClient;
};
}
public enum BusinessScene {
SENSITIVE_DATA, COMPLIANCE_CHECK, CITIZEN_INQUIRY,
PROFESSIONAL_TERM, DOCUMENT_CLASSIFY,
GENERAL_QA, DOCUMENT_SUMMARY, TRANSLATION
}
}接入OpenAI兼容接口(更简单的方式)
如果你的私有模型提供了OpenAI兼容接口(比如vLLM、Ollama),不需要自己实现ChatModel,直接改配置就行:
spring:
ai:
openai:
# 改成你的私有部署地址
base-url: http://your-internal-server:8080
api-key: your-internal-key-or-any-string
chat:
options:
model: your-model-name
# 其他参数保持不变这是最省力的接入方式,我强烈建议在私有化部署时,优先选择支持OpenAI接口规范的模型服务(vLLM、Xinference、Ollama都支持)。
各接入方式对比
| 接入方式 | 适用场景 | 开发工作量 | 维护成本 |
|---|---|---|---|
| OpenAI兼容接口 | 支持兼容协议的私有部署 | 极低(改配置) | 极低 |
| 官方Spring AI Starter | OpenAI/Claude/Azure等主流模型 | 低(仅配置) | 低 |
| 自定义ChatModel实现 | 完全私有API、国产模型 | 中(1-2天) | 中 |
| 自定义完整Autoconfigure | 需要打包成SDK发布 | 高(3-5天) | 高 |
实践建议:能用OpenAI兼容就用,不能用再考虑自己实现ChatModel。
踩坑经验
坑1:Token刷新的并发问题
我在getAccessToken()上加了synchronized,但早期版本忘了加,在高并发下会同时发出几十个刷新请求,把API限流了。这个坑被测试小哥发现的,我请他喝了一周咖啡。
坑2:流式响应的异常处理
WebFlux的流式响应,如果中间出了异常,默认行为是直接切断流。要加.onErrorResume()做优雅降级,否则用户会看到莫名其妙的截断响应。
坑3:Spring AI版本升级的接口变化
Spring AI 1.0 RC到1.0正式版,ChatResponseMetadata的构建方式改了。如果你发现编译报错,优先查看Spring AI的迁移文档,不要盲目Google。
