🐎 第8章 器以载道:Agent SDK 智能体开发套件
📄 一句话概括

SDK 里的钩子和 CLI 里是同一套事件,只是处理器从「一个外部程序」换成了「你的函数」。 实测六个工具调用:拦住一个、改写一个、放行四个。 改写比拦住更好用——意图是对的,只是目标该换。

📝 本章和前面几章的关系

上一节讲自定义工具(往外扩),这一节讲钩子(往里收)。 第 5 章已经讲过事件和语义,这一节只讲「换成函数之后有什么不一样」。

一、两个位置

PreToolUse:工具执行前跑。能拦、能改参数、能放行。 PostToolUse:工具执行后跑。能看结果、能补记,但改不了已经发生的事。 「改不了已经发生的事」这一条很关键:想阻止就得用前一个,事后再拦已经晚了。

所以设计钩子的时候先问一句:这件事我要「阻止」还是「记录」?答案决定挂哪个。

二、真跑:六个调用逐个过前钩子

演示里六个调用:读文件、跑 rm -rf、改 .env、跑测试、写文件、改代码。 结果拦住一个(危险命令)、改写一个(.env 改成 .env.example)、放行四个。 被改写的那一个最有意思:它没被拦,而是把路径换掉了。

这比「拦住」更好用——模型的意图是对的(想改配置),只是目标该换一个。 而直接拦会让它反复重试同一个动作,你还得告诉它「换成哪个」。

三、后钩子拿到什么

工具名与参数:知道刚才干了什么,可以写审计日志。 执行结果:成功还是失败、返回了什么,可以统计和告警。 会话标识:归到哪一次会话,排查时能串起来。

三样都拿到之后,最有价值的用法是「统计」:哪类工具被调得最多、哪种失败最常见。

四、和命令行钩子的关系

事件名、字段、拦截语义都一样,差的只是「处理器写在哪儿」。 命令行钩子要序列化成 JSON 过管道,程序化钩子直接拿对象——后者写起来短,也少一类解析错误。 但命令行钩子有个优势:它跟语言无关,换团队、换栈都不用重写。

所以选哪个不是能力问题,是「这段逻辑归谁维护」的问题。

五、一个坑:钩子里别做重活

钩子跑在主循环上,它慢一次,每次工具调用都慢一次。 要发通知、要上报,就丢到队列里异步做——这和 5.9 节那条判据是一样的。 判断标准还是那句:这个钩子的失败会不会影响正确性?不会,就不该让主流程等它。

补充一条:钩子里的异常不要往外抛。抛了会把整个会话打断,而你的本意只是「记一笔」。

六、动手 Demo:两个钩子挂在工具调用上,六个调用被改了两次

SDK 里的钩子和 CLI 里的钩子是同一套事件,只是处理器换成了你的函数。 这里真跑六个工具调用,过一遍前钩子的三个分支:拦、改、放。

def _pre(call):
    if call[u"name"] == u"Bash" and u"rm -rf" in call[u"args"][u"cmd"]:
        return u"deny", u"危险命令"
    if call[u"args"].get(u"path", u"").startswith(u".env"):
        call[u"args"][u"path"] = u".env.example"
        return u"rewrite", u"改指到示例文件"
    return u"pass", u""

实跑输出:

--------------------------------------------------------------------
两个钩子挂在工具调用上,六个调用被改了两次
--------------------------------------------------------------------

  SDK 里的钩子和 CLI 里的钩子是同一套事件,只是处理器换成了你的函数。
  好处是不用起进程、不用传 JSON 字符串,直接读写对象。

  ① 两个位置
  --------------------------------------------------------------------
| 钩子 | 什么时候跑 | 能做什么 |
|---|---|---|
| PreToolUse | 工具执行前 | 能拦、能改参数、能放行 |
| PostToolUse | 工具执行后 | 能看结果、能补记、但改不了已经发生的事 |

  「改不了已经发生的事」这一条很关键:想阻止就得用前一个,事后再拦已经晚了。

  ② 真跑:六个调用逐个过前钩子
  --------------------------------------------------------------------
