Skip to content

Appendix 08 / 附录

扩展机制补遗:Hooks 的接口边界 ​

小A和老Z观察主路径经过决策闸门和转换滚轮,旁路记录通道独立收集事件。

小A在概念篇学过 Hooks,可真要自己给 Agent 挂钩子时,先被一堆名字绊住了:convertToLlm、transformContext、beforeToolCall、afterToolCall、finishTurn、prepareNextTurn、prepareRequest……哪些能拦、哪些只是看,哪些在请求前、哪些在请求后,光看名字分不出来。

“别照着名字猜。”老Z说,“名字会骗你。beforeToolCall 听起来像‘工具调用前’,实际落在‘参数已校验之后’,你拦到的已是合法调用,不是原始输入。本附录列出 pi 这个快照里真实可核对的低层 loop hooks;它们不是通用 Hook 标准,换一个 Agent 框架,接口和时序可能完全不同。”

“那读这些接口,有什么章法?”小A问。

“每个 hook 按三样东西记:何时触发、能否改变什么、失败时发生什么。”老Z说,“前两样决定你能拿它干什么,第三样决定你写它时要不要胆战心惊。beforeToolCall 的名字只回答了‘什么时候’,真正要搞清的是后两样。”

小A:“为什么只讲 pi 的低层 loop hooks,不讲概念篇里那个更宽的 Hook 图景?”

“概念篇讲意图,这里给你坐标。”老Z说,“拦截器、订阅者、转换器那套分类帮你建立心智模型;可真要动手挂钩子,你得知道 pi 这个快照里每个接口的具体位置和时序。分类告诉你‘有什么可能’,坐标告诉你‘这个框架里哪里能落’。两样缺一,写出来的 hook 要么挂错地方,要么挂了个不存在的接口。”

“还有一个误区先排掉。”老Z接着说,“别把‘hook 存在’当成‘权限存在’。表格里 convertToLlm 那栏写着‘不负责权限’,这不是免责声明,是接口的真实边界:它只管消息形状的转换,权限校验发生在别处。读表时要连‘结果/边界’那栏一起读,一个 hook 能做什么,和它不能做什么,同样重要。”

先分清三类 Hook ​

老Z让小A把这一堆名字按“能做什么”分三类:

  • 拦截器(interceptor):能允许、拒绝或改写主路径。beforeToolCall(可 block)、afterToolCall(可覆盖结果)属于这类。
  • 订阅者(subscriber):只观察事件,不通过返回值改写主路径;异常是否影响主路径取决于实现。UI 渲染、日志记录通常用订阅者。
  • 转换器(transformer):改写输入,不返回继续或停止的决策;抛异常仍可能阻止请求。convertToLlm、transformContext 属于这类。

“这个分类说明用途,失败后果还得看调用代码。”老Z说,“拦截器出错可能让整个 turn 失败;pi 的 agent.ts 逐个 await listener,没有逐监听器的异常隔离,订阅者抛异常也可能终止主路径。transformContext 或 convertToLlm 抛异常会阻止本次 provider 请求;只有成功返回了错误内容,且后续未拒绝,请求才可能带着坏上下文发出去。调试时要结合分类和实际异常传播路径判断影响范围。”

小A:“那写的时候,到底按哪类写?”

“按你要不要影响主路径来选。”老Z说,“只想记录‘某个工具被调了’,就写订阅者,它出错不该把 turn 弄崩,所以要包 try/catch、要容错;要拦危险调用,就写拦截器,它的失败语义必须明确,默认放行还是默认拒绝,不能含糊;要改写上下文,就是转换器,要区分抛异常和返回错误内容:前者阻止本次请求,后者可能把坏数据送出去,必须校验返回内容。”

小A抢着归纳:“所以只读不改是订阅者,改输入是转换器,改决定(放行、拒绝、继续、停止)是拦截器?”

