Day 56 / 共 60 天 · 阶段9 进阶与生态

hooks:在"每次 LLM 调用/每次工具执行"前后插一脚

Day 55 的 @before_kickoff 是"整个 Crew 开跑前"的粗粒度钩子。但生产里你常需要更细:每次调 LLM 前审计一下、超过 5 轮就要求人工审批、每次工具执行前校验参数、执行后脱敏结果。这就是 hooks/ 模块——四个装饰器 @before_llm_call/@after_llm_call/@before_tool_call/@after_tool_call。今天拆开:钩子的类型协议、暴露可变状态的 Context 对象、装饰器如何注册到全局表、执行循环如何在关键点调用它们、以及"返回 False 拦截 / 返回 str 改结果"的约定

📍 你在 60 天里的位置(阶段9 进阶与生态 · D55-58)
阶段8 LLM 集成 D55 @CrewBase D56 hooks 钩子 D57 security 安全 D58 a2a 协作 阶段10 收官
💡 先用一个类比兜住今天 钩子就像安检口和质检台。before 钩子是"进门安检":LLM/工具要动手前,先过你这一关——你可以查一查(审计)、改行李(改 messages/参数)、甚至拦下不让进(返回 False)。after 钩子是"出门质检台":拿到结果后你可以返修(返回新字符串替换结果,比如脱敏)。整套机制的巧妙之处在于——安检员看到的不是复印件,而是真行李本身(Context 里的 messages 是原对象引用),你原地一改,后面就都变了。
L01

痛点:Agent 是个黑盒,想插一脚很难

🤔 痛点Day 08 那个 while 循环跑起来后就是个黑盒:它自己问 LLM、自己调工具。可产品需求全是"在中间插一脚"——每次调 LLM 前记一条审计日志;某些敏感工具执行前要人工点头;工具返回里含手机号要脱敏;迭代太多轮想强行终止。难道要去改 CrewAI 源码里那个循环吗?那升级版本就全丢了。得有个官方的"扩展点"。
💡 一句话本质 钩子系统 = 在执行循环的四个关键时刻埋了"回调点",允许你注册函数进去。四个点:LLM 调用前/后、工具执行前/后。约定极简:before 钩子返回 False 就阻断这次执行;after 钩子返回 str 就替换结果;返回 None 表示"不干预"。所有钩子都收到一个 Context 对象,里面是可原地修改的 messages/tool_input/response。
钩子时机返回值约定典型用途
before_llm_call问 LLM 前bool | None(False=阻断)审计、改 messages、人工审批、限流
after_llm_call拿到回答后str | None(str=替换)改写回答、追加历史、脱敏
before_tool_call执行工具前bool | None(False=阻断)校验/改参数、危险工具拦截
after_tool_call工具返回后str | None(str=替换)脱敏、裁剪超长结果
L02

四种钩子的类型协议:一个泛型 Protocol

钩子的"形状"用 Protocol 定义(hooks/types.py:17):

# hooks/types.py:17
ContextT = TypeVar("ContextT", contravariant=True)
ReturnT  = TypeVar("ReturnT", covariant=True)

@runtime_checkable
class Hook(Protocol, Generic[ContextT, ReturnT]):
    """Generic protocol for hook functions."""
    def __call__(self, context: ContextT) -> ReturnT: ...

# :47 before hooks 都返回 bool|None(False 阻断)
class BeforeLLMCallHook(Hook["LLMCallHookContext", bool | None], Protocol):
    def __call__(self, context: LLMCallHookContext) -> bool | None: ...

# :67 after hooks 都返回 str|None(str 替换)
class AfterLLMCallHook(Hook["LLMCallHookContext", str | None], Protocol):
    def __call__(self, context: LLMCallHookContext) -> str | None: ...