| 调用 | 动作 | 原因 |
|---|---|:---:|
| Read src/app.py | pass | — |
| Bash rm -rf / | deny | 危险命令 |
| Edit .env → .env.example | rewrite | 改指到示例文件 |
| Bash pytest -q | pass | — |
| Write out.json | pass | — |
| Edit src/app.py | pass | — |

  六个调用:拦住 1 个、改写 1 个、放行 4 个。
  被改写的那一个最有意思:它没被拦,而是把路径从 .env 换成了 .env.example。
  这比「拦住」更好用——模型的意图是对的(想改配置),只是目标该换一个。

  ③ 后钩子拿到什么
  --------------------------------------------------------------------
| 拿到什么 | 能干什么 | 典型用法 |
|---|---|---|
| 工具名与参数 | 知道刚才干了什么 | 写审计日志 |
| 执行结果 | 成功还是失败、返回了什么 | 统计、告警 |
| 会话标识 | 归到哪一次会话 | 关联排查 |

  后钩子跑在「事已至此」之后,所以它只适合记录和统计,不适合拦截。

  ④ 和命令行钩子的关系
  --------------------------------------------------------------------
  事件名、字段、拦截语义都一样,差的只是「处理器写在哪儿」。
  命令行钩子要序列化成 JSON 过管道,程序化钩子直接拿对象——后者写起来短,也少一类解析错误。
  所以选哪个不是能力问题,是「这段逻辑归谁维护」的问题。

  ⑤ 一个坑:钩子里别做重活
  --------------------------------------------------------------------
  钩子跑在主循环上,它慢一次,每次工具调用都慢一次。
  要发通知、要上报,就丢到队列里异步做——这和 5.9 节那条判据是一样的:
  只要失败不影响正确性,就不该让主流程等它。
  ! 钩子的注册方式以真实版本为准;这里演示的是「前能拦、后只能记」这件事。

①两个位置:PreToolUse 能拦能改能放,PostToolUse 只能看和记——它跑在「事已至此」之后。 ②六个调用:拦住一个、改写一个、放行四个。被改写那个是把 .env 换成 .env.example。 ③改写比拦住更好用:模型的意图是对的(想改配置),只是目标该换一个。 直接拦会让它反复重试同一个动作。 ④钩子跑在主循环上,别做重活。要发通知就丢队列异步做; 钩子里的异常也不要往外抛,抛了会把整个会话打断。

30 秒实验

  1. 把「改指」那条去掉,只看「拦」,重跑一遍。六个调用会变成什么样?模型接下来大概会做什么?
  2. 加一条前钩子:所有 Write 调用都额外记一行日志,重跑一遍。它属于「拦」还是「记」?该挂哪个位置?
  3. 给前钩子里加一句 time.sleep(1),重跑一遍。六个调用总共多花几秒?这说明什么?

常见误区

  1. 用 PostToolUse 做拦截。事已至此,拦不住。
  2. 能把参数改对的事直接拦掉。让意图保留、目标换掉,模型不容易反复重试。
  3. 在钩子里同步发网络请求。它跑在主循环上,每次工具调用都会被拖慢。
  4. 钩子里抛异常。会把整个会话打断,而你的本意只是记一笔。
  5. 为一次性需求写钩子。钩子是每次都要跑的,一次性的事该写进提示词。
自测三问(点开对答案) 1. 两个钩子位置分别能做什么? —— PreToolUse 在工具执行前,能拦、能改参数、能放行;PostToolUse 在执行后,只能看结果和记录。 2. 为什么「改写」有时比「拦住」更好? —— 因为模型的意图可能是对的,只是目标不对。拦住会让它反复重试,改写能保留意图换掉目标。 3. 钩子里为什么不能做重活? —— 它跑在主循环上,慢一次,每次工具调用都慢一次。要发通知就丢队列异步做。

钩子是「你写的拦截」,下一节看它自带的那几道防线。

相关笔记