起草模型的 SDK 不再自己重试:ModelSpec 加 max_retries,build_chat_model 一直传到 SDK 客户端(Bearer 鉴权换上的 Anthropic 客户端也带上);数据目录起草建的大、小两个模型都是 max_retries=0,429、5xx、网络错误、超时只由起草引擎按失败原因重试(最多 2 次)。以前 openai、anthropic 的 SDK 各自先重试 2 次,叠在引擎的重试上,一段最多发 9 次请求,报错却写「已重试 2 次」,网关限流时还放大流量。不写 max_retries 的调用照 SDK 默认,配方 AI 起草不动

This commit is contained in:
ThearchyHelios
2026-10-13 16:40:07 +08:00
parent bf0619a03c
commit ec6838dc10
4 changed files with 35 additions and 3 deletions
+3 -1
View File
@@ -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}))
+2 -1
View File
@@ -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:按事件循环各一个信号量,所有起草任务和同步
+7 -1
View File
@@ -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
@@ -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):