谈谈一个 base_url 引发的血案,以及不 fork SDK 怎么改掉它要打的 URL

给自己的 Agent 项目 ChatAgents 加「用户自带中转站」这个功能,本以为就是把前端那几个输入框接到后端,结果收到一个很怪的现象:模型清单下载成功了、界面上模型也能选,一发消息就 404。同一个地址,在「下载模型」和「运行」两步被解释成了两个 URL。这个 404 一路把我拽到官方 SDK 的 URL 拼接约定、httpx 的 request hook、以及一个我原本觉得写得挺好的客户端缓存——那个缓存在只有三个服务端档案的时候工作得很好,在用户自带端点进来的那一刻变成了负债。故写下这篇 BLOG,记录一下这趟「以为是接线、结果是拆弹」的经历。

先交代背景。这个项目支持三种上游协议——openai_responses、openai_chat_completions、anthropic_messages——一次模型调用需要的全部接入配置(协议、base URL、鉴权字段、密钥)被收敛成一个叫端点档案(EndpointProfile)的东西。服务端自己在 YAML 里配了两三个官方端点,这部分一直很稳。这次要做的是让用户在前端高级选项里填自己的中转站地址和 key,用他自己的额度跑。

起因:下载模型成功,发消息 404

用户报的现象很简单:界面上一切正常,发一条消息,回来一个 运行出错:Error code: 404。

翻跨度(span)记录,能看到的事实是这些:

1
2
3
4
protocol   = openai_chat_completions
model = gpt-5.6-luna
key_source = user_provided
main / auxiliary 两个跨度 status = error

key_source = user_provided 说明用户的 key 确实用上了,模型标识也确实传下去了。配置没丢,是打错了地方。

于是搭了个本地 fixture 复现:绑一个 127.0.0.1 随机端口的 HTTP server,提供 /v1/models、/v1/chat/completions、/v1/responses、/v1/messages,正确路径返回合法 SSE,其它路径返回空 body 的 404。然后把 protocol 和地址后缀做成矩阵跑一遍:

protocol base_url 的 path 发现(下载模型) 实际生成路径 运行结果
openai_chat_completions 根地址 /v1/models 成功 /chat/completions 裸 404
openai_chat_completions /v1 /v1/models 成功 /v1/chat/completions 成功
openai_responses 根地址 /v1/models 成功 /responses 裸 404
openai_responses /v1 /v1/models 成功 /v1/responses 成功
anthropic_messages 根地址 /v1/models 成功 /v1/messages 成功
anthropic_messages /v1 /v1/models 成功 /v1/v1/messages 裸 404

盯着这张表看了一会儿,才反应过来问题的形状:

同一个 base_url,在「下载模型」和「运行」两步走的是两套完全不同的拼接规则。

发现那一步是我自己写的:不管三七二十一,路径不以 /v1 结尾就补 /v1/models。

生成那一步是 SDK 写的:OpenAI SDK 把 resource path 直接拼到你给的 base 上,不补版本段;Anthropic SDK 自带 v1/。

两套规则各自都是自洽的,凑在一起就不是了。最恶心的是最后两行——OpenAI 和 Anthropic 的正确填法恰好相反:OpenAI 要填 /v1,Anthropic 要填根地址。用户面对的是同一个没有任何说明的输入框。

这不是「用户漏填路径」

一开始我确实往「那就在 placeholder 里写清楚要填 /v1」这个方向想了三分钟。想不下去的原因是:

  1. 界面接受了根地址,发现这一步还主动把它改成 /v1/models 并成功返回了模型清单。用户凭什么认为自己填错了?系统刚刚用行动告诉他填对了。
  2. 「下载模型」这个动作在设计上兼任 key 的验证位——能拉到清单,就说明地址和密钥都通。现在这个验证位照常通过,验证通过后那把 key 打的却是另一个 URL。界面给出的是一个完整的、无任何提示的假象。

所以这是配置语义的问题,不是文案问题。真正该问的是:**base_url 这个字段,到底表示什么?**


插曲:一刀切补 /v1 为什么也是错的

想到的第一个修法当然是「那就统一由应用来拼,一律补 /v1」。为了验证这个想法,我在 fixture 里加了个反例:一个合法的、但前缀不是 /v1 的中转站,只提供 /compatible/models 和 /compatible/chat/completions。

