跳至内容

OpenAI 兼容模型接入检查:工具调用、结构化输出、超时与重试

很多模型服务声称“兼容 OpenAI 接口”,改一下 base_url,ChatOpenAI 就能聊天,但聊天通了不等于工具调用、结构化输出、流式和用量统计都通了,更不等于出错时系统的行为符合预期。接入新模型服务时,一个本地模拟服务和一套检查脚本可以把检查拆成几项可重复执行的步骤,并展示几类典型失败的样子。

适用范围

项目内容
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 集成、聊天模型集成总览、OUTPUT_PARSING_FAILURE,对照官方文档仓库 langchain-ai/docs 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/openai-compatible-acceptance/,依赖锁定在 labs/requirements/v1.txt
  • 覆盖:检查方法和失败形态。实验里的服务是一个只有标准库的本地模拟器(绑定 127.0.0.1),它的“profile”是我们假设的几类典型差异,不描述任何真实服务商的行为。
  • 未覆盖:任何真实的中文大模型或第三方服务。某个具体服务商是否有这些问题,需要你用同一套脚本、你自己的密钥去检查。

接入新模型服务,先检查哪几件事

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

示例环境:本地模拟服务

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

"""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()

一、先跑一遍检查脚本,建立基线

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

"""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

二、五种兼容差异,各自怎么失败

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

三、工具参数非法时,Agent 为什么静默结束

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

"""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 为空(第一节的脚本已包含);
  • 线上对“最终回复为空且没有调用任何工具”的运行计数告警;
  • 官方的 OUTPUT_PARSING_FAILURE 页面建议优先使用工具调用或其他结构化输出技术,并换用能力更强的模型,作为参数格式不稳定时的处理方向。

四、一次失败到底发了几个请求

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

"""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 秒只是为了让脚本快速结束。在验证的版本中,默认的 ChatOpenAI() 其实没有超时(HTTP 层是 Timeout(timeout=None)),重试次数沿用 SDK 的默认值 2;退避时间、Retry-After 和两层重试叠加的实测见模型调用的延迟预算。

把它用在真实服务上

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

失败与回滚

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

依据与复现

  • ChatOpenAI 集成:API 范围声明、base_url 解析顺序与 stream_usage 默认值;
  • 聊天模型集成总览:Chat Completions 兼容端点只保证基础聊天,非标准功能需要专用集成包;
  • OUTPUT_PARSING_FAILURE:输出解析失败的处理方向;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab openai-compatible-acceptance;
  • 输出范围:全部输出来自站点仓库 labs/openai-compatible-acceptance/ 在“适用范围”所列版本下对本地模拟服务的实际运行;
  • 官方来源核对于 2026-10-25。

继续阅读

最后更新于