1. 从一次 Agent 失控说起:为什么 Hook 才是 DeepAgents 的真正骨架
去年冬天我接手一个内部知识库问答 Agent 的调优,模型换了两轮、提示词改了十几版,效果始终卡在一个尴尬的位置:简单问题答得漂亮,一旦涉及多步检索、工具调用、结果校验,就开始胡言乱语,要么把中间结果当最终答案吐出来,要么在同一个工具上反复横跳。当时我以为是模型能力不够,直到把整个 agent loop 的执行轨迹打出来逐帧看,才发现问题根本不在模型——在于我没有任何一个"卡点"能插手它的决策过程。
这就是 Hook 存在的意义。DeepAgents 系列里,Hook 不是锦上添花的装饰,而是整个 agent loop 的骨架。它让你在 Agent 思考、调用工具、生成回复的每一个关键节点上,都能插入自己的逻辑:拦截、改写、记录、熔断、重试。而"两种范式"则回答了另一个更本质的问题——Hook 到底应该以什么形态存在?是散落在各处的回调函数,还是统一收口的 middleware 中间件?
这篇是 DeepAgents 系列的第二篇,专门拆 Hook 和范式。我会把六种 Hook 逐个讲透,每一种都配上它解决的真实问题、参数设计的取舍、以及我在实际项目里踩过的坑。同时把"回调式"和"中间件式"两种范式的边界讲清楚,让你在 LangGraph 这类编排框架上搭 Agent 时,知道什么时候该用哪种、怎么组合。适合已经跑通过一个基础 Agent、但被复杂流程折磨过的同学,也适合刚接触 agent loop 想搞懂底层机制的新手。
2. 先搞懂 agent loop:Hook 到底挂在哪根绳子上
2.1 一个 Agent 回合的完整生命周期
不把 agent loop 讲清楚,谈 Hook 就是空中楼阁。一个标准的 Agent 回合,本质上是一个"思考—行动—观察"的循环,用 LangGraph 的话说就是一张状态图在节点间流转。我把它拆成五个阶段:
- 输入接收:用户消息进入,和历史对话、系统提示一起组装成上下文。
- 模型推理:把上下文喂给大模型,模型返回要么是最终回复,要么是一个工具调用请求。
- 工具执行:如果模型要调工具,就解析参数、执行工具、拿到结果。
- 结果回灌:把工具结果塞回消息列表,再次进入模型推理。
- 终止判断:模型不再调工具、直接给出回复,或者达到最大轮次,循环结束。
这五步里,第 2 到第 4 步会反复循环,直到第 5 步触发。Hook 的价值就在于,这五个阶段的每一个边界都可以成为你的插入点。你可以在模型推理前改写上下文,在工具执行前校验参数,在结果回灌前过滤敏感信息,在终止判断时强制熔断。
提示:很多人第一次接触 Hook 会误以为它只是"日志钩子"。实际上日志只是最浅的用法,真正有价值的是干预能力——你能改变流程的走向,而不只是旁观。
2.2 为什么"能插手"比"能调用"更重要
我见过太多 Agent 项目,工具写了一大堆,提示词精雕细琢,但整个流程是"黑盒"的:模型想怎么走就怎么走,开发者只能事后看日志叹气。这种 Agent 在 demo 阶段很惊艳,一上生产就崩。
原因很简单:大模型是不确定的,而生产系统需要确定性。你需要一些确定性的卡点,来保证无论模型怎么发挥,系统都不会越界。比如:
- 工具调用参数必须经过校验,不能把用户输入的原始字符串直接拼进数据库查询。
- 涉及金额、权限的操作必须二次确认,不能模型说执行就执行。
- 单次会话的工具调用次数必须有上限,防止死循环烧钱。
- 敏感信息在回灌给模型前必须脱敏。
这些需求,靠提示词是"求"模型别乱来,靠 Hook 是"强制"它不能乱来。前者是祈祷,后者是工程。这就是为什么我说 Hook 是骨架——它决定了这个 Agent 是玩具还是产品。
2.3 Hook 与 middleware 的关系:一个管"点",一个管"面"
这里要先厘清一个容易混淆的概念。Hook 是点,middleware 是面。
Hook 关注的是"在某个具体时刻做什么",比如"工具执行前校验参数"。它是一段逻辑,挂在某个确定的执行点上。而 middleware 关注的是"一类操作的整体处理方式",比如"所有工具调用都要经过鉴权、限流、日志、异常兜底这一整套流程"。它是一层包装,把多个 Hook 或者通用逻辑组织成一个可复用的处理链。
打个比方:Hook 像是马路上的一个个红绿灯和摄像头,middleware 像是整条路的交通管理系统。你可以只装红绿灯(用 Hook),也可以上一整套系统(用 middleware)。小项目用 Hook 就够了,大项目必须上 middleware,否则逻辑会散得到处都是,维护成本爆炸。
理解了这层关系,后面讲"两种范式"就顺了。
3. 六种 Hook 逐个拆解:每一种都对应一个真实痛点
3.1 模型调用前 Hook:上下文的第一道闸门
这是整个 loop 里最先能插手的地方,也是我改动最频繁的一个 Hook。它在"上下文组装完成、即将发给模型"这个时刻触发,你能拿到完整的消息列表,可以增删改。
它解决什么问题?最典型的是上下文膨胀。多轮对话跑下来,消息列表越来越长,token 成本飙升,而且模型容易被早期无关信息干扰。我在一个客服 Agent 里就遇到过,用户聊了二十轮之后,模型开始把第三轮提到的旧订单号当成当前订单。解决办法就是在模型调用前 Hook 里做上下文裁剪:保留系统提示、最近 N 轮对话、以及被标记为"重要"的历史消息,其余压缩成摘要。
参数设计的取舍:这个 Hook 通常接收messages(消息列表)和state(当前图状态),返回修改后的messages。注意,我强烈建议这个 Hook 做成纯函数——给定输入必然得到确定输出,不要在里面做网络请求或读写数据库。原因是一旦它有了副作用,你就很难复现问题,调试会变成噩梦。
def trim_context_hook(messages, state): system_msgs = [m for m in messages if m["role"] == "system"] recent = messages[-6:] important = [m for m in messages if m.get("important")] merged = system_msgs + important + recent # 去重,保持顺序 seen, result = set(), [] for m in merged: key = id(m) if key not in seen: seen.add(key) result.append(m) return result注意:裁剪时千万别把"工具调用"和"工具结果"这对消息拆散。模型看到工具结果却找不到对应的调用请求,会直接报错或者产生幻觉。裁剪的最小单位应该是"一轮完整的交互",而不是单条消息。
3.2 模型调用后 Hook:拦截幻觉与格式错误的第一现场
模型返回之后、结果被使用之前,这个 Hook 触发。它拿到的是模型的原始输出,可能是文本,也可能是工具调用请求。
真实痛点:模型不按格式输出。你要求它返回 JSON,它给你返回一段带 markdown 代码块的 JSON;你要求它调用工具,它给你一段"我建议你调用 xxx 工具"的自然语言。这些在 demo 里无所谓,在生产里就是灾难。
我在一个数据查询 Agent 里,就在这个 Hook 里做了一件事:格式校验与自动修复。如果模型返回的是工具调用,校验参数 schema;如果返回的是文本但本该是工具调用,尝试用正则从文本里抠出结构化内容;实在修不了,就注入一条系统消息让模型重试。
关键取舍:这个 Hook 要不要允许"重试"?我的经验是要,但必须限次。我一般设最多重试 2 次,超过就降级返回一个兜底回复。否则模型可能陷入"格式错误—重试—还是错"的死循环,把预算烧光。
3.3 工具执行前 Hook:参数校验与权限守门人
这是安全相关最重要的一个 Hook。工具即将执行,参数已经解析出来,但还没真正跑。你在这里可以校验、改写、甚至直接拒绝。
为什么必须有它?因为工具是 Agent 唯一能"对外界产生副作用"的通道。删数据、发消息、扣款、改配置,全在工具里。模型可能因为提示注入、上下文污染、或者单纯的理解偏差,生成危险的参数。工具执行前 Hook 就是最后一道防线。
我做过一个内部运维 Agent,工具里有个"重启服务"的能力。上线前我在这个 Hook 里加了三重校验:服务名必须在白名单里、操作必须带审批单号、同一服务十分钟内只能重启一次。这三条任何一条不过,直接拒绝并把原因回灌给模型,让它换个方式回答用户。
参数设计:这个 Hook 接收tool_name、tool_args、state,返回可以是"放行"、"改写后的参数"、或者"拒绝并附带原因"。我建议拒绝时一定要给模型一个明确的、可读的原因,这样模型有机会自我纠正,而不是一脸懵地继续。
| 校验类型 | 检查内容 | 不通过的后果 |
|---|---|---|
| 参数 schema | 类型、必填、取值范围 | 直接拒绝,回灌错误 |
| 权限 | 当前用户是否有权执行 | 拒绝并记录审计日志 |
| 频率 | 是否超过调用配额 | 拒绝并提示稍后重试 |
| 业务规则 | 白名单、审批、状态机 | 拒绝并说明业务原因 |
3.4 工具执行后 Hook:结果过滤与异常兜底
工具跑完了,结果拿到了,但还没回灌给模型。这个 Hook 让你有机会处理结果。
两个核心用途:一是脱敏与过滤,工具返回的数据里可能包含不该给模型看的字段(比如内部 ID、密钥、其他用户的信息),在这里删掉;二是异常兜底,工具执行失败抛了异常,在这里转成模型能理解的错误消息,而不是让整个 loop 崩掉。
我踩过的一个坑:早期我没做异常兜底,工具一报错,异常直接往上抛,整个 Agent 会话中断,用户看到的是"系统错误"。后来在这个 Hook 里统一捕获,把异常转成{"error": "查询超时,请稍后重试"}这样的结构化消息回灌,模型就能自然地告诉用户"刚才查询超时了,要不要再试一次"。体验天差地别。
提示:脱敏要脱得彻底。我见过有人只删了顶层字段,结果嵌套的 metadata 里还藏着敏感信息。建议用白名单而不是黑名单——只保留明确需要的字段,其余一律丢弃。
3.5 循环控制 Hook:防止 Agent 陷入死循环
这个 Hook 在每一轮循环结束时触发,用来判断"要不要继续下一轮"。它是成本控制的核心。
真实场景:模型在某个工具上反复调用,每次都拿到相似结果,但就是不肯收敛。或者两个工具互相调用,A 调 B、B 调 A,无限循环。没有这个 Hook,你的账单会教你做人。
我的做法是在这里维护一个计数器:记录总轮次、每个工具的调用次数、以及最近几轮结果的相似度。超过阈值就强制终止,返回一个"已达最大尝试次数"的兜底回复。相似度判断可以用简单的字符串比较,也可以用 embedding 余弦相似度,看你的精度要求。
参数取舍:最大轮次设多少?我的经验值是简单任务 5 轮、复杂任务 15 轮。设太小,复杂任务做不完;设太大,死循环烧钱。更好的做法是动态的——根据任务类型和当前已消耗的 token 数来调整。
3.6 终止 Hook:会话收尾与状态持久化
循环结束,Agent 要给出最终回复了。这个 Hook 在终止时刻触发,做收尾工作。
用途:持久化会话状态、写审计日志、触发下游通知、清理临时资源。我在一个工单 Agent 里,就在这个 Hook 里把整个会话的决策轨迹存进数据库,方便后续复盘和训练数据沉淀。
一个容易忽略的点:终止 Hook 要区分"正常终止"和"异常终止"。正常终止是模型自然给出回复,异常终止是触发了熔断、超时、或者错误。两者的收尾逻辑往往不同——异常终止可能需要告警,正常终止只需要记录。
4. 两种范式之争:回调式 Hook vs 中间件式 Middleware
4.1 回调式范式:轻量、直接、适合小项目
回调式就是最朴素的形态:框架在关键节点暴露一个"钩子",你注册一个函数进去,到点了框架调用你。上面讲的六种 Hook,如果都是独立注册的回调函数,那就是回调式范式。
优点:直观、上手快、没有额外抽象。你想在工具执行前加个校验,就注册一个函数,五行代码搞定。对于原型验证、小工具、单人项目,这是最高效的方式。
缺点:逻辑一多就散。六个 Hook 各注册各的,彼此之间要共享状态就得靠闭包或者全局变量,很快就乱成一团。而且复用性差——你想把这套校验逻辑用到另一个 Agent 上,只能复制粘贴。
我早期所有项目都是回调式,直到有一次要在一个 Agent 上加"全链路追踪",需要在每个 Hook 里都埋点,改得我怀疑人生。那次之后我就开始认真考虑中间件范式。
4.2 中间件式范式:统一收口、可组合、适合生产
中间件式把 Hook 组织成一条处理链。每个中间件是一个独立的处理单元,它包裹着核心逻辑,可以在前后插入自己的行为。多个中间件按顺序组合,形成一条流水线。
用 LangGraph 的语境说,middleware 就像是给每个节点套了一层洋葱。请求进来,先穿过最外层中间件的前置逻辑,再穿过内层,到达核心,然后原路返回执行后置逻辑。这种"洋葱模型"是中间件范式的精髓。
优点:逻辑收口、可组合、可复用。鉴权、限流、日志、异常兜底这些横切关注点,各自封装成一个中间件,想用就挂上,不想用就摘掉。换项目时整套中间件直接搬过去。
缺点:抽象成本高,调试链路变长。一个请求穿过五层中间件,出问题时你得逐层排查。而且中间件的顺序很关键,顺序错了行为就错了。
class AuthMiddleware: def __init__(self, next_handler): self.next = next_handler def handle(self, tool_name, tool_args, state): if not check_permission(state["user"], tool_name): return {"error": "无权限执行该操作"} return self.next.handle(tool_name, tool_args, state) class RateLimitMiddleware: def __init__(self, next_handler, limit=10): self.next = next_handler self.limit = limit def handle(self, tool_name, tool_args, state): if state["call_count"] >= self.limit: return {"error": "调用次数超限"} return self.next.handle(tool_name, tool_args, state)4.3 两种范式的选择矩阵
到底用哪种?我整理了一张决策表,按项目特征来选:
| 维度 | 回调式 Hook | 中间件式 Middleware |
|---|---|---|
| 项目规模 | 单人、原型、小工具 | 团队、生产、多 Agent |
| 逻辑复杂度 | 每个 Hook 一两件事 | 横切关注点多、需复用 |
| 复用需求 | 低 | 高 |
| 调试难度 | 低 | 中高 |
| 上手成本 | 低 | 中 |
| 可测试性 | 一般 | 好(每个中间件独立测) |
我的实际建议是:从回调式起步,逻辑一多就重构到中间件式。不要一上来就上中间件,那是过度设计;也不要一直停留在回调式,那是技术债。判断的临界点很简单——当你发现同样的逻辑在多个 Hook 里重复出现,或者你需要给所有 Hook 统一加一层行为时,就该重构了。
4.4 混合范式:我实际项目里的做法
真实项目里,我很少纯用一种。我的惯用组合是:核心流程用中间件,边缘逻辑用回调。
具体来说,鉴权、限流、日志、异常兜底这些"每个工具都要过一遍"的,做成中间件;而某个特定工具的特殊校验、某类消息的特殊处理,用回调挂在具体节点上。这样既保证了通用逻辑的收口,又保留了特殊逻辑的灵活性。
在 LangGraph 上实现时,中间件通常挂在工具节点上,回调则挂在图的边或者状态更新函数上。两者通过共享的 state 通信,互不干扰。
5. 在 LangGraph 上落地:从零搭一套 Hook 体系
5.1 状态设计:Hook 之间怎么共享数据
Hook 之间要协作,就得有共享的状态。LangGraph 的 state 是天然的载体,但设计 state 有讲究。
我的原则是:state 只放"跨 Hook 需要共享的、且需要持久化的"数据。比如调用计数、用户身份、审批单号、决策轨迹。那些只在单个 Hook 内部用的临时变量,不要塞进 state,否则 state 会膨胀得没法看。
from typing import TypedDict, Annotated from operator import add class AgentState(TypedDict): messages: Annotated[list, add] user: dict call_count: int tool_history: list audit_log: Annotated[list, add]注意Annotated[list, add]这种写法,它告诉 LangGraph 这个字段用"追加"而不是"覆盖"的方式更新。消息列表和审计日志都适合用追加,避免并发更新时丢数据。
5.2 把六种 Hook 映射到图节点
在 LangGraph 里,agent loop 是一张图。六种 Hook 分别对应图的哪些位置?我的映射方案:
- 模型调用前/后 Hook:包在"模型节点"外面,作为节点的前置/后置处理。
- 工具执行前/后 Hook:包在"工具节点"外面。
- 循环控制 Hook:挂在"条件边"上,决定下一步走向哪个节点。
- 终止 Hook:挂在"结束节点"上。
这种映射的好处是,每个 Hook 的职责和图的拓扑结构一一对应,看代码就知道流程怎么走。
5.3 一个可运行的最小示例
下面是我抽出来的最小骨架,跑通它你就理解了整套机制。为了聚焦 Hook,工具本身写得很简单。
from langgraph.graph import StateGraph, END def model_node(state): # 模型调用前 Hook state["messages"] = trim_context_hook(state["messages"], state) response = call_llm(state["messages"]) # 模型调用后 Hook response = validate_output_hook(response, state) return {"messages": [response]} def tool_node(state): last = state["messages"][-1] tool_name, tool_args = parse_tool_call(last) # 工具执行前 Hook check = pre_tool_hook(tool_name, tool_args, state) if check.get("rejected"): return {"messages": [make_error_msg(check["reason"])]} try: result = execute_tool(tool_name, tool_args) except Exception as e: result = {"error": str(e)} # 工具执行后 Hook result = post_tool_hook(result, state) return {"messages": [make_tool_msg(result)], "call_count": state["call_count"] + 1} def should_continue(state): # 循环控制 Hook if state["call_count"] >= MAX_CALLS: return "end" if is_final_answer(state["messages"][-1]): return "end" return "tools" graph = StateGraph(AgentState) graph.add_node("model", model_node) graph.add_node("tools", tool_node) graph.set_entry_point("model") graph.add_conditional_edges("model", should_continue, {"tools": "tools", "end": END}) graph.add_edge("tools", "model") app = graph.compile()这段代码里,六种 Hook 全部到位。你可以先跑通,再逐个替换成自己的逻辑。
5.4 参数计算:最大轮次和超时怎么定
这两个参数没有标准答案,但有一套估算方法。
最大轮次:先统计你的典型任务需要几步。比如"查订单—查物流—生成回复"是 3 步工具调用,加上首尾模型推理,大约 5 轮。那么最大轮次设 2 到 3 倍,即 10 到 15 轮,留足容错空间。如果任务类型差异大,就按最复杂的那类来设。
超时:单轮超时 = 模型推理时间 + 工具执行时间 + 缓冲。模型推理一般 2 到 10 秒,工具执行看具体,数据库查询几百毫秒,外部 API 可能几秒。我一般设单轮 30 秒,整体会话 5 分钟。超过就熔断。
注意:超时熔断后一定要走终止 Hook 做收尾,别让会话悬在那里。我见过有人熔断了但没清理临时资源,跑久了内存泄漏。
6. 常见问题与排查技巧实录
6.1 Hook 不生效?先查这三个地方
Hook 写了但没被调用,是最常见的困惑。按我的排查顺序:
- 注册了吗:Hook 函数定义了不等于注册了。检查它有没有被挂到图节点或者中间件链上。
- 顺序对吗:中间件链的顺序错了,可能前面的中间件直接返回了,后面的根本没机会执行。
- 异常被吞了吗:Hook 内部抛异常,如果被上层静默捕获,你会以为它没执行。加日志确认。
6.2 工具结果回灌后模型"失忆"
模型拿到工具结果却像没看见,继续重复调用。原因通常是消息格式不对——工具结果消息的tool_call_id和模型的调用请求对不上。检查这对 ID 是否一致,这是最容易出错的地方。
6.3 死循环排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 同一工具反复调用 | 结果没被模型识别 | 打印回灌的消息格式 |
| 两工具互相调用 | 缺少循环检测 | 加工具调用序列检测 |
| 轮次不增长但不停 | 计数器没更新 | 检查 state 更新逻辑 |
| 熔断不触发 | 阈值设太大 | 临时调小阈值验证 |
6.4 我的三条避坑心得
第一,Hook 里别做重活。Hook 是流程控制点,不是计算节点。我见过有人在模型调用前 Hook 里做 embedding 计算,结果每轮多花两秒,整个会话慢得没法用。重活放到工具里,或者异步做。
第二,所有 Hook 都要能"优雅失败"。Hook 自己报错不能拖垮整个 loop。用 try-except 包起来,失败时降级到默认行为,并记录日志。
第三,给 Hook 加开关。生产环境出问题时,你需要能快速关掉某个 Hook 来定位问题。用配置项控制每个 Hook 的启用状态,比改代码重新部署快得多。
7. 从 Hook 到体系:我对 Agent 工程化的一点体会
把六种 Hook 和两种范式讲完,回头看,其实核心就一句话:Agent 的可靠性不来自模型,来自你给它搭的骨架。模型负责聪明,Hook 负责靠谱。一个没有 Hook 的 Agent,再强的模型也只是个会说话的赌徒;一个 Hook 设计得当的 Agent,哪怕模型一般,也能稳定交付。
我个人在实际操作中的体会是,别急着堆功能,先把这六个卡点搭起来,让流程可控,再谈优化。很多团队一上来就追求"更智能",结果连基本的参数校验和循环控制都没有,上线就翻车。骨架搭好了,模型换哪家、提示词怎么调,都是锦上添花的事。
最后分享一个小技巧:把每次会话的 Hook 执行轨迹完整记录下来,包括每个 Hook 的输入输出和耗时。这份数据既是排查问题的依据,也是后续做流程优化的金矿。我靠这份轨迹,发现过好几个"模型背锅、其实是 Hook 逻辑写错"的 bug。