数据截至 (上游 commit 0b46c8430636)
控制面:hooks、middleware、干预与人类介入
30 秒导读: README 承诺你能「Stay in control / 加 Guardrails / 随时 Steering」。这一章讲清这些承诺在代码里怎么落地——四套彼此叠放的机制,让你在 agent 跑起来之后,依然能观察它、拦住它、改它、甚至把它按下暂停等人拍板。这正是 Strands 作为一个 harness(可管控运行时)、而不是一个"薄薄的模型调用包装"的分水岭。
本章上游是主线:递归事件循环——那里讲了循环怎么转;这里讲的是怎么在循环转的时候插手。两章互为表里:控制面的每个挂点,都对应循环里某个真实的消费点。
1. 这是什么(零基础也能懂)
先说一个直觉。
一个 agent 循环 = 「模型说要做什么 → 真去做 → 把结果喂回模型 → 再问模型」。控制面就是在这条流水线的每个关节上,预留的一排「插座」:你可以往插座里插一个小函数,在关节发生前 / 发生后被叫到,去看一眼、改一改、或者干脆喊停。
它解决谁的什么问题? 举几个真实场景:
- 你想在每次调用
delete_file工具前弹一句"确定吗?"等人点头——这是人类介入(human-in-the-loop)。 - 你想让某个用户永远不能调用某些工具——这是授权 / guardrail。
- 模型跑偏了,你想在它下一轮之前塞一句"你偏题了,回到 X"——这是steering(纠偏)。
- 你想给每一次模型调用记账、打日志、算 token、注入记忆——这是可观测性 / 上下文注入。
这些需求形状各异,但共同的本质只有一个:在 agent 自主运转的过程中,让外部代码有机会介入。Strands 没有把它们糊成一坨 if/else,而是分成了四种插座,各管一档。
一句话类比: 把 agent 循环想成一条工厂流水线,控制面就是流水线上的质检站、闸门和急停按钮——产品(工具调用 / 模型调用)经过时,你能检查、能拦下返工、能一键停线等人来看。
2. 顶层全景(四种插座,各管一档)
Strands 的控制面由四个子系统组成,从「最底层的通用机制」到「最上层的语义封装」层层叠放:
| 子系统 | 白话职责 | 是什么层级 | 代码位置 |
|---|---|---|---|
| Hook 系统 | 最底层的通用事件总线:在生命周期各点广播强类型事件,谁想听谁注册回调 | 机制底座 | strands-py/src/strands/hooks/ |
| 中间件管线 | 专门把「一次模型调用」包成可插拔的洋葱圈,能改输入、改输出、甚至短路缓存 | 单点的深度插座 | strands-py/src/strands/_middleware/ |
| 干预处理器 | 在 hook 之上的语义糖:你只管返回 Deny / Guide / Confirm / Transform,由它翻译成底层操作 | 高层策略封装 | strands-py/src/strands/interventions/ |
| 人类介入中断 | 让 agent 能在工具调用处真正挂起、把控制权交还给人、等回复后恢复 | 跨越单次调用的暂停机制 | strands-py/src/strands/interrupt.py |
它们不是四个独立王国,而是下层支撑上层:干预处理器其实是注册到 hook 系统上的一批回调;人类介入中断则是 hook 回调里抛出的一种特殊异常。理解顺序应当是从下往上——先懂 hook,后面三个都好懂。
这张图怎么读
从左到右是 agent 的一次「回合」;竖线是四种插座插进流水线的位置。Before 类在动作发生前触发(能拦、能改输入),After 类在动作完成后触发(能改结果、能要求重试)。
一次 agent 回合(cycle)
┌──────────────────────────────────────────────────────────────┐
│ │
│ [模型调用] [工具执行] │
│ │
│ BeforeModelCall ──► 中间件洋葱 ──► AfterModelCall │
│ │ (Input/Wrap/ │ │
│ │ Output 三相) │ │
│ ▼ ▼ │
│ 拦/改 prompt 改结果 / 要求重试 │
│ │
│ …模型说"调用工具 X"… │
│ │
│ BeforeToolCall ──► 执行 ──► AfterToolCall │
│ │ │ │
│ ▼ ▼ │
│ 拦/换工具/ 改结果/重试 │
│ 确认/抛中断 │
│ │ │
│ └──抛 InterruptException──► 挂起整个循环 │
│ 等人回复→恢复 │
└──────────────────────────────────────────────────────────────┘
贯穿始终:MessageAddedEvent(每加一条消息就广播)
BeforeInvocation / AfterInvocation(整次请求的头尾)
取消信号 _cancel_signal / Limits(在回合边界检 查)
下面逐层拆开讲。
3. Hook 系统:强类型的事件总线(底座)
3.1 它要解决的小问题
「让外部代码在生命周期各点被回调」——最土的做法是留一个 callback_handler 让你塞一个函数。但那样一个点只能挂一个人,而且传给你的是一坨 **kwargs,你得自己猜里面有啥。
Strands 换成了强类型事件 + 多订阅者:每个生命周期节点是一个事件类(如 BeforeToolCallEvent),字段清清楚楚;你按事件类型注册回调,同一个事件可以有任意多个订阅者。strands-py/src/strands/hooks/__init__.py:27 的模块注释直说这是"replaces the older callback_handler approach"。
3.2 三个核心角色
Hook 系统就三个概念:
- 事件(Event) —— 一个
@dataclass,携带该节点的上下文。基类BaseHookEvent在strands-py/src/strands/hooks/registry.py:49。 - 回 调(HookCallback) —— 一个收单个事件参数的函数,可同步可
async。协议定义在registry.py:141。 - 注册表(HookRegistry) —— 事件类型 → 回调列表的映射,负责分发。定义在
registry.py:169。
再加一个便利角色 HookProvider(registry.py:113):一个带 register_hooks(registry) 方法的对象,用来批量把一组相关回调注册进去——SDK 内部的会话管理器、对话管理器、重试策略,都是以 HookProvider 身份挂进来的(见 strands-py/src/strands/agent/agent.py:449-455)。
3.3 有哪些挂点(事件目录)
单 agent 生命周期的核心事件如下(都在 strands-py/src/strands/hooks/events.py):
| 事件 | 触发时机 | 可写字段(能改什么) | 定义 |
|---|---|---|---|
AgentInitializedEvent | agent 构造完成后 | 无 | events.py:27 |
BeforeInvocationEvent | 每次请求开始(__call__/stream_async/structured_output) | messages、cancel | events.py:39 |
AfterInvocationEvent | 每次请求结束(逆序回调) | resume | events.py:70 |
MessageAddedEvent | 框架每往历史加一条消息 | 无 | events.py:118 |
BeforeToolCallEvent | 每个工具执行前 | selected_tool、tool_use、cancel_tool | events.py:137 |
AfterToolCallEvent | 每个工具执行后(逆序回调) | result、retry | events.py:177 |
BeforeModelCallEvent | 每次模型调用前 | cancel | events.py:229 |
AfterModelCallEvent | 每次模型调用后(逆序回调) | retry | events.py:259 |
注:还有一组多 agent 编排事件(
BeforeNodeCallEvent等,events.py:335起),那属于越过单 agent的话题,这里不展开。
3.4 精华一:事件是「只读默认 + 白名单可写」
这是很妙的一个设计。一个 hook 回调能改什么、不能改什么,不是靠文档口头约定,而是在类型层面被强制的。
BaseHookEvent 重写了 __setattr__(registry.py:79):初始化完成后,任何赋值默认都抛 AttributeError;只有子类在 _can_write(name) 里显式列白名单的字段才放行。
# 真实源码 registry.py:79 —— 事件默认不可写
def __setattr__(self, name, value):
# 初始化期间放行;或子类显式声明该字段可写才放行
if not hasattr(self, "_disallow_writes") or self._can_write(name):
return super().__setattr__(name, value)
raise AttributeError(f"Property {name} is not writable")
于是 BeforeToolCallEvent._can_write(events.py:160)只允许改 cancel_tool / selected_tool / tool_use——你想在 before-tool 回调里改结果?门都没有,那是 after 的事。能改什么由挂点语义决定,越权直接报错。
3.5 精华二:执行顺序 = 优先级 + 注册序 + 可逆
多个回调听同一个事件,谁先谁后?HookOrder(registry.py:27)给了几个命名档位:
# registry.py:27 —— 数字越小越先执行
SDK_FIRST = -100
INTERVENTION_OUTPUT = -90
DEFAULT = 0
INTERVENTION_INPUT = 90
SDK_LAST = 100
注册时用 bisect.insort 按 order 有序插入(registry.py:258),同档位保持注册顺序。分发时(get_callbacks_for,registry.py:408)按序取出。
还有一个巧思:should_reverse_callbacks(registry.py:52)。像 AfterToolCallEvent、AfterInvocationEvent 这类收尾事件返回 True——同优先级内逆序回调(events.py:223 / events.py:112)。这符合直觉:setup 是 A→B→C,teardown 就该 C→B→A,像栈一样先进后出。
3.6 事件怎么被真正广播出来
分发入口是 invoke_callbacks_async(registry.py:301)。它遍历该事件的回调,同步就直接调、async 就 await。以工具执行为例,strands-py/src/strands/tools/executors/_executor.py:59-64 构造 BeforeToolCallEvent 并 await agent.hooks.invoke_callbacks_async(event);模型侧则在 strands-py/src/strands/event_loop/event_loop.py:590 广播 AfterModelCallEvent。
invoke_callbacks_async 的返回值是个 tuple:(event, list[Interrupt])——它顺手把回调抛出的中断收集起来了。这一句为下面第 6 节的人类介入埋了线,先记住。
4. 中间件管线:把「模型调用」包成洋葱(单点深度插座)
4.1 为什么模型调用需要单独一套机制
Hook 的 before/after 是两个离散的点——你能在调用前看一眼、调用后看一眼,但没法把这一次调用整个"包裹"起来:比如你想"如果缓存命中就根本别调模型"、或"给整个调用套一层计时/重试/限流"。这种"环绕(around)"语义,离散的 before/after 表达不了。
中间件就是干这个的。它把一次模型调用做成一圈圈可叠放的洋葱,每一圈都能决定"要不要往里走、往里传什么、拿到什么再往外传"。目前只实现了 InvokeModelStage 这一个阶段(strands-py/src/strands/_middleware/README.md 明说 tool/stream 阶段"will be added as needed")。
重要:
_middleware/是内部包(带下划线),不是公开 API。用户通过agent._middleware_registry.add_middleware(...)接触它。SDK 自带的记忆注入(memory/memory_manager.py:639)和 agentic 上下文的 token 计量(agent/agent.py:407)就是这么挂进去的。
4.2 三个相位:Input / Wrap / Output
一个中间件"阶段"(MiddlewareStage,_middleware/types.py:70)暴露三个子相位,分别对应三种插法:
| 相位 | 你拿到什么 / 返回什么 | 用来干嘛 |
|---|---|---|
| Input | 拿到 context,返回改过的 context | 调用前改输入(注入 system prompt、塞 token 预算) |
| Wrap | 拿到 (context, next),自己是个 async 生成器 | 完全环绕:计时、短路缓存、异常兜底 |
| Output | 拿到 MiddlewareResult 包裹的结果,返回改过的 | 调用后改结果事件 |
InvokeModelStage 的上下文是 InvokeModelContext(_middleware/stages.py:17),携带 messages / system_prompt / tool_specs / tool_choice / invocation_state。
4.3 精华:Python 没有生成器返回值,于是「最后一个 yield 就是结果」
这是 Strands 从 TypeScript SDK 移植过来时,一个非常聪明的对齐。
TS 用 async generator 的 return 值 + yield* 传递结果。但 Python 的 async 生成器不能 return 值。怎么办?README.md 给出的答案是:流里最后一个 yield 出来的事件,本身就是结果——这刚好契合 Python SDK 既有的约定(ModelStopReason 本就是 stream_messages() 流的最后一个事件)。中间件链是透明的,事件(包括那个"结果事件")自然流穿过去,没有额外的哨兵类型。
于是"透传"和"短路缓存"分别长这样:
# 示意,非源码(取自 _middleware/README.md 的模式)
async def passthrough(context, next_fn): # 透传:啥也不改,往下走
async for event in next_fn(context):
yield event
async def cached(context, next_fn): # 短路:直接吐结果,根本不调 next_fn → 不碰模型
yield ModelStopReason(stop_reason="end_turn", message=cached_msg, ...)
Output 相位要改结果,又不能污染中间流过的普通事件,于是引入了一个薄包装 MiddlewareResult(_middleware/types.py:16):注册表在调用 Output 处理器前把"最后那个结果事件"包进 MiddlewareResult,处理器改完再拆包塞回流里。真实的拆/包逻辑在 _add_output(_middleware/registry.py:74-100)。
4.4 链条怎 么组装与运行
MiddlewareRegistry.compose(_middleware/registry.py:102)把某阶段所有处理器从里到外包成一个大函数,invoke(_middleware/registry.py:142)则组装并驱动它。两个工程细节值得记:
- 零开销快路径:没注册任何中间件时,
compose直接返回 terminal(registry.py:108-109),不套任何一层。 - 生成器一定被清理:
compose用try/finally+ 显式aclose()(registry.py:128-134)确保内层生成器都被关闭,防止资源泄漏。
事件循环端的消费点在 strands-py/src/strands/event_loop/event_loop.py:551:构造 InvokeModelContext(用深拷贝做防御,见 4.5),然后 async for event in agent._middleware_registry.invoke(InvokeModelStage, ctx, terminal),最后一个事件即 ModelStopReason。
4.5 精华:防御性拷贝,把「能碰的」和「不能碰的」分清
InvokeModelContext 的注释(_middleware/stages.py:20)点破了一条纪律:messages / system_prompt / tool_specs / tool_choice 都是深拷贝——中间件改坏了也波及不到 agent 真实状态; 而 invocation_state 是按引用共享的(hook 和工具要往里写流式状态)。更狠的是 model_state 根本不放进 context(event_loop.py:542-546):在链外做快照,链跑完成功了才写回,中间件碰都碰不到模型状态。哪些能改、哪些是只读、哪些干脆不给看——分得明明白白。
5. 干预处理器:写策略的人只管「说要干嘛」(高层语义封装)
5.1 它要解决的小问题
Hook 很强,但太底层。你想写一条"未授权就拦掉这个工具"的策略,用裸 hook 得自己去改 event.cancel_tool = "..."、自己记得短路、自己拼消息字符串。写授权、写 guardrail 的人,不该关心这些管道。
干预处理器(intervention)就是架在 hook 之上的语义层:你继承 InterventionHandler,重写你关心的生命周期方法,返回一个语义化的动作——Proceed(放行)、Deny(拦)、Guide(纠偏)、Confirm(要人确认)、Transform(改内容)。翻译成底层 hook 操作的脏活,由 InterventionRegistry 全包了。__init__.py:2 直接把它定位成"authorization、steering、guardrails"的一等原语。
5.2 五种动作,一张兼容矩阵
动作都是冻结的 @dataclass(strands-py/src/strands/interventions/actions.py):
| 动作 | 语义 | 定义 |
|---|---|---|
Proceed | 放行,啥也不改 | actions.py:44 |
Deny | 拦掉,reason 作为取消消息喂给模型 | actions.py:56 |
Guide | 给反馈纠偏(具体行为随挂点而变) | actions.py:64 |
Confirm | 要人确认,仅 before_tool_call 支持 | actions.py:88 |
Transform | 就地改事件内容,apply(event) 变异 | actions.py:104 |
关键在于:同一个动作,在不同挂点含义不同。actions.py:120 的 InterventionAction 文档里嵌了一张兼容矩阵。挑重点看 Guide:在 before_tool_call 上它设 cancel_tool 让模型看到反馈;在 before_model_call 上它把反馈注入成一条 user 消息;在 after_model_call 上它丢弃这次回复并要求模型带着反馈重试。这三种落法分别对应 registry.py:141 / registry.py:166 / registry.py:178 三段代码。
5.3 精华:只为「被重写的方法」注册 hook
InterventionHandler(handler.py:43)的所有生命周期方法都有默认实现——直接返回 Proceed()。你只重写你在乎的那个。
那注册表怎么知道你重写了哪些?靠 _is_overridden(registry.py:58):拿子类的方法和基类的方法比是不是同一个对象。
# 真实源码 registry.py:58
def _is_overridden(self, handler, method):
handler_method = getattr(type(handler), method, None)
base_method = getattr(InterventionHandler, method, None)
return handler_method is not base_method # 不是基类那个 → 你重写了
然后 _register_hooks(registry.py:64)按需注册:只有至少一个 handler 重写了 before_tool_call,才给 BeforeToolCallEvent 挂回调。没人用的挂点一个回调都不挂——零成本抽象。注意注册用的 order 正是第 3.5 节那两个档位:输入类用 INTERVENTION_INPUT,输出类用 INTERVENTION_OUTPUT(registry.py:69 / registry.py:81)。
5.4 精华:多 handler 的仲裁——Deny 短路、Guide 累积
多个 handler 对同一挂点发话,谁说了算?_dispatch(registry.py:189)定了仲裁规则:
- 按注册顺序逐个 handler 求值(支持
async,registry.py:210用inspect.isawaitable分支)。 - 碰到
Deny(以及被拒的Confirm),apply返回True→ 立即短路,后面的 handler 不再问(registry.py:225-227)。第一个"不"就否决全场。 - 而
Guide不短路,是累积的(registry.py:221-222):所有 handler 的纠偏反馈攒到最后,拼成一条[handlerA] ...\n[handlerB] ...一起注入(registry.py:236-239)。多个纠偏声音都被听见。
5.5 精华:on_error 的三种失败姿态
策略检查器自己抛异常了怎么办?这在安全场景是要命的问题。OnError(handler.py:32)给了三档,由 _handle_error(registry.py:241)执行:
on_error | 行为 | 姿态 |
|---|---|---|
"throw"(默认) | 重新抛出,坏掉的策略阻断执行 | 最安全 |
"deny" | 记日志,当作 Deny | fail-closed(失败即拒) |
"proceed" | 记日志,当作 Proceed 继续 | fail-open(失败即放行) |
handler.py:39 的注释对 "proceed" 特意加了警告:这是 fail-open,一个坏掉的 handler 会悄悄停止执行它的策略,只在"可用性比强制执行更重要"时才用。默认给 throw 而不是 proceed,是一个"安全优先"的正确默认值。
6. 人类介入中断:把 agent 真正按下暂停(跨调用的暂停机制)
6.1 它要解决的小问题
前面几种机制,都在一次进程内、一个 await 里做决定——哪怕是 Confirm,也得当场有个答案。但真正的 human-in-the-loop 常常是异步的:agent 要删库,得停下来,把请求返回给调用方,可能几分钟、几小时后人才回一句"批准",然后 agent 从原地继续。
这要求的不是"回调里 sleep",而是把整个 agent 循环挂起、序列化、之后恢复。这就是 strands-py/src/strands/interrupt.py 干的事。
6.2 从使用者视角看一遍
strands-py/src/strands/types/interrupt.py:18 的文档给了完整示例,提炼其骨架:
# 示意(浓缩自 types/interrupt.py 的 docstring)
class ToolInterruptHook(HookProvider):
def register_hooks(self, registry, **kwargs):
registry.add_callback(BeforeToolCallEvent, self.approve)
def approve(self, event: BeforeToolCallEvent):
if event.tool_use["name"] != "delete_tool":
return
# 第一次调用 interrupt():没有回复 → 抛异常,挂起 agent
# 恢复后再次调用:返回人给的回复
approval = event.interrupt("for_delete_tool", reason="APPROVAL")
if approval != "A":
event.cancel_tool = "approval was not granted"
result = agent("delete object with key 'X'")
if result.stop_reason == "interrupt": # agent 停在这
responses = [{"interruptResponse": {"interruptId": it.id, "response": "A"}}
for it in result.interrupts]
result = agent(responses) # 带回复重新 invoke → 从原地恢复
重点看那个「第一次抛、第二次返回」的双相行为——它是整个机制的心脏。
6.3 机制全貌:一次挂起-恢复的完整链路
四个数据结构撑起这套机制:
| 结构 | 职责 | 定义 |
|---|---|---|
Interrupt | 一个中断请求(id / name / reason / response) | interrupt.py:11 |
InterruptException | 携带 Interrupt 的异常,用来"跳出" | interrupt.py:32 |
_InterruptState | 挂在 agent 上的挂起状态机 | interrupt.py:40 |
_Interruptible | 给事件加 interrupt() 方法的混入协议 | types/interrupt.py:79 |
_Interruptible.interrupt() 的双相逻辑(types/interrupt.py:82-111)是关键。它去 agent 的 _interrupt_state.interrupts 里按 id 找这个中断:
# 真实源码 types/interrupt.py:107
interrupt_ = state.interrupts.setdefault(id, Interrupt(id, name, reason, response))
if interrupt_.response is not None: # 已经有人回复了 → 直接把回复返回
return interrupt_.response
raise InterruptException(interrupt_) # 还没回复 → 抛异常,往上冒
同一个 name,BeforeToolCallEvent._interrupt_id(events.py:164)会用 tool_use['toolUseId'] 拼出稳定的 id——所以恢复后再问同一个中断,能对上号。
异常怎么变成"挂起"而不是"崩溃"? 回到第 3.6 节:invoke_callbacks_async(registry.py:301)专门 catch InterruptException(registry.py:335),把它收进一个 interrupts 字典(每个回调只准抛一个,重名报错),作为返回值的一部分吐出去——中断被"驯化"成了数据,而非崩溃。
数据一路往上传到循环层。 工具执行器拿到非空 interrupts 就 yield 一个 ToolInterruptEvent 并 return(tools/executors/_executor.py:159-161);事件循环把这些中断收集起来(event_loop.py:793-794),然后做关键三步(event_loop.py:806-819):
# 真实源码 event_loop.py:805
if interrupts:
agent._interrupt_state.context = {"tool_use_message": message, "tool_results": tool_results}
agent._interrupt_state.activate() # ① 把状态机点亮
yield EventLoopStopEvent("interrupt", message, ..., interrupts) # ② stop_reason="interrupt" 停循环
activate()(interrupt.py:57)把 activated=True。这个标志位就是挂起-恢复的总开关:
- 挂起态下会话可被持久化:
_InterruptState.to_dict/from_dict(interrupt.py:120-140)可序列化,types/session.py会把它存进会话——这就是"等几小时"也不丢状态的底气(详见上下文与持久化)。 - 恢复时跳过已做的事:用户带回复重新 invoke,
_run_loop调_interrupt_state.resume(prompt)(agent/agent.py:1176)。resume(interrupt.py:72)校验回复格式,把每个回复按 id 填回对应Interrupt.response。 - 恢复后循环的"重入"处理:事件循环开头见
activated就跳过模型调用(event_loop.py:283,因为消息里已有 tool_use),并把上次存的tool_results接回来、只重跑当时被中断的那个工具(event_loop.py:740-745)。于是第二次interrupt()命中response is not None分支,直接返回"A",工具正常往下走。 - 恢复完清场:一轮走完
deactivate()(interrupt.py:62)清空 interrupts 和 context。
为什么第 3 节说 invoke_callbacks_async 返回 tuple 是"埋线" —— 现在看明白了:那第二个返回值 list[Interrupt],就是这整条挂起链路的起点。
6.4 边界:中断只支持工具调用处
event_loop.py:282 的注释直说:"interrupts are currently only supported for tool calls"。模型调用中途不能挂起(想想也对,一次流式生成没法从中间恢复)。此外直接工具调用(tool_caller)里若撞上中断态会直接 RuntimeError(tools/_caller.py:84、_caller.py:121)——挂起中的 agent 不允许被旁路直调工具,以免状态错乱。
7. 取消与 Limits:控制面里的"急停"与"预算闸"
还有两个轻量但属于控制面的机制,顺带交代。
协作式取消(cooperative cancellation)。 agent 上挂着一个 threading.Event 叫 _cancel_signal(agent/agent.py:345)。外部调 agent.cancel()(agent/agent.py:588)只是 self._cancel_signal.set()(agent/agent.py:617),幂等、线程安全。真正的停,发生在循环的检查点:工具执行前会看 _cancel_signal.is_set()(event_loop.py:751),命中就给每个待执行工具补一个"cancelled"结果(维持消息状态合法)、然后以 stop_reason="cancelled" 收尾。SDK 不接受外部 cancel token,而是这套内部信号 + 边界检查——这是"协作式"的含义:不粗暴打断,在安全的关节停。
Limits:每次调用的预算闸。 Limits(strands-py/src/strands/types/agent.py:17)是个 TypedDict,给单次 invoke 设三种上限:turns(循环轮数)、output_tokens、total_tokens。它们是每次调用独立、在每轮循环顶部检查的软上限(types/agent.py:24 注释:同时触发时优先级 turns > total_tokens > output_tokens,对应 stop_reason 分别是 limit_turns 等)。它在控制面的位置:和取消信号一样,是循环边界上的闸门,只不过一个由人手动扳、一个由预算自动扳。
8. 巧妙之处(可带走的技术)
- 事件用
__setattr__做"白名单可写"(registry.py:79+ 各_can_write):把"这个挂点能改什么"从口头约定变成类型层强制,越权即AttributeError。可迁移到任何"回调能改一部分上下文"的场景。 should_reverse_callbacks让 after 类天然逆序(registry.py:52):setup/teardown 用同一套注册、自动镜像执行顺序,像栈。- 中间件"最后一个 yield 即结果"(
_middleware/README.md):绕开 Python async 生成器不能return值的限制,同时复用了 SDK"流的末事件即结果"的既有约定,零新概念。 _is_overridden按需注册(registry.py:58):没人用的挂点一个回调都不挂,零成本抽象;比"注册一堆空回调再判断"干净得多。on_error显式区分 fail-open/fail-closed(handler.py:32):把安全领域的关键取舍做成一个字段,并给了"throw"这个安全默认——而不是让实现者不小心 fail-open。- 中断把异常"驯化"成数据(
registry.py:335):InterruptException一路被捕获、聚合、序列化,让"崩溃式跳出"变成"可持久化、可恢复的挂起",这是 human-in-the-loop 能跨越小时级等待的根本。
9. 边界与局限(诚实说)
- 中断只在工具调用处(
event_loop.py:282):模型生成中途无法挂起。 - 中间件只实现了
InvokeModelStage(_middleware/README.md):tool/stream 阶段尚未落地。而且中间件、中断都不支持注销(README.md"No removal"、hook 系统同样不支持移除)——注册即终身。 _middleware/是私有 API:签名可能变,别在生产里依赖agent._middleware_registry。Guide在 model 前后注入的消息绕过会话管理(actions.py:77的 note):session manager 不会跟踪这些注入消息;after_model_call上的Guide触发重试且框架不设重试上限(actions.py:72警告:handler 自己得保证收敛)。- 取消/Limits 都是软的、边界检查的:不会瞬时打断正在跑的工具或超大的单次响应,只在下个关节生效。
10. 代码地图(导航索引)
用符号名 grep 比行号更抗漂移。下表按"想插手哪一档"组织。
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| Hook 注册与分发 | strands-py/src/strands/hooks/registry.py | HookRegistry、add_callback、invoke_callbacks_async、get_callbacks_for、HookOrder |
| 事件只读/可写强制 | strands-py/src/strands/hooks/registry.py | BaseHookEvent.__setattr__、_can_write |
| 生命周期事件目录 | strands-py/src/strands/hooks/events.py | BeforeToolCallEvent、AfterToolCallEvent、BeforeModelCallEvent、AfterModelCallEvent、MessageAddedEvent、BeforeInvocationEvent |
| HookProvider 批量注册 | strands-py/src/strands/hooks/registry.py | HookProvider、add_hook |
| 中间件阶段与相位 | strands-py/src/strands/_middleware/types.py | MiddlewareStage、MiddlewareInputPhase、MiddlewareResult |
| 中间件组装/驱动 | strands-py/src/strands/_middleware/registry.py | MiddlewareRegistry、compose、invoke、_add_output |
| 模型调用上下文 | strands-py/src/strands/_middleware/stages.py | InvokeModelContext、InvokeModelStage |
| 中间件消费点 | strands-py/src/strands/event_loop/event_loop.py | _middleware_registry.invoke、_make_invoke_model_terminal |
| 干预基类与动作 | strands-py/src/strands/interventions/handler.py、interventions/actions.py | InterventionHandler、OnError、Proceed/Deny/Guide/Confirm/Transform |
| 干预仲裁/桥接 | strands-py/src/strands/interventions/registry.py | InterventionRegistry、_is_overridden、_dispatch、_handle_error |
| 中断核心状态机 | strands-py/src/strands/interrupt.py | Interrupt、InterruptException、_InterruptState、activate/resume/deactivate |
| 中断触发接口 | strands-py/src/strands/types/interrupt.py | _Interruptible.interrupt、_interrupt_id |
| 中断在循环里的挂起/恢复 | strands-py/src/strands/event_loop/event_loop.py | _interrupt_state.activated(:283/:739)、activate(:808)、EventLoopStopEvent("interrupt") |
| 取消与预算 | strands-py/src/strands/agent/agent.py、types/agent.py | cancel、_cancel_signal、Limits、ConcurrentInvocationMode |
| 控制面在 Agent 上的装配 | strands-py/src/strands/agent/agent.py | hooks、_middleware_registry、_intervention_registry、_interrupt_state(:398-471) |
继续读: 中断态如何被序列化、跨会话恢复 → 上下文与持久化;这些挂点在完整回合里的触发时序 → 主线:递归事件循环;工具执行如何消费 BeforeToolCallEvent/中断 → 工具系统。