“对。三个问题依次问自己:我要改输出吗?我要改输入吗?我只是在看吗?答案会把你归到某一类。防御代码要按异常传播路径和失败后果来写,不能只按类别排多少。动笔前再问一句:我挂的这个 hook 挂了,整条 loop 应该怎么办?答得上来,才动手写。”

小A又问:“三类 hook 的调试难度,是不是也差很多?”

“会有差别,但不能只按类别排难度。”老Z说,“订阅者异常若被自己捕获并记录,可以从日志定位;若未隔离,也可能中断 turn。转换器抛异常可在请求前发现,成功返回错误内容却可能要等模型答错才暴露。拦截器也要分别检查显式拒绝和异常。调试点离触发点越远,越难查;每类都要记录触发位置、结果和失败原因。”

“这个‘问一句’,能给个具体例子吗?”

“拿拦截器举例。”老Z说,“你挂一个 beforeToolCall 拦 rm 工具,得先回答:这个 hook 自己抛异常时,工具还执行吗?执行,那你拦了个寂寞;不执行,那异常算不算你想要的‘拦截成功’?两个答案都成立,但必须显式选一个并写下来。含糊的 hook 最危险,它的行为完全取决于 loop 当时的心情。”

可核对的 loop hooks ​

下表列出 pi v1.0.0 快照里真实存在的低层 loop hooks,位置与触发时机来自 packages/agent/src/types.ts 与 agent-loop.ts,不是文档承诺。

接口位置与触发结果/边界
convertToLlmAgentConfig;每次 provider 请求前将 AgentMessage[] 转为 LLM Message[];不负责权限
transformContextAgentConfig;convertToLlm 前调整上下文;错误会影响请求路径
beforeToolCall参数已校验、tool_execution_start 后、执行前可返回 { block, reason };中止 signal 需要 hook 自己响应
afterToolCall工具结束、最终 tool result event 前可覆盖 content、details、structuredContent、isError、usage,或 terminate
finishTurnassistant 与全部 tool result 已追加、turn_end 前;error/aborted 也会调用返回 { action: "end" } 立即结束;{ action: "continue" } 保证再请求一次;error/aborted 时决策被忽略;不取消已运行工具
prepareNextTurn下一轮 turn_start 前;首轮不调用替换上下文、模型、思考级别或追加消息;finishTurn 已决定结束时不会调用
prepareRequest每次 provider 请求前,首轮也调用;待注入消息已追加替换上下文、模型、思考级别;不读队列、不追加消息

小A盯着表格问:“finishTurn 和 prepareNextTurn,谁先谁后?会不会准备完了又停止,准备工作白做?”

“v1.0.0 正好调整了这个顺序。”老Z说,“finishTurn 在本轮收尾时先决定去留;prepareNextTurn 推迟到下一轮真正开始时才调用。决定结束了,就不会再准备下一轮。旧版本的 shouldStopAfterTurn 排在 prepareNextTurn 之后,准备的副作用会先发生,这个坑在新版本里没有了。agent 包的 README 写明了迁移方法:原来返回 true 的地方,改成返回 { action: "end" }。”

“还有两个容易漏的细节。”老Z接着说,“第一,finishTurn 对 error 和 aborted 的回答也会被调用,只是决策被忽略;README 提醒,从旧 hook 迁移时如果判断逻辑有副作用、或者假设回答是成功的,要先自己排除这两种情况。第二,无条件返回 { action: "continue" } 会造成死循环,因为下一次请求结束后它又会被调用。”

“那我挂个 finishTurn 返回 end,正在跑的工具也会被中止吧?”

“恰恰相反。”老Z说,“finishTurn 被调用时,本轮的工具已经全部跑完、结果也追加了。它只决定‘下一次请求还要不要发’,不取消任何已经跑起来的东西。很多人会踩这个坑:以为停了就等于中止了。它是停止点,不是中断开关;想请求中止正在跑的工具,得走 signal,不是这个 hook。signal 是协作式中止:工具或底层进程响应它才会停。”

小A:“convertToLlm 和 transformContext 都改上下文,区别在哪?”