结果是:这个端点原本的生成是能成功的(SDK 老实地拼成 /compatible/chat/completions),反而是我那个”聪明”的发现逻辑擅自请求了 /compatible/v1/models,失败后降级成 fallback。

这下结论清楚了:

「根地址补一次版本段」和「见到路径就猜」是两件不同的事。 前者是补全,后者是篡改。而我原来的发现逻辑做的是后者。

/gateway/v1、/compatible 这类地址,用户写下它就是在明确告诉你 API 前缀是什么。应用再去改它,就是把用户的配置换成自己的猜测。

于是地址模式这个概念被逼了出来——与其猜,不如让用户直接说清楚”这串地址是什么”:

模式 地址是什么 清单 生成
auto(默认) 服务前缀 前缀 + models 前缀 + 各协议资源路径
full 最终生成 URL 认得出资源后缀才推得出,否则没有 原样使用
sdk_native 交给 SDK 自己拼 — 服务端预设专用,行为不变

auto 下**只有根地址补 v1**,已有 path 一律原样保留。三个协议共用同一前缀,Anthropic 不再额外追加 v1/,所以填 /v1 也不会拼出 /v1/v1/messages。

full 是给非标准网关留的出口:地址里可能有 /custom/infer 这种任何规则都推不出来的路径,那就一个字符都不加地发出去。

推不出来就说推不出来

full 模式带出一个新问题:生成 URL 是用户给的,那清单地址去哪找?

做法是从最终 URL 反推 API 前缀——把本协议的资源后缀剥掉,剩下的就是前缀。剥不掉呢?就如实说推不出来,不试探别的路径。

1
2
3
4
5
6
7
8
9
10
11
12
def _strip_operation_suffix(path: str, operation: str) -> str | None:
"""从最终 URL 反推 API 前缀;只认本协议那一个后缀,且整段匹配。

``chat/completions`` 是两段,必须整体剥离——只剥 ``completions`` 会留下一个
``chat/`` 前缀,清单就打到一个不存在的地方去了。
"""

candidate = path[:-1] if path.endswith("/") else path
suffix = f"/{operation}"
if not candidate.endswith(suffix):
return None
return candidate[: -len(operation)]

那句注释是踩出来的:chat/completions 是两段路径,如果按「剥掉最后一段」来实现,/v1/chat/completions 会剩下 /v1/chat/,清单就打到 /v1/chat/models 去了——一个不存在的地址,而且失败信息还很像”这个中转站不支持清单”。

推不出来时的行为是零 HTTP 请求,界面提示手填模型标识。这里我特意没有做「那就试试 /v1/models 吧」的兜底:

试探等于用一次猜测冒充用户的配置。而这次事故的全部起因,就是应用擅自替用户解释了地址。

一处解释,两个消费方

规则定了,接下来是把它放在哪。原来的毛病是发现和生成各写各的拼接,所以这次收敛成一个模块、一个函数、一个返回值:

1
2
3
4
5
6
7
8
@dataclass(frozen=True, slots=True)
class ResolvedAddress:
"""一次解析的全部结果——生成与发现共用它,不各自再拼一遍。"""

sdk_base_url: str
generation_url: str
discovery_url: str | None
discovery_unavailable_reason: str | None

生成和发现现在消费的是同一次解析的结果。两边不可能再分叉,因为不存在”两边”了。

还有一条容易被当成手滑的纪律:path 一律保持原样——不折叠重复的 /、不解码 %2e%2e、不删末尾的 /。

1
2
3
4
def _join(path: str, suffix: str) -> str:
"""只补分隔符。``path`` 已有的结尾 ``/`` 保留,因此重复段不会被吃掉。"""

return f"{path}{suffix}" if path.endswith("/") else f"{path}/{suffix}"

理由是两个 SDK 和 httpx 都按字面发送这些片段,解析层擅自规范化,就会与真实发出去的请求分叉——那不就是我们正在修的这个 bug 吗,只是换了个地方犯。


回到正题:SDK 不给你换 URL 的入口

规则想清楚了,实现的时候撞上一堵墙:三套官方 SDK 的资源方法都没有「这次打这个 URL」的入参。