# :128 便捷类型别名
BeforeLLMCallHookType = Hook["LLMCallHookContext", bool | None]
AfterLLMCallHookType  = Hook["LLMCallHookContext", str | None]
Protocol(鸭子类型)钩子不需要继承任何基类——只要"能接收一个 context、返回对应类型"就算数。普通函数、lambda、类方法都行。
Generic[ContextT, ReturnT]两个类型参数:吃什么上下文、吐什么返回值。LLM 钩子吃 LLMCallHookContext,工具钩子吃 ToolCallHookContext
contravariant / covariant参数逆变、返回协变——类型系统正确性细节。实际用不到,但保证类型检查器不误报。
before 返回 bool、after 返回 str★核心约定,用类型钉死:before 只能表达"放行/阻断",after 只能表达"替换/不换"。语义清晰不混淆。
💡 为什么用 Protocol 而不是抽象基类(ABC)?如果强制"钩子必须继承 BaseHook",那用户写个一行 lambda 都得先造个类,太重。Protocol 是"结构化类型"——只看形状不看血缘,@before_llm_call\ndef log(ctx): ... 这样一个裸函数就直接满足协议。让扩展点的使用成本降到最低。
L03

Context:钩子的"操作台",暴露可变状态

钩子能干活,全靠这个上下文对象(hooks/llm_hooks.py:24):

# hooks/llm_hooks.py:24
class LLMCallHookContext:
    """Context object passed to LLM call hooks.
    messages: Direct reference to messages (mutable list).
        IMPORTANT: Modify messages in-place (append/extend/remove).
        Do NOT replace the list (context.messages = []), as this will break the executor."""
    executor: ...
    messages: list[LLMMessage]     # ★对执行器 messages 的直接引用(不是拷贝)
    agent: Any; task: Any; crew: Any
    llm: BaseLLM | None
    iterations: int                # 当前第几轮
    response: str | None           # 只有 after 钩子才有值

    def __init__(self, executor=None, response=None, messages=None, ...):
        if executor is not None:
            self.executor   = executor
            self.messages   = executor.messages     # 直接指向执行器的那份 messages
            self.llm        = executor.llm
            self.iterations = executor.iterations
            ...
        self.response = response

它还带一个"请求人工输入"的方法,用于审批场景(hooks/llm_hooks.py:109):

# hooks/llm_hooks.py:109
def request_human_input(self, prompt, default_message="Press Enter to continue..."):
    """暂停实时输出、向用户展示 prompt、等待输入、再恢复。用于审批/调试。"""
self.messages = executor.messages★灵魂:不是拷贝,是同一个列表对象的引用。你在钩子里 ctx.messages.append(...),执行器下一轮问 LLM 时就会看到。
iterations当前轮数。钩子可据此判断"转太多轮了"——比如 if ctx.iterations > 5: 要求审批
response(仅 after)before 时 LLM 还没回答,response 是 None;after 时才填上 LLM 的回答,供你检查/替换。
direct LLM 兼容executor=None 的分支(:98)支持"不在 Crew 里、直接调 LLM"也能用钩子。一个 Context 覆盖两种场景。
数据结构:LLMCallHookContext 与它引用的执行器状态 CrewAgentExecutor messages: list ←──┐ llm / iterations agent / task / crew 真正的状态住在这 Context(给钩子) messages ──同一对象┘ iterations(拷值) response(仅 after) 是"操作台",不是副本 messages 引用同一列表 ctx.messages.append() → 执行器立刻看到
图注:Context 的 messages 是执行器那份的引用(同一对象)。原地改立刻生效;整体赋值只改局部变量、丢失改动。
⚠️ 边界:千万别 ctx.messages = [...] 整个替换 docstring 反复警告:要 append/extend/remove 原地改,不要赋值替换。因为 ctx.messagesexecutor.messages 是同一个列表——你 ctx.messages = [] 只是把局部变量指向了新列表,执行器那份没变,你的改动全丢。源码甚至在钩子跑完后检查"messages 还是不是 list",被换成非 list 就打警告并还原(见 L07)。这是"引用别名"最经典的坑。
L04

装饰器与全局注册表

四个装饰器由一个工厂统一生产(hooks/decorators.py:18):

