解决方案
OpenAI 兼容接入验收

OpenAI 兼容模型接入验收:工具调用、结构化输出、超时与重试实测

很多模型服务声称“兼容 OpenAI 接口”,把 base_url 改一下,ChatOpenAI 就能聊天。但聊天通了,不等于工具调用、结构化输出、流式、用量统计都通了,更不等于出错时系统的行为是你预期的。本页写给正准备把一个新模型服务接入 LangChain 应用的开发者:用一个本地模拟服务和一套验收脚本,把“接入验收”这件事拆成可重复执行的检查项,并实测几类典型失败长什么样。

本页没有测试什么

先把边界说清楚:本页没有测试任何真实的中文大模型或第三方服务。 实验里的服务是一个只有标准库的本地模拟器(绑定 127.0.0.1),它的“profile”是我们假设的几类典型差异,不描述任何真实服务商的行为。本页给出的是验收方法和失败形态;某个具体服务商是否有这些问题,需要你用同一套脚本、你自己的密钥去验收。

适用版本与验证信息

项目内容
验证日期2026-10-25
Python3.12.12
包版本langchain-openai==1.7.2、langchain==1.4.7、langchain-core==1.7.0、openai==3.37.0、pydantic==2.14.1
被测服务本地模拟的 OpenAI 兼容服务(labs/openai-compatible-acceptance/fake_provider.py),不联网、不需要真实密钥(实验用的是明显的占位字符串)
官方来源ChatOpenAI 集成 (opens in a new tab)、聊天模型集成总览 (opens in a new tab)、OUTPUT_PARSING_FAILURE (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/openai-compatible-acceptance/,依赖锁定在 labs/requirements/v1.txt

先说结论

  1. “兼容”通常只保证基础聊天。 官方文档写得很明确:ChatOpenAI 只针对官方 OpenAI API 规范,第三方的非标准字段(如 reasoning_content)不会被提取或保留;需要这些能力时,应使用对应服务商的专用集成包。
  2. 验收要拆成独立的检查项。 本页的验收脚本有 6 项:调用、流式、工具调用、两种结构化输出、用量统计。实验里每个差异 profile 都只让其中 1~2 项失败,其余照常通过,只测“能聊天”会漏掉一切。
  3. 工具参数不是合法 JSON 时不会抛异常。 tool_calls 为空、问题被放进 invalid_tool_calls,create_agent 会直接以空内容结束,工具一次也没执行(实验 3)。必须把 invalid_tool_calls 纳入验收和线上监控。
  4. 两种结构化输出的实现各自依赖不同的服务端能力。 默认方式依赖 response_format,method="function_calling" 依赖工具调用,一个被拒绝时另一个可能还能用(实验 2)。
  5. 重试会放大请求。 429 和超时默认会被 SDK 自动重试,4xx 的“不支持”不会:同样一次失败,请求数分别是 2、3、1(实验 4)。配额、成本和最坏延迟都要按总请求数估算。

实验 0:模拟服务

服务器按名字选择 profile:standard 行为正常;no_tools 拒绝带 tools 的请求;bad_tool_args 返回不是合法 JSON 的工具参数;no_json_schema 拒绝带 response_format 的请求;slow 延迟响应;flaky_429 第一次请求返回 429。

fake_provider.py
"""A local fake OpenAI-compatible server whose behavior is selected by a named profile.
 
The profiles are *hypothetical* provider quirks used to exercise an acceptance suite. They do not
describe any real provider. Only the standard library is used; the server binds to 127.0.0.1.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
PROFILES = {
    "standard": {},
    "no_tools": {"reject_tools": True},
    "bad_tool_args": {"tool_args_style": "python_dict"},
    "no_json_schema": {"reject_response_format": True},
    "slow": {"delay_seconds": 1.5},
    "flaky_429": {"rate_limit_first": 1},
}
 
 
class FakeProvider:
    def __init__(self, profile: str):
        self.cfg = PROFILES[profile]
        self.requests: list[dict] = []
        self._lock = threading.Lock()
        outer = self
 
        class Handler(BaseHTTPRequestHandler):
            def log_message(self, *args):  # keep stdout clean
                pass
 
            def do_POST(self):
                try:
                    self._handle()
                except (BrokenPipeError, ConnectionResetError):
                    pass  # the client gave up (for example after a timeout)
                except Exception as exc:  # a bug in this fake must surface as a readable 500
                    self._json(500, {"error": {"message": f"fake provider bug: {exc!r}"}})
 
            def _handle(self):
                length = int(self.headers.get("Content-Length", 0))
                body = json.loads(self.rfile.read(length) or b"{}")
                with outer._lock:
                    outer.requests.append(body)
                    seen = len(outer.requests)
                if self.path != "/v1/chat/completions":
                    return self._json(404, {"error": {"message": f"unknown path {self.path}"}})
                if outer.cfg.get("delay_seconds"):
                    time.sleep(outer.cfg["delay_seconds"])
                if seen <= outer.cfg.get("rate_limit_first", 0):
                    return self._json(429, {"error": {"message": "rate limited", "type": "rate_limit"}}, {"Retry-After": "0"})
                if outer.cfg.get("reject_tools") and body.get("tools"):
                    return self._json(400, {"error": {"message": "tools are not supported by this model", "type": "invalid_request_error"}})
                if outer.cfg.get("reject_response_format") and body.get("response_format"):
                    return self._json(400, {"error": {"message": "response_format is not supported", "type": "invalid_request_error"}})
                message = outer.build_message(body)
                if body.get("stream"):
                    return self._stream(message)
                return self._json(200, outer.completion(message))
 
            def _json(self, status: int, payload: dict, headers: dict | None = None):
                data = json.dumps(payload).encode()
                self.send_response(status)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(data)))
                for k, v in (headers or {}).items():
                    self.send_header(k, v)
                self.end_headers()
                self.wfile.write(data)
 
            def _stream(self, message: dict):
                self.send_response(200)
                self.send_header("Content-Type", "text/event-stream")
                self.end_headers()
                text = message.get("content") or ""
                pieces = [text[i : i + 4] for i in range(0, len(text), 4)] or [""]
                for piece in pieces:
                    chunk = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "fake-model",
                             "choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}]}
                    self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
                done = {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "fake-model",
                        "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
                self.wfile.write(f"data: {json.dumps(done)}\n\n".encode())
                self.wfile.write(b"data: [DONE]\n\n")
 
        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
 
    @property
    def base_url(self) -> str:
        return f"http://127.0.0.1:{self.server.server_address[1]}/v1"
 
    def build_message(self, body: dict) -> dict:
        if body.get("tools"):
            function = body["tools"][0]["function"]
            name = function["name"]
            sample = {"city": "Shenzhen", "temperature_c": 26}
            args = {key: sample.get(key, "x") for key in function["parameters"].get("properties", {})}
            raw = json.dumps(args) if self.cfg.get("tool_args_style") != "python_dict" else str(args)
            return {"role": "assistant", "content": None,
                    "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": name, "arguments": raw}}]}
        if body.get("response_format"):
            return {"role": "assistant", "content": json.dumps({"city": "Shenzhen", "temperature_c": 26})}
        return {"role": "assistant", "content": "Hello from the fake provider."}
 
    def completion(self, message: dict) -> dict:
        finish = "tool_calls" if message.get("tool_calls") else "stop"
        return {"id": "c1", "object": "chat.completion", "created": 0, "model": "fake-model",
                "choices": [{"index": 0, "message": message, "finish_reason": finish}],
                "usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12}}
 
    def __enter__(self):
        self.thread.start()
        return self
 
    def __exit__(self, *exc):
        self.server.shutdown()
        self.server.server_close()

实验 1:验收脚本与基线

验收脚本对一个端点依次执行 6 项检查,每项独立记录通过或失败,任何一项失败就以退出码 1 结束。要验收真实服务,把 FakeProvider 换成真实的 base_url,并用你自己环境里的密钥,其余不用改:

acceptance.py
"""Acceptance suite for an OpenAI-compatible endpoint, run here against the fake provider.
 
usage: acceptance.py <profile>
Each check prints PASS or FAIL with the failure class. Exit code 1 if any check fails.
Point `base_url` at a real endpoint (and use a real key from your own environment) to run the
same suite against a provider you are evaluating.
"""
import os
import sys
 
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")  # never route the local server through a proxy
 
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
 
from fake_provider import FakeProvider
 
 
@tool
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    return "sunny"
 
 
class Weather(BaseModel):
    city: str
    temperature_c: int
 
 
def check_invoke(llm):
    reply = llm.invoke("hi")
    assert reply.content, "empty content"
 
 
def check_stream(llm):
    text = "".join(chunk.content for chunk in llm.stream("hi"))
    assert text, "stream produced no text"
 
 
def check_tool_calling(llm):
    reply = llm.bind_tools([get_weather]).invoke("weather in Shenzhen?")
    if reply.invalid_tool_calls:
        bad = reply.invalid_tool_calls[0]
        raise AssertionError(f"invalid_tool_calls: {bad['name']} args={bad['args']!r}")
    assert reply.tool_calls, "no tool_calls returned"
    assert reply.tool_calls[0]["name"] == "get_weather"
    assert reply.tool_calls[0]["args"] == {"city": "Shenzhen"}
 
 
def check_structured_output(llm):
    result = llm.with_structured_output(Weather).invoke("weather in Shenzhen?")
    assert isinstance(result, Weather), f"unexpected result {result!r}"
 
 
def check_structured_output_tools(llm):
    result = llm.with_structured_output(Weather, method="function_calling").invoke("weather in Shenzhen?")
    assert isinstance(result, Weather), f"unexpected result {result!r}"
 
 
def check_usage(llm):
    reply = llm.invoke("hi")
    assert reply.usage_metadata and reply.usage_metadata["total_tokens"] > 0, "usage_metadata missing"
 
 
CHECKS = [
    ("invoke", check_invoke),
    ("stream", check_stream),
    ("tool_calling", check_tool_calling),
    ("structured_output", check_structured_output),
    ("structured_output_tools", check_structured_output_tools),
    ("usage_metadata", check_usage),
]
 
 
def describe(exc: Exception) -> str:
    """Prefer the provider's own error message over the SDK's full repr."""
    body = getattr(exc, "body", None)
    if isinstance(body, dict) and body.get("message"):
        return f"HTTP {getattr(exc, 'status_code', '?')} {body['message']}"
    return (str(exc).splitlines() or [""])[0]
 
 
def run_suite(base_url: str, api_key: str) -> int:
    llm = ChatOpenAI(model="fake-model", base_url=base_url, api_key=api_key, max_retries=0, timeout=0.5)
    failed = 0
    for name, check in CHECKS:
        try:
            check(llm)
            print(f"  PASS {name}")
        except Exception as exc:
            failed += 1
            print(f"  FAIL {name}: {type(exc).__name__}: {describe(exc)}")
    return failed
 
 
if __name__ == "__main__":
    profile = sys.argv[1] if len(sys.argv) > 1 else "standard"
    print(f"profile: {profile}")
    with FakeProvider(profile) as server:
        sys.exit(1 if run_suite(server.base_url, api_key="test-key-not-real") else 0)

正常行为的 profile 全部通过:

profile: standard
  PASS invoke
  PASS stream
  PASS tool_calling
  PASS structured_output
  PASS structured_output_tools
  PASS usage_metadata

实验 2:五种差异,各自怎么失败

no_tools:服务端不支持工具调用。 工具调用和基于工具的结构化输出失败,其余通过。错误被 langchain-openai 包装成 OpenAIInvalidRequestError,它继承自 openai.BadRequestError,所以既可以按 LangChain 的类捕获,也可以按 SDK 的类捕获:

profile: no_tools
  PASS invoke
  PASS stream
  FAIL tool_calling: OpenAIInvalidRequestError: HTTP 400 tools are not supported by this model
  PASS structured_output
  FAIL structured_output_tools: OpenAIInvalidRequestError: HTTP 400 tools are not supported by this model
  PASS usage_metadata

no_json_schema:服务端不支持 response_format。 默认的结构化输出失败,而 method="function_calling" 的方式仍然通过,这就是“换一种实现可能绕过去”的实验依据:

profile: no_json_schema
  PASS invoke
  PASS stream
  PASS tool_calling
  FAIL structured_output: OpenAIInvalidRequestError: HTTP 400 response_format is not supported
  PASS structured_output_tools
  PASS usage_metadata

bad_tool_args:工具参数不是合法 JSON。 注意失败的方式:没有抛出 HTTP 错误,工具调用检查是靠脚本主动检查 invalid_tool_calls 才发现的;基于工具的结构化输出甚至静默返回了 None:

profile: bad_tool_args
  PASS invoke
  PASS stream
  FAIL tool_calling: AssertionError: invalid_tool_calls: get_weather args="{'city': 'Shenzhen'}"
  PASS structured_output
  FAIL structured_output_tools: AssertionError: unexpected result None
  PASS usage_metadata

slow:响应超过客户端超时。 所有检查都因为超时失败,错误类是 OpenAITimeoutError(继承自 openai.APITimeoutError):

profile: slow
  FAIL invoke: OpenAITimeoutError: Request timed out.
  FAIL stream: OpenAITimeoutError: Request timed out.
  FAIL tool_calling: OpenAITimeoutError: Request timed out.
  FAIL structured_output: OpenAITimeoutError: Request timed out.
  FAIL structured_output_tools: OpenAITimeoutError: Request timed out.
  FAIL usage_metadata: OpenAITimeoutError: Request timed out.

flaky_429:第一次请求被限流。 验收脚本关闭了自动重试(max_retries=0),所以第一项检查失败,之后的请求恢复正常,错误类是 OpenAIRateLimitError:

profile: flaky_429
  FAIL invoke: OpenAIRateLimitError: HTTP 429 rate limited
  PASS stream
  PASS tool_calling
  PASS structured_output
  PASS structured_output_tools
  PASS usage_metadata

实验 3:工具参数非法时,Agent 静默结束

验收脚本能发现 bad_tool_args,但线上的 Agent 并不会替你检查。用 create_agent 对同一个服务跑一次:

silent_tool_failure.py
"""When a provider returns tool arguments that are not valid JSON, an agent can end silently."""
import os
 
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
 
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
 
from fake_provider import FakeProvider
 
EXECUTED: list = []
 
 
@tool
def get_weather(city: str) -> str:
    """Get the weather for a city."""
    EXECUTED.append(city)
    return "sunny"
 
 
with FakeProvider("bad_tool_args") as server:
    llm = ChatOpenAI(model="fake-model", base_url=server.base_url, api_key="test-key-not-real", max_retries=0, timeout=2)
    agent = create_agent(llm, tools=[get_weather])
    result = agent.invoke({"messages": [("user", "What is the weather in Shenzhen?")]})
 
last = result["messages"][-1]
print(f"messages in the run: {len(result['messages'])}")
print(f"tool executed: {EXECUTED}")
print(f"final content: {last.content!r}")
print(f"tool_calls: {last.tool_calls}")
print(f"invalid_tool_calls: {[(c['name'], c['args']) for c in last.invalid_tool_calls]}")
messages in the run: 2
tool executed: []
final content: ''
tool_calls: []
invalid_tool_calls: [('get_weather', "{'city': 'Shenzhen'}")]

整个运行只有 2 条消息(用户问题和模型回复),工具一次都没执行,最终内容是空字符串,tool_calls 为空,真正的线索只在 invalid_tool_calls 里。从调用方看,这是一次“成功”的运行:没有异常,也没有日志。这类问题的防线只能是:

  • 验收阶段断言 invalid_tool_calls 为空(实验 1 的脚本已包含);
  • 线上对“最终回复为空且没有调用任何工具”的运行计数告警;
  • 官方的 OUTPUT_PARSING_FAILURE (opens in a new tab) 页面建议优先使用工具调用或其他结构化输出技术,并换用能力更强的模型,作为参数格式不稳定时的处理方向。

实验 4:一次失败到底发了几个请求

max_retries 和 timeout 决定了一次失败的真实成本。下面的脚本把不同情形下服务端实际收到的请求数打印出来:

retry_behavior.py
"""How many HTTP requests does one failed call cost? Compare max_retries=0 with the default."""
import os
 
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
 
from langchain_openai import ChatOpenAI
 
from acceptance import get_weather
from fake_provider import FakeProvider
 
 
def attempt(profile: str, label: str, with_tools: bool = False, **kwargs) -> None:
    with FakeProvider(profile) as server:
        llm = ChatOpenAI(model="fake-model", base_url=server.base_url, api_key="test-key-not-real", **kwargs)
        try:
            (llm.bind_tools([get_weather]) if with_tools else llm).invoke("hi")
            outcome = "succeeded"
        except Exception as exc:
            outcome = f"failed with {type(exc).__name__}"
        print(f"{label:<34} requests sent: {len(server.requests)}, {outcome}")
 
 
attempt("flaky_429", "429 once, max_retries=0", max_retries=0)
attempt("flaky_429", "429 once, default retries")
attempt("no_tools", "400 on tools, default retries", with_tools=True)
attempt("slow", "timeout 0.3s, max_retries=0", max_retries=0, timeout=0.3)
attempt("slow", "timeout 0.3s, max_retries=2", max_retries=2, timeout=0.3)
429 once, max_retries=0            requests sent: 1, failed with OpenAIRateLimitError
429 once, default retries          requests sent: 2, succeeded
400 on tools, default retries      requests sent: 1, failed with OpenAIInvalidRequestError
timeout 0.3s, max_retries=0        requests sent: 1, failed with OpenAITimeoutError
timeout 0.3s, max_retries=2        requests sent: 3, failed with OpenAITimeoutError
  • 429 一次:关闭重试时 1 个请求失败;默认配置下 SDK 自动重试,共 2 个请求后成功;
  • 400(服务端不支持):默认重试配置下仍然只发 1 个请求,不会重试;
  • 超时:max_retries=2 时发了 3 个请求才放弃。最坏延迟约等于 (1 + max_retries) × timeout 再加上退避时间,而不是一个 timeout。

实验里把 timeout 设成 0.3 秒只是为了让脚本快速结束;默认配置下 SDK 的超时和重试次数由 openai 库决定,请在你的版本里确认后再按需显式设置。

把它用在真实服务上

  1. 复制 acceptance.py,把 base_url 换成目标服务的地址。官方文档说明了 base_url 的解析顺序:显式的 base_url 参数优先,其次是环境变量 OPENAI_API_BASE,最后是 OPENAI_BASE_URL;设置了 OPENAI_BASE_URL 时,LangChain 默认不启用流式用量(stream_usage),因为很多非 OpenAI 端点不支持;
  2. 密钥从你自己的环境变量或密钥管理服务读取,不要写进脚本、不要提交到仓库;验收用一把独立的、低额度的密钥;
  3. 把每次验收的结果(通过项、失败项、错误类)记在你自己的仓库里,模型或服务商版本变化后重跑;
  4. 需要服务商的非标准字段(推理内容等)时,改用该服务商的专用集成包,而不是继续把 ChatOpenAI 指向它;
  5. 本站的实验脚本由统一的运行器启动,运行器会清除 OPENAI_* 等环境变量,保证实验不会误连真实服务。如果你在自己的环境里复现,也应当这样做。

故障树:接入后出现异常怎么查

现象最可能的原因怎么验证处理
Agent 返回空内容、没调用任何工具工具参数不是合法 JSON,进入 invalid_tool_calls打印最后一条消息的 invalid_tool_calls换更稳定的模型或服务;监控空回复(实验 3)
OpenAIInvalidRequestError,提示不支持 tools服务端或该模型不支持工具调用看错误体里的 message换支持工具调用的模型;不要对该模型 bind_tools(实验 2)
结构化输出被 400 拒绝,提示 response_format服务端不支持 response_format单独跑验收脚本的结构化输出两项试 method="function_calling",并重新验收(实验 2)
结构化输出返回 None工具参数非法,解析器没有可解析的内容用 include_raw=True 取回原始消息,查看其中的 invalid_tool_calls同上,并在业务代码里显式处理 None(实验 2)
OpenAITimeoutError服务响应慢,或 timeout 设置过小记录响应耗时分布调整 timeout 和 max_retries,并按总请求数估算成本(实验 4)
OpenAIRateLimitError触发服务端限流看 Retry-After 等响应头降低并发;保留 SDK 的自动重试;评估是否需要队列(实验 4)
请求数、费用明显高于调用次数自动重试放大了请求在服务端或网关统计每次调用的请求数按成本和延迟预算显式设置 max_retries(实验 4)
拿不到服务商特有的字段(如推理内容)ChatOpenAI 不提取非标准字段对比服务商的原始响应使用服务商的专用集成包(官方说明)

失败与回滚

  • 接入切换:把模型构造封装在一个工厂函数里,base_url、model、timeout、max_retries 来自配置而不是写死在调用处。验收失败或线上异常时,回滚就是把配置切回上一个服务;
  • 灰度:新服务先用验收脚本过一遍,再按比例放量,并对“空回复”“invalid_tool_calls 非空”“重试次数”这几个指标设置告警;
  • 局限:本页所有失败形态来自模拟服务。真实服务的行为可能不同,也可能随版本变化,验收结果只对测试时的服务版本和库版本有效。

相关阅读

来源

以上来源均于 2026-10-25 核对。本页所有输出来自站点仓库 labs/openai-compatible-acceptance/ 在上表版本下对本地模拟服务的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-25;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab openai-compatible-acceptance;
  • 发现错误请按联系我们反馈。