开源编程 Agent pi 的钩子与可观测性:能插手哪些时机,又能看到哪些数据

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

pi 把”能改变执行结果的钩子”和”只能看不能干预的观测”当成两件事分开设计,这个分法决定了你能不能拿它做审计和成本归因。 前者是控制面,处理器返回什么,agent 的行为就跟着变;后者是被动的事件流,官方文档里写死了一句:观测不得影响 pi 的执行,订阅方的异常要被吞掉或隔离。想清楚一件事该落在哪一面,比记住几十个事件名有用得多。

pi 是 MIT 许可证的开源项目,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月在 GitHub 上约 8 万 star。下面讲的每个名字都能在仓库里搜到,你可以边看边对。

站内已经有两篇讲通用方法论的文章:Agent 可观测性与日志设计讲”一个 agent 系统该记哪些字段”,Agent 成本分配到部门讲”账单怎么摊到人头”。这篇不重复那些原则,只回答一个具体问题:pi 这个项目把这些事落到了哪些接口、哪些文件、哪些字段上,以及落到什么程度。

一、先分清控制面和观测面

你想挂逻辑,无非三类诉求:拦一下(这条命令别跑)、改一下(往上下文里塞点东西)、记一下(这次花了多少 token)。前两类必须能影响执行流,第三类最好一点都别影响。

pi 的做法是两套独立机制。

控制面是扩展系统。你写一个默认导出的工厂函数,拿到 ExtensionAPI,用 pi.on(event, handler) 订阅事件,处理器的返回值参与该事件的语义。这套东西是真实存在、有完整实现的代码。

观测面在 packages/agent/docs/observability.md 里定义为一份事件契约:pi 只负责发出结构化的生命周期事件,由外部订阅者把它们转成 OpenTelemetry span、Sentry span、日志或者自定义指标。文档开篇就写明目标是让核心包不依赖 OTel、Sentry 或任何 APM 厂商。

这里必须说清楚一个容易踩空的事实:这两份文档的落地程度不一样。控制面的扩展系统在仓库里有成体系的实现和几十个可运行示例;而观测面这份文档标题就叫 Design Notes,在仓库的 .ts 源码里全文检索 traceOperationPiObservabilityrunWithPiContext,一个命中都没有,packages/ 下也没有文档里规划的 packages/observability 目录。同样地,packages/agent/docs/hooks.md 里那套 AgentHarnessHooks / DefaultAgentHarnessHooks 接口名,在 .ts 里也搜不到——它描述的是钩子机制该长成什么样,实际跑起来的是 packages/agent 里 harness 自己的 on(),以及 packages/coding-agent 里那套扩展运行器。

把设计文档当成已交付的 API 去写代码,是读这个仓库最常见的翻车方式。下面凡是我标了”设计稿”的,你都得当成方向而不是接口。

二、控制面:你能挂在哪些时机上

packages/coding-agent/docs/extensions.md 里画了一张生命周期图,把一次交互从启动到结束的事件顺序摆得很清楚。核心的一段是这样:

user sends prompt
  ├─► (extension commands checked first, bypass if found)
  ├─► input (can intercept, transform, or handle)
  ├─► (skill/template expansion if not handled)
  ├─► before_agent_start (can inject message, modify system prompt)
  ├─► agent_start
  ├─► message_start / message_update / message_end
  │   ┌─── turn (repeats while LLM calls tools) ───┐
  │   ├─► turn_start
  │   ├─► context (can modify messages)
  │   ├─► before_provider_headers (can mutate headers)
  │   ├─► before_provider_request (can inspect or replace payload)
  │   ├─► after_provider_response (status + headers, before stream consume)
  │   │     ├─► tool_execution_start
  │   │     ├─► tool_call (can block)
  │   │     ├─► tool_execution_update
  │   │     ├─► tool_result (can modify)
  │   │     └─► tool_execution_end
  │   └─► turn_end
  ├─► agent_end
  └─► agent_settled (no retry/compaction/follow-up left)

这张图信息量很大,值得逐层看。

输入侧input 在技能和模板展开之前触发,它的返回值是三选一:{ action: "continue" }{ action: "transform", text, images? }{ action: "handled" }。前两个走下去,第三个直接短路,agent 循环根本不会启动。事件本身还带了 source 字段,取值是 "interactive" | "rpc" | "extension",这意味着你能区分这条输入是人敲的、RPC 打进来的、还是别的扩展发的——做审计时这个区分很关键。

