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):