feat(llm): migrate model calls to LangChain ChatOpenAI

This commit is contained in:
Eric Wang
2026-10-24 17:08:46 +08:00
parent 97de142e6d
commit d8d48c26a3
13 changed files with 489 additions and 96 deletions
+51
View File
@@ -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
View File
@@ -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)