你只能给它一个 base_url,剩下的路径它自己拼。而 full 模式的诉求恰恰是”别拼了,就打我给的这个”。

摆在面前的路有三条:

  1. 自己构造 HTTP 请求,不用 SDK 的资源方法。 代价是流式解析、鉴权头、错误处理、重试语义全部要自己重写一遍,三个协议各一份。
  2. fork SDK 或者 monkey-patch 它的 _base_client。 代价是每次升级 SDK 都要重新对一遍私有实现。
  3. 在 HTTP 客户端那一层把 URL 换掉。

选了第三条。因为两个 SDK 都支持传入自定义的 http_client,而 httpx 有个公开的 event_hooks["request"]——请求构造完、发出去之前,钩子能拿到 request 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def _install_generation_route(
client: httpx.AsyncClient | httpx2.AsyncClient, profile: EndpointProfile
) -> None:
resolved = resolve_endpoint_address(
profile.base_url, protocol=profile.protocol, mode=profile.address_mode
)
# SDK 自己会拼成什么——用同一个解析器算,两边规则不会各自漂移。
sdk_url = resolve_endpoint_address(
resolved.sdk_base_url, protocol=profile.protocol, mode="sdk_native"
).generation_url
if sdk_url == resolved.generation_url:
return

url_type = type(client.base_url)
target = url_type(resolved.generation_url)

async def route_generation(request: Any) -> None:
if request.method == "POST" and str(request.url) == sdk_url:
request.url = target

client.event_hooks["request"] = [*client.event_hooks["request"], route_generation]

这段二十行代码里有三个决定值得说道说道。

一、「SDK 会拼成什么」也用同一个解析器算

钩子要匹配”SDK 原本会发出的那个 URL”,那这个 URL 从哪来?可以硬编码,但那就等于在第二个地方重新描述一遍 SDK 的拼接规则——又是一次分叉的机会。

所以这里再调一次同一个解析器,只是把 mode 传成 sdk_native。「SDK 按原生规则会拼成什么」和「我们希望它打哪」,出自同一个函数的两次调用。如果两者相等,钩子根本不装。

二、只改那一个 POST,不无条件改写

钩子里那个 if 是必需的。代码注释里写的理由是「重定向后的请求也会再过一遍钩子,逐个改写会把重定向变成死循环」——这句话是设计意图,为了写这篇文章我专门验了一下:

1
2
3
4
5
6
async def hook(request):
hook_saw.append(str(request.url))

async with httpx.AsyncClient(base_url=origin, follow_redirects=True) as c:
c.event_hooks["request"] = [hook]
r = await c.post("/a", json={}) # 服务端把 /a 307 到 /b

跑出来:

1
2
3
4
final status: 200
server saw POSTs: ['/a', '/b']
hook fired on: ['/a', '/b']
hook fired N times: 2

钩子在每一跳重定向上都会重新触发。 如果实现成”每个请求都改成 target”,那么 target 返回的任何一个 3xx 都会被改回 target 自己,转成一个打不完的循环。按 method 加原始 URL 精确匹配,改完之后 URL 就不等于 sdk_url 了,第二跳自然不再命中。

三、url_type = type(client.base_url) 不是脱裤子放屁

这一行我自己回头看都愣了一下,为什么不直接 httpx.URL(...)?

因为这个项目里 httpx 有两个:

1
"""两库并存是预期状态(ADR-0025):anthropic SDK 用 httpx,openai SDK 用 httpx2。"""

anthropic SDK 依赖 httpx<1,openai SDK 依赖 httpx2>=2,锁文件里实际是 httpx 0.28.1 和 httpx2 2.10.0 并存。验证一下这俩的 URL 类型:

1
2
3
httpx  base_url type: <class 'httpx.URL'>
httpx2 base_url type: <class 'httpx2.URL'>
same class? False

是两个不相干的类。 一套钩子要同时服务两个客户端,就不能钉死其中任何一个的 URL 类型;从当前客户端的 base_url 上取运行时类型,是最不需要维护的写法。

改的只是 URL

这个手法最大的好处是边界极窄:body、鉴权头、流式、错误处理,全都还在 SDK 手里。测试直接对着这一点断言:

