换 LLM 提供商后因响应 schema 不一致花了 3 天修接口,作者呼吁把 API 契约当作产品来测试,而非只跑 benchmark。
两周前,我朋友所在的团队在一个周末内切换了 LLM 提供商——因为一个新模型在他们内部评测中得分更高。随后他们花了三天时间修复一个与推理质量毫无关系的 schema 不匹配问题。旧提供商的返回是 choices[0].message.content,而新提供商把答案包装在了一个 tool-call 对象里。代码库里每一个 prompt 模板都开始返回空字符串。没有人针对响应契约写过测试,因为没人相信契约是值得测试的东西。
每周都有另一个模型带着基准测试的桂冠和一份安静的弃用声明登场。把模型当作产品来对待的团队,永远在重复重建同一套集成。当前的 AI 新闻周期大多是换汤不换药的 churn,每个人现在都在构建 reasoning ledger 和 agent 编排层。他们大多数都跳过了那个在实际生产中最容易出问题的层——也就是你的应用与模型之间的契约。所以这就是我一直在捍卫的观点:模型是一个依赖项,而契约才是你真正交付的产品。先写契约,再选模型。
我可以用我现在处理每个 AI 功能时用到的工作流来演示这一点。最便宜的运行方式是用 MonkeyCode 的开源项目,它提供免费模型访问和免费服务器选项。声明:本文是作为 MonkeyCode 产品推广的一部分准备的。整个过程几乎零成本,唯一消耗的是先写契约的那一个小时。这能为你节省我朋友团队刚刚损失的那三天。
下面是我部署到免费服务器上的网关,注意它不包含什么。它接受一种请求形状,调用一个 provider,返回一种响应形状。代码中任何地方都没有出现硬编码的模型名称,因为 provider URL 和模型名称都是配置值。模型是一个依赖项,所以它存在于环境变量中,而不是你的产品逻辑里。
# gateway.py — the contract is the product, the model is a dependency
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import httpx, os
app = FastAPI()
class ChatRequest(BaseModel):
messages: list[dict]
max_tokens: int = Field(default=512, le=2048)
class ChatResponse(BaseModel):
content: str
provider: str
degraded: bool = False
PROVIDERS = [
{"name": "primary", "url": os.getenv("PRIMARY_URL"), "key": os.getenv("PRIMARY_KEY")},
{"name": "fallback", "url": os.getenv("FALLBACK_URL"), "key": os.getenv("FALLBACK_KEY")},
]
@app.post("/v1/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
for provider in PROVIDERS:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
provider["url"] + "/chat/completions",
headers={"Authorization": f"Bearer {provider['key']}"},
json={
"model": os.getenv(f"{provider['name'].upper()}_MODEL"),
"messages": req.messages,
"max_tokens": req.max_tokens,
},
)
r.raise_for_status()
return ChatResponse(
content=r.json()["choices"][0]["message"]["content"],
provider=provider["name"],
degraded=(provider["name"] == "fallback"),
)
except Exception as exc:
print(f"{provider['name']} failed: {exc}")
raise HTTPException(status_code=503, detail="all providers failed")
这个网关故意做得很无聊,因为真正有趣的部分是针对它运行的契约测试套件。我在每个 pull request 中都会在 CI 里运行这个套件,也会在本地针对免费服务器运行。免费 token 配额是预算,契约才是被测试的对象。如果契约破裂,套件就破裂,而背后的模型就无关紧要了。
# test_contract.py — if the contract is the product, test it like one
import time, httpx
GATEWAY = "http://localhost:8000/v1/chat"
def call(messages, max_tokens=512):
started = time.time()
response = httpx.post(GATEWAY, json={"messages": messages, "max_tokens": max_tokens}, timeout=30)
return response, time.time() - started
def test_response_shape_is_stable():
response, _ = call([{"role": "user", "content": "Reply with the single word: ok"}])
assert response.status_code == 200
body = response.json()
assert set(body.keys()) == {"content", "provider", "degraded"}
assert isinstance(body["content"], str) and body["content"]
def test_round_trip_stays_inside_budget():
response, elapsed = call([{"role": "user", "content": "Say hello"}], max_tokens=16)
assert elapsed < 15, f"round trip blew the budget: {elapsed:.1f}s"
def test_degraded_mode_still_returns_the_contract():
# point PRIMARY_URL at a dead port and the gateway must fail over
response, _ = call([{"role": "user", "content": "Still here?"}])
assert response.status_code == 200
assert response.json()["provider"] == "fallback"
第一个测试捕获了正是这个故障让我朋友的团队损失了三天。只要响应形状在底层发生变化,provider 切换就是危险的——这个测试在几秒内就能让那种变化变得可见。第二个测试捕获了基准测试永远不会显示的延迟 creep,因为基准测试测量的是模型,而用户测量的是往返时间。第三个测试是大多数团队会跳过的那一个,而这正是免费 tier 存在的意义。免费服务器是将你的主 provider 指向一个死端点并观察契约是否存活的最便宜的地方。可复用的检查清单很短:冻结请求形状、冻结响应形状、为往返时间做预算、为 failover 写测试。
部署它只需要三条命令,这也是我持续推动免费服务器选项用于 staging 工作的另一个原因。你安装依赖、启动 uvicorn,然后访问和你用户会访问的同一个 URL。整个栈可以运行在免费服务器上,因为网关是无状态的,也没有数据库。
pip install fastapi "uvicorn[standard]" httpx pydantic
uvicorn gateway:app --host 0.0.0.0 --port 8000
curl -s http://localhost:8000/v1/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Reply with ok"}],"max_tokens":16}'
注意这些测试没有做的是什么,因为那才是真正的论点。它们没有断言任何模型名称,没有比较基准测试分数,也不关心是哪个 provider 回答的。你的用户无法分辨是哪个模型回答了他们、你的测试也不应该关心,而你交付的产品是中间那一层稳定的契约。你现在的测试中有多少能在 provider 切换时零改动存活下来?
现在说说诚实的一面,因为每一种架构观点都需要它的边界条件。如果你的功能价值依赖于某个特定模型的推理能力,契约优先的网关救不了你。在这种情况下模型就是产品,而契约只是管道。如果你所在的行业受到监管,你需要的不只是一个规范化的响应形状。你需要一份审计轨迹,记录哪个模型处理了哪个请求,你应该把 provider 名称写在每一条日志里。而且如果你的 fallback 启发式算法是垃圾,degraded 模式比一个清晰的 503 更糟糕。要用和主 provider 同样严格的程度来测试 fallback。这个网关也是一个最小化示例,不是生产服务。它没有认证、没有速率限制、也没有持久化,所以在引入真实流量之前加上那些。
所以这是我的问题,而且我是把它当作一个设计练习而不是修辞问题来问的。如果你的 provider 明天消失了,你的栈中哪一层会最先挂掉?这种失败是一个干净的契约违反还是一个静默的空字符串?如果答案是你的 prompt 模板或你的响应解析,那么模型从来就不是你的产品,契约才是。MonkeyCode 的免费 tier 给你提供了一个服务器和 10M token 配额,让你在今天就能运行这个实验。唯一的真正成本是在选模型之前花一个小时写契约。