# hooks/decorators.py:18
def _create_hook_decorator(hook_type, register_function, marker_attribute):
    def decorator_factory(func=None, *, tools=None, agents=None):
        if tools:
            tools = [sanitize_tool_name(t) for t in tools]
        def decorator(f):
            setattr(f, marker_attribute, True)          # 打标记(如 is_before_llm_call_hook)
            sig = inspect.signature(f)
            params = list(sig.parameters.keys())
            is_method = len(params) >= 2 and params[0] == "self"   # 判断是不是类方法
            ...
            if not is_method:
                register_function(f)                    # 普通函数→立即注册到全局表
            return f
        if func is None:
            return decorator                            # @before_llm_call(agents=[...]) 带参用法
        return decorator(func)                          # @before_llm_call 裸用法
    return decorator_factory

注册就是往一个模块级列表里 append(hooks/llm_hooks.py:153):

# hooks/llm_hooks.py:153
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = []

# :157
def register_before_llm_call_hook(hook):
    ...
    _before_llm_call_hooks.append(hook)    # :190 存进全局表

# :221
def get_before_llm_call_hooks():
    return _before_llm_call_hooks.copy()   # 返回副本,防止外部篡改原表
setattr(f, marker, True)和 Day 55 一样的"身份牌"套路:给函数贴个 is_before_llm_call_hook=True。@CrewBase 里能靠它识别"类方法形式的钩子"。
is_method 判断看第一个参数是不是 self。是类方法就立即全局注册(要等 crew 实例化时按实例绑定,见 L07);是普通函数就直接进全局表。
func is None ? 分流兼容两种写法:裸 @before_llm_call(func 是函数)和带参 @before_llm_call(agents=["X"])(func 是 None,返回 decorator)。
get_...copy()对外只给副本。遍历执行时不怕别人同时改表,也防止调用方误 clear 掉全局注册。
L05

before 钩子:执行循环怎么调用、怎么阻断

钩子在执行前被批量调用(utilities/agent_utils.py:1668):

# utilities/agent_utils.py:1668
def _setup_before_llm_call_hooks(executor_context, printer, verbose=True) -> bool:
    if executor_context and executor_context.before_llm_call_hooks:
        original_messages = executor_context.messages
        hook_context = LLMCallHookContext(executor_context)   # 造上下文(内含 messages 引用)
        try:
            for hook in executor_context.before_llm_call_hooks:
                result = hook(hook_context)                   # 逐个调用
                if result is False:                           # ★任一返回 False → 阻断
                    if verbose:
                        printer.print("LLM call blocked by before_llm_call hook", color="yellow")
                    return False                              # 告诉调用方:别问 LLM 了
        except Exception as e:
            printer.print(f"Error in before_llm_call hook: {e}", color="yellow")  # 钩子出错不崩主流程
        if not isinstance(executor_context.messages, list):   # ★防御 L03 的坑
            executor_context.messages = original_messages if isinstance(original_messages, list) else []
    return True   # 放行

而执行循环里是这样用它的(utilities/agent_utils.py:422):

# utilities/agent_utils.py:422
if not _setup_before_llm_call_hooks(executor_context, printer, verbose=verbose):
    ...   # 返回 False → 跳过这次真正的 LLM 调用
for hook in ...: result = hook(ctx)按注册顺序依次执行所有 before 钩子。每个都拿到同一个 ctx(共享 messages)。
if result is False: return False★"熔断"语义:只要有一个钩子说 False,立刻返回 False,后面的钩子不再跑、LLM 也不调。用于"审批未通过就别烧钱"。
except: 不 raise钩子自己抛异常,只打 warning 不中断主流程——钩子是"增强",不该因为一个日志钩子写崩就让整个 Agent 挂掉
isinstance(..., list) 还原兜底 L03 那个坑:钩子若不小心把 messages 换成非 list,这里恢复成原来的,避免执行器崩。
📝 例子:超过 5 轮就要人工审批
@before_llm_call
def gate(ctx):
    if ctx.iterations > 5:
        fb = ctx.request_human_input("已经 5 轮了,继续吗?")
        if fb.strip().lower() == "stop":
            return False        # 阻断:不再问 LLM
    ctx.messages.append({"role":"system","content":"请更简洁"})  # 原地改 prompt