1
2
3
4
5
6
7
def test_rewriting_the_generation_url_keeps_the_key_and_body_intact() -> None:
"""改的只是 URL:鉴权头与请求体仍由 SDK 构造,钩子一个字段都不碰。"""
...
assert gateway.requests == [("POST", "/custom/infer", MODEL)]
assert gateway.headers[0]["authorization"] == API_KEY
assert gateway.headers[0]["host"] == gateway.origin.removeprefix("http://")
assert gateway.payloads[0]["messages"][0]["content"] == "hi"

那句 host 断言是有来历的:交给 SDK 的 sdk_base_url 始终与最终 URL 同源,只换 path 不换 origin。否则 Host 头和鉴权作用域就跟着漂了,那才是真正难查的 bug。


那个原本挺好的客户端缓存

这功能做到一半,才意识到还有一处东西被这个改动波及——一个我之前写完就没再想过的 HTTP 客户端缓存。

它长这样,四十来行:

1
2
3
4
5
6
7
8
9
10
11
12
13
_CAPACITY: Final[int] = 8

def _get_or_create(
cache: OrderedDict[str, _ClientT], base_url: str, make: Callable[[], _ClientT]
) -> _ClientT:
if base_url in cache:
cache.move_to_end(base_url)
return cache[base_url]
client = make()
cache[base_url] = client
if len(cache) > _CAPACITY:
cache.popitem(last=False)
return client

以 base_url 为键复用 AsyncClient,LRU 容量 8。复用它是有实际意义的——AsyncClient 内部持有一个 AsyncConnectionPool,复用同一个客户端才谈得上复用底层的 TCP/TLS 连接,不然每次调用都要重新握手一次 TLS。

在服务端预设的世界里,这段代码毫无问题:档案总共两三个,键就那么几个,缓存永远填不满,命中率 100%。

用户自带端点进来之后,同一段代码的性质变了。

变化一:容量 8 成了租户之间的踩踏

预设档案是有限且已知的,访客的中转站地址是无限且未知的。第 9 个访客进来,第 1 个访客的客户端就被淘汰;等第 1 个访客再发一条消息,又把别人挤出去。缓存命中率在多租户下从 100% 掉到接近 0,但它仍然占着内存、仍然在做 LRU 的簿记。

变化二:淘汰不关闭客户端

回头看 popitem(last=False) 那一行——它只是把对象从 OrderedDict 里摘掉,**没有 await client.aclose()**。

这在原来的场景下是个纯理论问题:缓存装不满,淘汰分支根本不会执行。而在访客场景下,淘汰会持续发生,每淘汰一次就丢下一个没关闭的连接池。

顺带说,这里想”顺手补上”也不容易:popitem 是同步的,aclose() 是协程,要在一个同步函数里正确关掉它,得引入 task 调度和一堆生命周期问题。这本身就是个信号——不是缓存没写好,是这个东西压根不该进缓存。

变化三:缓存键装不下地址模式

这条是这次改动直接撞出来的。钩子是装在客户端实例上的,而缓存键只有 base_url。同一个地址、auto 和 full 两种解释,会命中同一个缓存条目,拿到一个装着错误钩子的客户端。

三条凑一起,结论就很清楚了:这不是要给缓存加个 TTL 或者加个健康检查,是这两类端点的生命周期本来就不一样。

于是分成了两个入口。服务端预设走缓存,一次运行不必收尾:

1
2
3
4
5
6
7
8
9
def get_model_port(
profile: EndpointProfile, *, client_cache: HttpClientCache | None = None
) -> ModelPort:
"""服务端预设档案的入口——HTTP 客户端取自进程级缓存,调用方不必收尾。"""

# 缓存以 base_url 为键,装不下「同一个 base_url、两种地址解释」这件事,而钩子
# 是装在客户端上的。自定义档案必须走 model_port_scope(shared_client=False)。
if profile.address_mode != "sdk_native":
raise ValueError("自定义地址模式的档案不能使用共享客户端缓存")

访客的客户端随运行创建、随运行关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
@asynccontextmanager
async def model_port_scope(
profile: EndpointProfile, *, shared_client: bool = True
) -> AsyncIterator[ModelPort]:
if shared_client:
yield get_model_port(profile)
return

