我第一次把生产环境里的 gpt-4o 换成 gpt-5.1,服务发出的第一个请求就吃了个 HTTP 400。不是上线两周后才暴露,而是第一次调用就报错。更糟的是,报错指向的那个参数,我在自己代码里根本没写过——它是被一个我用了两年的 LangChain 辅助函数悄悄绑上去的。

所以这篇我想认真拆一下:从 GPT-4 家族迁移到 GPT-5 家族,在 Azure OpenAI(Microsoft Foundry)上到底有哪些破坏性变更,那些没人提前警告你的集成陷阱,以及要让同一处调用代码同时兼容两个模型家族、不做分支,我到底需要哪几个文件。

这篇写给谁看:手上维护着一套已经跑在生产上、直接或者通过 LangChain 调用 Azure OpenAI / OpenAI 的代码,现在要接入 GPT-5.x,但在灰度期间还得让 GPT-4 的部署继续活着的工程师。

看完你能拿走的东西:一个可以直接复制粘贴的兼容模块、一个很小的 LangChain 子类、一套 prompt 审计工具,还有一份十步走的灰度清单。

1. 这次迁移为什么不一样

以前每次 Azure OpenAI 升级——3.5 到 4、4 到 4o、4o 到 4o-mini——都是加法。你把 engine="gpt-4o" 一改,其它照旧跑。

GPT-5.x 是头一次做减法的一代:你以前一直在发的参数,现在会回你一个 400 Unsupported parameter。协议本身变了,因为 GPT-5 是推理模型——它在给答案之前会先花 token 在内部"想",所以那些控制旧采样管线的参数(temperaturetop_ppresence_penaltyfrequency_penalty)在请求 schema 里干脆就不存在了。

这对生产代码意味着什么:

  • 一套在 gpt-4o 上全绿的测试,换到 gpt-5.1 上会在第一次调用就以 HTTP 400 挂掉。
  • 反过来,一套在 gpt-5.1 上全绿的测试,在每一个老的 gpt-4* 部署上都会失败,因为新的推理控制参数(reasoning_effortverbosity)在那边不被识别。
  • 那些两年没动过、一直好好的 LangChain 辅助函数(尤其是 create_sql_query_chain)会悄悄往你的 LLM 上绑一个 stop=[...],触发同样的 400。你在源码里 grep 是找不到这一行的,因为它藏在库里面。

好在,这种分叉是机械的。一个探测函数、一个参数构造器、再加一个很小的 LangChain 子类,你就能让同一份代码同时跑在两个家族上。

2. 破坏性变更对照表

