使用url接入langchain的OpenAI或者ChatOpenAI

前言

上一篇文章中,我们探讨了如何自定义LLM类。但是看到最新的LangGraph版本中,主要利用的是OpenAI或者ChatOpenAI,并使用了ChatOpenAI独有的bind_tool方法,使得图结构有了更为丰富的动作与功能,这让我非常眼红。于是本文就探讨了OpenAI或者ChatOpenAI包装自定义LLM的方法。

OpenAI为什么可以?

有些人已经比较熟悉了,为什么偏偏包括OpenAI在内的大模型厂商都可以使用url+api-key的模式访问信息并获取返回结果。这里还是再度解析一下。

我们找到Python文件中的from openai import OpenAI,并选择进入OpenAI看一看原理,我们可以发现,OpenAI类继承自SyncAPIClient,而SyncAPIClient继承自BaseClient[httpx.Client, Stream[Any]],类内有一个成员变量:_client: httpx.Client

核心的部分在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def request(
self,
cast_to: Type[ResponseT],
options: FinalRequestOptions,
remaining_retries: Optional[int] = None,
*,
stream: bool = False,
stream_cls: type[_StreamT] | None = None,
) -> ResponseT | _StreamT:
if remaining_retries is not None:
retries_taken = options.get_max_retries(
self.max_retries
) - remaining_retries
else:
retries_taken = 0

return self._request(
cast_to=cast_to,
options=options,
stream=stream,
stream_cls=stream_cls,
retries_taken=retries_taken,
)

_request中,有一段是这样的

1
2
3
4
5
6
7
8
9
10
response = self._client.send(
request,
(
stream=stream or
self._should_stream_response_body(
request=request
)
),
**kwargs,
)

其中,send方法都并不需要太关注源码,直接看注释都明白是在干啥了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def send(
self,
request: Request,
*,
stream: bool = False,
auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
) -> Response:
"""
Send a request.

The request is sent as-is, unmodified.

Typically you'll want to build one with `Client.build_request()`
so that any client-level configuration is merged into the request,
but passing an explicit `httpx.Request()` is supported as well.

See also: [Request instances][0]

[0]: /advanced/clients/#request-instances
"""

也就是说,OpenAI的本质就是一个巨大的网络请求客户端,而urlapi-key就是访问客户端的两个参数。

OpenAI怎么传参?

那么知道了这些之后,我们又应该怎么找规律,从而把自己的urlapi-key塞进去呢?

首先我们注意到其中有一个方法:

1
2
3
4
5
@property
@override
def auth_headers(self) -> dict[str, str]:
api_key = self.api_key
return {"Authorization": f"Bearer {api_key}"}

这个和MindIE的使用方法相当一致。我们接着找,然后在这里找到了一点蛛丝马迹:

1
2
3
4
5
6
7
8
9
10
@property
def default_headers(self) -> dict[str, str | Omit]:
return {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": self.user_agent,
**self.platform_headers(),
**self.auth_headers,
**self._custom_headers,
}

在这里,self.auth_headers中构建的字典在这里被展开了,成为了新字典中的若干个字段。如果你对HTTP请求略有基础,你会发现,原来内置的部分属性AcceptContent-TypeUser-Agent都是仅出现在请求头中的内容。也就是说,这个api-key实际上是应当放在请求头里面一起携带过去。

url最终也将成为BaseClient类中的一个参数,传入httpxRequest._models.Request类:

1
2
3
4
5
6
7
8
9
10
11
12
return Request(
method,
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
extensions=extensions,
)

OpenAI怎么兼容?

既然大概知道了OpenAI的原理,那么关键就是兼容自己的LLM了。

首先,我们需要一个apiurl,也就是请求返回结果是按照OpenAI接口格式返回的一个数据接口,一般情况下是以v1/chat/completions为结尾的。

然后,我们需要一个apikey,这个key必须得是Bearer 开头的,否则就没办法兼容OpenAI了,除非你愿意花这个时间把整个OpenAI的接口实现一遍,然后在自定义api_key的时候做出不一样的行为。

然后,我们就可以开开心心地访问了:

1
2
3
4
llm = OpenAI(
base_url="<your-api-url>",
api_key="Bearer <your-api-key>"
)

如果你比较细心的话,你会发现通义千问的方法也是这个:

1
2
3
4
llm = OpenAI(
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="<your-dashscope-key>"
)

这也反向证明了我们的正确性。

ChatOpenAI如何兼容?

我们同样从源码中找到答案。

首先,找到from langchain_openai import ChatOpenAI,其实这也是同等于from langchain_openai.chat_models.base import ChatOpenAI

在这里,我们找到有一个client属性与root_client属性。其中:

1
2
3
4
self.root_client = openai.OpenAI(
**client_params, **sync_specific
)
self.client = self.root_client.chat.completions

看出来了吗?ChatOpenAI本质上就是openai.OpenAI的封装,root_client属性就是openai.OpenAI的实例,client属性是v1/chat/completions接口的实例。

这就没什么悬念了。所以ChatOpenAI实际上与OpenAI一样,只需要这样:

1
2
3
4
5
llm = ChatOpenAI(
base_url="<your-api-url>",
api_key="Bearer <your-api-key>",
model="<your choice>"
)