请求侧context 能改传给模型的消息数组,before_provider_request 拿得到即将发出的 payload 并可以整体替换,before_provider_headers 让你在请求头里塞追踪 ID。文档特别写了这个头部钩子每次 provider 请求只跑一次,重试会复用已装配好的头而不再触发。文档还提醒:在 payload 层面改掉的系统指令不会反映到 ctx.getSystemPrompt(),后者报的是 pi 自己的系统提示串,不是最终序列化出去的 provider payload。这两条差异,做合规校验的时候会直接决定你该在哪一层取证。

响应侧after_provider_response 在响应流被消费之前触发,带 statusheaders。要统计 HTTP 层的失败率、抓限流响应头,这是唯一合适的位置。

工具侧tool_call 可以返回 { block: true, reason } 直接把这次调用拦掉,tool_result 可以改写 content / details / isErrortool_execution_start / update / end 是纯观察位。仓库里的 packages/coding-agent/examples/extensions/permission-gate.ts 是最短的完整示例:

pi.on("tool_call", async (event, ctx) => {
	if (event.toolName !== "bash") return undefined;

	const command = event.input.command as string;
	const isDangerous = dangerousPatterns.some((p) => p.test(command));

	if (isDangerous) {
		if (!ctx.hasUI) {
			// In non-interactive mode, block by default
			return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
		}
		// ...
	}

	return undefined;
});

注意 ctx.hasUI 那个分支。同一段策略在有人盯着的交互模式下弹窗问,在 CI 之类的无界面模式下默认拒绝。权限设计里”默认拒绝还是默认放行”这个老问题,在这里落成了一个字段判断——展开讲可以看最小权限的 Agent 设计

会话侧session_before_compactsession_before_treesession_before_switchsession_before_fork 这一组都能取消。压缩事件带的信息尤其细:reason 区分 "manual" | "threshold" | "overflow",还有个 willRetry 标记这次压缩之后是否会重跑被中断的那一轮。上下文压缩是成本的大头之一(上下文管理那篇讲了原理),能按触发原因分开统计,账才算得清。

三、这些东西分别住在哪

组成部分它负责什么仓库位置你什么时候会碰到它
事件与上下文类型定义声明每个事件的字段、返回值形状、ExtensionAPI 上的所有 on() 重载packages/coding-agent/src/core/extensions/types.ts想确认某个事件到底带哪些字段时
扩展运行器加载扩展、按事件类型分发、保留来源信息packages/coding-agent/src/core/extensions/runner.ts扩展没生效、要查分发逻辑时
会话装配在 agent 循环里决定何时发出哪个事件packages/coding-agent/src/core/agent-session.ts需要确认事件先后顺序时
harness 层事件agent 内核自己那一小组事件packages/agent/src/harness/agent-harness.tspackages/agent/src/harness/types.ts直接用 agent 包、不走 coding-agent 时
钩子设计稿描述 observe / on / emit 的目标形态与各事件的合并语义packages/agent/docs/hooks.md想理解设计意图、不是找 API 时
观测设计稿定义事件契约、traceId/spanId 模型、安全字段边界packages/agent/docs/observability.md打算把 pi 接进现有链路追踪时
会话文件格式落盘 JSONL 的字段,含每条消息的 usage 与 costpackages/coding-agent/docs/session-format.md做成本归因时的主要数据源
扩展使用文档生命周期图、扩展存放位置、ExtensionAPI 全部方法packages/coding-agent/docs/extensions.md上手第一站

扩展的存放位置在文档里是一张明确的表:~/.pi/agent/extensions/ 下的 *.ts*/index.ts 是全局的,项目里的 .pi/extensions/ 同样两种形态,但项目级扩展要等项目被信任之后才加载。这个信任判定本身也有事件,叫 project_trust,它在项目资源加载之前触发,而且只有全局扩展和命令行传入的扩展能参与——项目本地扩展这时候还没被加载。做企业内统一审计策略时,这个先后顺序意味着你的审计扩展应该装在全局位置,否则它管不到”项目要不要被信任”这一步。

四、观测面能给你什么数据

观测设计稿定义的心智模型是标准的 trace/span:一次用户交互是一棵因果树,每个耗时操作是树上一个节点,用 ID 而不是对象指针来表达父子关系。核心接口是这样:

export interface PiObservability {
  getContext(): PiObservabilityContext | undefined;
  runWithContext<T>(context: PiObservabilityContext, fn: () => T): T;
  emit(event: PiObservabilityEvent): void;
  hasSubscribers(): boolean;
}

事件本身带 type(取值 start / end / error / event)、nametraceIdspanIdparentSpanIdtimestampdurationMs,以及分开的 contextpayload 两个字段。

对成本归因来说,最要紧的是 context 这一层。文档给的用法是:

await runWithPiContext(
  {
    userId: "u123",
    orgId: "acme",
    region: "eu",
  },
  () => harness.prompt("fix this"),
);

这个异步链里发出的每一条事件都会带上这份上下文。也就是说归因的维度是调用方自己定的,不是 pi 规定的——你想按人、按团队、按项目、按客户摊,写进去就是了,pi 不预设组织结构。跨异步链传播靠的是运行时适配层,Node 侧用 AsyncLocalStorage,浏览器和 Worker 环境只能退化成本地订阅者集合加有限的手工传播。文档明确说 ALS 不能当核心抽象,因为 pi 要在 Node、Bun、浏览器、Worker 等多种运行时里跑。

计划中的事件名分两批。初始一批是 pi.agent.promptpi.agent.skillpi.agent.prompt_templatepi.agent.compactionpi.agent.branch_navigationpi.agent.session.append_entrypi.ai.provider.request;后续一批包括 pi.agent.turnpi.agent.tool_callpi.ai.provider.retrypi.ai.provider.first_tokenpi.ai.provider.usagepi.session.readpi.session.write。end 和 error 的 payload 里可以带停止原因、状态码、重试次数、输入输出与总 token 数、成本合计、以及是否中止或超时的标记。

安全边界是这份文档里写得最硬的一段,直接给了两张清单。默认安全的:provider、model、API 标识、session id、entry 类型、工具名、状态码、停止原因、token 数、成本、耗时。默认不安全的:提示词、补全内容、工具参数、工具结果、shell 输出、文件内容、provider 请求体与响应体、API key、请求头。内容捕获被留成了后续的可选项,需要配合显式的脱敏钩子。这个默认值选得很克制——观测数据大概率会流进第三方 APM,默认不外泄任何内容体,是把”日志脱敏”这件事前置到了契约层面。

但请记住第一节说过的:这一整套是设计稿。今天你要做成本归因,真正能立刻用上的数据不在这里,在会话文件里。

五、成本归因今天真正能落地的路径

packages/coding-agent/docs/session-format.md 描述的落盘格式里,每条 assistant 消息都带一个 usage 对象:

interface Usage {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  totalTokens: number;
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
}

四类 token 和四类成本分开记,缓存读和缓存写各自独立。工具结果消息上还有一个可选的 usage,用来记工具内部自己发起的那部分模型调用——子任务的开销不会被算成主链路的,这对归因很重要。压缩条目上的 usage 记的是生成摘要那次调用的开销,文档写明它计入会话的 token 与成本总计。

会话默认存在 ~/.pi/agent/sessions/,按工作目录组织。交互界面的底栏实时显示 token / 缓存用量、成本、上下文占用和当前模型,/session 命令能列出会话文件、ID、消息数、token 和成本。

还有一条容易被忽略的通道:bash 工具会把当前会话信息通过环境变量暴露给命令,包括 PI_SESSION_IDPI_SESSION_FILEPI_PROVIDERPI_MODELPI_REASONING_LEVEL。注入发生在 spawn 钩子之前,所以钩子里能读到这些值。这意味着 agent 跑出来的每一次 git 提交、每一条构建命令,都可以在自己的日志里带上会话 ID——事后要把一段代码变更追回到哪次会话、哪个模型,链路是通的。这条通道可以用 exposeSessionEnvironment: false 关掉。

一句关于账单口径的提醒:cost 字段是 pi 按模型配置里的单价算出来的估算值,不是服务商的对账单。各家服务商的计价与折扣规则不同且会调整,以官方最新说明为准;内部分摊可以用它,对外结算别拿它当凭证。另外,几家海外模型服务商官方对中国大陆存在区域限制、不支持直连,市面上存在第三方中转,稳定性和合规责任需要你自己评估。

六、边界与代价

这套设计放弃了不少东西,说清楚比夸它有用。

钩子是同步阻塞在关键路径上的。 pi.on() 的处理器可以是 async,而 agent 会等它。你在 tool_call 里发一个网络请求去查权限中心,那次工具调用就得等你的往返。控制面的能力是拿延迟换来的,重活应该放进观测面或者异步队列。

观测面今天还是纸面契约。 想要 trace/span、想接 OTel,现在没有现成的包可以 import。可行的替代是用控制面事件自己拼:turn_start / turn_end 圈时间,before_provider_headers 塞追踪 ID,after_provider_response 抓状态码,message_end 拿 usage。能跑,但你得自己承担 pi 后续真正实现观测层之后的迁移成本。

观察者看不到中间态。 钩子设计稿里明确写了这条限制:观察者只看到最初发出的那个事件一次,看不到后续处理器逐个施加的修改。想拿到最终变换结果,得走该事件专属的处理器,或者另外发一个终态事件。做”改了什么”的审计时,这个限制意味着你不能只挂一个全局观察者了事。

