feat(timeout): 统一超时预算单点治理(2026-09-16用户裁定:超时逻辑禁散落各产线各角色)——payload._timeout语义升级为一次调用总预算落ctx.budget,_post_upstream按deadline约束重试(总时长≈预算,客户端=预算+60恒覆盖上游,消灭3x预算放大→客户端先断连→模糊TimeoutError→分类器误判永久错误的事故链);attempt超时=剩余预算(合法长生成一次用满,373s实测样本),端点配置timeout(百炼120)不再掐断attempt——供应商参数不知道调用方需求,正是09-05提取/09-14develop/09-16QC三次事故共同根因,仅在直连未传_timeout时与_TOTAL_TIMEOUT取大兜底;预算耗尽(<15s)不再发起必然超时的attempt,抛结构化错误

This commit is contained in:
yumoqing 2026-09-16 17:58:13 +08:00
parent 28c7726ff9
commit 680afb7f9d

View File

@ -41,6 +41,9 @@ _RETRYABLE_STATUS = (429, 500, 502, 503, 504)
_MAX_ATTEMPTS = 3
_TOTAL_TIMEOUT = 300
_CONNECT_TIMEOUT = 30
# 单次 attempt 最少剩余预算2026-09-16 统一预算 deadline 配套):剩余不足
# 该值不再发起新 attempt必然超时白烧直接抛预算耗尽错误
_MIN_ATTEMPT_SECONDS = 15
EST_TOKENS_PER_CHAR = 2 # 预估用量:中文约 1 字符≈0.5 token保守按 2 字符 1 token
@ -426,15 +429,34 @@ async def _post_upstream(ctx, payload, require_choices=True):
kind = 'chat' if require_choices else 'gen'
# method 独立列2026-09-06 用户定夺):默认 POSTprofile 声明优先
method = (profile.get('method') or 'POST').upper()
timeout = int(_fnum(ctx.get('timeout')) or _TOTAL_TIMEOUT)
# 统一预算 deadline单点治理2026-09-16 用户裁定收敛):
# ctx['budget'] = 本次调用【总预算】bridge._resolve_budget 经 payload.
# _timeout 恒传。attempt 超时 = 剩余预算(合法长生成可一次用满,
# 373s 实测样本;快速失败的瞬时错误自然留出重试余量)。重试受
# deadline 约束,总时长≈预算,客户端(预算+60s恒覆盖上游。
# 端点配置 timeout百炼 120 等)不再掐断 attempt——它是供应商参数
# 不知道调用方需求,正是 09-05/09-14/09-16 三次事故的根因;仅在
# 直连端点且未传 _timeout 时参与兜底(与 _TOTAL_TIMEOUT 取大)。
# 旧实现每 attempt 独享全额超时,最坏 3×预算+退避 > 客户端等待 → 客户端
# 先断连报模糊 TimeoutError分类器误判永久错误2026-09-16 QC 事故链一环)。
budget = int(_fnum(ctx.get('budget')) or 0)
if budget <= 0:
budget = max(int(_fnum(ctx.get('timeout')) or 0), _TOTAL_TIMEOUT)
deadline = time.time() + budget
# 可诊断性:只记长度不记值(真 key 不落日志。print 确保进应用日志
# (模块 logging.getLogger 的 handler 未接应用日志管道2026-09-04 实测)
print("[inference] 组包 url=%s auth头长度=%d body字段=%s" % (
print("[inference] 组包 url=%s auth头长度=%d body字段=%s 预算=%ds" % (
url.split('?')[0], len(headers.get('Authorization', '') or ''),
sorted(body.keys())))
sorted(body.keys()), budget))
last = None
for attempt in range(_MAX_ATTEMPTS):
remaining = deadline - time.time()
if remaining < _MIN_ATTEMPT_SECONDS:
raise ValueError('上游调用失败(总预算 %ds 耗尽,已试 %d/%d 次,%s: %s' % (
budget, attempt, _MAX_ATTEMPTS, type(last).__name__ if last else '',
(repr(last) if last else '')[:200]))
timeout = int(remaining)
_t0 = time.time()
try:
async with aiohttp.ClientSession() as session:
@ -887,8 +909,14 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref='',
# llm_usage.call_id 同值 → 流水可反查全部原文trace.record_roundtrip / llm_call_trace
from appPublic.uniqueID import getID
ctx['call_id'] = getID()
# 统一预算2026-09-16 收敛payload._timeout = 本次调用【总预算】→
# ctx['budget']_post_upstream 按 deadline 约束重试,总时长≈预算)。
# ctx['timeout'] 保持供应商端点配置语义 = 单 attempt 上限,不再被覆盖
# (旧实现覆盖后每 attempt 独享全额预算,最坏 3× 超客户端等待)。
# 未传 _timeout直连端点的第三方调用→ budget 走 _TOTAL_TIMEOUT 平台
# 缺省,不再落到端点小配置掐断长生成。
if req_timeout > 0:
ctx['timeout'] = min(req_timeout, 900)
ctx['budget'] = min(req_timeout, 900)
# 异步模型:提交→轮询→取结果(用量因子走 usages 记账)
model_row = ctx.get('model_row') or {}
if (model_row.get('sync_mode') or 'sync') == 'async':