跳到正文
astorlm
语言: 简体中文
← 地图

第 6 关

钩子

事件让你观察循环。钩子让你改变循环。钩子就是你写的一个函数,循环会在固定的点调用它,而它返回什么,循环就照做什么。
1/26 手风琴褶数:
  • user
  • assistant
  • tool_result
  • tool_result(错误)
一个企业差旅助手。循环是一条封闭的环形轨道,每个钩子点都是轨道边的一座岗亭。这次运行有两座岗亭有人值守:一座在每个工具调用运行之前检查它,另一座在模型读到每个结果之前检查它。

EventBus

问题

你的差旅助手运转正常。然后公司加了一条规定:没有经理批准,不许订头等舱。法务又加了一条:乘客的身份证号码绝不能到达模型那里。

这两条规定都和模型无关。你可以告诉模型,但提示词只是一个请求,不是一把锁。这两条规定管的是循环做什么:它执行哪些工具调用,往历史记录里放什么。如果循环不给你任何介入的入口,剩下的唯一办法就是复制它的代码再改。这就是 Ironclad,那个密封的循环。

事件也帮不上忙。事件只能告诉你 book_ticket 马上就要运行了。等你的监听器收到它时,你在那里做什么都拦不住了。

解决方案

循环在每一轮的固定点调用你的函数,并使用它们的返回值。五个点几乎涵盖了一切:

  • beforeTurn

    每一轮开始时。

    检查预算、记录这一轮、停掉一次跑得太久的运行。

  • beforeProviderCall

    请求发往模型之前的那一刻。

    修改要发送的内容:裁掉旧消息、加上今天的日期、在这一轮隐藏某个工具。

  • beforeToolExecution

    模型申请工具之后、工具运行之前。

    放行、拒绝(模型会收到你给出的理由),或者用一个预设结果来回应。

  • afterToolExecution

    工具运行之后、结果进入历史记录之前。

    改写模型将要读到的内容:隐藏个人数据、截短超长的输出。

  • afterTurn

    模型的回复和所有工具结果都到齐之后。

    保存进度、更新仪表盘、统计成本。

经验法则:事件负责观察,钩子负责改变。只想知道发生了什么时,用事件。需要决定会发生什么时,用钩子。

角色

还是那群熟悉的角色,这次换到了铁路模型上。

环形轨道 循环
一圈只朝一个方向跑的封闭轨道。每跑一圈就是一轮:经过神谕者,经过工具,再来一圈。
Astor 跑循环的人
背着装满消息的手风琴,压着手摇车绕轨道跑。
岗亭 钩子
每个钩子点一座。没人值守的岗亭什么也不做。有人值守的岗亭会拦下手摇车,检查它载着什么,可以放下栏杆,也可以在货物上盖章遮挡。这次运行有两座岗亭有人值守:beforeToolExecution 和 afterToolExecution。
看台 EventBus
三位观众,把经过的一切都记下来。他们什么都看得到,却什么都碰不了。
站台 工具
远处弯道上的 find_trains 和 book_ticket。

在 EventBus 面板里,hook 和 code 两种行标记的是你自己的函数在运行:你的钩子和你的工具。astorlm 不会为它们发出事件。注意它们出现的位置:tool_execution_end 出现在 afterToolExecution 之后,所以它带的已经是盖过章的文本。

代码

使用 astorlm:给智能体传一个 hooks 对象。beforeToolExecution 返回 { authorize: false } 来拒绝一次调用,afterToolExecution 返回模型将要读到的文本。

从零手写:第 2 关的循环,加上对五个钩子各自的一次调用。没人设置的钩子会被直接跳过。

import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const findTrains = tool({
  name: 'find_trains',
  description: 'List the trains to a destination on a date, with the fare for each class.',
  schema: z.object({ to: z.string(), date: z.string() }),
  execute: async ({ to, date }) => searchTimetable(to, date), // your code
})

const bookTicket = tool({
  name: 'book_ticket',
  description: 'Book one seat on a train for the employee who is asking.',
  schema: z.object({ train: z.number().int(), seat_class: z.enum(['first', 'tourist']) }),
  execute: async ({ train, seat_class }) => reserveSeat(train, seat_class), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [findTrains, bookTicket],
  maxTurns: 10,
  hooks: {
    // The first booth: runs before every tool call, and decides whether it runs at all.
    beforeToolExecution: async ({ toolName, input }) => {
      const { seat_class } = input as { seat_class?: string }
      if (toolName === 'book_ticket' && seat_class === 'first') {
        // The tool never runs. The model reads this text as an error result instead.
        return { authorize: false, mockResult: 'Blocked by policy: first class needs a manager’s approval. Book tourist instead.' }
      }
      return { authorize: true }
    },
    // The second booth: runs after every tool call. What you return is what the model reads.
    afterToolExecution: async ({ output }) => output.replace(/DNI [\d.]+/g, 'DNI ***'),
  },
})

// Events only watch. By the time this fires, the hook has already stamped over the DNI.
agent.on('tool-end', ({ name, output, isError }) => console.log(name, isError ? 'refused:' : 'ok:', output))

const last = await agent.run('Book me the most comfortable seat to Mar del Plata on Friday.')
console.log(last.content)

注意事项

  • 拒绝时要说明原因。拒绝会以错误结果的形式回到模型那里。“因政策被拦截:请改订经济舱”能换来一张经济舱车票。一句光秃秃的“拒绝”只会换来同样的调用再来一次。
  • 钩子在每次调用时都会运行,所以要让它们足够快。一个要查数据库的钩子,会把这段延迟加到每个工具、每一轮上。
  • 抛出异常的钩子会让整次运行崩掉。循环会接住你工具里的错误,但不会接住你钩子里的错误。任何可能出错的地方都要包起来。
  • 别用钩子来观察。如果你只是记日志,就去监听事件。把钩子留给你需要改变某些东西的时候。