【Bug已解决】Related to #31887 (custom header pattern support) 解决方案
一、现象长什么样
在用 LangChain 的某些聊天模型/LLM 客户端(这一支与 issue #31887 关联)对接需要自定义请求头的模型服务时,发现客户端不允许注入自定义 header,或者只允许极少数写死的 header,导致:
# 想给请求加一个自定义鉴权/路由 header,但客户端不支持 chat_model = ChatX(api_key=..., extra_headers={"X-Custom-Route": "eu"}) # 实际发出的 HTTP 请求里没有 X-Custom-Route -> 服务端拒绝/走错区 # 或 E LangChain: ChatX does not accept arbitrary custom headers具体表现:
- 部分模型服务商要求特定自定义头(比如按 header 做区域路由、按 header 传租户 ID、或某种非标准的 API key 位置),LangChain 客户端不支持,调用直接失败或行为不对。
- 客户端要么硬编码了固定的一组 header(如只放行
Authorization),要么完全不暴露extra_headers参数。 - 想用“header 模式(pattern)”批量匹配并注入一类头(例如所有
X-*头)也不行,只能一个个改源码。 - 与 #31887 关联:该 issue 讨论的就是“支持自定义 header pattern”,即让用户声明一个模式/前缀,客户端在拼请求时把匹配的自定义头带上去。
关键特征:LLM 客户端对请求头是“白名单/写死”的,用户无法按服务端要求注入自定义 header,对接非标准鉴权/路由的 provider 时受阻。
二、背景
LangChain 的聊天模型客户端在发起 HTTP 请求调用模型 API 时,会构造一组请求头,通常包括Authorization、Content-Type等标准头。但现实中的模型服务商五花八门:
- 有的用
Authorization: Bearer标准鉴权; - 有的要求把 key 放在自定义头
X-Api-Key; - 有的用 header 做区域/租户路由(
X-Region: eu、X-Tenant: abc); - 有的要求带某种trace / session头用于审计。
如果一个客户端把 header 写死(或只允许Authorization),用户就没法把这些必要的自定义头带上,于是服务端要么拒绝(401/403),要么因为缺路由头走到了错误的部署。
issue #31887 提出的“custom header pattern support”意思是:客户端应支持用户声明一个header 模式(pattern)——比如“允许所有X-开头的头”,或“允许X-Custom-*,并按正则匹配”,客户端在组装请求时,把用户提供的、符合模式的自定义头原样注入。这样既能灵活对接各种 provider,又不会无脑地把任意头都放出去(避免误带敏感头)。
三、根因
根因是LLM 客户端对请求头的处理是“硬编码白名单/不暴露扩展点”,没有支持用户按模式声明并注入自定义 header:
- header 写死:客户端请求构造里只设了
Authorization/Content-Type等固定头,没有extra_headers参数,也没有“把用户给的头合并进去”的逻辑。 - 无 pattern 机制:即使有
extra_headers,也不支持“按模式批量放行”(比如X-*),用户得逐个指定,碰到一组相关头就很麻烦。 - 合并缺失:请求最终组装时,用户提供的自定义头没有被合并进
requests/httpx的 headers,导致发不出去。 - 与 #31887 脱节:该 issue 明确要“custom header pattern”,但实现没跟上,于是需要自定义头的 provider 全卡住。
一句话:客户端请求头是封闭的,用户无法按模式注入自定义 header,对接非标准鉴权/路由的模型服务时请求缺失关键头,调用失败。
四、最小可运行复现
下面用 Python 模拟“请求头合并 + 模式过滤”的机理,复现“不支持自定义头”vs“支持 pattern”:
from typing import Dict def build_headers_buggy(base: Dict, user_headers: Dict) -> Dict: """错误:只用固定头,忽略用户自定义头。""" out = dict(base) # {'Authorization': ..., 'Content-Type': ...} # 用户头被完全丢弃 return out def build_headers_fixed(base: Dict, user_headers: Dict, pattern=None) -> Dict: """修复:合并用户头,并按 pattern(如 'X-' 前缀)过滤放行。""" out = dict(base) for k, v in user_headers.items(): if pattern is None or k.startswith(pattern): out[k] = v return out base = {"Authorization": "Bearer xxx", "Content-Type": "application/json"} user = {"X-Custom-Route": "eu", "X-Tenant": "abc", "Bad-Header": "secret"} print("buggy:", build_headers_buggy(base, user)) # {'Authorization':..., 'Content-Type':...} <- 自定义头丢失 print("fixed:", build_headers_fixed(base, user, pattern="X-")) # 含 X-Custom-Route、X-Tenant,且可按 pattern 控制范围buggy把用户自定义头丢得一干二净,fixed按X-模式合并进去——正是 #31887 要的“custom header pattern support”。
五、解决方案(第一层:最小直接修复)
最小修复是在 LLM 客户端暴露extra_headers参数,并在组装请求时按声明的 pattern 合并用户自定义头:
# chat_client.py(修复片段) class ChatX(BaseChatModel): def __init__(self, api_key: str, extra_headers: Dict[str, str] | None = None, header_pattern: str | None = "X-", **kwargs): self.api_key = api_key self.extra_headers = extra_headers or {} self.header_pattern = header_pattern def _build_headers(self) -> Dict[str, str]: headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } # 按 pattern 合并用户自定义头 for k, v in self.extra_headers.items(): if self.header_pattern is None or k.startswith(self.header_pattern): headers[k] = v return headers这一层让需要自定义头的 provider(区域路由、租户 ID、非标准 key 位置)能正常对接,X-*类头按 pattern 被带上去。
六、解决方案(第二层:结构性改进)
把“LLM 客户端如何合并/放行自定义请求头”收口成唯一的配置对象LangChainHeaderPatternPolicy,所有客户端读它:
from dataclasses import dataclass from typing import Tuple, Optional @dataclass(frozen=True) class LangChainHeaderPatternPolicy: """LLM 客户端自定义请求头模式的单一事实来源。""" # 客户端必须暴露 extra_headers 扩展点 support_extra_headers: bool = True # 按模式放行(如 X- 前缀),而不是全放或全禁 pattern_based_allow: bool = True # 默认放行前缀(可按 provider 调整) default_pattern: Optional[str] = "X-" # 禁止硬编码吞掉用户头 forbid_drop_user_headers: bool = True # 代码评审卡点 forbidden_patterns: Tuple[str, ...] = ( "headers = {Authorization, Content-Type} ignore user", "no extra_headers param", ) def merge(self, base: dict, user: dict) -> dict: out = dict(base) for k, v in user.items(): if self.pattern_based_allow: if self.default_pattern and k.startswith(self.default_pattern): out[k] = v else: out[k] = v return out def describe(self) -> str: return "客户端暴露 extra_headers,按模式放行自定义请求头" POLICY = LangChainHeaderPatternPolicy() def plan_headers(base: dict, user: dict, policy: LangChainHeaderPatternPolicy = POLICY) -> dict: return policy.merge(base, user)所有 LangChain LLM 客户端都读POLICY,自定义头的合并与放行被固化,对接各种 provider 不再卡在 header 上。
七、解决方案(第三层:断言 / CI 守护)
把“支持 extra_headers、按模式放行、不丢弃”做成断言。下面用 pytest 守护:
import pytest def test_support_extra_headers(policy): assert policy.support_extra_headers is True assert "no extra_headers param" in policy.forbidden_patterns def test_pattern_allow(policy): assert policy.pattern_based_allow is True out = policy.merge({"Authorization": "x"}, {"X-Route": "eu", "Y-Bad": "z"}) assert out.get("X-Route") == "eu" assert "Y-Bad" not in out # 不符合模式不放行 def test_no_drop_user_headers(policy): assert policy.forbid_drop_user_headers is True assert ("headers = {Authorization, Content-Type} ignore user" in policy.forbidden_patterns) def test_base_headers_kept(policy): out = policy.merge({"Authorization": "x"}, {"X-Tenant": "abc"}) assert out["Authorization"] == "x" # 基础头保留 def test_none_pattern_allows_all(policy): p = LangChainHeaderPatternPolicy(pattern_based_allow=False, default_pattern=None) out = p.merge({"Authorization": "x"}, {"Any-Header": "v"}) assert out.get("Any-Header") == "v"这五组断言锁住:(1) 支持 extra_headers;(2) 按模式放行;(3) 不丢弃;(4) 基础头保留;(5) 关闭模式时全放行。CI 跑通即代表自定义头支持不会再缺失。
八、排查清单
遇到 LLM 客户端无法注入自定义 header:
- 看是不是 header 写死:请求里没有你设的自定义头 → 客户端没合并(本题,关联 #31887)。
- 确认 provider 需求:是否服务端要求
X-*路由/租户/非标准 key 头。 - 查客户端:有没有
extra_headers参数,组装请求时有没有合并用户头。 - 加 pattern 支持:暴露
extra_headers,按X-等模式放行并合并。 - 统一到
LangChainHeaderPatternPolicy:CI 断言禁止吞掉用户头。 - 安全边界:用 pattern 限制放行范围,避免无脑带所有头。
- 端到端:发出的 HTTP 请求里确实带上了自定义头,provider 调用成功。
九、小结
Related to #31887 (custom header pattern support)的根因是:LangChain 的 LLM 客户端在组装模型 API 的 HTTP 请求时,把请求头写死成固定白名单(只Authorization/Content-Type),既不暴露extra_headers扩展点,也不支持“按模式批量放行自定义头”,导致需要自定义头(区域路由、租户 ID、非标准鉴权位置)的模型服务收不到关键头,调用失败或被路由错——这正是 issue #31887 要解决的“custom header pattern support”。
最小修复是暴露extra_headers并在组装请求时按声明的 pattern(如X-前缀)合并用户自定义头;结构性改进是用唯一的LangChainHeaderPatternPolicy固化头的合并与放行;CI 用五组断言守护“支持 extra_headers、按模式放行、不丢弃用户头”。记住:LLM 客户端面对的 provider 千差万别,请求头必须是可扩展的,用 pattern 放行既灵活又不失控。