“transformContext 改的是送给转换的原始材料,convertToLlm 改的是转换后的最终形状。”老Z说,“前者在‘转换前’,你可以裁剪、增补、排序上下文;后者在‘转换那一刻’,把 AgentMessage[] 变成 provider 认识的 Message[]。想在最后一步统一调整消息格式,挂 convertToLlm;想先决定‘哪些材料进上下文’,挂 transformContext。两者抛异常都会阻止本次 provider 请求;成功返回错误内容时,前者影响转换的输入,后者影响转换后的请求内容。”

“还有个顺序细节,”老Z补充,“transformContext 在 convertToLlm 之前,所以 transformContext 的改动会经过 convertToLlm。如果你的两个 hook 都动了同一条消息,后者的改动会盖在改动之后的材料上。写 hook 前把这条链画出来,比事后对着事件日志猜顺序省事得多。”

小A又想到一处:“那 prepareNextTurn 跟 transformContext 都‘为下一轮做准备’,功能重叠吗?”

“有重叠,但作用时间不同。”老Z说,“transformContext 每一轮请求前都跑,改的只是这一次送进 convertToLlm 的材料;prepareNextTurn 在 turn 与 turn 之间跑,prepareRequest 在每次请求前跑,它们返回的上下文、模型和思考级别会替换运行状态,影响本次和之后的请求。作用范围一错,你就不知道哪次改动生效了。”

两个最容易误用的接口 ​

beforeToolCall:拦了一次 ≠ 安全控制 ​

源码事实: packages/agent/src/types.ts 的 BeforeToolCallResult:

ts
export interface BeforeToolCallResult {
	block?: boolean;
	reason?: string;
	/** 被拦截时提示本批工具结束后停止;整批结果都带 terminate 才生效 */
	terminate?: boolean;
}

小A:“beforeToolCall 阻止一次工具,是不是就完成安全控制了?”

“不是。它只在这条 loop 路径生效,三个边界要分清:”

  • hook 没装时:默认放行。不能假设‘我没写 beforeToolCall,工具就不会执行’。
  • hook 失败时:异常按 loop 的错误处理走,可能不是‘安全失败’。
  • hook 之外:工具实现仍要自己验证输入、限定权限。beforeToolCall 是循环层的闸门,不是工具层的权限。

“工具的输入校验(workspacePath、参数 schema)和 beforeToolCall 是两道独立的闸。”老Z强调,“去掉任何一道,另一道还在;但不能因为有一道,就拆掉另一道。”

小A:“这条闸的失败语义,怎么定义才算明确?”

“三选一,写清楚。”老Z说,“默认放行、默认拒绝、失败即崩溃,三种都是明确的语义;最不能接受的是‘没想好’,因为没想好的默认行为往往就是放行。选哪种看场景:审计类拦截可以放行并记录,高风险类拦截应该拒绝,‘必须拦住否则出大事’的拦截应该失败即崩溃,宁可不干活,不能放行。定义失败语义,就是回答‘我宁可错杀,还是宁可放过’。”

小A追着问:“那 reason 字段呢?拦住之后,这个字符串去哪了?”

“它会进入工具结果消息,成为模型下一轮看到的上下文。”老Z说,“模型会看到‘这个调用被拒,原因是……’;没有独立的 reason 通道。所以 reason 不是给人看的备注,是给模型的行为指导:写‘参数越界’和写‘文件路径不能包含 ..’,模型下一轮的行动完全不同。reason 写得越具体,模型的自我修正越有效;写得太笼统,它下一轮还会用同样的姿势再撞一次。这条经验也适用于工具错误信息,失败原因的颗粒度,直接决定模型能不能改对。”

“最后补一个 hook 之外的动作。”老Z说,“beforeToolCall 只能拦,不能代替你处理。拦下来之后,记录、通知、改参数重试、还是直接停循环,这些后续动作要由宿主代码写。hook 是闸门,开关之后走哪条路,是宿主的事。把‘拦截’和‘拦截后的处理’分清楚,才不会出现‘拦了但没人管’的尴尬。”

