用 shell 命令做 AI Agent 的插件系统:为什么 Hook 不是函数调用

这是 《写完一个 AI 编程助手之后,我才确定 prompt 工程不是重点》 系列的第七篇(最后一篇)。前六篇讲了进程模型、权限、并发调度、上下文压缩、记忆系统。这一篇讲 Hook——整个项目里最"丑"但最有用的设计。

整个项目开源:github.com/your-handle/code-agent。从 src/core/agent/loop.ts 开始读,200 行就能看完主循环。欢迎点点star。

Agent 需要插件系统。

记忆系统要在工具执行后记录数据。权限系统要在工具执行前拦截。上下文压缩要在 token 超限时触发。未来还会有日志、监控、审计……

第一反应是写一个 EventEmitter:

agent.on('post-tool-use', async (event) => {
  await memorySystem.record(event)
})

我没这么做。我用了 shell 命令。

{
  'post-tool-use': [{
    command: 'curl -X POST http://localhost:37777/api/sessions/observations ...',
    onError: 'ignore',
    timeout: 3000
  }]
}

看起来很丑。但这是我做过的最正确的架构决策之一。


一、为什么不用 EventEmitter

EventEmitter 有三个致命问题:

1. 插件必须跟 Agent 同进程

如果记忆系统跑在独立 Worker 里(第二篇讲过为什么要拆),EventEmitter 就没法用。你得在 listener 里写 HTTP 调用,那跟直接写 shell 命令有什么区别?

2. 插件必须用同一种语言

Agent 是 TypeScript。如果我想用 Python 写一个分析插件(因为 Python 的 NLP 生态更好),EventEmitter 做不到。shell 命令不关心你用什么语言实现。

3. 插件崩溃会拖垮主进程

EventEmitter 的 listener 如果抛异常,要么你 try-catch 吞掉(隐藏 bug),要么让它冒泡(主进程挂)。shell 命令天然隔离——子进程崩了,主进程只看到一个非零退出码。


二、Hook 的完整实现:100 行

核心是 HookManagersrc/core/hooks/manager.ts)。两个公开方法:

// 触发事件,不关心返回值
async fire(event: HookEvent, env: Record<string, string>): Promise<void>

// 触发事件,用返回值变换 payload
async transform<T>(event: HookEvent, payload: T, env: Record<string, string>): Promise<T>

fire 用于通知型 hook(记忆记录、日志)。transform 用于拦截型 hook(权限检查、内容过滤)。

底层都是同一个 run 方法:

private run(entry: HookEntry, extraEnv: Record<string, string>, stdin: string | null): Promise<string | null> {
  return new Promise((resolve, reject) => {
    const proc = spawn('bash', ['-c', entry.command], {
      cwd: process.cwd(),
      env: { ...process.env, ...extraEnv },
      stdio: stdin !== null ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe']
    })

    let stdout = '', stderr = ''
    let killed = false

    const timer = setTimeout(() => {
      killed = true
      proc.kill('SIGTERM')
      setTimeout(() => proc.kill('SIGKILL'), 3000)
    }, entry.timeout)

    proc.stdout.on('data', d => { stdout += d.toString() })
    proc.stderr.on('data', d => { stderr += d.toString() })

    if (stdin !== null && proc.stdin) {
      proc.stdin.write(stdin)
      proc.stdin.end()
    }

    proc.on('close', (code) => {
      clearTimeout(timer)
      if (killed) { handleError(...); return }
      if (code !== 0) { handleError(...); return }
      resolve(stdout)
    })
  })
}

就是 spawn('bash', ['-c', command])。没有 SDK,没有协议,没有序列化格式。

数据通过环境变量传入,通过 stdout 传出。


三、环境变量是最好的 IPC 格式(对于小数据)

Hook 需要知道当前上下文:哪个工具被调用了、输入是什么、结果是什么、session id 是什么。

我用环境变量传:

await this.context.hooks?.fire('post-tool-use', {
  AGENT_CWD: process.cwd(),
  TOOL_NAME: tool.name,
  TOOL_INPUT: JSON.stringify(tool.input),
  TOOL_RESULT: resultStr.slice(0, 10000),
  SESSION_ID: this.context.sessionManager?.getCurrentSession()?.id || 'unknown'
})

Hook 命令里直接用 $TOOL_NAME$SESSION_ID

curl -X POST http://localhost:37777/api/sessions/observations \
  -H 'Content-Type: application/json' \
  -d "{\"toolName\":\"$TOOL_NAME\",\"contentSessionId\":\"$SESSION_ID\"}"

为什么不用 stdin + JSON?

  • 环境变量零解析成本。shell 直接展开,不需要 jq、不需要 python -c。
  • 环境变量自文档化。看一眼 hook 配置就知道有哪些变量可用。
  • 环境变量有大小限制(通常 128KB)。这是优点——强制你只传必要的数据。TOOL_RESULT 我截断到 10000 字符,避免一个巨大的文件内容撑爆环境。

