【Bug已解决】fix(groq,xai): omit timeout from client_params when request_timeout is None 解决方案一、现象长什么样在langchain-groq与langchain-xai里当你把ChatGroq(..., request_timeoutNone)或ChatXAI(..., request_timeoutNone)显式设成None意图是让底层 HTTP 客户端使用它自己的默认超时或干脆不超时时实际表现却和直觉相反即便你传了request_timeoutNone构造出的httpx.Client/httpx.AsyncClient依然拿到了一个 timeout 字段结果客户端并不是无超时而是被套上了一层库作者预想的默认超时更隐蔽的一种情况是代码把None直接塞进client_params的timeout键底层httpx收到timeoutNone后行为取决于版本有时表现为立即按 5 秒默认超时、有时表现为完全不校验。这种不确定性在不同机器、不同httpx版本上表现不一致导致本地能跑、线上偶发超时如果你依赖不设超时就让长推理跑完的语义比如 Groq 上跑一个会思考很久的长 prompt你会观察到请求在几十秒后被底层客户端主动断开而你的业务代码完全没设置过超时——锅在框架替你加的那一行。一句话概括request_timeoutNone的本意是不限制超时但client_params仍然把timeout带进去了使得语义被篡改。二、背景ChatGroq和ChatXAI都是langchain-core里BaseChatModel的子类。它们底层通过httpx发起请求而httpx的Client/AsyncClient构造函数接受timeout参数用来控制连接、读、写、池化的超时。langchain这一层为了让用户能控制超时暴露了request_timeout字段早期也叫timeout、max_retries等后来统一成request_timeout。在_default_params或构造client_params的地方大致逻辑是client_params { api_key: self.groq_api_key, timeout: self.request_timeout, # 问题就在这里 max_retries: self.max_retries, }当用户传入request_timeoutNone时timeout键的值就是None。这里有个关键点httpx的timeout参数在传None时不同版本语义不一样。在较老版本里timeoutNone表示不超时inf但在某些版本和某些封装里框架会在更外层做if self.request_timeout:之类的判断或者干脆不做判断直接传。一旦None被透传最终行为不可控。而用户的真实意图其实非常明确None在 Python 里读作我没指定。一个成熟的封装应当把我没指定翻译成我不往httpx里塞timeout让httpx用自己的默认通常是 5 秒读超时或者由用户后续显式构造httpx.Client(timeout...)来掌控。三、根因根因有两层第一层直接原因无条件把request_timeout写进client_params。构建client_params的代码没有对None做省略处理而是无条件地放了timeout键。这导致None被透传给httpx。修复思路就是当request_timeout is None时不要在client_params里放timeout。第二层设计原因None的语义在两层之间没有对齐。框架层把None当作一个合法的可传值而传输层httpx对None的解释又随版本漂移。正确的契约应该是框架只负责用户给了就用用户没给None就别传把默认值是什么的决策权交还给传输层。把None硬塞下去等于框架在替传输层做决定而这个决定在不同版本上并不一致于是出现本地能跑、线上偶发超时的现象。# 错误示范无条件透传 def _build_client_params(self) - dict: return { api_key: self.api_key, timeout: self.request_timeout, # None 也会进去 }# 正确契约None 时不传 timeout def _build_client_params(self) - dict: params {api_key: self.api_key} if self.request_timeout is not None: params[timeout] self.request_timeout return params四、最小可运行复现下面用一个最小可复现例子模拟这个 bug不依赖真实网络只验证client_params是否真的把None塞了进去from dataclasses import dataclass, field from typing import Any, Optional dataclass class _BuggyGroqLike: 复现 bug 的精简模型无条件透传 request_timeout。 api_key: str test-key request_timeout: Optional[float] None max_retries: int 2 def client_params_buggy(self) - dict: # 注意timeout 永远在即使 request_timeout 是 None return { api_key: self.api_key, timeout: self.request_timeout, max_retries: self.max_retries, } dataclass class _FixedGroqLike: 修复后的模型None 时省略 timeout。 api_key: str test-key request_timeout: Optional[float] None max_retries: int 2 def client_params_fixed(self) - dict: params: dict[str, Any] {api_key: self.api_key} if self.request_timeout is not None: params[timeout] self.request_timeout params[max_retries] self.max_retries return params def main() - None: buggy _BuggyGroqLike(request_timeoutNone) fixed _FixedGroqLike(request_timeoutNone) print(buggy:, buggy.client_params_buggy()) # - {api_key: test-key, timeout: None, max_retries: 2} # 把 timeoutNone 透传给 httpx语义不确定 print(fixed:, fixed.client_params_fixed()) # - {api_key: test-key, max_retries: 2} # 没有 timeout 键httpx 用自身默认符合用户 None 语义 assert timeout not in fixed.client_params_fixed() assert timeout in buggy.client_params_buggy() if __name__ __main__: main()运行python repro.py你会看到 buggy 版本里timeout: None被带了进去而 fixed 版本里压根没有timeout键。这正是真实 bug 的核心差异httpx.Client(timeoutNone)和httpx.Client()在部分版本上行为并不等价。五、解决方案第一层最小直接修复最小修复就是在构造client_params的地方把省略None这句判断加上。以ChatGroq为例同理适用于ChatXAIdef _prepare_client_params(self) - dict: params: dict { api_key: self.groq_api_key, max_retries: self.max_retries, } # 关键修复request_timeout 为 None 时不塞 timeout if self.request_timeout is not None: params[timeout] self.request_timeout # 其余字段base_url、organization 等照旧 if self.base_url is not None: params[base_url] self.base_url return params然后构造客户端时保持不变import httpx client httpx.Client(**self._prepare_client_params())这样当用户写ChatGroq(modelllama-3.1-8b-instant, request_timeoutNone)时得到的httpx.Client没有timeout约束完全交给httpx自身默认处理语义清晰、可跨版本复现。如果某个业务确实想要无超时应当让用户显式传一个很大的数例如request_timeoutmath.inf或request_timeout600而不是用None来表达——None只应表示未指定。六、解决方案第二层结构化改进为了让Groq、xAI以及未来其它基于httpx的 chat 模型都遵守同一套契约应当把超时参数归一化抽成一个独立的、可单测的配置对象作为唯一事实来源single source of truth。from dataclasses import dataclass, field from typing import Any, Optional dataclass(frozenTrue) class LangChainGroqXaiTimeoutPolicy: 超时参数策略统一管理 request_timeout - httpx timeout 的映射。 规则 1. None 表示未指定构造 client_params 时省略 timeout 2. 正数表示秒级超时 3. 负数或 0 视为非法构造时直接报错避免静默误用。 request_timeout: Optional[float] None max_retries: int 2 def to_client_params(self, *, api_key: str, base_url: Optional[str] None) - dict: params: dict[str, Any] {api_key: api_key, max_retries: self.max_retries} if self.request_timeout is not None: if self.request_timeout 0: raise ValueError( frequest_timeout 必须为正数或 None收到: {self.request_timeout!r} ) params[timeout] self.request_timeout if base_url is not None: params[base_url] base_url return params def build_sync_client(self, **kwargs: Any) - httpx.Client: import httpx return httpx.Client(**self.to_client_params(**kwargs)) def build_async_client(self, **kwargs: Any) - httpx.AsyncClient: import httpx return httpx.AsyncClient(**self.to_client_params(**kwargs))这样ChatGroq与ChatXAI内部只需持有LangChainGroqXaiTimeoutPolicy实例再调用to_client_params(...)就再也不会出现忘了判断 None的疏漏。新增模型例如某天加入的ChatDeepseek也可以复用同一套策略保证超时语义在所有 chat 模型间一致。七、解决方案第三层断言 / CI 守护用单元测试把这条契约锁死确保以后任何重构都不会再把None透传进timeoutimport httpx import pytest from your_module import LangChainGroqXaiTimeoutPolicy def test_none_timeout_omitted_from_client_params(): policy LangChainGroqXaiTimeoutPolicy(request_timeoutNone) params policy.to_client_params(api_keyk) assert timeout not in params # 验证 httpx 真的能用不传 timeout 时构造成功 client policy.build_sync_client(api_keyk) assert isinstance(client, httpx.Client) client.close() def test_positive_timeout_present(): policy LangChainGroqXaiTimeoutPolicy(request_timeout30.0) params policy.to_client_params(api_keyk) assert params[timeout] 30.0 def test_non_positive_timeout_rejected(): policy LangChainGroqXaiTimeoutPolicy(request_timeout0) with pytest.raises(ValueError): policy.to_client_params(api_keyk) def test_real_groq_none_timeout(): # 直接验证 ChatGroq 行为需要安装 langchain-groq try: from langchain_groq import ChatGroq except ImportError: pytest.skip(langchain-groq 未安装) model ChatGroq(modelllama-3.1-8b-instant, request_timeoutNone, groq_api_keydummy) # 通过内部 client_params 校验没有 timeoutNone 透传 params model._prepare_client_params() if hasattr(model, _prepare_client_params) else {} assert timeout not in params or params.get(timeout) is not None把这些测试加进libs/groq/tests、libs/xai/tests的 CI就能防止回归。八、排查清单你的ChatGroq/ChatXAI是否显式传了request_timeoutNone若是确认构造出的httpx.Client是否还带timeout键。在出问题的机器上打印client_params看timeout字段是否存在且为None。检查httpx版本pip show httpx不同版本对timeoutNone的解释不同。若需要长推理不超时不要依赖None改为request_timeout600之类的明确数值。本地能跑、线上偶发断开优先怀疑是框架替你加了超时而不是网络问题。在 CI 里加一条断言None时timeout键必须不存在。九、小结这个 bug 的表象是设置了request_timeoutNone却还是被超时根因是框架无条件把request_timeout透传进了client_params的timeout键而httpx对None的语义在不同版本上并不稳定。修复只需一行判断当request_timeout is None时省略timeout。更稳妥的做法是把超时归一化抽成独立的LangChainGroqXaiTimeoutPolicy策略对象作为唯一事实来源并用 pytest 把None 必须省略这条契约锁进 CI避免任何后续重构再次引入同样的透传问题。