跳至内容
做 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 截断

排查结论:

  1. 国内接口把 tools 丢进 prompt 文本,并未返回结构化 tool_calls
  2. 兼容层若假装支持 tools,属于虚假兼容
  3. 正确做法:在模型元数据里标注 supports_tools: false,或在适配器内做「可声明的降级」

承诺表比口号重要:

能力OpenAI 上游本地 Ollama某兼容接口
chat.completions
stream + [DONE]需归一化
tools视模型❌ 明确关闭
vision部分未验证

7. 落地建议

  1. 先写契约测试(非流式 / 流式 / tools / 429)再接新上游
  2. 日志打 upstream, mapped_status, tool_call_count, stream_frames
  3. 对外文档写清支持矩阵,拒绝「100% 兼容」话术
  4. 与 Gateway 文章同一路线:虚拟 Key、fallback、费用观测放控制面

兼容层是 AI 工具链的插座标准——插座稳了,Web、桌面、插件才不用每换一家模型就翻修一次。