stdin 留给 transform 型 hook——它需要接收完整 payload、修改后从 stdout 返回。这种场景数据量大,环境变量装不下。

小数据走环境变量,大数据走 stdin/stdout。不要统一成一种格式——两种场景的约束不同。


四、onError 三态:ignore / warn / abort

Hook 失败了怎么办?又是三态:

type OnError = 'ignore' | 'warn' | 'abort'
  • ignore:吞掉错误,主流程继续。记忆系统用这个——记忆挂了不影响 Agent 工作。
  • warn:打一行警告到 stderr,主流程继续。日志系统用这个——你想知道它挂了,但不想停。
  • abort:抛异常,主流程中断。权限检查用这个——权限 hook 挂了,宁可停也不能放行。

这三个选项覆盖了我遇到的所有场景。不需要更多。

记忆系统的 hook 配置:

// src/worker/hooks.ts
'post-tool-use': [{
  command: 'curl -X POST ... || true',
  onError: 'ignore',
  timeout: 3000
}]

注意 || true——即使 curl 失败,bash 也返回 0。再加上 onError: 'ignore',双重保险。记忆系统的可用性不应该影响 Agent 的可用性。


五、timeout 是必须的,不是可选的

每个 hook 都有 timeout:

const timer = setTimeout(() => {
  killed = true
  proc.kill('SIGTERM')
  setTimeout(() => proc.kill('SIGKILL'), 3000)
}, entry.timeout)

没有 timeout 的 hook 系统是定时炸弹。

真实场景:记忆系统的 Worker 挂了,curl 连接超时默认 120 秒。如果没有 timeout,Agent 每次工具调用后都会卡 120 秒等 hook 返回。

我的 timeout 设置:

Hook 类型 timeout 原因
user-prompt-submit 5000ms 初始化可能要建表
post-tool-use 3000ms 正常情况 < 500ms,3s 是容错
session-end 2000ms 只是一个 HTTP POST

timeout 不是"以防万一",是"一定会发生"。任何网络调用都会超时,区别只是你是主动控制还是被动等待。


六、transform vs fire:拦截 vs 通知

fire 是单向的——触发 hook,不等返回值。适合记录、日志、监控。

transform 是双向的——把 payload 通过 stdin 传给 hook,hook 修改后从 stdout 返回。适合拦截、过滤、变换。

async transform<T>(event: HookEvent, payload: T, env: Record<string, string>): Promise<T> {
  let current = payload
  for (const entry of entries) {
    const result = await this.run(entry, env, JSON.stringify(current))
    if (!result?.trim()) continue
    try {
      current = JSON.parse(result.trim())
    } catch {
      this.onWarn(`Hook returned non-JSON — ignoring`)
    }
  }
  return current
}

多个 transform hook 串行执行,前一个的输出是后一个的输入。这是 Unix pipe 的思想——每个 hook 是一个 filter。

实际用例:

  • pre-tool:hook 可以修改工具的 input(比如自动补全路径)
  • post-sampling:hook 可以修改模型的输出(比如过滤敏感信息)
  • pre-compress:hook 可以在压缩前归档消息

七、为什么这个设计能活下来

这套 hook 系统上线两个月,我没改过一行核心代码。期间加了:

  • 记忆系统(3 个 hook)
  • 上下文压缩通知(2 个 hook)
  • 工具执行计时(1 个 hook)
  • 调试用的 verbose 日志(1 个 hook)

每次都是加一条配置,不动框架代码。

这就是 shell 命令做 hook 的真正优势:扩展点和实现完全解耦

  • 框架只知道"在这个时机执行一条命令"
  • 命令可以是 curl、python 脚本、go 二进制、甚至另一个 Agent
  • 命令挂了有 onError 兜底
  • 命令慢了有 timeout 兜底

最好的插件系统是最笨的那个。不要发明协议,不要定义接口,不要写 SDK。给一个 shell,传几个环境变量,收一个 stdout。够了。


系列总结

六篇写完了。回头看,整个系列讲的是同一件事:

AI Agent 的难点不在 AI,在工程。

篇目 核心判断
总论 prompt 工程不是重点,工程问题决定好坏
进程模型 阻塞操作 > 100ms 就拆进程
权限系统 让工具自己说危不危险,框架只做仲裁
并发调度 让工具自己声明并发安全,调度器一行搞定
上下文压缩 200K 也会满,四层兜底让 Agent 永不崩溃
记忆系统 记忆是信噪比问题,不是存储问题
Hook 系统(这篇) 最笨的插件系统最耐用

贯穿六篇的元原则:

框架的工作是定义接口,不是实现逻辑。把语义留给组件,把策略留给框架,把实现留给 shell。

整个项目开源:github.com/your-handle/code-agent。从 src/core/agent/loop.ts 开始读,200 行就能看完主循环。欢迎点点star。

如果你也在写 Agent,这六篇可以当 checklist 用。每改对一个,体感跳一个台阶。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