1. 从 middleware 到 Hook:DeepAgents 里那层被低估的“控制面”
第一次翻 DeepAgents 源码的人,十有八九会把注意力放在 agent loop 上——毕竟那是“大脑”,是决定下一步干什么的地方。但真正跑过几个稍微复杂点的任务之后你会发现,agent loop 本身其实相当“老实”:它按部就班地推理、选工具、执行、观察结果,然后进入下一轮。真正让一个 agent 从“能跑”变成“可控、可观测、可干预”的,是挂在 loop 周围的那一圈 Hook。
DeepAgents 系列的第二篇要聊的就是这六种 Hook 和两种范式。这个题目看起来像是文档目录,但它背后其实回答了一个很实际的问题:当 agent 的行为不符合预期时,你到底能在哪些点上插手?是只能在 prompt 里求它“乖一点”,还是能在执行链路的某个确定位置做确定的事?
我自己的体会是,早期做 agent 项目时,最痛苦的不是模型能力不够,而是“它做了我不想要的事,但我不知道在哪拦”。比如它连续调了五次同一个搜索工具、比如它在写文件之前没有先读原文件、比如它把一段明显是幻觉的内容当成了事实继续往下推。这些问题如果只能靠改 prompt 解决,那基本等于碰运气。Hook 的价值就在于,它把这些“碰运气”变成了“可编程”。
关键词里出现的 middleware、agent loop、LangGraph 其实是一条线索:DeepAgents 的 Hook 机制并不是凭空造出来的,它和 LangGraph 的图执行模型、和传统 middleware 的拦截思想都有血缘关系。理解这层关系,比死记六个 Hook 的名字重要得多。这篇就按我实际读代码和踩坑的顺序,把这六种 Hook 逐个拆开,再讲清楚两种范式到底在选什么。
2. 六种 Hook 各自守在哪一道关口
2.1 为什么 Hook 要分六种而不是一种
一个自然的疑问是:既然都是“拦截”,为什么不做成一个通用的 before/after 钩子?答案藏在 agent 执行链路的异质性里。agent 的一轮循环里,至少有几类性质完全不同的动作:模型调用(LLM call)、工具调用(tool call)、状态更新(state update)、以及循环本身的控制流(loop control)。这四类动作的输入输出结构、失败模式、可干预程度都不一样。
如果只有一个通用 Hook,你要么拿到一个巨大的 context 对象自己判断“现在是什么阶段”,要么就得在 Hook 内部写一堆 if-else。DeepAgents 选择拆成六种,本质上是把“判断当前处于哪个阶段”这件事从用户代码里挪到了框架层。你注册哪个 Hook,框架就知道你想干预哪一类动作,context 也是为那类动作量身定制的。
这六种 Hook 大致可以按“干预时机”和“干预对象”两个维度来理解。按时机分,有循环开始前、模型调用前后、工具调用前后、循环结束后;按对象分,有面向消息的、面向工具调用的、面向状态的。下面逐个说,但我不打算按官方文档的顺序,而是按“你实际最可能用到”的顺序来排。
2.2 模型调用前后的 Hook:最常用也最容易写错
模型调用前后的 Hook 是我在项目里用得最多的一类。它的典型用途包括:在发给模型之前裁剪上下文、注入系统级约束、记录 token 消耗;在模型返回之后做输出校验、格式修正、敏感内容过滤。
这里有个很容易踩的坑:很多人会把“裁剪上下文”写成“删除旧消息”。看起来没问题,但 agent 的推理依赖消息之间的因果链,你删掉中间某条工具返回结果,模型下一轮就会看到一个“调用了工具但没有结果”的断裂上下文,行为会变得很奇怪。正确的做法通常是做摘要压缩或者按轮次整体丢弃,而不是随机删单条。
另一个坑是“在 before 里改 prompt 却在 after 里按原 prompt 校验”。我见过一个案例:before Hook 往 system prompt 里追加了一段“必须用 JSON 输出”的约束,after Hook 却还在用正则去匹配自然语言格式,结果永远校验失败。Hook 之间是有状态耦合的,写的时候要意识到你改了什么,后面的 Hook 得知道。
def before_model_call(state, config): messages = state["messages"] # 按轮次压缩,而不是删单条 if len(messages) > MAX_TURNS * 2: messages = compress_oldest_turns(messages, keep_recent=MAX_TURNS) return {"messages": messages} def after_model_call(state, config): last = state["messages"][-1] # 校验逻辑要和 before 里注入的约束保持一致 if not is_valid_json(last.content): return {"messages": [repair_message(last)]} return {}2.3 工具调用前后的 Hook:权限与副作用的真正闸门
如果说模型调用 Hook 管的是“想什么”,那工具调用 Hook 管的就是“做什么”。这是安全性和可控性最关键的一道关口。工具调用前的 Hook 可以做参数校验、权限检查、危险操作拦截;工具调用后的 Hook 可以做结果清洗、错误重试、副作用确认。
我印象最深的一次事故是 agent 在调试模式下把生产数据库的某张表清了。事后复盘,问题不在于模型“坏”,而在于没有任何一道 Hook 去检查工具名和参数。如果当时有一个工具调用前 Hook,判断tool_name == "execute_sql"且参数里包含DELETE或DROP就拦截,这事根本不会发生。
工具调用后的 Hook 同样重要,但常被忽略。工具返回的结果往往不是模型能直接消化的格式——可能是一个巨大的 JSON、可能包含一堆无关字段、可能是分页的第一页。在 after Hook 里做结果规整,比让模型自己去“理解”原始返回要可靠得多。
注意:工具调用 Hook 里做重试要非常小心。如果工具本身有副作用(比如发邮件、写文件),盲目重试会造成重复副作用。重试逻辑应该只针对幂等工具,或者配合幂等键使用。
2.4 状态更新与循环控制 Hook:让 agent 知道“该停了”
状态更新 Hook 和循环控制 Hook 是六种里最不显眼、但决定 agent 稳定性的两种。状态更新 Hook 关注的是 agent 内部状态(比如 scratchpad、任务列表、中间变量)的变更;循环控制 Hook 关注的是“这一轮结束后,是继续还是终止”。
很多 agent 跑飞的根本原因不是模型不行,而是没有明确的终止条件。模型会一直觉得“还有事没做完”,于是无限循环。循环控制 Hook 就是给你一个确定的位置去判断:任务是否完成、是否超预算、是否连续多轮没有进展。连续无进展检测尤其有用——如果连续三轮工具调用返回的结果高度相似,基本可以判定卡住了,这时候主动终止比让它继续烧 token 明智得多。
状态更新 Hook 的一个实用场景是维护任务清单。agent 在多步任务里经常“忘记”自己做到哪了,你可以在状态更新 Hook 里强制维护一个结构化的 todo 列表,每轮检查是否有未完成项,避免它提前收工或者重复劳动。
2.5 六种 Hook 的对照速查
| Hook 类型 | 干预时机 | 典型用途 | 最容易踩的坑 |
|---|---|---|---|
| 循环开始前 | loop 启动 | 初始化状态、加载配置 | 状态未初始化导致后续 Hook 报错 |
| 模型调用前 | LLM call 之前 | 上下文裁剪、约束注入 | 随机删消息破坏因果链 |
| 模型调用后 | LLM call 之后 | 输出校验、格式修正 | 与 before 注入的约束不一致 |
| 工具调用前 | tool call 之前 | 权限检查、参数校验 | 漏检危险工具名 |
| 工具调用后 | tool call 之后 | 结果清洗、幂等重试 | 对非幂等工具盲目重试 |
| 循环控制 | 每轮结束 | 终止判断、预算控制 | 无进展检测缺失导致死循环 |
这张表我建议贴在显示器边上。写 Hook 的时候先问自己:我要干预的是哪一类动作?对应哪个 Hook?context 里有没有我需要的信息?想清楚这三个问题,大部分 Hook 都能一次写对。
3. 两种范式:装饰器式注册 vs 图节点式编排
3.1 两种范式到底在争什么
DeepAgents 的 Hook 有两种接入范式,一种更接近传统 middleware 的装饰器式注册,一种更接近 LangGraph 的图节点式编排。这两种范式不是“新旧替代”关系,而是面向不同复杂度场景的两套工具。选错了不会跑不起来,但会让代码在后期变得难以维护。
装饰器式注册的核心是“声明”:你写一个函数,用装饰器标注它是哪类 Hook,框架在运行时自动把它挂到对应位置。优点是写起来快、读起来直观,适合 Hook 数量少、逻辑独立的场景。缺点是当 Hook 之间有依赖、需要条件性启用、或者需要动态调整顺序时,装饰器就不够用了。
图节点式编排的核心是“显式连接”:你把 Hook 当作图里的节点,自己定义节点之间的边和条件。优点是控制力强、顺序明确、便于调试和可视化。缺点是样板代码多,简单场景下显得杀鸡用牛刀。
3.2 装饰器式:什么时候它是最优解
如果你的项目满足这几个条件,装饰器式基本就是最优解:Hook 数量在十个以内、每个 Hook 职责单一、Hook 之间没有顺序依赖、不需要运行时动态开关。比如一个内部用的文档处理 agent,只需要在模型调用前裁剪上下文、在工具调用前检查文件路径合法性,两个 Hook 各管各的,装饰器写起来五行代码搞定。
@hook("before_model_call") def trim_context(state, config): ... @hook("before_tool_call") def check_path(state, config): ...这种写法的心智负担极低,新人接手也能一眼看懂。我个人的经验是,能用装饰器解决的就别上图,图带来的灵活性在简单场景里是纯负担。
3.3 图节点式:复杂度上来了才值得
什么时候必须上图?我总结了几条触发线:Hook 之间有明确的前后依赖(比如必须先做权限检查再做参数脱敏)、需要根据运行时状态决定是否执行某个 Hook、需要把 Hook 的执行结果反馈到 agent loop 的控制流里、或者需要把 Hook 链路可视化给非技术同事看。
图节点式的一个典型场景是“分级审批”。低风险工具直接放行,中风险工具需要参数校验通过,高风险工具需要人工确认节点介入。这种带条件分支和人工介入的流程,用装饰器写会变成一团乱麻,用图来表达则一目了然。
graph.add_node("risk_check", risk_check_node) graph.add_node("human_approval", human_approval_node) graph.add_conditional_edges( "risk_check", route_by_risk, {"low": "execute", "mid": "validate", "high": "human_approval"} )3.4 混用是常态,但要有边界
实际项目里,纯装饰器或纯图的情况都少,混用才是常态。我的建议是划一条清晰的边界:无状态、无依赖的横切关注点(日志、token 统计、简单校验)用装饰器;有状态、有分支、涉及控制流的用图。这条边界一旦模糊,代码就会变成“一半在图里一半在装饰器里,谁也说不清执行顺序”的灾难。
提示:混用时,务必在文档或代码注释里明确标注每个 Hook 用的是哪种范式、挂在哪一层。我吃过这个亏——半年后回看自己的代码,花了半小时才理清某个校验到底是在装饰器层还是图节点层执行的。
4. 把 Hook 挂进 agent loop:一次完整的执行链路复盘
4.1 一轮循环里 Hook 的触发顺序
光知道有六种 Hook 还不够,得知道它们在一轮循环里的实际触发顺序。我按一次典型的“模型推理 → 工具调用 → 状态更新 → 判断是否继续”流程,把触发点串一遍。
循环开始前 Hook 最先触发,负责初始化这一轮需要的上下文。接着进入模型调用前 Hook,此时你可以修改即将发给模型的消息。模型返回后,模型调用后 Hook 触发,你可以校验或修正输出。如果模型决定调用工具,工具调用前 Hook 触发做检查,工具执行完工具调用后 Hook 触发做结果处理。工具结果写回状态时,状态更新 Hook 触发。一轮结束,循环控制 Hook 判断是否继续。
这个顺序不是随便定的,它反映了数据在系统里的自然流向。理解顺序的意义在于:你知道在某个 Hook 里能拿到什么、改了什么会影响后面的谁。比如你在模型调用后 Hook 里改了消息内容,那工具调用前 Hook 看到的就是改过之后的内容。
4.2 一个真实的多步任务拆解
拿一个“读取项目里的配置文件,修改某个字段,然后写回”的任务举例。这个任务看起来简单,但涉及读文件、改内容、写文件三个工具调用,中间还有模型推理。
第一轮:循环开始前 Hook 初始化任务状态。模型调用前 Hook 注入“先读后写”的约束。模型决定调用读文件工具。工具调用前 Hook 检查路径是否在允许目录内。工具执行,返回文件内容。工具调用后 Hook 把内容规整成模型易读的格式。状态更新 Hook 记录“已读取”。
第二轮:模型基于文件内容生成修改后的版本。模型调用后 Hook 校验输出是否是合法配置格式。模型决定调用写文件工具。工具调用前 Hook 检查:是否已经读过这个文件?如果状态里没有“已读取”记录,拦截。这是防止 agent 盲目覆盖的关键。
第三轮:写文件成功。循环控制 Hook 检测到任务完成,终止循环。
这个链路里,如果没有“先读后写”的检查,agent 完全可能直接覆盖原文件,造成数据丢失。Hook 在这里扮演的不是“锦上添花”,而是“底线保障”。
4.3 Hook 之间的数据怎么传
Hook 之间传递数据靠的是共享的 state 对象。这里有个设计上的取舍:state 是全局可变的好,还是每轮返回新对象好?DeepAgents 倾向于后者——每个 Hook 返回一个增量,框架负责合并。这样做的好处是 Hook 之间解耦,坏处是你得清楚合并规则。
我踩过的坑是:两个 Hook 都往同一个 key 写值,后执行的覆盖了先执行的,而且没有任何警告。排查了半天才发现是 key 冲突。后来我养成的习惯是,给每个 Hook 写的 state key 加前缀,比如auth_check_passed、token_count_this_turn,避免撞车。
5. 那些文档不会告诉你的 Hook 实战坑
5.1 异常处理:Hook 抛异常了会怎样
这是最容易被忽略的问题。Hook 里抛异常,框架是吞掉继续、还是中断整个 loop?不同 Hook 的行为可能不一样。我的经验是:模型调用前和工具调用前的 Hook 抛异常,通常应该中断当前操作(因为检查没通过就不该继续);而日志类、统计类的 Hook 抛异常,应该被吞掉,不能因为记日志失败就搞挂整个 agent。
所以写 Hook 时要有意识地区分“关键 Hook”和“辅助 Hook”。关键 Hook 里该抛就抛,辅助 Hook 里一律 try-except 包住。我见过因为一个统计 token 的 Hook 里除了零导致整个任务失败的案例,非常不值。
5.2 性能:Hook 不是免费的
每个 Hook 都是一次函数调用,如果 Hook 里做了重活(比如调外部 API 做内容审核),那它会直接拖慢整个 loop。我实测过一个场景:在模型调用后 Hook 里同步调用一个审核服务,每次增加 300ms,一个十轮的任务就多花 3 秒。后来改成异步 + 缓存,才把开销压下去。
原则是:Hook 里只做必要的、快的操作。重活要么异步化,要么挪到 loop 外面批量做。别让 Hook 成为性能瓶颈。
5.3 调试:怎么知道 Hook 到底跑没跑
Hook 不生效是高频问题。原因通常有三类:注册的 Hook 名字拼错了、Hook 挂在了错误的范式层、或者条件判断让它根本没进。我的调试方法是:在每个 Hook 入口打一行日志,带上 Hook 名和关键 state 字段。跑一次任务,看日志顺序对不对。这比盯着代码猜快得多。
5.4 顺序依赖:谁先谁后不是小事
同一类 Hook 注册了多个时,执行顺序由注册顺序决定。这个顺序在“检查类”和“修改类” Hook 混在一起时特别关键。比如一个 Hook 做权限检查、另一个 Hook 做参数脱敏,如果脱敏先执行,权限检查看到的就不是原始参数,可能漏判。我的做法是:把检查类 Hook 统一注册在修改类之前,并在代码里用注释标明依赖关系。
6. 从 LangGraph 的视角重新理解 Hook
6.1 Hook 本质上是图上的拦截节点
把 DeepAgents 的 Hook 放到 LangGraph 的语境里看,它其实就是图执行过程中的拦截节点。LangGraph 的图由节点和边组成,Hook 相当于在特定边上插入的一个“检查站”。这个视角的价值在于:你可以用图论的方式思考 Hook 的编排——哪些是串行的、哪些可以并行、哪些需要条件分支。
一旦这么想,很多设计问题就清晰了。比如“多个独立校验能不能并行”这个问题,在图视角下就是“这几个节点之间有没有边”,没有边就可以并行。装饰器范式下你很难表达并行,图范式下这是天然的。
6.2 middleware 思想在 agent 场景的迁移
传统 web 框架里的 middleware 是“请求进来、层层处理、响应出去”的洋葱模型。DeepAgents 的 Hook 借鉴了这个思想,但做了适配:agent 的“请求”不是一次性的,而是循环往复的,所以 Hook 也不是一次性的洋葱,而是每轮都要过的关卡。
理解这个差异很重要。web middleware 里你写一次逻辑,请求生命周期内只跑一次;agent Hook 里你写的逻辑,每一轮循环都会跑。这意味着 Hook 里的逻辑必须考虑“重复执行”的语义——比如计数、比如状态累积,不能假设只跑一次。
6.3 什么时候该跳出 Hook 思维
Hook 不是万能的。有些需求用 Hook 实现会非常别扭,比如“根据任务类型动态选择不同的 agent 拓扑”。这种需求本质上是“编排”而不是“拦截”,应该在图构建层面解决,而不是硬塞进 Hook。我的判断标准是:如果这个逻辑需要知道“整个任务的全貌”而不只是“当前这一步”,那它大概率不该是 Hook。
7. 选型建议:什么项目该用哪种范式
7.1 按项目阶段选
项目早期,我强烈建议用装饰器式。快速验证、快速迭代,别过早引入图的复杂度。等 Hook 逻辑稳定了、依赖关系清晰了,再考虑要不要迁移到图。反过来,一上来就上图,往往会在需求还没定型时被图的样板代码拖累。
7.2 按团队规模选
一个人开发,装饰器足够。三个人以上协作,尤其是有人负责 Hook 逻辑、有人负责 agent 主流程时,图节点式的显式连接会大幅降低沟通成本——因为执行顺序是画出来的,不是猜出来的。
7.3 按可观测性需求选
如果这个 agent 要上生产、要给别人看执行过程、要做审计,图节点式几乎是必选。因为图天然可以可视化,每个节点的输入输出都能被记录。装饰器式的执行链路是隐式的,做审计要额外埋很多点。
| 场景 | 推荐范式 | 理由 |
|---|---|---|
| 原型验证、单人开发 | 装饰器式 | 上手快,样板少 |
| Hook 逻辑稳定、数量少 | 装饰器式 | 心智负担低 |
| 多 Hook 有顺序依赖 | 图节点式 | 顺序显式可控 |
| 需要人工介入节点 | 图节点式 | 条件分支天然支持 |
| 生产环境、需审计 | 图节点式 | 可可视化、可追溯 |
| 横切关注点(日志、统计) | 装饰器式 | 无状态、无依赖 |
这张表不是绝对的,但它覆盖了我遇到的大部分决策场景。核心判断依据就一条:这个 Hook 需不需要知道“全局”和“顺序”?需要就上图,不需要就装饰器。
8. 我在实际项目里沉淀下来的几条经验
写 Hook 这件事,写得越多越觉得它像给 agent 装护栏。护栏装得好,agent 跑得野一点也没关系;护栏装得差,agent 稍微一偏就出事。我自己的几条经验,都是踩坑换来的。
第一条:先写循环控制 Hook,再写其他。很多人从模型调用 Hook 开始写,结果 agent 跑飞了才发现没有终止条件。循环控制 Hook 是保命的,应该最先到位。
第二条:每个 Hook 只做一件事。我见过一个 Hook 里同时做权限检查、参数脱敏、日志记录、结果缓存,后来想改其中任何一项都不敢动,因为牵一发动全身。拆开写,哪怕 Hook 数量多一点,维护成本反而低。
第三条:Hook 里的判断要保守。宁可多拦一次让 agent 重试,也不要放过一次危险操作。拦截的代价是重试,放过的代价可能是数据损坏。这个不对称性决定了 Hook 的默认姿态应该是“怀疑”。
第四条:给 Hook 写测试。Hook 是纯函数居多,测试起来其实很容易。构造一个 state,调用 Hook,断言返回值。我现在的习惯是每个关键 Hook 都配一个单测,改逻辑时先跑测试,比手动跑 agent 快得多。
第五条:记录 Hook 的决策。每次 Hook 拦截或修改,都往日志里写一条“谁、在什么条件下、做了什么决定”。出问题时,这份日志就是排查的起点。没有它,你只能看到 agent 行为异常,却不知道是哪道关卡放行的。
这六种 Hook 和两种范式,说到底是一套“在正确的位置做正确干预”的方法论。工具本身不复杂,难的是判断在哪个位置干预、用什么方式干预。这个判断力,只能靠一次次实际项目里踩坑积累。我到现在也不敢说自己完全摸透了,但至少知道哪些坑不该再踩第二遍。