多模型切换的工程实现——不是改个配置那么简单
多模型切换的工程实现——不是改个配置那么简单
去年有段时间 OpenAI 服务不稳定,公司里有个同事信誓旦旦地说:"我们做个备用模型切换,把 Claude 配上,OpenAI 挂了自动切到 Claude,5 分钟搞定。"
我当时就想说:你低估这件事了。
他真的搞了一下午,上线之后第二天,用了 Claude 的用户纷纷反映 AI 回答的 JSON 格式解析失败,前端页面一片空白。结果紧急排查到凌晨两点。
这件事让我意识到,多模型切换的难点根本不在切换本身,而在于不同模型之间深层的行为差异。这篇文章我来说清楚这些差异是什么,以及怎么用适配层把它们屏蔽掉。
为什么说不只是改 URL
很多人第一反应是:换个模型不就是把请求地址和模型名字改一下吗?
从 HTTP 协议层面看确实如此,OpenAI 和 Claude 都有 API,都能发 POST 请求,都能拿到文本响应。但这只是表面。
我数了一下,在我们实际项目里,模型切换需要处理的差异点有以下几类:
1. 请求格式差异
OpenAI API:
{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "你好"}
]
}Claude API(Anthropic SDK):
{
"model": "claude-3-5-sonnet-20241022",
"system": "你是一个助手",
"messages": [
{"role": "user", "content": "你好"}
],
"max_tokens": 1024
}注意到了吗?Claude 的 system 消息不在 messages 数组里,而是单独的顶级字段。如果你直接把 OpenAI 格式的请求发给 Claude,会报错或者 system 提示完全失效。
2. 响应格式差异
OpenAI 的响应:
{
"choices": [{
"message": {
"role": "assistant",
"content": "你好!"
}
}],
"usage": {
"prompt_tokens": 20,
"completion_tokens": 5,
"total_tokens": 25
}
}Claude 的响应:
{
"content": [{
"type": "text",
"text": "你好!"
}],
"usage": {
"input_tokens": 20,
"output_tokens": 5
}
}字段名不一样,结构不一样,Token 统计字段名也不一样。你要是直接用 response.getChoices().get(0).getMessage().getContent() 这种代码,换了 Claude 之后直接 NPE。
3. 结构化输出的差异
这是我们踩得最深的坑。
OpenAI 有 response_format: {"type": "json_object"} 这个参数,告诉模型必须输出有效 JSON。Claude 没有这个参数。
更要命的是,即使你在 Prompt 里写了"请用 JSON 格式输出",不同模型对这个指令的遵从程度也不一样。GPT-4 系列一般比较听话,Claude 有时候会在 JSON 前面加一句"以下是 JSON 格式的输出:",这一句话就能让你的 JSON 解析器崩掉。
4. 函数调用 / Tool Call 的差异
OpenAI 的 Function Calling:
{
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取天气",
"parameters": {...}
}
}]
}Claude 的 Tool Use:
{
"tools": [{
"name": "get_weather",
"description": "获取天气",
"input_schema": {...}
}]
}参数里 parameters 和 input_schema 语义相同但字段名不同,返回结果的格式也有差异。
5. 行为差异
这是最难量化的部分。不同模型在面对同样的 Prompt 时:
- 拒绝回答的边界不同(安全过滤策略不同)
- 回答长度的默认倾向不同
- 对角色扮演 Prompt 的响应方式不同
- 对中文的处理质量不同(这在国内场景很重要)
- 输出代码的风格不同
这些差异没有好坏之分,但会影响你的下游逻辑。
真实踩坑:从 GPT-4 切到 Claude 后结构化输出全挂
让我具体说一下我们那次的问题。
我们有一个合同分析功能,会把合同文本发给 AI,让它提取关键条款,输出一个 JSON 结构。Prompt 大概是这样的:
请分析以下合同,提取关键信息,以 JSON 格式返回:
{
"parties": ["甲方名称", "乙方名称"],
"amount": "合同金额",
"deadline": "截止日期",
"key_clauses": ["关键条款1", "关键条款2"]
}GPT-4 的返回基本是这样:
{
"parties": ["北京科技有限公司", "上海贸易有限公司"],
"amount": "100万元",
"deadline": "2024年12月31日",
"key_clauses": ["保密条款", "违约责任"]
}干净的 JSON,直接 JSON.parseObject() 搞定。
切到 Claude 之后,返回变成了:
以下是合同分析结果的 JSON 格式:
```json
{
"parties": ["北京科技有限公司", "上海贸易有限公司"],
"amount": "100万元",
"deadline": "2024年12月31日",
"key_clauses": ["保密条款", "违约责任"]
}希望这个分析对您有帮助。
你看,JSON 内容是对的,但被 Markdown 代码块包裹起来了,还加了一句话。直接解析当然报错。
有人说这好办,加个后处理,把 Markdown 代码块去掉就行。
但这不是根本解法。假设下次又有另一个模型,它的输出格式又不一样,你还要加一层处理?日积月累,代码里全是针对特定模型的 hack。
正确的做法是在适配层里统一处理,让上层业务代码完全感知不到模型差异。
## 模型适配层的设计
核心思路:**定义统一的内部模型接口,每个具体模型实现一个适配器,上层业务只依赖内部接口**。
这是经典的适配器模式,但 AI 场景里有一些特殊处理。
### 统一的请求/响应模型
首先定义内部统一格式,不依赖任何特定厂商的 SDK:
```java
// 统一请求格式
@Data
@Builder
public class UnifiedChatRequest {
private String systemPrompt;
private List<UnifiedMessage> messages;
private UnifiedModelConfig modelConfig;
private OutputFormat outputFormat; // TEXT, JSON, STREAM
@Data
@Builder
public static class UnifiedMessage {
private MessageRole role; // USER, ASSISTANT, TOOL_RESULT
private String content;
private String toolCallId; // 工具调用结果时使用
}
@Data
@Builder
public static class UnifiedModelConfig {
private double temperature;
private int maxTokens;
private boolean stream;
}
public enum OutputFormat {
TEXT, JSON, STREAM
}
}
// 统一响应格式
@Data
@Builder
public class UnifiedChatResponse {
private String content;
private TokenUsage tokenUsage;
private String modelVersion;
private Long latencyMs;
@Data
@Builder
public static class TokenUsage {
private int inputTokens;
private int outputTokens;
private int totalTokens;
}
}模型适配器接口
public interface ModelAdapter {
/**
* 支持的模型提供商
*/
ModelProvider getProvider();
/**
* 执行对话
*/
UnifiedChatResponse chat(UnifiedChatRequest request);
/**
* 流式对话
*/
Flux<String> chatStream(UnifiedChatRequest request);
/**
* 健康检查
*/
boolean isHealthy();
/**
* 模型是否支持结构化输出
*/
default boolean supportsNativeJsonOutput() {
return false;
}
}OpenAI 适配器实现
@Component
@Slf4j
public class OpenAIModelAdapter implements ModelAdapter {
@Autowired
private OpenAIClient openAIClient;
@Value("${ai.openai.model:gpt-4o}")
private String defaultModel;
@Override
public ModelProvider getProvider() {
return ModelProvider.OPENAI;
}
@Override
public UnifiedChatResponse chat(UnifiedChatRequest request) {
long startTime = System.currentTimeMillis();
// 转换为 OpenAI 格式
ChatCompletionRequest openAIRequest = convertToOpenAIRequest(request);
// 如果需要 JSON 输出,设置 response_format
if (request.getOutputFormat() == OutputFormat.JSON) {
openAIRequest.setResponseFormat(new ResponseFormat("json_object"));
}
ChatCompletionResult result = openAIClient.createChatCompletion(openAIRequest);
String content = result.getChoices().get(0).getMessage().getContent();
return UnifiedChatResponse.builder()
.content(content)
.tokenUsage(UnifiedChatResponse.TokenUsage.builder()
.inputTokens((int) result.getUsage().getPromptTokens())
.outputTokens((int) result.getUsage().getCompletionTokens())
.totalTokens((int) result.getUsage().getTotalTokens())
.build())
.modelVersion(defaultModel)
.latencyMs(System.currentTimeMillis() - startTime)
.build();
}
private ChatCompletionRequest convertToOpenAIRequest(UnifiedChatRequest request) {
List<ChatMessage> messages = new ArrayList<>();
// system 消息放在 messages 数组里
if (StringUtils.hasText(request.getSystemPrompt())) {
messages.add(new ChatMessage("system", request.getSystemPrompt()));
}
// 用户消息
for (UnifiedChatRequest.UnifiedMessage msg : request.getMessages()) {
messages.add(new ChatMessage(msg.getRole().name().toLowerCase(), msg.getContent()));
}
return ChatCompletionRequest.builder()
.model(defaultModel)
.messages(messages)
.temperature(request.getModelConfig().getTemperature())
.maxTokens(request.getModelConfig().getMaxTokens())
.build();
}
@Override
public boolean supportsNativeJsonOutput() {
return true;
}
}Claude 适配器实现
@Component
@Slf4j
public class ClaudeModelAdapter implements ModelAdapter {
@Autowired
private AnthropicClient anthropicClient;
@Value("${ai.claude.model:claude-3-5-sonnet-20241022}")
private String defaultModel;
@Override
public ModelProvider getProvider() {
return ModelProvider.CLAUDE;
}
@Override
public UnifiedChatResponse chat(UnifiedChatRequest request) {
long startTime = System.currentTimeMillis();
// 构建 Claude 格式请求
MessageCreateParams params = buildClaudeRequest(request);
Message response = anthropicClient.messages().create(params);
// 提取文本内容(Claude 的 content 是 List)
String rawContent = response.content().stream()
.filter(block -> block.type().equals("text"))
.map(block -> block.text())
.findFirst()
.orElse("");
// 如果期望 JSON 输出,做后处理清洗
String content = request.getOutputFormat() == OutputFormat.JSON
? extractJsonFromResponse(rawContent)
: rawContent;
return UnifiedChatResponse.builder()
.content(content)
.tokenUsage(UnifiedChatResponse.TokenUsage.builder()
.inputTokens(response.usage().inputTokens())
.outputTokens(response.usage().outputTokens())
.totalTokens(response.usage().inputTokens() + response.usage().outputTokens())
.build())
.modelVersion(defaultModel)
.latencyMs(System.currentTimeMillis() - startTime)
.build();
}
private MessageCreateParams buildClaudeRequest(UnifiedChatRequest request) {
MessageCreateParams.Builder builder = MessageCreateParams.builder()
.model(defaultModel)
.maxTokens(request.getModelConfig().getMaxTokens());
// Claude 的 system 是顶级字段,不在 messages 里
if (StringUtils.hasText(request.getSystemPrompt())) {
builder.system(request.getSystemPrompt());
}
// 构建消息列表
List<MessageParam> messages = request.getMessages().stream()
.map(msg -> MessageParam.builder()
.role(convertRole(msg.getRole()))
.content(msg.getContent())
.build())
.collect(Collectors.toList());
builder.messages(messages);
return builder.build();
}
/**
* 从 Claude 响应中提取 JSON,处理 Markdown 包裹等情况
*/
private String extractJsonFromResponse(String rawContent) {
if (rawContent == null || rawContent.isBlank()) {
return rawContent;
}
// 情况1:```json ... ``` 包裹
Pattern jsonBlockPattern = Pattern.compile("```(?:json)?\\s*\\n?([\\s\\S]*?)\\n?```");
Matcher matcher = jsonBlockPattern.matcher(rawContent);
if (matcher.find()) {
return matcher.group(1).trim();
}
// 情况2:直接以 { 或 [ 开头
String trimmed = rawContent.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return trimmed;
}
// 情况3:从文本中找到第一个完整的 JSON 对象
int braceStart = trimmed.indexOf('{');
int bracketStart = trimmed.indexOf('[');
int start = -1;
if (braceStart >= 0 && (bracketStart < 0 || braceStart < bracketStart)) {
start = braceStart;
} else if (bracketStart >= 0) {
start = bracketStart;
}
if (start >= 0) {
// 找到对应的结束括号(简单实现,生产环境建议用完整的 JSON 解析器)
return trimmed.substring(start);
}
log.warn("Unable to extract JSON from Claude response: {}", rawContent.substring(0, Math.min(200, rawContent.length())));
return rawContent;
}
@Override
public boolean supportsNativeJsonOutput() {
return false; // Claude 不支持强制 JSON 模式,需要后处理
}
}统一的模型服务入口
@Service
@Slf4j
public class UnifiedModelService {
@Autowired
private List<ModelAdapter> adapters;
@Autowired
private ModelRoutingConfig routingConfig;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
public UnifiedChatResponse chat(UnifiedChatRequest request) {
ModelProvider primaryProvider = routingConfig.getPrimaryProvider();
ModelProvider fallbackProvider = routingConfig.getFallbackProvider();
// 先尝试主模型
try {
ModelAdapter primaryAdapter = getAdapter(primaryProvider);
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(
primaryProvider.name());
return circuitBreaker.executeSupplier(() -> primaryAdapter.chat(request));
} catch (CallNotPermittedException e) {
// 熔断器打开,直接走备用模型
log.warn("Primary model {} circuit breaker open, using fallback", primaryProvider);
return useFallback(request, fallbackProvider);
} catch (Exception e) {
log.error("Primary model {} failed, trying fallback: {}", primaryProvider, e.getMessage());
return useFallback(request, fallbackProvider);
}
}
private UnifiedChatResponse useFallback(UnifiedChatRequest request, ModelProvider fallback) {
if (fallback == null) {
throw new AiServiceException("No fallback model configured");
}
ModelAdapter fallbackAdapter = getAdapter(fallback);
return fallbackAdapter.chat(request);
}
private ModelAdapter getAdapter(ModelProvider provider) {
return adapters.stream()
.filter(a -> a.getProvider() == provider)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No adapter for provider: " + provider));
}
}多模型切换架构图
几个工程细节
Prompt 兼容性问题
不同模型对 Prompt 的理解有差异。我的建议是:维护一个 Prompt 模板库,每个模板针对不同模型有不同的变体。业务代码传 Prompt 模板 ID + 参数,适配层负责根据模型选择合适的模板变体。
超时配置要区分
Claude 的响应速度一般比 GPT-4 慢一些(但比 GPT-4 更便宜)。不要把所有模型用同一个超时配置,应该为每个模型设置独立的超时。
日志里要记录用了哪个模型
出问题排查时,你第一个要知道的问题是"这个请求用了哪个模型"。每个请求的日志和 trace 里都要有模型标识,别等到出问题才发现日志里根本找不到这个信息。
Token 计费方式不同
不同模型的定价方式和 Token 计算方式不同,同一段文本的 Token 数在不同模型里可能不一样。做成本分摊时要注意这点,不要用一套计算公式套所有模型。
最后
多模型切换这个事,如果只是个人项目随便玩玩,直接改配置就行。但如果是生产环境,特别是有结构化输出需求的场景,不建立适配层迟早出问题。
适配层的投入不大,也就几天工作量,但它把所有模型差异集中在一个地方处理,避免业务代码里到处都是针对特定模型的 hack,维护成本会低很多。
