From ec6838dc1091b16b7fbe89b71e2a68291e110357 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 13 Oct 2026 16:40:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=20SDK=20=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E9=87=8D=E8=AF=95?= =?UTF-8?q?=EF=BC=9AModelSpec=20=E5=8A=A0=20max=5Fretries=EF=BC=8Cbuild=5F?= =?UTF-8?q?chat=5Fmodel=20=E4=B8=80=E7=9B=B4=E4=BC=A0=E5=88=B0=20SDK=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=88Bearer=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=8D=A2=E4=B8=8A=E7=9A=84=20Anthropic=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B9=9F=E5=B8=A6=E4=B8=8A=EF=BC=89=EF=BC=9B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=B5=B7=E8=8D=89=E5=BB=BA=E7=9A=84?= =?UTF-8?q?=E5=A4=A7=E3=80=81=E5=B0=8F=E4=B8=A4=E4=B8=AA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=83=BD=E6=98=AF=20max=5Fretries=3D0=EF=BC=8C429=E3=80=815xx?= =?UTF-8?q?=E3=80=81=E7=BD=91=E7=BB=9C=E9=94=99=E8=AF=AF=E3=80=81=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E5=8F=AA=E7=94=B1=E8=B5=B7=E8=8D=89=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=8C=89=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E9=87=8D=E8=AF=95?= =?UTF-8?q?=EF=BC=88=E6=9C=80=E5=A4=9A=202=20=E6=AC=A1=EF=BC=89=E3=80=82?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=20openai=E3=80=81anthropic=20=E7=9A=84=20SDK?= =?UTF-8?q?=20=E5=90=84=E8=87=AA=E5=85=88=E9=87=8D=E8=AF=95=202=20?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E5=8F=A0=E5=9C=A8=E5=BC=95=E6=93=8E=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=B8=8A=EF=BC=8C=E4=B8=80=E6=AE=B5=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E5=8F=91=209=20=E6=AC=A1=E8=AF=B7=E6=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E5=8D=B4=E5=86=99=E3=80=8C=E5=B7=B2=E9=87=8D?= =?UTF-8?q?=E8=AF=95=202=20=E6=AC=A1=E3=80=8D=EF=BC=8C=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E9=99=90=E6=B5=81=E6=97=B6=E8=BF=98=E6=94=BE=E5=A4=A7=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E3=80=82=E4=B8=8D=E5=86=99=20max=5Fretries=20?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E7=94=A8=E7=85=A7=20SDK=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=EF=BC=8C=E9=85=8D=E6=96=B9=20AI=20=E8=B5=B7=E8=8D=89=E4=B8=8D?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/catalog.py | 4 +++- backend/app/data/catalog_draft.py | 3 ++- backend/app/providers/factory.py | 8 ++++++- backend/tests/test_catalog_draft_settings.py | 23 ++++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py index 45eec1bb..058c8b16 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -1570,6 +1570,8 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]: 不关思考(spec 里不传 thinking,按接入默认)、温度 0、额度 MODEL_MAX_TOKENS,服务方嫌大时退回 MODEL_FALLBACK_MAX_TOKENS(DraftModel)。单次调用的时限 MODEL_TIMEOUT_S 也交给 SDK,免得它先按全局的 5 分钟断开。 + SDK 自己不重试(max_retries=0):429、5xx、网络错误、超时由起草引擎按失败原因重试(最多 MODEL_RETRIES 次), + 不然两层叠起来一段最多发 9 次请求,报错却写「已重试 2 次」,网关正在限流时还会放大流量。 只建模型对象,不发请求。 和 recipe_ai.resolve_draft_model 一样不用 get_chat_model:它在没有接入时静默退回演示模型,拿演示模型 @@ -1585,7 +1587,7 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]: except DraftModelUnavailable as e: raise CatalogModelUnavailable(str(e)) from e spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=MODEL_MAX_TOKENS, temperature=0, - timeout=MODEL_TIMEOUT_S) + timeout=MODEL_TIMEOUT_S, max_retries=0) try: big = build_chat_model(provider, spec) small = build_chat_model(provider, spec.model_copy(update={"max_tokens": MODEL_FALLBACK_MAX_TOKENS})) diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py index 8833add9..4406c5c7 100644 --- a/backend/app/data/catalog_draft.py +++ b/backend/app/data/catalog_draft.py @@ -23,7 +23,8 @@ - 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草 后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。 - 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。 -- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。 +- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK + 自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。 - 别的错误:这一段没写出来,原因照实写。 **并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步 diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index b94ae6d6..e7fbb29b 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -28,6 +28,9 @@ class ModelSpec(BaseModel): thinking: str | None = None # off | adaptive effort: str | None = None # low | medium | high | xhigh | max timeout: int | None = None + # SDK 自己遇到 429、5xx、连接错误、超时时重试几次;None 照 SDK 默认(openai、anthropic 都是 2 次)。 + # 自己按失败原因重试的调用方(数据目录起草)设 0,免得两层重试叠在一起、实际次数和报错写的对不上 + max_retries: int | None = None extra: dict[str, Any] = Field(default_factory=dict) @@ -227,6 +230,8 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel: # 以前只有节点显式配了才传,而没有任何地方给 spec.timeout 赋过值——所有调用 # 都走 SDK 默认的 10 分钟 kwargs["timeout"] = spec.timeout or settings.model_timeout_seconds + if spec.max_retries is not None: + kwargs["max_retries"] = spec.max_retries # 采样参数:能力允许才传 if spec.temperature is not None and catalog.supports_sampling(model): @@ -314,7 +319,8 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel: # 且 api_key=None 时不会发出 x-api-key。 import anthropic - client_args = {"base_url": base_url, "api_key": None, "auth_token": api_key} + client_args = {"base_url": base_url, "api_key": None, "auth_token": api_key, + "max_retries": chat.max_retries} chat._client = anthropic.Anthropic(**client_args) chat._async_client = anthropic.AsyncAnthropic(**client_args) return chat diff --git a/backend/tests/test_catalog_draft_settings.py b/backend/tests/test_catalog_draft_settings.py index c4cbbec4..d36e73e1 100644 --- a/backend/tests/test_catalog_draft_settings.py +++ b/backend/tests/test_catalog_draft_settings.py @@ -196,9 +196,31 @@ async def test_catalog_draft_uses_the_draft_setting_keeps_thinking_and_a_big_bud assert catalog.MODEL_MAX_TOKENS == 32768 and catalog.MODEL_FALLBACK_MAX_TOKENS == 8192 assert all(s.thinking is None and s.temperature == 0 for _, s in specs), "不关思考,按接入默认" assert all(s.timeout == catalog.MODEL_TIMEOUT_S == 420 for _, s in specs) + assert all(s.max_retries == 0 for _, s in specs), "重试只由起草引擎按失败原因来做,SDK 自己不再重试" assert model.budget == 32768 and model.model_name == "模型甲" +@pytest.mark.parametrize("kind,extra", [ + ("openai_compatible", {}), + ("anthropic", {}), + ("anthropic", {"auth_style": "bearer"}), +], ids=["openai-compatible", "anthropic", "anthropic-bearer"]) +def test_the_built_model_does_not_retry_on_its_own_when_asked(kind, extra): + """ModelSpec.max_retries 一直传到 SDK 客户端(Bearer 鉴权换上的客户端也是);不写就照 SDK 默认。""" + from app.core.crypto import encrypt + from app.providers.factory import ModelSpec, build_chat_model + + provider = Provider(name="虚构接入", kind=kind, api_key=encrypt("sk-test"), base_url="https://gateway.example/v1", + models=[], default_model="model-x", extra=extra) + + def client_retries(chat: Any) -> int: + client = chat.root_async_client if kind == "openai_compatible" else chat._async_client + return client.max_retries + + assert client_retries(build_chat_model(provider, ModelSpec(model="model-x", max_retries=0))) == 0 + assert client_retries(build_chat_model(provider, ModelSpec(model="model-x"))) == 2 + + async def test_catalog_draft_falls_back_to_the_assistant_model(mem, no_env_keys, monkeypatch): await _setup(mem, copilot={"provider": "", "model": "模型乙"}) specs = _spy(monkeypatch) @@ -213,6 +235,7 @@ async def test_recipe_ai_draft_follows_the_same_order_but_keeps_its_own_spec(mem assert avail.available and model_id == "模型丙" and avail.provider == "丙接入" assert [s.max_tokens for _, s in specs] == [A.AI_MAX_TOKENS, A.AI_FALLBACK_MAX_TOKENS] assert all(s.thinking == "off" and s.temperature == 0 for _, s in specs), "配方 AI 起草的思考设置不动" + assert all(s.max_retries is None for _, s in specs), "配方 AI 起草的重试照旧(SDK 默认)" async def test_a_broken_draft_setting_says_where_to_fix_it(mem, no_env_keys):