来源追溯在设计稿里被列成了待办。 钩子设计稿的”Poking holes”一节自己承认:现有运行器知道错误、资源、工具分别是哪个扩展产生的,而裸的 on() 会丢掉这个信息,需要补作用域或者注册元数据。错误策略同样被列成待明确项,倾向是给 coding-agent 保留”出错继续”的默认行为。审计场景里”谁改的”和”改失败了怎么办”都是刚需,别假设它已经齐了。

有些能力根本不是事件。 工具、命令、快捷键、命令行标志、消息渲染器、provider 注册、OAuth provider、自定义模型 provider,这些在设计稿里被明确归为注册表而非钩子,不走 emit()。你不会收到”某个工具被注册了”这样的事件。

它明确不管的事:不管把数据发到哪去,不管采样率,不管保留期,不管多机聚合。事件契约的整个立意就是”pi 定义发生了什么,适配器定义去哪里”。落地成一套可查询的系统,仍然是你的工作量。

七、上手与避坑清单

别把设计文档当 API 用。 为什么会踩:两份文档写得像最终形态,hooks.md 开头就是一行 “Final design”。怎么避:动手前先在 .ts 源码里搜一次你要用的类型名或函数名,搜不到就说明只存在于文档。判定标准很硬——packages/ 下有没有对应的目录,比读多少段文字都可靠。

别在工厂函数里起后台资源。 为什么会踩:写扩展的直觉是在默认导出的函数体里初始化一切。但文档写明扩展工厂可能运行在根本不会启动会话的调用里,进程、socket、文件监听、定时器起在那里就会泄漏。怎么避:把后台资源的启动推迟到 session_start 或者真正需要它的那个命令、工具、事件里,并注册一个幂等的 session_shutdown 处理器负责关闭。

审计扩展要装在全局位置。 为什么会踩:装在项目的 .pi/extensions/ 下感觉更贴合项目,但项目本地扩展要等项目被信任之后才加载,而 project_trust 事件只有全局扩展和命令行传入的扩展能参与。怎么避:任何要管住”信不信任这个项目”的策略,都放到 ~/.pi/agent/extensions/ 下。

别在 ctx.getSystemPrompt() 上做合规取证。 为什么会踩:名字看着像”最终发出去的东西”。实际上通过 before_provider_request 在 payload 层改掉的系统指令,不会反映到这个方法的返回值里。怎么避:要证明”实际发给模型的是什么”,取证点应该在 payload 层,不是提示串层。

before_provider_headers 里注入追踪 ID 时,记住重试不会重新触发。 为什么会踩:想当然地认为每次 HTTP 请求都会走一遍钩子,于是每次生成一个新 ID,指望用它区分重试。实际上一次 provider 请求只跑一次,重试复用同一份头。怎么避:把重试计数放到别处统计,这个钩子只用来给整次请求打一个稳定标识。

扩展会以你的全部系统权限运行。 为什么会踩:把它当成浏览器插件那样的沙箱。文档在扩展位置那一节上方就挂了警告,扩展可以执行任意代码。怎么避:只装可信来源的扩展;团队内共享扩展走 settings.json 里的 packagesextensions 配置集中管理,而不是让每个人各自往家目录里扔文件。

成本统计别只看主链路。 为什么会踩:只聚合 assistant 消息的 usage,会漏掉工具内部嵌套的模型调用和压缩摘要那几次调用。怎么避:把工具结果消息上的可选 usage、压缩条目上的 usage 一起算进去。

收个尾

如果你要评估 pi 能不能撑起团队级的审计与成本归因,按这个顺序自己核一遍就够了:

  1. 打开 packages/coding-agent/docs/extensions.md 的生命周期图,圈出你的策略需要卡在哪几个事件上;
  2. packages/coding-agent/src/core/extensions/types.ts 里确认这几个事件带的字段和返回值形状,够不够你做判断;
  3. 跑一次真实任务,直接看 ~/.pi/agent/sessions/ 下的 JSONL,确认 usage.cost 的粒度是不是你要的口径;
  4. .ts 源码里搜一遍你打算依赖的观测函数名,确认它今天存不存在,不存在就先用控制面事件自己拼一版;
  5. 最后再决定要不要为它写适配器——这一步的工作量,取决于前四步你圈了多少个点。

先读 extensions.md,再读 session-format.md,最后才读那两份设计稿。顺序反了,你会花很多时间去实现一个还不存在的接口。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的安全边界开源编程 Agent pi 的模型解析链路

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。