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 改结果"的约定。
痛点:Agent 是个黑盒,想插一脚很难
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=替换) | 脱敏、裁剪超长结果 |
四种钩子的类型协议:一个泛型 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 只能表达"替换/不换"。语义清晰不混淆。BaseHook",那用户写个一行 lambda 都得先造个类,太重。Protocol 是"结构化类型"——只看形状不看血缘,@before_llm_call\ndef log(ctx): ... 这样一个裸函数就直接满足协议。让扩展点的使用成本降到最低。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 覆盖两种场景。ctx.messages = [...] 整个替换
docstring 反复警告:要 append/extend/remove 原地改,不要赋值替换。因为 ctx.messages 和 executor.messages 是同一个列表——你 ctx.messages = [] 只是把局部变量指向了新列表,执行器那份没变,你的改动全丢。源码甚至在钩子跑完后检查"messages 还是不是 list",被换成非 list 就打警告并还原(见 L07)。这是"引用别名"最经典的坑。装饰器与全局注册表
四个装饰器由一个工厂统一生产(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 掉全局注册。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,这里恢复成原来的,避免执行器崩。@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 影响下一轮"。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 才重新校验,没改就跳过,省一次校验开销。按 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 实例生效,作用域更小更安全。_before_llm_call_hooks)用起来最省事——一个裸函数加 @before_llm_call 就全进程生效,适合"全局审计/全局限流"。但全局是双刃剑:多个 Crew 共享、测试间会互相污染(所以有 clear_*_hooks 清理函数)。实例级钩子(写在 @CrewBase 里)作用域收窄到单个 crew,天然隔离、更适合"这个 crew 特有的逻辑"。CrewAI 两者都提供,让你按"影响范围"选工具,而不是逼你用一种。取舍 + 边界 + 今日小结
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')
"
ctx.agent.security_config.fingerprint——每个 Agent 都有一枚"指纹"。明天读 security/:Fingerprint 怎么用 UUID5 生成可复现的身份、SecurityConfig 怎么在 Agent 上挂载、指纹如何在工具调用/审计里追溯"是谁干的"。