http_client = _new_http_client(profile)
try:
yield _build_port(profile, http_client)
finally:
await http_client.aclose()

注意 get_model_port 里那个 ValueError:误用不是降级成”那就不缓存了”,而是当场炸。有测试专门盯着这一点:

1
2
def test_custom_address_modes_never_use_the_shared_client_cache() -> None:
"""共享缓存以 base_url 为键,装不下同址两解释;误用必须当场报错而不是串地址。"""

这个决定和项目里另一条既有原则是同源的——访客的东西不进全局设施。那条原则原本是讲模型清单不落共享数据库的(本站无鉴权,把访客的中转站地址和”这台机器上有哪些模型”写进公共库,等于把他的基础设施摊开给所有人),放到客户端缓存上同样成立。

顺带一提,_get_or_create 全程没有 await。在 asyncio 单线程调度下,它不会被中途打断,所以不需要加锁——这是当初唯一写对的地方,不过说实话当时并没有想这么多。

怎么防止这套规则再次分叉

修完之后最担心的其实是”下次又分叉”。这次的根因就是同一个概念在两个地方各写了一遍实现,而前端为了给用户做路径预览,又需要第三份。

做法是把规则本身抽成数据——一张 JSON 用例表,三方共用:

1
2
3
4
backend/tests/fixtures/endpoint_address_cases.json
├── backend/tests/llm/test_endpoint_address.py 解析器单测
├── backend/tests/llm/test_endpoint_routing.py 真实 SDK 打真实 HTTP
└── frontend/scripts/check-model-options-regression.mjs 前端预览

表里就是一行行的「模式 + 协议 + 输入 path → 期望的生成路径、期望的清单路径」:

1
2
3
4
5
6
{"mode": "auto", "protocol": "anthropic_messages", "path": "/v1",
"generation": "/v1/messages", "discovery": "/v1/models"}
{"mode": "auto", "protocol": "openai_responses", "path": "/compatible/%2e%2e/v1",
"generation": "/compatible/%2e%2e/v1/responses", "discovery": "/compatible/%2e%2e/v1/models"}
{"mode": "full", "protocol": "openai_responses", "path": "/custom/infer",
"generation": "/custom/infer", "discovery": null}

前端那份预览实现的注释里写得很直白:

1
2
3
4
5
6
7
/**
* 界面预览——只展示 path,不展示 origin、凭据或 query。
*
* 规则必须与后端 `endpoint_address.py` 逐条一致,两边共用
* `backend/tests/fixtures/endpoint_address_cases.json` 校对;这里只负责显示,
* 真正发出的地址仍由后端解析。
*/

两份实现仍然存在(一个 Python 一个 TypeScript,这是跨语言项目躲不掉的),但判断它们是否一致的标准只有一个,而且是可执行的。

路由测试那一层还有个值得抄的做法:它不是 mock transport,而是起一个绑 127.0.0.1:0 的真实 loopback HTTP server,记录收到的 method、path、model、headers 和 body。所以它断言的是”真实 SDK 真的打出了这个 URL”,而不是”我传给 mock 的参数是对的”。

上一版测试栽的就是这个跟头:BYOK 那个测试用一个假的 _ScriptedPort,断言配置和模型标识都正确传递了——断言全绿,而真实 SDK 打出的 URL 从来没有被任何测试看到过。

还有一个更安静的坑:填不全的时候悄悄用了服务端的 key

功能跑通之后 code review 又拎出来一个东西,比 404 更值得警惕。

前端在自定义端点四件套(地址、密钥、main 模型标识,外加协议)没填齐的时候,返回的是「不传覆盖」。而后端收到”没有覆盖”的含义是用服务端预设档案跑。

于是这一轮会拿服务端的档案和服务端的密钥,正常地跑完、正常地回答。用户全程以为自己的中转站正在工作。

这个失败形状和最开头那个 404 是同一类,甚至更坏——404 至少还报错了。修法是把二值改成三态:

1
2
3
4
5
6
export type ModelOverrideDecision =
| { kind: "none" }
| { kind: "override"; override: ModelOverride }
| { kind: "incomplete"; missing: string };

