feat(llm): migrate model calls to LangChain ChatOpenAI
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""ChatOpenAI 流的显式生命周期管理。
|
||||
|
||||
当前 langchain-core 的 astream 不显式关闭嵌套的 provider 迭代器。
|
||||
提前停止消费(非法前缀、客户端断开)时,等待 GC 会延后 HTTP 连接释放。
|
||||
这里只跟踪并关闭原生迭代器,模型请求与 callbacks 仍由 ChatOpenAI 实现。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextvars import ContextVar
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_openai.chat_models.base import BaseChatOpenAI
|
||||
|
||||
_stream_iterators: ContextVar[list[AsyncIterator] | None] = ContextVar(
|
||||
"chat_model_stream_iterators", default=None,
|
||||
)
|
||||
|
||||
|
||||
class ManagedChatOpenAI(ChatOpenAI):
|
||||
async def astream(self, *args, **kwargs):
|
||||
providers: list[AsyncIterator] = []
|
||||
source = super().astream(*args, **kwargs)
|
||||
try:
|
||||
while True:
|
||||
# 每次推进时绑定,yield 前复原;兼容在不同任务中 anext/aclose。
|
||||
token = _stream_iterators.set(providers)
|
||||
try:
|
||||
chunk = await anext(source)
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
finally:
|
||||
_stream_iterators.reset(token)
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
await source.aclose()
|
||||
finally:
|
||||
for provider in reversed(providers):
|
||||
await provider.aclose()
|
||||
|
||||
def _astream(self, *args, **kwargs):
|
||||
# ChatOpenAI 的协议路由也会创建一层未显式关闭的生成器。
|
||||
# 保留真正持有 HTTP 响应的原生 provider 迭代器。
|
||||
if self._use_responses_api({**kwargs, **self.model_kwargs}):
|
||||
source = BaseChatOpenAI._astream_responses(self, *args, **kwargs)
|
||||
else:
|
||||
source = BaseChatOpenAI._astream(self, *args, **kwargs)
|
||||
providers = _stream_iterators.get()
|
||||
if providers is not None:
|
||||
providers.append(source)
|
||||
return source
|
||||
+58
-41
@@ -2,8 +2,8 @@
|
||||
|
||||
fake 与真实 provider 实现同一 ``LLMService`` Protocol,节点不感知差异:
|
||||
- ``FakeLLMService``:脚本化固定输出,离线可重复,日常测试用它;
|
||||
- ``OpenAILLMService``:真实 provider(TODO 关键代码)——
|
||||
超时、取消、有限重试、模型配置。
|
||||
- ``OpenAILLMService``:通过 LangChain ChatOpenAI 调用 OpenAI 兼容接口,
|
||||
统一管理超时、取消、有限重试与模型配置。
|
||||
|
||||
``build_llm_service`` 按命名提供商列表(Config.LLM_PROVIDERS / LLM_PROVIDER)
|
||||
选择实现,见 core.config。
|
||||
@@ -87,8 +87,9 @@ class FakeLLMService:
|
||||
|
||||
|
||||
class OpenAILLMService:
|
||||
"""单层重试:首次 + max_retries;每次尝试超时覆盖整个响应读取。
|
||||
"""ChatOpenAI 适配;保留名称以兼容既有调用方。
|
||||
|
||||
单层重试:首次 + max_retries;每次尝试超时覆盖整个响应读取。
|
||||
SDK 自动重试关闭。流式仅在首个文本 token 前重试,输出后失败终止。
|
||||
默认 60 秒、3 次重试、1/2/4 秒退避:单次 LLM 调用预算上界 247 秒
|
||||
(不含响应关闭的清理时间),不代表多次 LLM 调用的整轮 HTTP 超时。
|
||||
@@ -115,40 +116,51 @@ class OpenAILLMService:
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
from .chat_model import ManagedChatOpenAI
|
||||
from ..core.config import Config
|
||||
|
||||
options = {
|
||||
"api_key": self._api_key, "timeout": self._timeout_seconds,
|
||||
"max_retries": 0,
|
||||
"http_client": httpx.AsyncClient(trust_env=Config.LLM_HTTP_TRUST_ENV),
|
||||
}
|
||||
if self._base_url:
|
||||
options["base_url"] = self._base_url
|
||||
self._client = AsyncOpenAI(**options)
|
||||
self._client = ManagedChatOpenAI(
|
||||
model=self._model,
|
||||
api_key=self._api_key,
|
||||
base_url=self._base_url or None,
|
||||
timeout=self._timeout_seconds,
|
||||
max_retries=0,
|
||||
temperature=self._temperature,
|
||||
extra_body=dict(self._extra_body) or None,
|
||||
# 所有命名 profile 使用 Chat Completions,避免按模型名自动切协议。
|
||||
use_responses_api=False,
|
||||
# 兼容网关未必支持 include_usage;不额外改变现有请求参数。
|
||||
stream_usage=False,
|
||||
# 代理统一由下方 httpx 客户端和 LLM_HTTP_TRUST_ENV 控制。
|
||||
openai_proxy="",
|
||||
http_client=httpx.Client(trust_env=Config.LLM_HTTP_TRUST_ENV),
|
||||
http_async_client=httpx.AsyncClient(trust_env=Config.LLM_HTTP_TRUST_ENV),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def aclose(self):
|
||||
if self._client is not None:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
client = self._client
|
||||
try:
|
||||
await client.root_async_client.close()
|
||||
finally:
|
||||
try:
|
||||
client.root_client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
def _request_args(self, system_prompt, user_message, history, *, stream=False):
|
||||
options = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*[dict(message) for message in (history or [])],
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
}
|
||||
if stream:
|
||||
options["stream"] = True
|
||||
if self._temperature is not None:
|
||||
options["temperature"] = self._temperature
|
||||
if self._extra_body:
|
||||
options["extra_body"] = dict(self._extra_body)
|
||||
return options
|
||||
@staticmethod
|
||||
def _messages(system_prompt, user_message, history):
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*[dict(message) for message in (history or [])],
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _text(message):
|
||||
# text 会只提取文本块,忽略 reasoning/tool/usage 等非正文内容。
|
||||
return str(message.text)
|
||||
|
||||
@staticmethod
|
||||
def _retryable(exc):
|
||||
@@ -182,12 +194,15 @@ class OpenAILLMService:
|
||||
import asyncio
|
||||
|
||||
client = self._get_client()
|
||||
options = self._request_args(system_prompt, user_message, history)
|
||||
messages = self._messages(system_prompt, user_message, history)
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
response = await client.chat.completions.create(**options)
|
||||
content = response.choices[0].message.content if response.choices else None
|
||||
response = await client.ainvoke(
|
||||
messages,
|
||||
config={"run_name": "llm.complete", "metadata": {"llm_attempt": attempt + 1}},
|
||||
)
|
||||
content = self._text(response)
|
||||
if not content:
|
||||
raise LLMAPIError("LLM 返回了空内容")
|
||||
return content
|
||||
@@ -205,18 +220,18 @@ class OpenAILLMService:
|
||||
from contextlib import suppress
|
||||
|
||||
client = self._get_client()
|
||||
options = self._request_args(system_prompt, user_message, history, stream=True)
|
||||
messages = self._messages(system_prompt, user_message, history)
|
||||
emitted = False
|
||||
for attempt in range(self._max_retries + 1):
|
||||
response = None
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
response = await client.chat.completions.create(**options)
|
||||
response = client.astream(
|
||||
messages,
|
||||
config={"run_name": "llm.stream", "metadata": {"llm_attempt": attempt + 1}},
|
||||
)
|
||||
async for event in response:
|
||||
choices = getattr(event, "choices", None)
|
||||
if not choices:
|
||||
continue
|
||||
token = getattr(getattr(choices[0], "delta", None), "content", None)
|
||||
token = self._text(event)
|
||||
if token:
|
||||
emitted = True
|
||||
yield token
|
||||
@@ -229,10 +244,12 @@ class OpenAILLMService:
|
||||
if emitted or attempt == self._max_retries or not self._retryable(exc):
|
||||
raise self._error(exc) from exc
|
||||
finally:
|
||||
close = getattr(response, "close", None)
|
||||
close = getattr(response, "aclose", None)
|
||||
if close is not None:
|
||||
with suppress(Exception):
|
||||
await asyncio.wait_for(close(), timeout=5)
|
||||
# 在原任务中关闭,保留 callback/contextvars 上下文。
|
||||
async with asyncio.timeout(5):
|
||||
await close()
|
||||
await self._retry_delay(attempt)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user