这个钩子既演示了"返回 False 阻断",又演示了"append 原地改 messages 影响下一轮"。
L06

after 钩子:拿回答后替换/加工

after 钩子的返回值语义不同——返回 str 就替换结果(utilities/agent_utils.py:1724):

# utilities/agent_utils.py:1724
def _setup_after_llm_call_hooks(executor_context, answer, printer, verbose=True):
    if executor_context and executor_context.after_llm_call_hooks:
        if isinstance(answer, BaseModel):                  # 结构化输出:先转成 json 字符串给钩子
            pydantic_answer = answer
            hook_response = pydantic_answer.model_dump_json()
            original_json = hook_response
        else:
            pydantic_answer = None
            hook_response = str(answer)

        hook_context = LLMCallHookContext(executor_context, response=hook_response)
        try:
            for hook in executor_context.after_llm_call_hooks:
                modified_response = hook(hook_context)
                if modified_response is not None and isinstance(modified_response, str):
                    hook_response = modified_response        # ★返回 str → 用它替换
        except Exception as e:
            printer.print(f"Error in after_llm_call hook: {e}", color="yellow")
        ...
        if pydantic_answer is not None and hook_response != original_json:
            answer = type(pydantic_answer).model_validate_json(hook_response)  # 改完再校验回模型
    ...
response=hook_response造 Context 时把 LLM 回答塞进 ctx.response,钩子读它来决定要不要改。
链式替换多个 after 钩子依次跑,后一个看到的是前一个改过的 hook_response——责任链:脱敏钩子改完,裁剪钩子接着改。
BaseModel 特殊处理结构化输出(Day 15)先 model_dump_json() 转字符串给钩子改,改完再 model_validate_json() 校验回 Pydantic 对象——保证类型不破。
只有变了才 validatehook_response != original_json 才重新校验,没改就跳过,省一次校验开销。
控制流:一次 LLM 调用被 before/after 钩子夹住 before 钩子 调用 LLM after 钩子 回答→下一轮 返回 False→阻断 返回 str→替换
图注:before 决定"要不要执行"(可阻断),after 决定"结果长什么样"(可替换)。工具调用同理。
L07

按 agent/tool 过滤 + Crew 级钩子绑定

agents=/tools= 参数时,装饰器额外包一层过滤(hooks/decorators.py:57):

# hooks/decorators.py:57
if tools or agents:
    @wraps(f)
    def filtered_hook(context):
        if tools and hasattr(context, "tool_name"):
            if context.tool_name not in tools:
                return None                       # 不是我关心的工具 → 直接不干预
        if agents and hasattr(context, "agent"):
            if context.agent and context.agent.role not in agents:
                return None                       # 不是我关心的 agent → 跳过
        return f(context)                         # 命中才真的执行你的钩子
    if not is_method:
        register_function(filtered_hook)
    return f

而写在 @CrewBase 类里的方法钩子,靠标记类的 __get__ 描述符绑到实例(hooks/wrappers.py:29):

# hooks/wrappers.py:29
class BeforeLLMCallHookMethod:
    is_before_llm_call_hook: bool = True
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return lambda context: self._meth(obj, context)   # 绑定 self → 变成"只吃 context"的钩子
filtered_hook 提前 return None过滤不匹配时返回 None = "我不干预"。既不阻断也不替换,等于这个钩子对该次调用透明。
tool_name / agent.role 匹配过滤维度:按工具名、按 Agent 角色。让你精准地"只对 Researcher 的 LLM 调用记日志"。
__get__ 描述符类方法钩子签名是 (self, context),但执行器只会传 context。描述符在访问时把 obj(实例)预先绑好,抹掉 self,对齐协议。
全局钩子 vs Crew 级裸函数注册进全局表(进程内所有 Crew 都生效);@CrewBase 里的方法钩子只对那个 crew 实例生效,作用域更小更安全。
💡 设计取舍①:全局注册表 vs 实例级钩子,为什么两者都要? 全局表(模块级 _before_llm_call_hooks)用起来最省事——一个裸函数加 @before_llm_call 就全进程生效,适合"全局审计/全局限流"。全局是双刃剑:多个 Crew 共享、测试间会互相污染(所以有 clear_*_hooks 清理函数)。实例级钩子(写在 @CrewBase 里)作用域收窄到单个 crew,天然隔离、更适合"这个 crew 特有的逻辑"。CrewAI 两者都提供,让你按"影响范围"选工具,而不是逼你用一种。
L08