/** 只在偏离默认时产出覆盖;incomplete 与 none 必须保持可区分。 */

incomplete 就地停住并说明还差哪几项,压根不碰后端。后端那边也补了对称的一条——没给 base_url 却带了 api_key 一律 422,而不是默默忽略:

1
2
3
4
if self.base_url is None:
for field in ("protocol", "auth_field", "api_key", "full_url"):
if getattr(self, field) is not None:
raise ValueError(f"{field} 只能和 base_url 一起使用")

同一轮还补了 key_source 这个字段(取值 user_provided / server_default),跟着跨度落库。它不记 key 本身,只记这次用的是谁的 key——正是它让开头那个 404 能被一眼确认成”地址错了”而不是”key 没传下去”。

复盘:这次能复用的经验

  1. 一个配置字段被两处代码各自解释,就是一颗定时炸弹。 判据很好用:同一个用户输入,是否存在两条独立的代码路径去解释它?这次是「发现」和「生成」,各自都自洽,凑一起就废了。修法不是让两边小心保持同步,而是**消灭”两边”**——一次解析,一个返回值,多个消费方。
  2. 「补全」和「猜测」的边界要划清楚。 根地址补一次版本段是补全;见到路径就往里塞 /v1 是猜测。判据是:用户已经写下的东西,你有没有改。推不出来的时候,如实说推不出来,比试探一个”大概率对”的路径要好——后者会把配置错误伪装成上游故障。
  3. 在别人的 SDK 上做定制,优先找它的公开扩展点。 httpx 的 event_hooks["request"] 让我只改了 URL 一个字段,body、鉴权、流式、错误处理仍归 SDK。相比 fork 或者 patch 私有方法,这个边界窄到升级 SDK 基本不用重新对。但用扩展点要顺着它的语义:钩子在每一跳重定向上都会重新触发,所以必须精确匹配、不能无条件改写。
  4. 缓存的收益要按”键空间和租户模型”重新评估,而不是按代码质量。 那四十行 LRU 一个字都没写错,它只是被用在了一个假设不再成立的场景里:键从有限已知变成无限未知,容量 8 从”永远够用”变成”租户互相踩踏”,淘汰不关闭从”永不触发的理论问题”变成”每次淘汰泄漏一个连接池”。同一段代码,换个上下文就从优化变成负债。
  5. 测试要断言”真实发出去的是什么”,而不是”我传给 mock 的是什么”。 上一版 BYOK 测试全绿,因为它只验证了参数传递,真实 SDK 拼出的 URL 从未进入任何断言的视野。起一个真实的 loopback server 记录 method / path / headers / body,成本不高,但它覆盖的正是最容易分叉的那一层。
  6. 静默回落是比报错更坏的失败。 用户填了一半的配置,系统悄悄用服务端的档案和密钥跑完并给出正常回答——这比 404 恶劣得多。凡是”用户表达了意图但表达得不完整”的场合,incomplete 必须和 none 保持可区分。

当然也留了几个尾巴:full 模式的钩子只认精确 URL 匹配,如果某个上游在生成端点上做了非幂等的重定向,行为需要再验;重定向不被二次改写这件事,我在文章里是用一段独立脚本验证的,仓库里那套 fixture 目前还没有 3xx 分支,测试断言的只是”每次生成只产生一个 POST”;访客客户端的 aclose() 由 finally 保证,但也还没有一个专门 spy 它的用例。这几点在当前规模下不影响使用,但值得写进注释,免得以后被人当成已经证明过的性质。

写在最后

这件事最开始在我脑子里的形状是「把前端几个输入框接到后端」,属于那种排期时会写”半天”的活。结果真正花时间的地方一个都不在接线上——一个字段的语义、一个 SDK 不给你的入口、一段写得没错但用错了地方的缓存。

回头看,三个坑其实是同一个:我在某个地方替用户做了决定,而且没告诉他。 发现逻辑替他改了地址、缓存替他决定了客户端的生命周期、前端替他决定了”填不全就用默认的吧”。用户看到的都是”一切正常”,直到某个地方突然 404。

写代码久了会有种直觉,觉得”帮用户处理掉”总是好意。这次算是被提醒了一下:帮用户处理,和替用户决定,中间那条线比想象中细。