Agent Hook:在概率推理之上,为 Agent 叠加确定性控制 我最早用 LangChain 做 Agent 的时候写过这样一个调试器在每个可能的调用点前面加print()。LLM 调用之前 print工具执行之后 printAgent 思考步骤前后也 print。用了一段时间发现如果 Agent 链有五层深度三个工具来回调终端里刷出来的日志就像有人拿脸在键盘上滚了一遍。这种事大多数 Agent 开发者迟早会遇到。Agent 的执行过程是个黑盒——输入一段话它自己在那儿想、搜、调工具、再想、再搜最后吐一个结果。你看不到中间发生了什么、有没有调用不该调的工具、token 消耗到底是哪一步最多。Hook 机制解决的就是这个看不见和拦不住的问题。没有 Hook 的时代你只能猜先回到最原始的场景。假设你用最直接的方式写了一个 Agent把 prompt 塞给 OpenAI API拿到返回的工具调用执行工具把结果再塞回去循环到 Agent 觉得可以停了。这个过程里你能知道的只有两件事你给了什么输入最后拿到了什么输出。中间每一步——模型想了什么、工具执行了多久、有没有异常——大多无从得知。有几种典型的事故会在这种架构下发生第一个token 消耗失控。Agent 工作得很好但你不知道它每次任务到底调了多少次模型。生产环境跑了一个月一看账单翻了三倍排查只能靠猜。因为它可能在某个环节反复重试模型给出的工具调用参数不对工具返回错误模型换种写法再试反复四五次——而你很难察觉到这个循环的存在。第二个工具调用静默失败。Agent 调用了一个搜索 API返回了空结果或者部分结果Agent 没检测到异常基于不完整的信息继续推理最终给出一个看起来自洽但方向全错的结论。你事后复盘时连它到底搜了什么、搜到了什么都看不到。第三个安全边界失守。Agent 有一个发送邮件的工具设计意图是只让它在用户确认后调用。但因为没 hook 点你只能在系统 prompt 里写请勿在未经用户确认的情况下调用 send_email。模型大部分时候遵守偶尔忽略。这种事发生在 prompt 层面靠概率约束是不够的但你又没有别的手段。这三个问题的共同本质是Agent 执行过程缺少可插拔的拦截点。你不能插进去看一眼也不能插进去拦一下。这里有一个关键认识LLM 的输出是概率性的不可靠的。你需要一个确定性的层叠在上面——这个层不负责聪明只负责规则。Hook 就是这个确定性的层。第一批解法观察者模式的回调LangChain 在 2023 年给出的第一个答案就是回调系统Callback System。它的设计思想很直接把 Agent 执行过程抽象为一组生命周期事件——LLM 调用开始、LLM 调用结束、工具调用开始、工具调用结束、链开始、链结束、错误、重试。你不需要知道框架内部怎么跑的只需要在这些事件点上注册你的处理函数。Zylos Research 在 2026 年 3 月的一篇分析[1]把它归类为事件驱动的观察者模式——框架是发布者Publisher它按固定节奏发布事件你的回调处理器是订阅者Subscriber接收事件并执行自定义逻辑。关键特征是订阅者只能观察不能修改执行流。LangChain 的BaseCallbackHandler是一个基类继承了六个 MixinMixin监听的事件做什么LLMManagerMixinon_llm_start/end/error,on_llm_new_token追踪模型调用、流式输出ChainManagerMixinon_chain_start/end/error,on_agent_action/finish追踪链的启动和结束ToolManagerMixinon_tool_start/end/error追踪工具调用RetrieverManagerMixinon_retriever_start/end/error追踪文档检索CallbackManagerMixin回调注册管理器自身RunManagerMixinon_text,on_retry,on_custom_event通用事件每个事件携带一个run_id和parent_run_id所有事件天然构成一棵执行树。这个设计让 tracing 变得非常简单——你不需要自己拼父子关系框架在触发事件时就把链路搭好了。用起来大概是这样的from langchain_core.callbacks import BaseCallbackHandler class SimpleLogger(BaseCallbackHandler): def on_llm_start(self, serialized, prompts, *, run_id, **kwargs): print(f[LLM Start] {run_id}, Prompt: {prompts[0][:50]}...) def on_tool_start(self, serialized, input_str, **kwargs): print(f[Tool Start] {serialized[name]}, Input: {input_str}) def on_llm_end(self, response, **kwargs): token_usage (response.llm_output or {}).get(token_usage, {}) print(f[LLM End] Tokens: {token_usage}) handler SimpleLogger() # chain 可以是任意 LangChain Runnable如 LLMChain、AgentExecutor 等 chain.invoke({input: ...}, config{callbacks: [handler]})这在当时解决了一个实在的痛点你终于能看见 Agent 内部在干什么了。不需要在每个节点插print()不需要事后翻日志猜执行路径。日志、监控、成本追踪这些需求在回调出现之前基本靠框架魔改和 monkey-patch。LangSmith、LangFuse、Weights Biases 这些可观测性平台底层全都建立在 LangChain 的回调总线上。LangChainInstrumentor().instrument()这一行代码背后就是注册了一个 OTel 适配器到回调管理器上自动把每个事件转为 OpenTelemetry Span。能看不能动回调的边界回调解决了看见的问题。但生产环境跑久了你很快会发现另一类需求是回调无法满足的。来看看这些场景权限与预算拦截。你的 Agent 挂了 20 个工具但某些任务——比如面向外部用户的服务——它只能使用其中 5 个。同时你想对每个用户设定每日 token 配额超限时自动切换低成本模型或直接拦截。这两类需求都需要在调用前检查并阻断。PII 脱敏。用户输入可能包含手机号、身份证号、邮箱。你希望在 prompt 送进 LLM 之前自动识别并脱敏。人工审批门控。Agent 要发送邮件、执行数据库写入、调用支付接口——这些操作你需要人工看一眼再放行。这些需求的共同特征是你需要的不是看见而是修改或阻断。而回调做不到这一点。回调是观察者——它接收事件可以记录、可以上报、可以报警但它不能修改事件携带的数据不能阻止事件对应操作的执行。回调里返回False不会让工具调用中止回调里修改prompts不会改变 LLM 实际收到的输入。DZone 2026 年 6 月的一篇分析[2]把这个问题定性为中间件差距Middleware Gap能力中间件回调修改 system prompt是否动态过滤工具列表是否转换消息历史是否取消模型调用是否跨轮次追踪状态是部分观察输出是是这个差距不是实现上的疏忽而是架构约束。观察者模式的设计前提就是订阅者不改变被观察对象的状态。要突破这个约束需要一种新的抽象。AgentMiddleware从观察到干预2026 年 3 月LangChain 在 1.0 版本的create_agent中引入了一个新的抽象AgentMiddleware。它不再是回调的演进版而是一套完全不同的设计哲学。官方博客[3]是这样定位它的中间件让你可以在 Agent 利用 LLM 的动态推理能力的同时叠加确定性的业务策略。中间件的灵感来源是 Web 框架的中间件模式——Express 和 Koa 的洋葱模型。如果你写过 Koa对这个模式不会陌生请求从最外层中间件进入逐层向内传递到达核心LLM 调用或工具执行然后响应逐层向外返回。AgentMiddleware 定义了五个钩子点钩子触发时机能做什么before_agentAgent 启动时仅一次加载记忆、校验输入before_model每次 LLM 调用前裁剪历史、PII 脱敏、动态过滤工具wrap_model_call包裹整个 LLM 调用缓存、重试、模型回退、动态工具选择after_model每次 LLM 响应后人工审批、输出校验、结果转换after_agentAgent 结束时仅一次清理、通知、持久化还有wrap_tool_call包裹工具执行用于审批门控和沙盒。关键不是多了几个钩子而是这些钩子的组合方式。多个中间件按注册顺序组合成洋葱结构[before_agent_1] → [before_agent_2] → [before_model] → [wrap_model_call] → LLM → [after_model] → [after_agent_2] → [after_agent_1]before_*按注册顺序进入after_*逆序展开。wrap_*从最外层开始嵌套。来看一个实际的例子。假设你要实现三个需求PII 脱敏、成本追踪、人工审批。怎么写from langchain.agents import create_agent from langchain.agents.middleware import ( PIIMiddleware, HumanInTheLoopMiddleware, SummarizationMiddleware, ModelRetryMiddleware, ) agent create_agent( modelgpt-4o, tools[search_tool, email_tool, db_tool], # 示意实际的工具对象需预先定义 middleware[ PIIMiddleware(email, strategyredact), PIIMiddleware(phone, strategyredact), SummarizationMiddleware( modelgpt-4o-mini, trigger(tokens, 8000), keep(messages, 4), ), ModelRetryMiddleware(max_retries3, backoff_factor2), HumanInTheLoopMiddleware( interrupt_on{send_email: True, db_write: True} ), ], )执行时发生了什么1用户输入先经过PIIMiddleware的before_agent手机号和邮箱被替换为[REDACTED]2进入SummarizationMiddleware的before_model如果对话历史超过 8000 token自动用gpt-4o-mini压缩3wrap_model_call由ModelRetryMiddleware包裹——LLM 调用失败时自动重试退避 2s → 4s → 8s4Agent 决定调用send_emailHumanInTheLoopMiddleware的wrap_tool_call拦截住弹出审批5所有步骤中回调系统继续工作把事件流推送给 LangSmith这里有一个重要的设计选择中间件运行在create_agent编译出的 LangGraph 图内部不是外部附加层。这意味着中间件可以访问和修改 Agent 的运行时状态state可以在before_agent中通过can_jump_to[end]直接终止整个执行流程。这也解释了为什么回调无法实现中间件的功能回调在运行时图的外部观察事件中间件在图内部参与执行。LangChain 1.0 预置了五类中间件•SummarizationMiddlewaretoken 阈值监测 历史压缩•HumanInTheLoopMiddleware工具调用前的人工中断•PIIMiddleware检测和脱敏•ModelRetryMiddleware指数退避重试•ShellToolMiddlewareshell 资源生命周期管理生态位其他框架怎么做的在 LangChain 演进的同时其他 Agent 框架也在各自的 Hook 系统上做出了不同的设计选择。这些选择的差异本质上反映了对Agent 应该如何被控制这个问题的不同回答。CrewAI 选择了一条更简单的路径——装饰器式 Hookfrom crewai import CrewBase from crewai.hooks import before_llm_call, after_tool_call, LLMCallHookContext, ToolCallHookContext CrewBase class MyCrew: before_llm_call def add_system_context(self, context: LLMCallHookContext): context.messages.insert(0, { role: system, content: 当前用户等级: VIP }) after_tool_call def validate_result(self, context: ToolCallHookContext): if context.tool_result isNone: return工具返回空结果请换一种方式重试CrewAI 的钩子可以直接修改上下文对象——context.messages和context.tool_input是可变的。而且钩子返回值会影响执行before_llm_call返回False会阻断 LLM 调用after_tool_call返回字符串会替换工具结果。这种设计对简单场景非常友好但全局注册机制的副作用是多个钩子的执行顺序依赖于注册先后组合起来不够透明。OpenAI Agents SDK 采用了双作用域设计——RunHooks跨越整个Runner.run()包括 Agent 移交AgentHooks附加到特定 Agent 实例。两个作用域共享相同的事件签名on_agent_start、on_llm_start、on_tool_start等但都是纯观察者模式不能修改或阻断。它的核心价值在于移交handoff感知——当 Agent A 把控制权移交给 Agent B 时on_handoff事件同时传递给两个作用域这是多 Agent 协作场景下的关键能力。LlamaIndex 和 AutoGen 选择了差异更大的路径。LlamaIndex 在 v0.10.20 后从CallbackManager迁移到instrumentation模块用Dispatcher实现层级事件传播类似 Python logging 的冒泡机制并把Event单点时刻和Span持续操作分离让 tracing 语义更清晰。AutoGen v0.4 则走消息总线路线——Agent 之间通过消息传递而非直接调用intervention_handler.on_send可以在消息发送前拦截工具调用天然支持分布式多 Agent 系统但代价是中间件的组合顺序变得不可预测。有一个值得单独讨论的框架是Vercel AI SDK 72026 年发布它在 Agent 接口上直接内置了生命周期回调。onStart→onStepStart→onToolExecutionStart→onToolExecutionEnd→onStepEnd→onEnd的事件序列加上toolApproval中断机制以及WorkflowAgent支持进程重启后恢复把 Hook 的语义从框架层提升到了接口规范层。三个你一定要知道的坑不论用哪个框架的 Hook有三类问题是反复出现的事故源头。坑一同步回调阻塞异步 event loop这是影响最大的性能问题波及范围也最广。LangChain 的StdOutCallbackHandler默认同步运行底层的BaseCallbackManager使用ThreadPoolExecutor(max_workers1)。当并发超过 15 时这个单线程队列开始拥塞。基准测试数据显示20 并发时 p95 延迟从 320ms 暴涨到 4.8 秒——增长了 12 到 15 倍Markaicode SGLang LangChain 基准测试[4]。根因不是回调逻辑重而是队列在等。所有回调处理排队等一个 worker 线程而这个 worker 线程又被下一个 LLM 调用依赖——因为框架要等回调处理完才继续。修复方案三选一设置LANGCHAIN_CALLBACKS_WORKERS4环境变量所有生产回调继承AsyncCallbackHandler而非BaseCallbackHandler在不需要回调的路径上把model.callbacks置空。坑二在回调中做同步 I/O这是一个逻辑正确但工程错误的选择。你在on_llm_end里调了requests.post(https://api.cost-tracker.com, jsondata)。看起来没问题——数据确实需要上报。但requests.post是同步的这意味着每次 LLM 调用都要等这个 HTTP 请求完成才能返回。如果你的成本追踪 API 响应时间是 200ms那每次 Agent 调用就被无意义地加了 200ms 延迟。正确做法回调只做内存队列的 enqueue。一个独立的BatchSpanProcessor在后台线程批量 flush。上报延迟不影响 Agent 关键路径。坑三混淆 Callback 和 Middleware 的能力边界这与其说是坑不如说是一种常见的框架误用。你需要阻断某个工具调用于是在BaseCallbackHandler.on_tool_start中写了逻辑。它不 work——回调不能阻断执行。你需要的是AgentMiddleware.wrap_tool_call。反过来你只是想做日志输出却写了一个完整的AgentMiddleware——这当然可以 work但复杂度远超需求。一个StdOutCallbackHandler就够了。经验法则是观察用回调修改用中间件。需要读数据、做统计、推送到外部平台——回调需要改 prompt、过滤工具、阻断执行则交给中间件。把 Hook 用好五条实践准则这些准则来自生产环境的反复验证不是理论推演。一、用 instrumentor 替代手写 tracing handler。LangChainInstrumentor().instrument()一行代码接入 OpenTelemetry比你手动管理run_id拼树健壮得多。手写 handler 容易在并行调用时把parent_run_id搞混——Langfuse 的 Issue #3491[5] 就是一个典型案例全局单例 callback handler 在 asyncio 并行调用时run_id → span映射出现竞态导致KeyError。只在需要业务特有信号时才自定义 handler。二、控制基数爆炸。 三个最常见的基数地雷把用户原始输入作为 span attributePII 风险 聚合失效每个 retriever chunk 单独建 span50 chunks × 千次查询 数百万唯一 attribute 值把 request ID 嵌入 span name每个 span name 唯一聚合查询完全失效。用 JSON 数组替代单个 chunk span用gen_ai.input.messages作为 opt-in 属性而非默认开启。三、尾部分段采样而非头部采样。 深度 Agent 链通常产生每请求 20 到 100 个 span。如果只在头部 1% 采样你会丢失 99% 的失败链路。正确的策略是100% 保留所有 ERROR 链路100% 保留低 eval 分数链路100% 保留超过延迟或成本阈值的链路其余 5% 到 20% 均匀采样。这个逻辑可以放在高流量的 collector 端执行。四、HITL 中断前后提防 double-execution。 LangGraph 的interrupt()在 resume 时整个 node 从头重执行。如果你在interrupt()之前调了外部 API 或发了通知resume 时会再执行一次。把审批逻辑放在独立 node 里有副作用的操作放在interrupt()之后。五、中间件组合时注意顺序。 在洋葱模型中before_*钩子的执行顺序就是策略的执行顺序。把 PII 脱敏放在日志记录之前脱敏后的内容不会出现在日志中。反过来先记录日志再脱敏原始手机号就泄露到日志系统里了。这不是框架的 bug是你自己编排的策略链。Agent Hook 的演进本质上是一个确定性控制层逐步侵入概率性推理引擎的过程。2023 年的回调只能看2026 年的中间件可以拦、可以改、可以跳。这个趋势不太可能停在框架层——AWS AgentCore Gateway 已经把 Hook 下沉到了网络基础设施层MCP 协议虽然还没有原生的拦截机制这是一个会持续很久的争议点但 Agent Protocol、A2A、OpenTelemetry GenAI 语义约定已经在标准化不同层次的控制点。无论这些基础设施怎么演变那条经验法则不会变想让 Agent 做什么靠 prompt不想让 Agent 做什么靠 hook。