首页 / 文章

在代码中实现代理规则:PreToolUse、PostToolUse与Stop钩子函数

了解为何LLM智能体的授权应放在确定性工具调用钩子中,如何安全地拒绝调用,以及如何在不使用递归的情况下封装调度器。

发布者:REACTAPP.TOP阅读约 12 分钟2721 词

来源: LLM Agents, The Way Nobody Told You — Part 8: Hooks

那些写着“只有高级用户才能申请退款”的系统提示看似是规则,但对模型而言只是强烈建议。几乎所有输入都会被遵守,只有那些表述方式稍有异常的请求才会被忽略。本文将介绍如何把真正重要的规则从提示中移出,放入钩子函数中:这些小型确定性代码会在每次工具调用时执行,能够获取可信的服务器端上下文,并在操作发生前加以阻止。读完本文后,你将知道针对不同任务该使用哪种类型的钩子函数、如何在不破坏对话的前提下拒绝请求,以及如何安全地将钩子函数附加到现有的工具调度器上。

提示规则在实践中的失效方式

想象一下,一名客户支持人员与退款处理专家一起工作。系统提示仅高级用户可以提交退款申请,而免费套餐用户则应被礼貌地告知其套餐不包含退款服务。测试结果显示正常:免费用户会被拒之门外,而高级用户则能获得相应的申请文件。

随后,一名免费套餐用户以一种不寻常的方式发送了投诉。虽然内容看似是退款请求,但实际上是想申请另一种账户积分。这个本应提供帮助的模型因对投诉表述的意图感到不确定,仍判定情况足够相似而创建了申请文件。这些规则对人类来说本不存在任何歧义,但这位原本根本无权提交申请的顾客现在却有了申请记录。

这就是需要消除的错误钩子所对应的类别。该模型在遵循指令方面并非表现不佳,它只是基于概率运行。对于99%的输入都有效的规则,在剩余1%的输入中仍会有部分情况失效,而在实际应用中就会遇到这些例外情况。当违反规则会带来实际代价时,就必须通过每次都会运行的代码来强制执行该规则,而且模型无法对此提出异议。

工具调用生命周期中的三个钩点