afterToolCall:覆盖不是深合并 ​

源码事实: 同文件的 AfterToolCallResult 是部分覆盖结果,包含 content、details、structuredContent、isError、usage 和 terminate。你提供字段就替换原值,它不是对 content 的深合并。有一个例外:只替换 content 而不给 structuredContent 时,loop 会删掉原来的 structuredContent,注释说明这是为了避免两者对不上。

小A:“那我只想改 content 里的一个字段呢?”

“你要返回完整的 content,不能指望框架帮你合并。”老Z说,“这是部分覆盖(partial override)的语义:你给的字段整体替换,你没给的字段保持原值。只给 content 的一个片段,模型看到的就是那个片段,不是‘原 content 加上你的修改’。这个语义和深合并(deep merge)完全不同,混淆了会产生静默的数据丢失。”

老Z又补了一条失败语义的提醒:“afterToolCall 覆盖了 isError,就等于你接管了‘这次调用算不算失败’的判定。把一个本该是 error 的结果覆盖成 success(比如想‘修一下让它继续跑’),模型就会把错误结果当成正常数据往下用,这种看似好心的改写,往往是后续一连串错乱答案的源头。覆盖之前先问:我改的是显示,还是改了语义?改语义的覆盖要慎之又慎。”

“还有 terminate 字段。”老Z说,“它是工具层提前结束循环的提示,beforeToolCall 拦截时也能带。注意注释写的是:整批工具结果都带 terminate 才会停,只有一个带不算。terminate 和 finishTurn 的区别要分清:前者由工具结果投票,后者由宿主在回合收尾时直接决定。想‘这批工具跑完就停’且每个工具都同意,用前者;想由宿主按自己的规则决定,用后者。搞混了,会出现‘想停却多跑了一轮’。”

小A:“usage 覆盖那栏呢?这也能改?”

“能,但改之前先想清楚为什么。”老Z说,“usage 是报告给上层的 token 统计。正常情况下你不需要动它,只有‘工具结果里包含的用量需要合并进这次调用’时才需要。覆盖 usage 的风险是审计失真,上层按 usage 算成本、做监控,你改它等于在成本账上动手脚。除非有明确的合并需要,否则别碰这一栏。”

小A在 usage 旁边写了两个字:慎改。

文档里的目标 ≠ 运行时代码 ​

老Z最后提醒了一个陷阱。

“旧版本的 packages/agent/docs/agent-harness.md 里有一段‘designed, not implemented’的通用 harness hook 计划,v1.0.0 连同 harness 一起删掉了。”老Z说,“这正好说明一件事:不要把文档中的目标设计误写成当前运行时代码。文档写的是‘我们打算支持这些 hook’,源码写的是‘现在真的有这些 hook’。本附录只列已在 AgentConfig/loop 契约中可核对的接口,也就是源码里真实存在的。”

小A:“那 README 里的描述能信吗?”

“源码事实优先。”老Z说,“比如 packages/agent/README.md 说 finishTurn 对 error 和 aborted 也会运行、但决策被忽略,这条要回到 runLoop 的代码核对。README 和源码冲突时,以源码为准,把 README 标记为待更新。不要调和冲突,要择一并说明理由。”

“再给一个判断口诀:‘设计了’和‘实现了’之间,隔着一次 grep。”老Z说,“源码里搜不到符号名的接口,无论文档把它描述得多完整,都当成规划,别当成可用的钩子。反过来,源码里存在的接口,文档没写,那是文档欠账,不影响你使用。源码是事实层,文档是意向层,拿事实层压意向层。”

小A:“那版本升级之后,这些 hook 的接口会不会变?”

“会,而且变了不会专门告诉你。”老Z说,“pi 在演进,接口、时序、失败语义都可能调整。本附录里所有位置都基于当前快照,升级后要重新核对,别拿旧坐标当新地图。核对的办法就是那三问:接口还在吗?触发时序变了吗?失败语义改了吗?三问都答完,才敢继续用。”

Hook 的价值与硬约束 ​

