做 OpenAI / Anthropic 兼容层时,真正难的不是转发
做 OpenAI / Anthropic 兼容层时,真正难的不是转发
July 16, 2026
很多工具写着「OpenAI 兼容」,于是大家以为改个 base_url 就结束。真做代理之后会发现:难的是表面兼容、语义不一致——尤其是流式、工具调用和错误码。
本文按兼容代理(含 lingma-proxy 一类项目)的真实坑位来写,示例用 Python 标准库,方便你本地改着玩。
1. 「只转发」为什么不够
# naive_proxy.py —— 看起来能用,流式和工具调用很快露馅
from flask import Flask, request, Response
import requests
app = Flask(__name__)
UPSTREAM = "https://api.openai.com"
@app.post("/v1/chat/completions")
def chat():
r = requests.post(
f"{UPSTREAM}/v1/chat/completions",
headers={
"Authorization": request.headers.get("Authorization", ""),
"Content-Type": "application/json",
},
json=request.get_json(force=True),
stream=True,
timeout=120,
)
return Response(r.iter_content(chunk_size=1024), status=r.status_code,
content_type=r.headers.get("Content-Type", "application/json"))问题:
- Anthropic 的事件名与 OpenAI 不同,原样转给 OpenAI SDK 客户端会解不动
tool_calls在流式里是碎片,不缓冲拼装会得到半截 JSON- 上游 529 / 内容审查 / 余额不足若都映射成 500,客户端无法决策
2. 流式:先约定对外事件,再适配上游
对外先冻结一种客户端契约(OpenAI 风格示例):
data: {"id":"chatcmpl-x","object":"chat.completion.chunk","choices":[{"delta":{"content":"你"},"index":0}]}
data: {"id":"chatcmpl-x","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]最小归一化写出器:
# sse_normalize.py
from __future__ import annotations
import json
from typing import Iterator
def openai_text_chunks(text: str, chunk_size: int = 8) -> Iterator[str]:
"""把完整文本伪造成 OpenAI SSE,便于本地联调客户端。"""
cid = "chatcmpl-demo"
for i in range(0, len(text), chunk_size):
piece = text[i : i + chunk_size]
payload = {
"id": cid,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
end = {
"id": cid,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(end)}\n\n"
yield "data: [DONE]\n\n"客户端联调:
from openai import OpenAI
client = OpenAI(api_key="sk-test", base_url="http://127.0.0.1:8080/v1")
stream = client.chat.completions.create(
model="demo",
messages=[{"role": "user", "content": "hi"}],
stream=True,
)
for event in stream:
delta = event.choices[0].delta.content
if delta:
print(delta, end="", flush=True)案例: 某 IDE 插件接自建代理后「一直转圈」——抓包发现上游结束帧是自定义 event: done,插件只认 data: [DONE]。兼容层补一行结束帧后立刻恢复。这种 bug 和模型质量无关,全是契约问题。
3. Tool Calling:流式碎片必须拼装
OpenAI 流式里,工具参数常常是多帧字符串碎片:
{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":"{\"q\":"}}]}}
{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"hugo\"}"}}]}}缓冲拼装示例:
# tool_call_buffer.py
from __future__ import annotations
import json
from dataclasses import dataclass, field
@dataclass
class ToolCallBuf:
id: str | None = None
name: str | None = None
arguments: str = ""
@dataclass
class StreamToolAssembler:
calls: dict[int, ToolCallBuf] = field(default_factory=dict)
def feed(self, tool_calls: list[dict]) -> None:
for tc in tool_calls:
idx = tc.get("index", 0)
buf = self.calls.setdefault(idx, ToolCallBuf())
if tc.get("id"):
buf.id = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
buf.name = fn["name"]
if fn.get("arguments"):
buf.arguments += fn["arguments"]
def finished(self) -> list[dict]:
out = []
for idx, buf in sorted(self.calls.items()):
# 参数必须是合法 JSON,否则不要冒充「兼容成功」
json.loads(buf.arguments or "{}")
out.append(
{
"id": buf.id or f"call_{idx}",
"type": "function",
"function": {"name": buf.name, "arguments": buf.arguments},
}
)
return out兼容层若直接把半截 arguments 交给 Agent,表现就是「模型会说话但不会干活」。拼装 + JSON 校验应发生在代理边界。
4. 错误语义:映射表比「一律 500」有用
# error_map.py
from dataclasses import dataclass
@dataclass
class PublicError:
status: int
code: str
message: str
retryable: bool
def map_upstream_error(status: int, body: str) -> PublicError:
b = body.lower()
if status in (401, 403):
return PublicError(401, "unauthorized", "网关或上游鉴权失败", False)
if status == 429 or "rate" in b:
return PublicError(429, "rate_limited", "上游限流,请稍后或换模型", True)
if status in (408, 504) or "timeout" in b:
return PublicError(504, "timeout", "上游超时", True)
if status in (529, 503) or "overload" in b:
return PublicError(503, "upstream_unavailable", "上游过载", True)
if "content" in b and ("filter" in b or "policy" in b):
return PublicError(400, "content_filtered", "内容被安全策略拦截", False)
return PublicError(502, "bad_gateway", "上游错误", True)客户端拿到 retryable=true 才重试;content_filtered 应提示用户改输入,而不是自动再打三枪。
5. 最小兼容层骨架
# mini_compat_app.py
from flask import Flask, request, Response, jsonify
import json
from sse_normalize import openai_text_chunks
from error_map import map_upstream_error
app = Flask(__name__)
@app.post("/v1/chat/completions")
def chat_completions():
body = request.get_json(force=True) or {}
messages = body.get("messages") or []
stream = bool(body.get("stream"))
# 这里应:鉴权虚拟 Key → 选上游 → 适配协议
# 演示:固定回显最后一条 user 消息
user = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "")
answer = f"[compat-demo] 收到:{user[:200]}"
if not stream:
return jsonify(
{
"id": "chatcmpl-demo",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": answer},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 12, "total_tokens": 22},
}
)
return Response(openai_text_chunks(answer), mimetype="text/event-stream")
@app.get("/healthz")
def healthz():
return {"ok": True}真实项目里,把 answer = ... 换成「上游适配器」即可:OpenAI 适配器、Anthropic 适配器、Ollama 适配器各管翻译,对外只暴露这一份契约。
6. 案例:同一插件打三家,只有一家 tool 成功
现象:
- 直连 OpenAI:工具调用正常
- 经「伪兼容」打国内接口:能流式出字,但从不触发工具
- 经自建兼容层:工具偶发参数 JSON 截断
排查结论:
- 国内接口把 tools 丢进 prompt 文本,并未返回结构化
tool_calls - 兼容层若假装支持 tools,属于虚假兼容
- 正确做法:在模型元数据里标注
supports_tools: false,或在适配器内做「可声明的降级」
承诺表比口号重要:
| 能力 | OpenAI 上游 | 本地 Ollama | 某兼容接口 |
|---|---|---|---|
| chat.completions | ✅ | ✅ | ✅ |
stream + [DONE] | ✅ | ✅ | 需归一化 |
| tools | ✅ | 视模型 | ❌ 明确关闭 |
| vision | ✅ | 部分 | 未验证 |
7. 落地建议
- 先写契约测试(非流式 / 流式 / tools / 429)再接新上游
- 日志打
upstream, mapped_status, tool_call_count, stream_frames - 对外文档写清支持矩阵,拒绝「100% 兼容」话术
- 与 Gateway 文章同一路线:虚拟 Key、fallback、费用观测放控制面
兼容层是 AI 工具链的插座标准——插座稳了,Web、桌面、插件才不用每换一家模型就翻修一次。