本节摘要:外挂的调用不是一行函数调用,是一段带保险的点火时序。本节实现从参数校验、并发闸门、点火、期限等待、退避重试到结果回填的完整骨架,为"慢、坏、丢"三态各配机制——超时与降级、熔断与隔离、幂等键与去重,并以 mock 工具联调与超时连环排错收尾。
一次库存查询发出去之后,就再没有回来。调用的代码停在那里等,主循环等调用,任务等主循环,整架飞行器为一个外部接口的沉默陪葬——这不是段子,是没装点火骨架的系统的日常。第 4 章为舵面指令定过纪律:期限、回执、幂等;外部工具的调用面对的世界更恶劣——对方会慢(响应拖过容忍线)、会坏(报错、返回垃圾)、会丢(请求石沉大海,成败未知)。本节的执行骨架就是为这三态设计的点火电路:每一态都有预定机制,主回路在任何一态下都不被拖下水。
完整时序分七步,顺序即纪律:构建(按清单参数模式组装请求,附幂等键与期限)、校验(门口拒带病参数,省一次注定失败的调用)、闸门(并发限额:对同一外挂的同时在飞调用数封顶,防自己把对方打垮)、点火(经适配器发出)、等待(按清单超时预算设期限,绝不无限等)、重试(可重试的失败按指数退避重发,幂等键护航)、回填(结果写状态板与工作记忆,调用全程入日志)。任何一步失败都不抛回主循环裸异常——按结构化回执交出,重试与降级的决策留给骨架而不是调用点。
import time, uuid, threading class ToolExecutor: """外挂点火电路:主回路的调用唯一入口。""" def __init__(self, registry, board, audit): self.registry = registry # 挂架注册表 self.board = board self.audit = audit # 点火全程入审计日志 self._inflight = {} # 外挂名 -> 当前在飞数 self._fail_streak = {} # 熔断计数 self._broken_until = {} # 熔断解除时刻 self._lock = threading.Lock() def fire(self, tool_name, params, budget_scale=1.0): manifest, adapter = self.registry.get(tool_name) receipt = {"tool": tool_name, "ok": False} if time.monotonic() < self._broken_until.get(tool_name, 0): receipt["reason"] = "熔断中:该外挂暂停调用" return receipt # 熔断态快返 if not self._acquire_slot(tool_name, manifest): receipt["reason"] = "并发闸门已满:稍后重试" return receipt try: idem = str(uuid.uuid4()) if manifest.idempotent else None deadline = manifest.timeout_s * budget_scale for attempt in range(1, MAX_RETRY + 1): t0 = time.monotonic() try: raw = adapter.call({**params, "idem_key": idem}) receipt["ok"], receipt["result"] = True, raw self._reset_streak(tool_name) break except TimeoutError: receipt["reason"] = f"超时(第{attempt}次)" except PeerError as exc: # 对方明确报错 receipt["reason"] = f"对端报错:{exc}" if not retryable(exc): break # 不可重试即止 if attempt < MAX_RETRY: time.sleep(min(0.2 * 2 ** attempt, 2.0)) # 退避 receipt["spent"] = time.monotonic() - t0 self._judge(tool_name, receipt) # 熔断计数 finally: self._release_slot(tool_name) self.board.write(f"tool:{tool_name}", receipt, writer="executor") self.audit.append("tool_fire", receipt) # 全程留痕 return receipt def _judge(self, name, receipt): """熔断裁决:连续失败到阈值,暂停该外挂一段时间。""" with self._lock: if receipt.get("ok"): self._fail_streak[name] = 0 else: streak = self._fail_streak.get(name, 0) + 1 self._fail_streak[name] = streak if streak >= BREAK_LIMIT: self._broken_until[name] = time.monotonic() + 60
骨架里最见功夫的是**"坏"与"丢"的区分**。对端明确报错(坏):按错误类型决定重试与否——参数错重试一百次也是错;超时无响应(丢):成败不明,只有幂等操作才敢直接重发,非幂等操作必须带幂等键让对端去重,否则宁可先查询对账再决定。把这两种失败混为一谈的系统,迟早送出双份订单。
三态各有机制,对照如下:慢——期限与降级。超时预算来自清单(6.1 的字段在此兑现),预算_scale 允许按任务松紧调节;超时后不是干等,是走降级路径(缓存答案、近似结果、排队改批处理)。坏——错误分类与熔断。可重试错误退避重试,不可重试错误即止;连续失败到阈值触发熔断,该外挂暂停调用一段时间,主回路改走替代方案——熔断保护的不是外挂,是自己的主回路。丢——幂等键与对账。请求发出无响应,重发靠幂等键去重;非幂等调用先对账再决定,宁可慢半拍不可双执行。
降级路径的注册表让"慢"有处可逃:
FALLBACKS = { # 外挂名 -> 按优先级尝试的降级链 "inventory_query": [ lambda p: read_cache(p), # 先查缓存 lambda p: approximate_by_last(p), # 再用最近近似值 lambda p: queue_for_batch(p), # 最后转批处理 ], "weather_feed": [ lambda p: use_last_known(p, max_age=600), ], } def degrade(tool_name, params): """降级:沿链尝试,任一成功即返回并标注降级来源。""" for step in FALLBACKS.get(tool_name, []): try: value = step(params) return {"ok": True, "degraded": True, "value": value} except Exception: continue return {"ok": False, "reason": "降级链全灭,交复飞决策"}
点火骨架的联调全部用 mock 工具:可控地模拟三态——延迟可调的慢接口、按次数报错后恢复的坏接口、吞请求不回的丢接口。剧本化演练:慢到超时看降级是否接管、坏满阈值看熔断是否触发且按时解除、丢配非幂等看是否先对账。真工具上线前,mock 剧本的通过记录就是它的出厂纸。
按症状排查:超时连环(一挂全挂)多半是并发闸门缺失——失败重试叠加新调用,把外挂彻底压垮;给熔断降级路径"让路",连锁自然断开。熔断震荡(反复熔断反复恢复)是阈值太敏或恢复太急,按对方故障的平均修复时长调暂停窗口。回执丢失(调了但状态板没有)查回填路径是否绕过接口——与全册所有"旁路直写"同罪。幂等键形同虚设(重发仍双执行)查对端是否真的实现了去重——幂等是双方的契约,单方面声明不算数。
💡 关键直觉:调用骨架的全部设计,是在替主回路挡子弹。主回路只该看到两种结果——成功的结果,或结构化的失败回执;裸异常、无限等待、悬空请求,任何一样漏进去,都是把点火电路的活交给了运气。
外挂能安全地点火了,还差最后一把锁:谁能挂、谁能点火、危险动作谁批准。下一节上技能库与安全锁。