钩子会附加在工具调用的特定时刻。总共有三个这样的钩点,每个都有不同的功能:

  • PreToolUse在工具执行之前运行。它可以拒绝调用、修改输入内容,或者仅进行观察。
  • PostToolUse在工具执行之后运行。它只能进行观察:记录日志、审计或发送通知,无法改变已经发生的事情。
  • Stop在会话结束时运行,负责清理、总结及数据持久化处理。
  • 一个简单的记忆方法是:PreToolUse用于判断某件事是否应该发生,PostToolUse用于记录该事确实发生了,而Stop则标志着会话的结束。

    这些角色不可互换。授权功能应属于PreToolUse,因为在PostToolUse中的检查是在操作已经生效之后才进行的。审计日志则应属于PostToolUse,因为在PreToolUse中记录的只是意图;该请求仍可能被拒绝或失败,所以日志中描述的内容可能是从未发生过的。应将每项功能与时间节点最匹配的钩子对应起来。

    能力、授权和政策是不同的检查机制

    假设你想为案例文件工具添加三条新规则:

    • 规则A:案件档案需要包含非空的证据列表。
    • 规则B:免费套餐的用户不得创建案件档案。
    • 规则C:在每周部署期间,即凌晨2点至4点之间,无法创建新案件。

    这些规则各自会拒绝某些工具调用,但却分布在不同的地方。将每条规则归类为以下四类会更有帮助:

    • 功能完整性,即输入数据是否内部一致,属于工具实现部分的内容。“证据列表必须非空”就是对功能完整性的检查。
    • 授权权限,即当前调用者是否有权限执行该操作,属于PreToolUse钩子函数的处理范畴。“免费用户无法创建案件档案”就属于授权权限检查。
  • 策略,即适用于整个组织的规则,也存在于 PreToolUse 钩子中。“在部署期间禁止写入”即为一种策略。
  • 观察结果,即对所发生事件的记录,存在于 PostToolUse 钩子中。“将所有写入操作记录到审计日志中”即为一种观察结果。
  • 一个有用的快捷方式是:工具会检查自身是否可以使用接收到的参数来执行操作,而钩子则负责判断调用方当前是否有权限执行该操作。

    为何要根据各层能看到的内容进行区分

    这种区分并非出于风格考虑,而是源于各层所接收的输入不同。工具仅能接收到模型生成的参数,它可以将这些参数相互比较,确认列表是否为空或数值是否为正数,但除此之外没有其他可供比较的内容。

    钩子会接收到相同的参数以及服务器整理好的可信上下文:经过验证的用户ID、用户的实际等级、当前时间以及会话ID。该上下文存储在会话启动时创建的HookContext对象中,之后便不再更改。

    掌握了这些信息后,钩子可以这样判断:模型想要使用user_id='u_999'调用lookup_user函数,但该会话属于u_004,因此请求被拒绝。由于工具无法知晓会话所属用户,它根本无法做出这样的决策。授权始终意味着将不可信的模型输出与可信的服务器状态进行比对,而只有钩子同时拥有这两者。

    将可信上下文与不可信输入分开

    HookContext 是服务器端状态。在会话开始时,你的代码会根据你能够控制的来源(如认证层、会话管理器以及数据库)来构建它。在整个会话期间,该状态都是不可变的,用户和模型都无法对其产生影响。下面这个被标记为冻结的数据类包含了你所需的最少信息:

    @dataclass(frozen=True)
    class HookContext:
        user_id: str         # set from your auth layer
        tier: Literal["free", "premium"]
        session_id: str
    

    frozen=True 在这里起到了关键作用:一旦对象被创建,就没有任何代码路径能够修改它,即便是存在缺陷的钩子函数也无法改变它。

    相比之下,tool_input 是模型放在其 tool_use 块中的字典。应将其视为传入的 HTTP 请求正文来处理。用户可以通过提示注入或单纯依靠持久化手段,迫使模型生成几乎任何内容。

    授权检查会对比这两者。模型提供的user_id是否与HookContext.user_id相同?HookContext.tier == “free”这一条件是否会排除使用该写入工具?保障系统安全的原理很简单:安全决策必须基于HookContext,绝不能依据模型提供的任何信息。

    拒绝请求而不中断对话

    显而易见的拒绝请求方式就是直接跳过该请求。但对于遵循Anthropic消息格式的工具调用API来说,这种方式行不通。助手发出的每个tool_use块都必须在紧接着的用户消息中得到一个带有相同tool_use_id的tool_result块作为回应。如果有一个请求没有得到回复,后续的API请求就会因400错误而失败。

    因此,PreToolUse 拒绝响应必须生成一个能够替代工具输出的结果。下面的示例是一个被标记为错误的 Python 字典(注意其中的 True),其中包含说明拒绝原因及相应处理方式的消息:

    {
      "type": "tool_result",
      "tool_use_id": "toolu_01ABC",
      "is_error": True,
      "content": "BLOCKED: free-tier users cannot call prepare_case_file. Suggest upgrade or route to general FAQ."
    }
    

    这符合 API 规范,模型会将其视作与其他观测值相同的输入。它识别出请求因层级限制被拒绝,随后会道歉并提供其他解决方案,从而使对话能够正常继续。

    有两个细节使得这一机制运作良好。

    统一的前缀可将拒绝信号化

    BLOCKED: 这一前缀并非 API 的强制要求,而只是一种约定。统一的标记有助于模型区分这是有意的拒绝还是暂时的故障,从而改变处理方式而非重复发起相同请求。同样的标记还能让后续的评估工具将故意的拒绝与真正的工具错误区分开来。原本只是文本形式的约定,由此变成了机器可识别的信号。

    拒绝消息即提示语

    模型会根据你返回的文本来构建下一条回复,因此应像编写提示语一样谨慎地撰写内容。仅仅写“被拒绝”这样的字眼根本无法为模型提供任何参考信息。而若能说明原因(如免费套餐无法使用该工具)并给出替代方案(升级或参考常见问题解答),就能让模型既了解请求失败的原因,也知道应提供什么替代内容。

    通过包装调度器来附加钩子

    大多数代理已经拥有一个带有call_tool函数的调度器,该函数接收一个tool_use块并调用对应的Python函数。添加钩子的最小侵入式方法是封装该函数而非重写调度器。第一步是在对任何内容进行修改之前先保留原始函数的引用:

    original_call_tool = dispatcher.call_tool   # save the reference FIRST
    

    接着定义一个包装器,该包装器在原始函数执行前后运行钩子链,并将其替换为旧函数。在已发布的代码片段中,最终的return result语句与重新赋值操作dispatcher.call_tool = hooked_call_tool被合并到了一行上;实际上它们是两个独立的语句,其中重新赋值操作位于函数体之外。

    def hooked_call_tool(name, args):
        event = build_event(name, args)
        if denial := registry.run_pre(event):
            return synthetic_error_result(denial)   # PreToolUse denied
        result = original_call_tool(name, args)     # call the saved original
        registry.run_post(event, result)
        return resultdispatcher.call_tool = hooked_call_tool   # NOW replace it
    

    该包装器执行五个步骤:

    1. 将工具调用转换为钩子事件。
    2. 运行所有的PreToolUse钩子。
  • 如果其中任何一个拒绝执行,就返回一个模拟的错误结果;真正的工具永远不会被调用。
  • 否则,调用已保存的原始版本并获取其执行结果。
  • 运行每一个 PostToolUse 回调函数,以便进行审计、通知和日志记录。
  • 替换前先保存

    需要遵循的原则是在替换之前先保存原始版本,这一原则同样适用于装饰器、中间件和拦截器。试想一下,如果跳过保存的引用而直接写入 dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw) 会怎样。该 lambda 只会在执行时才会查找 dispatcher.call_tool,而到那时该属性指向的已经是这个 lambda 本身。这样包装函数就会不断调用自身,最终导致无限递归和栈溢出。

    首先将原始函数赋值给变量,意味着闭包持有的是真实函数对象的引用,而非之后会被重新绑定的名称。因此,包装器始终能够调用到真正的实现代码。

    为何钩子优于提示规则和静态工具列表

    有三种常见的方法可以阻止智能体执行某些操作,但每种方法的失效方式各不相同:

    • 诸如“不要做X”这样的提示指令,在模型不确定时可直接被忽略。
    • 工具过滤意味着将某工具完全从列表中移除,这种方式是静态的,无法响应运行时的状态变化。
    • 钩子则是确定性的,并且能够感知运行时的状态。

    工具过滤依然很有价值。如果免费套餐的专家永远不需要使用create_jira_ticket功能,那就不要赋予它该工具。模型不会为从未被告知的工具生成tool_use指令块,而模型缺乏的能力比它可能忽略的指令更为可靠。

    对于那些依赖会话状态的规则,过滤功能就显得力不从心了。用户是否属于免费套餐取决于当前登录的用户:代理代码是相同的,但不同的会话可以执行不同的操作。静态的工具列表无法表达这一点,而钩子功能则可以。

    这种完善的架构将三者整合在一起:

    1. 在专家层面进行工具过滤,剔除专家永远用不到的工具。
    2. 通过提示语来引导用户的偏好选择并给予适当建议。
    3. 利用钩子功能实现那些必须无条件遵守的运行时规则。

    如需了解框架内的类似实现方式,可参阅LangChain 的防护机制与中间件是如何处理相同问题的。

    单一工具接口即意味着单一执行层

    当智能体能够执行的每项操作都被视为工具调用时,钩子功能能发挥最大作用——包括子智能体委托、外部 API 请求、数据库查询以及知识库检索。此时 hooked_call_tool 包装器可以处理所有这些操作,包括后续添加的工具,且授权、策略和审计机制可统一应用于整个操作层面。

    如果将任务委托给子代理通过直接函数调用来实现,钩子层就永远无法感知到这一操作。此时就需要额外的授权机制来处理路由逻辑,这意味着要维护两个系统,且错误也有可能隐藏在两个系统中。从一开始就设计统一的工具接口,会让钩子的添加变得简单;而事后再添加则会导致大规模的代码重构。

    PreToolUse 钩子链的行为方式

    实际系统中会存在多个 PreToolUse 钩子,例如层级检查、部署时间窗口检查、用户 ID 校验以及支出限额检查。这些钩子会按照特定顺序依次执行,其规则如下:

    • 只要有一个钩子拒绝请求,整个流程就会中断,后续的钩子以及工具本身都不会被执行。
    • 只有所有钩子都允许请求通过,流程才能继续。
  • 修改是逐个组合的。Hook B 接收到的输入是 Hook A 修改后的结果。
  • 应按照成本和重要性对处理链进行排序,将成本低且能决定结果的检查放在最前面。判断用户是否属于免费层级只需读取一个字段;而检查每周的组织支出限额可能需要执行数据库查询。先进行成本较低的检查,可以避免对那些无论如何都会被拒绝的请求做无谓的处理,这就好比在 SQL 的 WHERE 子句中先将最严格的条件放在前面。

    当钩子本身崩溃时应该怎么办

    不同类型的钩子需要不同的故障处理策略,如果处理不当就会引发真正的漏洞而非仅仅是风格问题:

    • PreToolUse 错误应当被传递出去。崩溃的策略钩子本身就是缺陷。假装该检查已经执行而实际上并未执行,比直接停止还要糟糕,因此应在策略修复之前暂停整个代理程序的运行。
    • PostToolUse 错误必须被忽略。 审计日志记录失败不应导致用户请求中断,此时用户的请求比日志条目更为重要。应在单独的通道中记录该故障,然后继续执行。
    • Stop 错误必须被忽略。 会话反正即将结束,因此最佳做法是记录清理失败后直接退出。

    那种默默捕获自身异常的 PreToolUse 钩子存在安全漏洞:检查并未执行,调用依然完成,而审计追踪中也无法提供任何解释。允许异常外露的 PostToolUse 钩子则会让每一次日志记录故障都演变成代理程序的故障。

    审计追踪的存储位置

    简单的配置会创建两个只读追加文件:

    • data/audit_log.jsonl,由 PostToolUse 在每次工具调用时生成,包含工具名称、输入内容、输出结果、错误标志以及时间戳。
    • data/session_summaries.jsonl,由 Stop 钩子在每个会话中生成一次,记录每次对话的高级统计信息。

    审计日志很快就会成为项目中最有价值的文件。它从项目初期就被纳入设计,为合规性检查、性能评估、成本分析、故障排查以及数据看板提供统一的真实数据来源。在生产环境中,通常会将这些记录存储到持久化存储中,但数据结构保持不变。

    关键要点

    • 提示指令仅具有建议性质;任何违反后会带来实际后果的规则都必须在代码中得到强制执行。
  • 将输入一致性检查放在工具中,授权与策略处理在PreToolUse阶段,审计则在PostToolUse阶段进行。
  • 所有安全决策都应以冻结的服务器端上下文为依据,将模型接收的工具输入视为不可信的请求数据。
  • 通过返回带有明确前缀的合成tool_result来拒绝调用,从而保持对话的有效性并让模型能够恢复。
  • 先保存原始函数,再将调度器封装在其中,让所有智能体操作都通过该工具接口传递。
  • 让PreToolUse阶段的失败阻止智能体继续执行,而PostToolUse及Stop阶段的失败则仅记录后忽略。