OpenAI 兼容协议和 ChatResponse 标准化有什么区别?
在做 AI Agent 或工作流平台时,经常会遇到一个很现实的问题:
我们不可能只接一家大模型。
可能有的节点想用 OpenAI,有的节点想用 DeepSeek,有的节点想用通义千问,还有的节点想用智谱。
这时候问题就来了:
不同厂商的 API 不一样,后端难道要给每一家都单独写一套调用代码吗?
老王就问我:
“你们接入了 OpenAI、DeepSeek、通义千问好几家模型,这些厂商的 API 不一样吧?怎么统一的?”
我说:
“主要靠两层统一。”
第一层:OpenAI 兼容协议,统一请求格式
第二层:ChatResponse 标准化,统一返回结果
这篇文章就把这两个概念讲清楚。
一、为什么需要 OpenAI 兼容协议?
最早接大模型时,不同厂商的接口格式差异比较明显。
比如 A 厂商叫 prompt,B 厂商叫 messages。
A 厂商的接口地址是 /chat,B 厂商的接口地址是 /generate。
A 厂商的温度参数叫 temperature,B 厂商可能叫 top_p 或别的名字。
如果每家都单独适配,代码很快就会变成这样:
if 是 OpenAI,就走 OpenAIClient
if 是 DeepSeek,就走 DeepSeekClient
if 是通义千问,就走 QwenClient
if 是智谱,就走 ZhipuClient
一开始看起来没问题。
但模型厂商越来越多之后,代码会越来越难维护。
所以现在很多模型厂商都会提供一种能力:
OpenAI 兼容接口
意思是:
虽然底层模型不是 OpenAI 的,但接口格式尽量按照 OpenAI 的格式来。
这样调用方就可以用一套 OpenAI 风格的请求结构,去调用多家模型。
二、OpenAI 兼容到底兼容了什么?
所谓 OpenAI 兼容,主要兼容的是请求协议。
比如大家都尽量支持类似这样的接口:
/v1/chat/completions
请求体结构也差不多:
{
"model": "deepseek-chat",
"messages": [
{
"role": "user",
"content": "帮我总结一下这段文字"
}
],
"temperature": 0.7
}
这几个字段是大模型调用里最常见的:
model:使用哪个模型
messages:对话上下文
temperature:生成随机性
stream:是否流式输出
tools:是否开启工具调用
所以,只要厂商支持 OpenAI 兼容协议,我们的后端就不需要为每一家重新设计一套请求结构。
大部分时候,只需要换三个东西:
base_url
api_key
model
比如:
OpenAI:
https://api.openai.com/v1
DeepSeek:
https://api.deepseek.com/v1
通义千问兼容模式:
https://dashscope.aliyuncs.com/compatible-mode/v1
虽然地址不一样,但请求格式基本一致。
这就是 OpenAI 兼容协议最大的价值:
用一套调用方式,接入多家模型厂商。
三、在代码里怎么统一创建模型客户端?
在 PaiAgent 里,可以通过一个 ChatClientFactory 来统一创建模型客户端。
核心代码类似这样:
private ChatModel createOpenAICompatibleModel(
String apiUrl,
String apiKey,
String model,
Double temperature
) {
OpenAiApi openAiApi = new OpenAiApi(apiUrl, apiKey);
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(model)
.temperature(temperature)
.build();
return new OpenAiChatModel(openAiApi, options);
}
这段代码的意思很简单:
1. 用 apiUrl 和 apiKey 创建 OpenAiApi
2. 用 model 和 temperature 创建模型参数
3. 最后创建 OpenAiChatModel
重点在这里:
new OpenAiApi(apiUrl, apiKey)
apiUrl 是动态传进来的。
所以它不一定非得是 OpenAI 官方地址,也可以是 DeepSeek,也可以是通义千问的兼容地址。
也就是说,不管外部传进来的是:
https://api.openai.com/v1
还是:
https://api.deepseek.com/v1
还是:
https://dashscope.aliyuncs.com/compatible-mode/v1
都可以走同一套创建逻辑。
四、工厂方法怎么屏蔽厂商差异?
一般在工厂类里,会有一个类似 switch 的逻辑。
比如:
public ChatModel createChatModel(ModelConfig config) {
switch (config.getProvider()) {
case "openai":
case "deepseek":
case "qwen":
return createOpenAICompatibleModel(
config.getApiUrl(),
config.getApiKey(),
config.getModel(),
config.getTemperature()
);
default:
throw new IllegalArgumentException("Unsupported provider");
}
}
你会发现,openai、deepseek、qwen 虽然是不同厂商,但它们都指向了同一个方法:
createOpenAICompatibleModel(...)
这就说明,只要这些模型厂商都支持 OpenAI 兼容协议,我们在代码里就可以把它们当成同一类模型来处理。
区别只放在配置里:
{
"provider": "deepseek",
"apiUrl": "https://api.deepseek.com/v1",
"apiKey": "sk-xxx",
"model": "deepseek-chat",
"temperature": 0.7
}
换成通义千问,也只是配置变化:
{
"provider": "qwen",
"apiUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"apiKey": "sk-xxx",
"model": "qwen-plus",
"temperature": 0.7
}
业务代码不用改。
这就是工厂模式带来的好处:
把模型创建逻辑集中收口,把厂商差异放到配置里。
五、OpenAI 兼容是不是等于完全一样?
不是。
这一点非常重要。
OpenAI 兼容主要解决的是请求格式统一,不代表所有厂商的返回结果都百分百一样。
老王接着问:
“那 Response 呢?各家返回的格式也完全一致吗?”
我说:
“不完全一致。大部分字段差不多,但细节上还是会有差异。”
比如普通非流式返回里,很多厂商都会返回类似结构:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "这是模型生成的回答"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150
}
}
我们最常用的内容一般在:
choices[0].message.content
所以从“拿模型回答”这个角度看,各家差异不大。
但细节字段可能不一样。
比如 token 统计字段,有的厂商可能是:
{
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150
}
}
有的厂商可能更偏向:
{
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150
}
}
再比如流式输出时,大家虽然大多用 SSE,但 chunk 的细节也可能不同。
比如:
finish_reason 的枚举值可能不完全一样
usage 返回时机可能不一样
tool call 的 delta 结构可能略有差异
异常返回格式可能不一样
所以,千万不要以为“OpenAI 兼容”就等于“完全没有适配成本”。
更准确的说法是:
OpenAI 兼容降低了接入成本,但没有完全消灭厂商差异。
六、那 Response 怎么统一?
这时候就轮到框架层出场了。
在 Spring AI 里,我们不是直接拿各家厂商原始返回的 JSON 到处传,而是使用统一的 ChatResponse。
也就是说,底层厂商返回的数据可能略有不同,但 Spring AI 会尽量帮我们转换成统一结构。
我们业务代码里拿到的是类似这样的对象:
ChatResponse response = chatModel.call(prompt);
然后从里面取结果:
String content = response.getResult()
.getOutput()
.getText();
或者取 token 用量:
Usage usage = response.getMetadata().getUsage();
这个时候,业务代码不需要关心底层厂商到底叫:
prompt_tokens
还是:
input_tokens
因为框架已经帮我们做了一层标准化。
所以这里要分清两个概念:
OpenAI 兼容协议:统一请求怎么发
ChatResponse:统一结果怎么拿
一个偏输入,一个偏输出。
一个解决“怎么调用模型”,一个解决“怎么消费结果”。
七、OpenAI 兼容和 ChatResponse 的区别
可以用一张表来理解:
OpenAI 兼容协议:
解决请求格式统一
关注 apiUrl、apiKey、model、messages、temperature
作用在模型调用前
ChatResponse:
解决返回结果统一
关注 content、metadata、usage、finishReason
作用在模型调用后
再通俗一点:
OpenAI 兼容协议,管的是“怎么问模型”。
ChatResponse 标准化,管的是“模型回答后怎么取结果”。
举个例子。
你要去不同餐厅点餐。
OpenAI 兼容协议像是大家都支持同一种点餐格式:
我要一份牛肉面,少辣,不要香菜。
不管你去 A 餐厅还是 B 餐厅,都能这么点。
但是每家餐厅端上来的小票格式可能不一样。
有的写:
主食:牛肉面
价格:28
有的写:
商品名:牛肉面
实付金额:28
这时候 ChatResponse 就像一个统一小票解析器。
它把不同餐厅的小票都整理成统一格式:
菜品名称
价格
备注
所以:
OpenAI 兼容负责统一点餐方式。
ChatResponse 负责统一小票格式。
八、为什么不能直接用原始 Response?
理论上可以,但不建议。
如果业务代码直接解析原始 JSON,就会变成这样:
if (provider.equals("openai")) {
content = json.get("choices")
.get(0)
.get("message")
.get("content");
} else if (provider.equals("deepseek")) {
content = json.get("choices")
.get(0)
.get("message")
.get("content");
} else if (provider.equals("qwen")) {
content = json.get("output")
.get("text");
}
一开始你可能觉得还能接受。
但是后面要处理更多东西:
流式输出
token 用量
工具调用
finish reason
异常信息
安全拦截
模型拒答
多模态内容
代码就会越来越乱。
更好的方式是:
底层适配不同厂商
上层只处理统一对象
也就是:
厂商原始 Response
↓
框架适配层
↓
统一 ChatResponse
↓
业务代码使用
这样业务层就干净很多。
九、运行时怎么动态切换模型?
老王继续问:
“你们是怎么实现运行时动态切换模型的?不重启服务就能换?”
答案是:
模型客户端不是写死的,也不是固定注入一个 Spring 单例,而是在运行时根据节点配置动态创建。
也就是说,每个节点都可以有自己的模型配置。
比如工作流 JSON 里可以这样定义:
{
"id": "node_analysis",
"type": "llm",
"modelConfig": {
"provider": "deepseek",
"apiUrl": "https://api.deepseek.com/v1",
"apiKey": "sk-xxx",
"model": "deepseek-chat",
"temperature": 0.5
}
}
另一个节点可以这样定义:
{
"id": "node_polish",
"type": "llm",
"modelConfig": {
"provider": "openai",
"apiUrl": "https://api.openai.com/v1",
"apiKey": "sk-xxx",
"model": "gpt-4o",
"temperature": 0.8
}
}
这样一个工作流里就可以出现:
第一个节点:用 DeepSeek 做初步分析
第二个节点:用 GPT 做精细加工
第三个节点:用 Qwen 做结果总结
执行时,系统读取当前节点的配置,然后调用 ChatClientFactory 创建对应的 ChatModel。
流程大概是:
读取工作流配置
↓
执行到某个 LLM 节点
↓
读取该节点的 modelConfig
↓
ChatClientFactory 创建 ChatModel
↓
调用模型
↓
返回统一 ChatResponse
所以,前端拖拽编辑器里改了模型名称、apiUrl 或 temperature,下次执行工作流时就能生效。
不需要重启服务。
十、为什么不用 Spring 单例注入?
很多项目里,我们习惯这样写:
@Autowired
private ChatModel chatModel;
这种方式适合模型配置固定的场景。
比如整个系统就用一个模型,那当然可以。
但工作流平台不一样。
工作流平台的特点是:
不同用户可能用不同模型
不同工作流可能用不同模型
同一个工作流的不同节点也可能用不同模型
同一个节点下次执行时配置也可能变化
如果把 ChatModel 写成固定 Spring 单例,就不够灵活了。
因为单例对象在应用启动时就创建好了。
它的 apiUrl、apiKey、model 基本都是固定的。
而 PaiAgent 需要的是:
运行时读配置,运行时创建客户端,运行时决定调用哪个模型。
所以这里更适合使用工厂模式,而不是直接注入一个固定模型对象。
十一、动态创建 ChatClient 有什么好处?
第一,灵活。
每个节点都可以单独配置模型。
第二,扩展简单。
新增一个支持 OpenAI 兼容协议的厂商时,通常只需要加配置,不需要大改业务逻辑。
第三,适合工作流编排。
工作流本来就是配置驱动的。
既然节点、边、参数都可以配置,模型自然也应该可以配置。
第四,方便灰度和测试。
同一个流程,可以快速切换不同模型做效果对比。
比如:
DeepSeek 版本
GPT 版本
Qwen 版本
通过不同配置跑一遍,就可以比较输出质量、成本和耗时。
十二、动态创建有什么缺点?
缺点也很明显:
每次调用都 new OpenAiApi
每次调用都 new OpenAiChatModel
可能会有一定对象创建开销
对于低频工作流调用,这个问题不大。
因为一次工作流执行里,大模型调用本身才是最耗时的部分。
相比模型推理耗时,创建几个客户端对象的开销通常可以接受。
但如果是高并发在线推理场景,比如:
每秒几百次请求
每秒几千次请求
实时对话服务
在线客服系统
那就要进一步优化。
可以考虑:
1. 按 apiUrl + model 缓存 ChatModel
2. 复用底层 HTTP Client
3. 做连接池管理
4. 控制客户端对象数量
5. 给不同模型配置限流策略
也就是说:
工作流低频场景,可以动态创建;
高并发在线场景,最好做缓存和连接复用。
十三、最终架构可以怎么理解?
整个多模型接入链路可以总结成这样:
工作流节点配置
↓
读取 modelConfig
↓
ChatClientFactory
↓
OpenAI Compatible Client
↓
调用不同厂商模型
↓
厂商原始响应
↓
Spring AI 标准化
↓
ChatResponse
↓
业务节点继续处理
这里面有三个关键角色。
1. OpenAI 兼容协议
它解决的是:
不同厂商怎么用同一种请求格式调用。
重点是统一:
接口路径
请求字段
messages 格式
model 参数
temperature 参数
stream 参数
2. ChatClientFactory
它解决的是:
运行时根据配置创建不同模型客户端。
重点是统一:
apiUrl
apiKey
model
temperature
provider
3. ChatResponse
它解决的是:
不同厂商返回结果怎么用同一种方式读取。
重点是统一:
模型输出内容
token 用量
finish reason
metadata
所以这三者的关系是:
OpenAI 兼容协议负责统一请求;
ChatClientFactory 负责动态创建客户端;
ChatResponse 负责统一响应结果。
十四、一句话总结
如果只记一句话,可以这样说:
OpenAI 兼容协议解决“怎么用同一套格式请求不同模型”,ChatResponse 解决“怎么用同一套格式读取不同模型的返回结果”。
再进一步:
OpenAI 兼容偏调用协议;
ChatResponse 偏结果抽象;
ChatClientFactory 偏工程落地。
在工作流平台里,这套设计非常实用。
因为它让我们可以做到:
模型厂商可切换
节点模型可配置
请求协议可复用
返回结果可统一
服务无需重启
工作流运行时动态生效
表面上看,只是换了一个 apiUrl 和 model。
但底层真正体现的是一种架构思想:
把厂商差异收敛在适配层,把业务代码稳定在统一抽象上。
这也是多模型 Agent 平台最核心的设计之一。