Brain loop 与降级恢复 深度解析
拆解 Brain.process 如何把 payload 变成 Decision,并在 judge 出错时保守降级。
导入链与契约
这个子页面聚焦 brain loop。关键证据包括 core/brain.py:52;core/brain.py:115;core/brain.py:247;core/brain.py:271;core/brain.py:130;core/state.py:1。这些引用不是源码索引,而是帮助定位边界:入口在哪里、数据对象在哪里、失败时由谁兜底。
▸Brain 构造参数
49class Brain: 50 """The triage→associate→decide loop for one incoming event (SPEC §4.2).""" 51 52 def __init__( 53 self, 54 state: State, 55 judge: Judge, 56 *, 57 policy_path, 58 quiet_hours: str = "23:00-08:00", 59 night_whitelist: list[str] | None = None, 60 scene_engine=None, 61 classifier: SimilarityClassifier | None = None, 62 memory=None, 63 inferrer=None, 64 shadow=None, 65 audit: AuditLog | None = None, 66 user_profile: str = "", 67 default_executor: str = "claude_code", 68 embedder=None, 69 actor: Callable | None = None, 70 now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), 71 judge_timeout: float = 150.0, # > HTTPJudge worst case (2 attempts x 60s) + slack 72 ): 73 self.state = state 74 self.judge = judge 75 self.policy_path = policy_path 76 self.quiet_hours = quiet_hours 77 self.night_whitelist = night_whitelist or ["family", "production_incident"] 78 self.scene_engine = scene_engine 79 self.classifier = classifier 80 self.memory = memory 81 self.inferrer = inferrer 82 self.shadow = shadow 83 self.audit = audit 84 self.user_profile = user_profile 85 self.default_executor = default_executor 86 self.embedder = embedder 87 self.actor = actor # async (Event, Decision) -> None; delivery/dispatch side 88 self.now_fn = now_fn 89 self.judge_timeout = judge_timeout 90 self._degraded: bool | None = None # None = unknown until first judgment 91 self._last_error: str | None = None
核心实现,逐段拆
▸process 主流程
process 在 core/brain.py:115 到 core/brain.py:198 完成 normalize、policy、scene、dedup、triage merge、stage1、stage2/judge、shadow、save 和 actor 调度。它像一条很克制的事件流水线。
115 async def process(self, payload: dict | Event) -> Decision: 116 from ingest.normalize import normalize 117 118 now = self.now_fn() 119 clock = _StageClock() 120 if isinstance(payload, Event): 121 event = payload 122 else: 123 event = await normalize(payload, inferrer=self.inferrer, now=now) 124 clock.mark("normalize") 125 126 policy = load_policy(self.policy_path) 127 scene = self._scene(now) 128 recent_keys = await self.state.recent_dedup_keys(now - timedelta(hours=24)) 129 130 # Triage merge (SPEC §4.2 step 1): a same-topic near-duplicate within 131 # 10 min folds into the earlier event; the earlier decision stands. 132 if self.embedder: 133 recent = await self.state.recent_events(now - timedelta(minutes=10)) 134 prior = find_mergeable(event, [e for e in recent if e.id != event.id], 135 embedder=self.embedder) 136 clock.mark("triage_merge", note="merged into prior" if prior else "no near-duplicate") 137 if prior: 138 merged = merge_events(prior, event) 139 await self.state.save_event(merged) 140 existing = await self.state.load_decision(prior.id) 141 if existing: 142 # the prior decision (and its trace) stands for the merged event 143 return existing.model_copy( 144 update={"reason": existing.reason + "; merged near-duplicate"} 145 ) 146 147 hit = stage1( 148 event, 149 now=now, 150 policy=policy, 151 quiet_hours=self.quiet_hours, 152 night_whitelist=self.night_whitelist, 153 recent_dedup_keys=recent_keys, 154 ) 155 if hit: 156 clock.mark("stage1", note=f"rule {hit.rule} fired") 157 decision = Decision( 158 event_id=event.id, 159 route=hit.route, # type: ignore[arg-type] 160 scene=scene.scene, 161 scene_confidence=scene.confidence, 162 cost=0.0, 163 matched_rules=[hit.rule], 164 reason=hit.reason, 165 stage=1, 166 trace=DecisionTrace(stages=clock.stages), 167 ) 168 else: 169 clock.mark("stage1", note="no hard rule fired") 170 decision = await self._stage2_or_judge(event, scene, policy, now, clock) 171 172 if self.shadow: 173 route, annotation = await self.shadow.apply(decision, now=now) 174 if annotation: 175 decision = decision.model_copy( 176 update={"route": route, "reason": f"{decision.reason}; {annotation}"} 177 ) 178 179 await self.state.save_event(event) 180 await self.state.save_decision(decision) 181 if self.actor: 182 asyncio.ensure_future(self._act_safely(event, decision)) 183 if self.audit: 184 self.audit.write( 185 { 186 "at": now.isoformat(), 187 "event_id": event.id, 188 "route": decision.route, 189 "stage": decision.stage, 190 "score": decision.score, 191 "scene": decision.scene, 192 "reason": decision.reason, 193 "cost": decision.cost, 194 "prompt_version": decision.trace.prompt_version if decision.trace else None, 195 "degraded": decision.degraded, 196 } 197 ) 198 return decision
▸judge 降级
降级块在 core/brain.py:247 到 core/brain.py:282,异常时写入 degraded meta,并把 route 保守设为 digest。恢复后 core/brain.py:283 到 core/brain.py:286 清除标记。
247 try: 248 result = await asyncio.wait_for( 249 self.judge.judge(event, context), self.judge_timeout 250 ) 251 except Exception as exc: # malformed output, timeout, backend down — never crash 252 logger.warning("judge %s unavailable (%s); degrading to rules-only", backend, exc) 253 last_error = f"{type(exc).__name__}: {exc}"[:200] 254 if self._degraded is not True or last_error != self._last_error: 255 # transition or changed failure mode: refresh the marker, but 256 # preserve `since` from any still-active record (incl. one 257 # written before a daemon restart mid-outage) 258 current = await load_degraded(self.state) 259 await self.state.set_meta( 260 DEGRADED_KEY, 261 { 262 "active": True, 263 "since": current["since"] if current else now.isoformat(), 264 "last_error": last_error, 265 }, 266 ) 267 self._degraded, self._last_error = True, last_error 268 clock.mark("judge", note=f"FAILED: {type(exc).__name__}") 269 # retries that failed were still paid for — bill them (JudgeError.usage) 270 usage = getattr(exc, "usage", None) 271 return Decision( 272 event_id=event.id, 273 route="digest", # conservative: never interrupt, never drop, while blind 274 scene=scene.scene, 275 scene_confidence=scene.confidence, 276 cost=self._bill(backend, usage), 277 reason=f"judge unavailable ({type(exc).__name__}); " 278 "conservative rules-only routing to digest", 279 stage=3, 280 degraded=True, 281 trace=self._usage_trace(clock, backend, usage), 282 ) 283 if self._degraded is not False: # recovery (or first success): clear marker once 284 await self.state.set_meta(DEGRADED_KEY, {"active": False}) 285 self._degraded = False 286 clock.mark("judge", note=f"backend {backend}")
关键流程
流程上,brain loop 并不是一个孤立函数。它会和 core/brain.py:52、core/brain.py:115、core/brain.py:247 共同形成一个闭环:先产生可解释中间状态,再把 route、score、cost 或 rejected reason 存回系统。
边界 / 失败模式
▸失败不是异常,而是产品路径
边界处理在源码里是显式产品逻辑:core/brain.py:271 负责保守降级,core/brain.py:130 负责避免重复或噪声,core/state.py:1 负责留下可追踪状态。这让 agent-chief 的可信度来自失败路径,而不是只来自 happy path。
- judge 不可用时不得盲目 interrupt。
- 历史相似事件只能在阈值内复用,不应跳过新奇事件。
- 派发结果必须验证,不能信任“done”的自述。