关注点 GPT-4 / GPT-4o(旧) GPT-5.x / o1 / o3(推理)
输出预算 max_tokens max_completion_tokens(拒绝 max_tokens
temperature 0.01.0 只接受默认值(1)——直接省略
top_p 支持 拒绝
presence_penalty, frequency_penalty 支持 拒绝
logprobs, logit_bias 支持 拒绝
stop 序列 支持 大多数推理部署上拒绝
reasoning_effort 拒绝 新增:minimal | low | medium | high
verbosity 拒绝 新增:low | medium | high(有时要走 extra_body
系统指令角色 system 推荐 developersystem 仍作为别名可用
输出 token 计费 只算输出 token 输出 + 推理 token 一起占你的上限
推荐 API 版本 2024-12-01-preview 或更早 2025-03-01-preview 或更新

有两个后果特别容易漏:

  1. max_completion_tokens 是一个共享预算。 GPT-5.1 在吐出第一个响应 token 之前,内部可能烧掉 2–4 倍的 token。一个在 GPT-4o 上舒舒服服装得下一条 SQL 的 4096 上限,到了 GPT-5.1 上会把答案在半途悄悄截断。把你的旧预算乘以大约 2.5 倍,再加一个下限(比如 4096)再发出去。
  2. stop 参数才是那个无声杀手。 任何调用 llm.bind(stop=[...]) 的辅助函数——langchain 里有好几个——都会在你换部署的瞬间,把一条本来好好的代码路径变成 400。

3. 兼容策略:探测,别分叉

一上来的诱惑是分叉:一条分支给 GPT-4,一条给 GPT-5。别这么干。正确的抽象粒度是一个把部署归类成某个家族的函数,再加一个为那个家族构造出 SDK 能接受的 kwargs 字典的函数。

兼容层架构图,所有调用点汇入同一个 kwargs 构造器

每一处调用点——SDK、LangChain、裸 HTTP——都汇入同一个 kwargs 构造器。等哪天你真要下线 GPT-4,只需要在一个文件里删掉那条旧分支,而不是在五十处代码里删。

4. 与业务无关的兼容模块

把下面这个文件丢进你的项目。它在模块加载时不 import 任何 Azure / OpenAI / LangChain,所以同一份文件在 Web 服务、Serverless 函数、Notebook 或者 CLI 工具里都能用。

4.1 model_compat.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""
Model compatibility helper for GPT-5.x with GPT-4 backward compatibility.

This module centralises the parameter translation needed to talk to the
"reasoning" generation of OpenAI / Azure OpenAI models (GPT-5, GPT-5.1,
o1, o3, o4) while keeping older deployments (gpt-4, gpt-4o, gpt-4-32k,
gpt-3.5-turbo, etc.) working unchanged.
"""

from __future__ import annotations

import logging
import os
import re
from typing import Any, Dict, Iterable, Mapping, Optional

# ---------------------------------------------------------------------------
# Family detection
# ---------------------------------------------------------------------------

_REASONING_PATTERNS = (
    # gpt-5, gpt5, gpt-5.1, gpt_5, GPT 5, gpt5mini-prod-eu, ...
    re.compile(r"(?i)(^|[^a-z0-9])gpt[-_ ]?5(\.\d+)?([^0-9]|$)"),
    # o1, o3, o4, o1-mini, o3-preview ...
    re.compile(r"(?i)(^|[^a-z0-9])o[134](-mini|-preview)?([^a-z0-9]|$)"),
)

_LEGACY_PATTERNS = (
    re.compile(r"(?i)gpt[-_ ]?4o"),
    re.compile(r"(?i)gpt[-_ ]?4(?!\d)"),
    re.compile(r"(?i)gpt[-_ ]?4[-_ ]?32k"),
    re.compile(r"(?i)gpt[-_ ]?3\.?5"),
    re.compile(r"(?i)gpt[-_ ]?35"),
)

def get_model_family(model_or_deployment: Optional[str]) -> str:
    """Return ``"reasoning"`` for GPT-5.x / o-series, ``"legacy"`` otherwise.

    Honours an ``OPENAI_MODEL_FAMILY`` env-var override for deployments whose
    user-defined name does not embed the model family (e.g. ``prod-default``).
    """
    override = (os.getenv("OPENAI_MODEL_FAMILY") or "").strip().lower()
    if override in {"reasoning", "gpt-5", "gpt5", "gpt-5.1", "o-series", "o1", "o3"}:
        return "reasoning"
    if override in {"legacy", "gpt-4", "gpt4", "gpt-3.5", "gpt35", "chat"}:
        return "legacy"

    name = (model_or_deployment or "").strip()
    if not name:
        # Fail closed: when we don't know, assume legacy so old code keeps
        # working. Misclassifying a reasoning deployment as legacy fails fast
        # with a clear "Unsupported parameter" 400; the reverse silently
        # drops parameters the caller expected.
        return "legacy"

    for pat in _REASONING_PATTERNS:
        if pat.search(name):
            return "reasoning"
    for pat in _LEGACY_PATTERNS:
        if pat.search(name):
            return "legacy"
    return "legacy"

def is_reasoning_model(model_or_deployment: Optional[str]) -> bool:
    return get_model_family(model_or_deployment) == "reasoning"

# ---------------------------------------------------------------------------
# Reasoning controls
# ---------------------------------------------------------------------------

_VALID_REASONING_EFFORT = {"minimal", "low", "medium", "high"}
_VALID_VERBOSITY = {"low", "medium", "high"}

def _coerce_choice(raw: Optional[str], valid: Iterable[str]) -> Optional[str]:
    if raw is None:
        return None
    value = str(raw).strip().lower()
    if not value:
        return None
    if value not in set(valid):
        logging.warning(
            "Ignoring unsupported value '%s'; expected one of %s",
            raw, sorted(valid),
        )
        return None
    return value

def get_reasoning_effort(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_REASONING_EFFORT"),
        _VALID_REASONING_EFFORT,
    )

def get_verbosity(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_VERBOSITY"),
        _VALID_VERBOSITY,
    )

# ---------------------------------------------------------------------------
# max_completion_tokens scaling
# ---------------------------------------------------------------------------

def _reasoning_token_scale() -> float:
    """Multiplier applied to legacy ``max_tokens`` when targeting a reasoning model."""
    try:
        scale = float(os.getenv("OPENAI_REASONING_TOKEN_SCALE", "2.5"))
    except (TypeError, ValueError):
        scale = 2.5
    return scale if scale > 0 else 1.0

def _reasoning_token_floor() -> int:
    try:
        floor = int(os.getenv("OPENAI_REASONING_TOKEN_FLOOR", "4096"))
    except (TypeError, ValueError):
        floor = 4096
    return floor if floor > 0 else 4096

def scale_max_tokens_for_reasoning(max_tokens: Optional[int]) -> Optional[int]:
    """Scale a legacy ``max_tokens`` budget up for reasoning models.

    ``None`` and ``-1`` ("no explicit cap") are passed through.
    """
    if max_tokens is None:
        return None
    if max_tokens == -1:
        return -1
    return max(int(round(max_tokens * _reasoning_token_scale())), _reasoning_token_floor())

# ---------------------------------------------------------------------------
# Kwargs builders
# ---------------------------------------------------------------------------

_SAMPLING_KEYS = ("temperature", "top_p", "presence_penalty", "frequency_penalty")

def _drop_none(mapping: Mapping[str, Any]) -> Dict[str, Any]:
    return {k: v for k, v in mapping.items() if v is not None}

def build_openai_chat_kwargs(
    model: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    presence_penalty: Optional[float] = None,
    frequency_penalty: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
    extra: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``openai.OpenAI / AzureOpenAI .chat.completions.create``.

    Splat the result directly: ``client.chat.completions.create(**kwargs)``.
    Unsupported parameters are silently omitted for reasoning models; legacy
    deployments retain the historical behaviour.
    """
    family = get_model_family(model)
    kwargs: Dict[str, Any] = {"model": model}

    # ---- output budget ----
    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    # ---- sampling ----
    if family == "legacy":
        kwargs.update(_drop_none({
            "temperature": temperature,
            "top_p": top_p,
            "presence_penalty": presence_penalty,
            "frequency_penalty": frequency_penalty,
        }))
    else:
        for key, value in (
            ("temperature", temperature), ("top_p", top_p),
            ("presence_penalty", presence_penalty), ("frequency_penalty", frequency_penalty),
        ):
            if value is not None:
                logging.debug(
                    "Dropping unsupported parameter '%s' for reasoning model '%s'",
                    key, model,
                )

    # ---- reasoning controls ----
    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            # ``verbosity`` is not a top-level kwarg in openai-python <= 1.65.x;
            # route it via ``extra_body`` so it lands in the JSON without a
            # TypeError from the SDK.
            kwargs.setdefault("extra_body", {})["verbosity"] = verb

    # ---- caller-supplied extras (already filtered) ----
    if extra:
        for key, value in extra.items():
            if value is None:
                continue
            if family == "reasoning" and key in _SAMPLING_KEYS:
                continue
            kwargs[key] = value

    return kwargs

def build_langchain_chat_kwargs(
    deployment_name: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``langchain_openai.AzureChatOpenAI`` / ``ChatOpenAI``.

    Older ``langchain-openai`` releases don't expose ``max_completion_tokens``
    as a top-level kwarg, so we forward it through ``model_kwargs`` (which
    langchain passes straight to the SDK).
    """
    family = get_model_family(deployment_name)
    kwargs: Dict[str, Any] = {}
    model_kwargs: Dict[str, Any] = {}

    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            model_kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            model_kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            model_kwargs.setdefault("extra_body", {})["verbosity"] = verb
    else:
        if temperature is not None:
            kwargs["temperature"] = temperature
        if top_p is not None:
            kwargs["top_p"] = top_p

    if model_kwargs:
        kwargs["model_kwargs"] = model_kwargs
    return kwargs

def get_system_role(model_or_deployment: Optional[str] = None) -> str:
    """Return ``"developer"`` for reasoning models when opted in, ``"system"`` otherwise.

    Defaulting to ``"system"`` preserves compatibility with LangChain prompt
    templates and SDK helpers that don't yet recognise the new role. Opt in
    with ``OPENAI_USE_DEVELOPER_ROLE=1`` once your stack supports it.
    """
    if not is_reasoning_model(model_or_deployment):
        return "system"
    raw = os.getenv("OPENAI_USE_DEVELOPER_ROLE", "")
    return "developer" if raw.strip().lower() in {"1", "true", "yes", "on"} else "system"

4.2 它帮你省下什么

每一处直接调 SDK 的地方都塌缩成两行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from openai import AzureOpenAI
from model_compat import build_openai_chat_kwargs

client = AzureOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    api_version=os.environ["OPENAI_API_VERSION"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)

kwargs = build_openai_chat_kwargs(
    model=os.environ["OPENAI_ENGINE"],
    max_tokens=4096,           # automatically becomes max_completion_tokens for GPT-5
    temperature=0.2,           # automatically dropped for GPT-5
    reasoning_effort="low",    # automatically dropped for GPT-4
)
response = client.chat.completions.create(
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": user_input},
    ],
    **kwargs,
)

同一处调用点,现在能正确打到 gpt-5.1gpt-4ogpt-4-32ko3-mini,或者任何未来那些名字里嵌了家族标识的部署上。碰到部署别名不透明的情况,你还能用 OPENAI_MODEL_FAMILY 环境变量强制覆盖。

4.3 裸 HTTP 调用点

有些老代码路径绕过 SDK,直接 POST JSON。同一个构造器在那边照样能用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import json
import requests
from model_compat import build_openai_chat_kwargs, get_system_role

deployment = os.environ["OPENAI_ENGINE"]
api_version = os.environ["OPENAI_API_VERSION"]
endpoint = (
    f"{os.environ['AZURE_OPENAI_ENDPOINT']}/openai/deployments/{deployment}"
    f"/chat/completions?api-version={api_version}"
)

payload = {
    "messages": [
        {"role": get_system_role(deployment), "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ],
}
# Splat the kwargs into the payload, then strip the SDK-only ``model`` key.
payload.update(build_openai_chat_kwargs(
    model=deployment,
    max_tokens=800,
    temperature=0.7,
    top_p=0.95,
    reasoning_effort="low",
))
payload.pop("model", None)        # ``model`` is encoded in the URL for Azure
payload.pop("extra_body", None)   # already on the payload root

resp = requests.post(
    endpoint,
    headers={"Content-Type": "application/json", "api-key": api_key},
    data=json.dumps(payload),
    timeout=60,
)
resp.raise_for_status()

5. LangChain:那个藏起来的 stop 参数

langchain.chains.sql_database.query.create_sql_query_chain 内部会调 llm.bind(stop=["\nSQLResult:"]),好在它 prompt 里的示例块之前把模型输出截断。那个 stop 值会在每次调用时被转发给 SDK。GPT-5.1 直接拒绝:

openai.BadRequestError: Error code: 400 - {'error': {
  'message': "Unsupported parameter: 'stop' is not supported with this model.",
  'type': 'invalid_request_error',
  'param': 'stop',
}}

你没法伸手进链里把它关掉。干净的修法是写一个很薄的 AzureChatOpenAI 子类,只对推理模型丢掉 stop

5.1 langchain_compat.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
"""LangChain-side compatibility shim for reasoning-class deployments."""

from __future__ import annotations

from typing import Any, List, Optional

from langchain_core.callbacks.manager import (
    AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun,
)
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatResult
from langchain_openai import AzureChatOpenAI   # use ChatOpenAI for non-Azure

from model_compat import is_reasoning_model

class ReasoningSafeAzureChatOpenAI(AzureChatOpenAI):
    """``AzureChatOpenAI`` variant that hides parameters reasoning models reject.

    Reasoning models (GPT-5.x, o1/o3/o4) return HTTP 400 when a request
    payload carries ``stop``. LangChain's SQL helpers unconditionally bind it,
    so the unsupported parameter reaches the SDK regardless of how the caller
    configured the LLM. This subclass strips ``stop`` for reasoning
    deployments while forwarding it unchanged for legacy GPT-4 / GPT-3.5
    deployments - the behaviour is byte-identical to upstream LangChain
    for those models.
    """

    def _deployment_id(self) -> str:
        # ``langchain-openai`` >= 0.2 exposes ``azure_deployment``; older
        # releases use ``deployment_name``. Either may be set by the caller.
        return (
            getattr(self, "azure_deployment", None)
            or getattr(self, "deployment_name", None)
            or ""
        )

    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[CallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)

    async def _agenerate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)

拿它当原地替换用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from langchain_compat import ReasoningSafeAzureChatOpenAI
from model_compat import build_langchain_chat_kwargs

llm_kwargs = build_langchain_chat_kwargs(
    deployment_name=os.environ["OPENAI_ENGINE"],
    max_tokens=6000,
    temperature=0,
    reasoning_effort="low",
)
llm = ReasoningSafeAzureChatOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    azure_deployment=os.environ["OPENAI_ENGINE"],
    openai_api_version=os.environ["OPENAI_API_VERSION"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
    **llm_kwargs,
)

就这一处替换,create_sql_query_chainSQLDatabaseChain,还有基于 ChatOpenAI 的那些 RAG 辅助函数,全都能在 GPT-5.1 上跑起来,别的什么都不用改。

6. LangChain 的第二个坑:本该是 SQL 的地方冒出了大白话

create_sql_query_chain 的文档里写着,当 LLM 拼不出查询时它会返回字面字符串 “I don’t know”(或者类似的兜底话)。而默认代码路径会拿链的输出直接丢去数据库跑:

1
2
sql = chain.invoke({...})              # -> "I don't know"
result = db.run(sql)                   # -> sends "I don't know" to pyodbc

数据库老老实实回你:

[42000] Unclosed quotation mark after the character string 't know'. (105)

到最终用户面前,就成了一句误导人的"SQL 语法错误"。缓解办法是加一行守卫,在执行前先校验链的输出看起来像不像 SQL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import re

_SQL_START_RE = re.compile(
    r"^\s*(?:WITH|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|MERGE|EXEC|EXECUTE|TRUNCATE)\b",
    re.IGNORECASE,
)

def looks_like_sql(text: str) -> bool:
    """True only if ``text`` starts with a recognised SQL DML/DDL keyword."""
    if not text or not text.strip():
        return False
    return bool(_SQL_START_RE.match(text))

sql = extract_sql_query(chain.invoke({...}))
if not looks_like_sql(sql):
    logging.warning("SQL chain returned a non-SQL response: %r", sql[:200])
    return (
        "I couldn't form a SQL query for that question. "
        "Please rephrase or add more context."
    )
result = db.run(sql)

这一手其实跟 GPT-5.1 没啥专属关系——任何给 SQL agent 撑腰的 LLM,加上它都是好卫生习惯。只不过在推理模型上,这个失败模式会频繁得多,因为它们更擅长拒绝

7. 把 Markdown 从 create_sql_query_chain 的输出里洗掉

推理模型爱把答案包在一个 markdown 代码围栏里,末尾再缀一段 “Note:” 或者 “Explanation:"。这些东西没一个能挺过 db.run()。一个防御性的 extract_sql_query 能把各种变体都拿下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import re

def extract_sql_query(text: str) -> str:
    """Strip markdown fences, leading prose, and trailing explanations."""
    # 1) Prefer SQL inside a markdown code fence.
    m = re.search(r"```(?:sql|SQL|Sql)?\s*\n(.*?)\n```", text, re.DOTALL)
    if m:
        text = m.group(1)
    text = text.strip()

    # 2) Drop any prose *before* the SQL by jumping to the first SQL keyword.
    m = re.search(
        r"(?im)^\s*(WITH|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|MERGE|EXEC|EXECUTE|TRUNCATE)\b",
        text,
    )
    if m:
        text = text[m.start(1):]

    # 3) Cut at the first "Explanation:" / "Note:" / "This query..." marker.
    m = re.compile(
        r"(?im)^\s*(?:Explanation|Note|Notes|Here(?:'|\u2019)?s|"
        r"This\s+(?:query|SQL|statement|returns|counts|selects|will|gets|finds)|"
        r"The\s+(?:query|SQL|above|result|statement)|"
        r"Result|Results|Description|Output|Answer)\b[^\n]*"
    ).search(text)
    if m:
        text = text[: m.start()].rstrip()

    # 4) Drop any trailing fence that survived step 1.
    if text.endswith("```"):
        text = text[:-3].rstrip()
    return text.strip()

8. 包版本

你的 requirements.txt / environment.yml 最低限度要满足的:

最后一个只支持 GPT-4 的版本 第一个 GPT-5.x 安全的版本 备注
openai 1.55.x 1.65.x(推荐 1.65.4+) 更早的版本会把 max_completion_tokens 和 reasoning_effort 当成未知 kwarg 拒掉
langchain-openai 0.2.14 0.3.7+ 0.3.x 这条线暴露了 azure_deployment,并且能把 model_kwargs 正确转发给新 SDK
langchain 0.3.14 0.3.21+ 和 langchain-openai、langchain-core 一起钉版本
langchain-core 0.3.29 0.3.49+ 跟其它几个同步升级
langchain-community 0.3.14 0.3.20+ 大多是传递依赖;SQLDatabase 辅助函数会用到
tiktoken 0.7.x 0.8.0+ GPT-5.1 的编码在 0.8.0 里才有;更老的版本会对未知模型退回 cl100k_base
tokencost(可选) 0.1.16 0.1.20+ 升级以拿到 GPT-5.x 的价格表
Azure OpenAI API 版本 2024-12-01-preview 2025-03-01-preview 第一个带 reasoning_effort 和 GPT-5.x 路由的版本

测试通过之后把版本钉死——LangChain 有个习惯,喜欢在小版本之间挪动公开的 re-export。

requirements.txt 片段:

openai==1.65.4
langchain==0.3.21
langchain-core==0.3.49
langchain-openai==0.3.7
langchain-community==0.3.20
tiktoken==0.8.0

9. GPT-5.x 值得用的新旋钮

一旦上了推理部署,有两个新参数就能用了。两个都是可选的,都有合理的默认值,而且上面那个 kwargs 构造器在目标是旧模型时会自动把它们剥掉。

reasoning_effort
  • minimal——一次性查询、分类。
  • low——确定性的结构化输出(SQL、JSON-schema 抽取、基于规则的改写)。开销最低。
  • medium(默认)——RAG、摘要、普通问答。
  • high——多步分析推理、复杂代码合成。

一个好用的套路是按任务画像来选档位,而不是在每个调用点上硬写:

1
2
3
4
5
6
7
8
TASK_EFFORT = {
    "sql":                 "low",
    "structured_extract":  "low",
    "kg_cleaning":         "low",
    "rag_qa":              "medium",
    "vision":              "medium",
    "analytical":          "high",
}
verbosity

low | medium | high。它控制的是响应的长度,不是内容实质。给聊天 UI 打底时很有用——/answer 端点设 low 拿到干脆的答案,“像资深工程师那样解释"的面板设 high

注意:在 openai-python <= 1.65.x 里,verbosity 还不是顶层关键字参数;要走 extra_body 传(上面那个构造器已经替你这么干了)。

developer 角色

GPT-5.x 更喜欢用 {"role": "developer", "content": "..."} 来放那些以前用 system 的指令。这个改动在 Azure 侧不破坏兼容——system 仍然作为别名被接受——但下游有些 LangChain prompt 模板成文更早,构造时就会拒掉这个角色。眼下先把 developer 当成 opt-in(OPENAI_USE_DEVELOPER_ROLE=1);等你的 prompt 模板版本确认没问题了,再翻转默认值。

10. 审计你现有的 prompt

线级别的迁移做完之后,你的服务确实能跟 GPT-5.x 说上话了——但这不代表它说的就是对的话。推理模型读 prompt 的方式不一样,而这些差异不会以 400 的形式冒出来:

  • 它们更字面地对待指令。 一个在 GPT-4o 上"打圆场"时还能用的 prompt,可能会让 GPT-5.x 把每个边界情况都一字不落地兜出来。
  • 它们更常拒绝。 “I don’t know” / “I cannot help with that” 出现得更频繁,因为推理模型更不愿意瞎编。
  • 它们无视"简洁点"“说短点”。 改用新的 verbosity 旋钮。
  • 一步步 / 思维链的指令变多余了。 模型已经在内部推理了;额外那些"回答前先想想"的话会跟它自己的思维链打架,往往拉低输出质量。
  • 只有否定的指令可能反噬。 “永远别输出 X” 这类 prompt 偶尔会引发拒绝,而你其实更想要一个变通方案。

10.1 搭一个 prompt 回归测试台

把你服务发出的每一条 system+user prompt 都记进一个 CSV,然后把每一条同时打到两个部署上,diff 它们的输出。这个 diff 是你在切换之前能产出的最有用的东西:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# prompt_audit.py - minimal differential tester
import csv
from openai import AzureOpenAI
from model_compat import build_openai_chat_kwargs

LEGACY      = "gpt-4o"
REASONING   = "gpt-5.1"
client      = AzureOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    api_version=os.environ["OPENAI_API_VERSION"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)

def run(model: str, system: str, user: str) -> str:
    kw = build_openai_chat_kwargs(
        model=model,
        max_tokens=4096,
        temperature=0.2,           # auto-dropped for reasoning
        reasoning_effort="medium", # auto-dropped for legacy
    )
    resp = client.chat.completions.create(
        messages=[
            {"role": "system", "content": system},
            {"role": "user",   "content": user},
        ],
        **kw,
    )
    return resp.choices[0].message.content or ""

with open("prompts.csv") as f_in, open("diff.tsv", "w", newline="") as f_out:
    writer = csv.writer(f_out, delimiter="\t")
    writer.writerow(["id", "legacy_first80", "reasoning_first80",
                     "len_legacy", "len_new", "identical"])
    for row in csv.DictReader(f_in):
        legacy = run(LEGACY,    row["system"], row["user"])
        new    = run(REASONING, row["system"], row["user"])
        writer.writerow([
            row["id"],
            legacy[:80].replace("\n", " "),
            new[:80].replace("\n", " "),
            len(legacy), len(new),
            legacy.strip() == new.strip(),
        ])

每条 prompt 抓三个信号,就足够分诊 95% 的漂移了:

  • 格式合规。 输出还能不能按预期解析成 JSON / YAML / Markdown / SQL?拿你现有的下游解析器在两列上各跑一遍。
  • token 成本差。 推理模型默认更啰嗦。超出 +20% 的,就是 verbosity="low" 旋钮的候选。
  • 语义漂移。 手工抽查 5–10% 的行。你要盯的是意图上的变化,不是措辞上的变化。

10.2 让 prompt 跟模型无关的几种常见改写

目标不是写两份 prompt,而是写一份,靠把约束从自然语言正文里挪进请求形状,让它在两个家族上都产出正确输出。

10.2a. 格式约束属于 response_format,不属于正文

别这么写:

Output ONLY a JSON object with keys `name` and `score`. Do not include any explanation. Do not wrap in markdown. Do not say anything else.

这么写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
resp = client.chat.completions.create(
    messages=[...],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "scored_entity",
            "schema": {
                "type": "object",
                "properties": {
                    "name":  {"type": "string"},
                    "score": {"type": "number"},
                },
                "required": ["name", "score"],
                "additionalProperties": False,
            },
            "strict": True,
        },
    },
    **kw,
)

response_formatgpt-4o>= 2024-08-06)和整条 GPT-5.x 线上都被支持。prompt 甩掉了三行脆弱的自然语言约束,你还白得一个经过 schema 校验的输出。

10.2b. 用 reasoning_effort 取代"think step by step”

别这么写:

Let's think step by step. First identify the entity. Then find the category. Then compute the score. Then format the answer.

这么做:

把这段话删了,对推理部署传 reasoning_effort="medium"(或者 "high")。kwargs 构造器对 GPT-4 模型会自动丢掉这个参数,于是同一份 prompt 现在能产出:

  • 在 GPT-5.x 上内部做一步步推理(输出 token 成本更低),
  • 在 GPT-4o 上,还是那段啰嗦 prompt 以前引出来的同一个最终答案。
10.2c. 用 n 采样取代靠 temperature 制造多样性

如果你的代码以前靠 temperature=0.9 拿到多样的补全,GPT-5.x 每次基本会回给你差不多一样的答案。改用显式的方式造多样性:

1
2
resp = client.chat.completions.create(messages=[...], n=5, **kw)
candidates = [c.message.content for c in resp.choices]

或者用略微不同的措辞把模型调用 N 次。两种套路对两个家族都好使,别的代码一点不用改。

10.2d. 把流程性指令搬到 developer 角色

对多步工作流,新的 developer 角色把系统强制的东西用户在问的东西分得更清楚:

1
2
3
4
5
messages = [
    {"role": get_system_role(deployment), "content": role_card_for_assistant},
    {"role": "developer",                 "content": procedural_instructions},
    {"role": "user",                      "content": user_question},
]

get_system_role 对旧模型返回 "system",对通过 OPENAI_USE_DEVELOPER_ROLE=1 开了口子的推理模型返回 "developer"。等你的 LangChain 模板支持了新角色,就能翻转默认值。

10.2e. 给严格格式加一个字面执行头

对那些精确输出形状很要命的 prompt(生成表格、列顺序固定的 SQL、结构化事故报告),在前面加一个显式的字面执行头,好让推理模型别飘去搞"善意的改进”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
LITERAL_EXECUTION_HEADER = (
    "Execution mode: follow the instructions below literally and in order. "
    "Do not infer intent, skip, reorder, merge, or add steps. Honour the "
    "exact formatting, tone, and verbosity specified. If a step is "
    "ambiguous, respond with the literal interpretation and flag the "
    "ambiguity instead of guessing."
)

def apply_literal_execution(prompt: str) -> str:
    if LITERAL_EXECUTION_HEADER in prompt:
        return prompt
    return f"{LITERAL_EXECUTION_HEADER}\n\n{prompt}"

它在 GPT-4o 上是个空操作(老模型本来就够字面地照着指令走),在 GPT-5.1 上则是一道有意义的护栏。用一个 OPENAI_LITERAL_EXECUTION 开关把它挂起来,这样不用重新部署就能关掉。

10.3 一份 prompt 形状的检查清单

把你服务发出的每条 prompt 都拿这些问题过一遍:

问题 动作
是不是在正文里规定了输出格式? 挪去 response_format(10.2a)
是不是含"think step by step"? 删掉;设 reasoning_effort(10.2b)
是不是设了语气约束(“be concise”)? 用 verbosity
是不是只用了否定式指令(“never X”)? 加上正向替代(“do Y instead”)
是不是嵌了会变的示例输出值? 把具体值换成占位符 token(
是不是靠 temperature > 0 制造多样性? 用 n=K 采样(10.2c)
system prompt 是不是超过 2k token? 拆成角色卡(system)+ 流程(developer)
输出顺序要不要紧? 加字面执行头(10.2e)

10.4 上线前先打分

别靠瞄一个例子就批准一份改写过的 prompt。给它打分:

  • 格式合规率。 N=50 条输出里,有多大比例能过你现有的下游解析器 / JSON schema 校验。
  • token 成本差。 相对旧基线,回归上限设 +20%。超了就往下调 verbosity="low" 或者收紧 prompt。
  • 延迟 p50 / p95 差。 推理模型会加尾延迟。如果你的 SLA 很紧,这条路径就设 reasoning_effort="low",或者把它挪到后台队列里。

任何一项超出你容忍窗口的 prompt,都得挂在特性开关后面上线,并且把回滚接好。

11. 测试策略

两层测试能抓住 90% 以上的回归:

家族分类测试
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import pytest
from model_compat import get_model_family, build_openai_chat_kwargs

@pytest.mark.parametrize("name,expected", [
    ("gpt-5.1", "reasoning"),
    ("gpt5", "reasoning"),
    ("gpt-5-prod-eu", "reasoning"),
    ("o3-mini", "reasoning"),
    ("o1", "reasoning"),
    ("gpt-4o", "legacy"),
    ("gpt-4", "legacy"),
    ("gpt-4-32k", "legacy"),
    ("gpt-35-turbo", "legacy"),
    ("",          "legacy"),    # unknown -> fail closed to legacy
    (None,        "legacy"),
])
def test_family(name, expected):
    assert get_model_family(name) == expected

def test_kwargs_for_reasoning_drops_temperature():
    kw = build_openai_chat_kwargs(
        model="gpt-5.1", max_tokens=1000, temperature=0.2, top_p=0.9,
        reasoning_effort="low",
    )
    assert "temperature" not in kw
    assert "top_p" not in kw
    assert kw["max_completion_tokens"] >= 4096   # floor applied
    assert kw["reasoning_effort"] == "low"

def test_kwargs_for_legacy_keeps_temperature():
    kw = build_openai_chat_kwargs(
        model="gpt-4o", max_tokens=1000, temperature=0.2, top_p=0.9,
    )
    assert kw["max_tokens"] == 1000
    assert kw["temperature"] == 0.2
    assert kw["top_p"] == 0.9
    assert "reasoning_effort" not in kw
线级别冒烟测试

对你维护的每一个 LLM 调用点,写一个集成测试,让链打到一个真实(或者 mock 的)端点上,然后断言:

  • HTTP 200,
  • 内容非空,
  • finish_reason != "length"(这样你能抓到无声的截断),
  • (可选)针对一个金标准输出做分类器式断言。

这套测试对旧部署跑一遍,对新部署再跑一遍——同一份测试代码,两个 OPENAI_ENGINE 值。

12. 那些变的东西

很容易矫枉过正。有几块管道不用动照样能用:

  • 认证。 AAD token provider、托管标识、API key 都没变。
  • 嵌入。 text-embedding-3-smalltext-embedding-3-largetext-embedding-ada-002 不属于推理这一代;嵌入调用的形状完全一样。
  • 函数调用 / 工具使用。 同样的 JSON schema,同样的响应形状。
  • 流式。 SSE 格式没变。
  • token 计数器。 tiktoken 还能用,但升到 0.8.0+,好让新模型名解析到正确的编码,而不是悄悄退回 cl100k_base

13. 下一步

如果这篇你只打算做四件事,就按顺序做这几件:

  1. 在你现有 GPT-4 部署旁边并排部署一个 GPT-5.1 模型,就在 Microsoft Foundry 里。把 GPT-4 的部署留着别删;并行运行那段时间你两个都要用。
  2. model_compat.pylangchain_compat.py 丢进你的项目(第 4、5 节)。把每一处 AzureChatOpenAI(...) 的构造换成 ReasoningSafeAzureChatOpenAI,把每一个 kwargs 字面量都过一遍构造器。
  3. 跑 prompt 审计工具台(10.1 节),针对你调用最频繁的前 50 条 prompt。拿 10.3 的清单去分诊那个 diff。
  4. 挂在按百分比的开关后面灰度。 先放 5% 的流量跑 24 小时,把质量和成本遥测跟 GPT-4o 基线对一对,然后再往上加。

我自己走完这套之后最大的感受是:GPT-5.x 是两年来第一次真的要求你改代码的大版本升级,但改动最后收敛成一个小小的兼容模块,加一个一行的 LangChain 子类。放好之后,你的代码就同时是前向兼容(今天就能在推理模型上跑)和后向兼容(在你还没迁的每个 GPT-4 部署上照样跑)的。这笔投入还会持续回本:等下一次推理大版本到来时,唯一需要更新的文件就是 model_compat.py

参考资料

附录 A - 最小 .env 模板

# Endpoint and auth (unchanged between families)
AZURE_OPENAI_ENDPOINT=https://<resource>.openai.azure.com
AZURE_OPENAI_API_KEY=<key>

# The deployment name decides the family. The classifier reads it.
OPENAI_ENGINE=gpt-5.1
OPENAI_API_VERSION=2025-03-01-preview

# Optional override for opaque deployment names
# OPENAI_MODEL_FAMILY=reasoning   # or "legacy"

# Optional reasoning controls (ignored for legacy deployments)
OPENAI_REASONING_EFFORT=medium
OPENAI_VERBOSITY=medium
OPENAI_REASONING_TOKEN_SCALE=2.5
OPENAI_REASONING_TOKEN_FLOOR=4096

# Flip when your LangChain templates support it
# OPENAI_USE_DEVELOPER_ROLE=1

附录 B - 一行命令的 sanity check

1
2
3
4
5
6
7
8
9
# Does a deployment name classify correctly?
python -c "from model_compat import get_model_family; print(get_model_family('gpt-5.1'))"
# -> reasoning

# Does the LangChain LLM strip ``stop`` when the deployment is GPT-5.1?
python -c "
from langchain_compat import ReasoningSafeAzureChatOpenAI
import inspect; print(inspect.getsource(ReasoningSafeAzureChatOpenAI._generate))
"

配套仓库:把 model_compat.pylangchain_compat.py 挨着放进你的 utils/ 包里。它们在 import 时零依赖,所以你可以把它们 vendored 进任何服务——Web、函数、批处理作业——而不用把 Azure SDK 或者 LangChain 拖进模块加载阶段。