小A:“那 Hook 到底能保证什么?”

“Hook 的价值是让可观察、可审计的扩展点进入控制流。”老Z说,“你可以在工具执行前记录、在请求发出前转换、在 turn 结束后判断是否停止。但硬性安全约束仍应由工具、权限和执行环境承担,不能由 hook 独自承担。”

小A:“既然 hook 不能兜底,那它和‘配置’有什么区别?不都是往 Agent 里插东西吗?”

“区别在是否进入控制流。”老Z说,“配置是静止的,你读它,它不动作;hook 是活的,它在特定时刻被调用,能返回结果,结果会改变主路径。配置改变的是‘Agent 是什么样’,hook 改变的是‘Agent 在某个瞬间做什么’。所以 hook 的接口和时序要精确核对,配置只需要核对取值。这也解释了为什么本附录花大篇幅讲时序:配置写错了,改一行就好;hook 挂错了时机,行为整个走样。”

小A又问:“hook 的‘可观察’价值,具体怎么兑现?”

“把不透明的 loop 变成有记号的 loop。”老Z说,“没有 hook,工具执行就是一个黑箱,你只知道它发生了,不知道它怎么发生;挂了 hook,你可以在每个关键点留记号:请求发出前、工具执行前、执行后、turn 结束时。记号多了,loop 的行为就可以被回放、被审计、被分析,这正是调试和事故复盘要的基础。”

小A:“审计这个用途,有没有推荐的落地方式?”

“有,订阅者是最适合审计的那一类。”老Z说,“订阅工具执行前后的事件,把参数摘要、调用 ID、结果写进审计日志。只读不代表异常自动隔离:pi 会等待监听器,审计订阅者要自行捕获并处理异常,才能避免记录失败中断主路径;它的耗时也会增加等待。审计 hook 和拦截 hook 不要混写:拦截失败可能让整个 turn 失败,你不想因为‘审计组件坏了’而让合法工具也执行不了。审计用订阅者,拦截用拦截器,各归其位。”

“还有一条纪律。”老Z说,“审计日志要包含调用 ID 和参数摘要,但不要包含完整秘密 payload。日志会进文件、进备份、进事故复盘,把密钥写进日志,等于把审计对象搬进了事故现场。审计要回答‘谁在什么时候调了什么’,不需要回答‘参数全文是什么’。”

小A合上笔记,问了句收尾的话:“那我到底该把什么放心地交给 hook?”

“交给它观察和编排,别交给它兜底。”老Z说,“观察,记录、审计、统计,订阅者管得住;编排,在正确的时间点插入策略、在 turn 之间准备上下文,转换器和拦截器做得到。兜底,无论 hook 有没有装、装得对不对,主路径都必须安全,这是工具层和权限层的事。分清这两者,你才不会在事故复盘时发现‘那个安全 hook 一直没装上,而我们以为它装着’。”

小A想了想,补了一句:“也就是说,要按‘没装 hook 也安全’的标准去设计工具,再按‘装了 hook 更可控’的标准去挂 hook。”

“对,这就是顺序。”老Z说,“先保证主路径安全,再谈扩展;先保证默认行为正确,再谈拦截。hook 是主路径上的节点,主路径本身才是地基。”

Hook 的两条边界

  1. 软扩展点,不是硬安全层:Hook 能拦截、能改写,但它的存在不是强制的(没装就放行),它的失败不保证安全。
  2. 安全要纵深:工具层验证输入,权限层限定范围,执行环境(OS 沙箱/容器)隔离副作用,Hook 层做审计和策略。四层各有职责,不能互相替代。

回到全书目标:Hook 让 Agent 的控制流可扩展、可观察;但安全、权限和隔离是独立的工程问题,不能寄望于一个 hook 接口解决。

小A把这张表折起来放进兜里。第二天,他给 Agent 挂的第一个 hook 是审计用的订阅者。他记得老Z那句“先分清三类”,也记得那句“审计用订阅者,拦截用拦截器”。名字还是那些名字,但这次他分得清了。