取舍 + 边界 + 今日小结

💡 设计取舍②:钩子出错为什么"吞掉"而不是抛出? 回顾 L05/L06 都是 try: ... except Exception: printer.print(...)——钩子抛异常只打 warning,主流程继续。这和 Day 08 里"litellm 底层错要抛出"看似矛盾,其实标准一致:钩子是"锦上添花"的增强逻辑,不是主链路。一个日志钩子写了 bug,不该让整个 Agent 任务失败。反过来,鉴权错是主链路的错,必须抛。判断标准仍是"这个组件是核心还是增强"。(当然,若你的钩子做的是"审批"这种关键闸门,就得自己在钩子内确保逻辑健壮,别指望框架替你兜。)
⚠️ 边界:全局钩子在测试里会串味 因为 _before_llm_call_hooks 是模块级列表,import 一次就常驻。写单测时,A 用例注册的钩子会泄漏到 B 用例。源码专门提供 clear_before_llm_call_hooks()hooks/llm_hooks.py:293)等清理函数,返回清掉的数量。测试 setUp/tearDown 里记得清,否则会遇到"单跑通过、一起跑失败"的诡异现象。

👶 小白:我同时挂了三个 before_llm_call 钩子,它们按什么顺序跑?其中一个返回 False 会怎样?

👨‍🏫 老师:按注册顺序依次跑(就是你 @before_llm_call 的先后)。一旦任一个返回 False,立刻熔断——后面的钩子不再执行、这次 LLM 也不调(回看 L05 的 if result is False: return False)。所以把"最可能拦截的闸门"放前面能省点开销;也要注意别让某个爱返回 False 的钩子挡住后面钩子该做的事。

🧠 今天你应该能回答

  • 四种钩子分别在什么时机触发?返回值约定是什么?
  • before 返回 False、after 返回 str 分别意味着什么?
  • Context 里的 messages 为什么能"原地改就影响后续"?为什么不能整体替换?
  • 装饰器怎么区分"裸用法/带参用法"、"普通函数/类方法"?
  • 为什么钩子出错只打 warning 不中断主流程?
  • 全局钩子和 Crew 级钩子的作用域差别?测试里要注意什么?

✋ 10 分钟动手

P=lib/crewai/src/crewai/hooks
sed -n '17,131p'  $P/types.py            # 四种钩子的 Protocol + 返回约定
sed -n '18,85p'   $P/decorators.py       # 装饰器工厂 + 过滤逻辑
sed -n '24,108p'  $P/llm_hooks.py        # LLMCallHookContext + 注册表
sed -n '1668,1790p' ../utilities/agent_utils.py  # before/after 执行点
# 亲手挂个钩子看效果
python -c "
from crewai.hooks import before_llm_call
@before_llm_call
def log(ctx): print('LLM call, iter=', ctx.iterations)
print('hook registered')
"
明日预告 · Day 57:钩子里我们见到了 ctx.agent.security_config.fingerprint——每个 Agent 都有一枚"指纹"。明天读 security/Fingerprint 怎么用 UUID5 生成可复现的身份、SecurityConfig 怎么在 Agent 上挂载、指纹如何在工具调用/审计里追溯"是谁干的"。
← Day 55 @CrewBase Day 57 · security 安全 →