图解 DSH · 02 目录 术语表 上一章 下一章(即将发布)

Agent 循环的钩子:在哪儿插手,怎么插手

上一章说,插件还能在 Agent 干活的过程中插手。这一章看它是怎么做到的:Agent 循环的代码,一行都不用改。

Agent 循环干活时在几个关键时刻
停下来问一句
钩子那几个关键时刻
插件挂在这上面
插件挂在这些时刻上
插手
模型只看得到
最后发给它的内容
这一章回答一个问题:不改 Agent 循环的一行代码,怎么让 Agent 多做一件事?比如每一步都告诉模型现在几点。答案就三句:
  1. Agent 循环在几个固定时刻会停下来,喊一个名字,比如 agent/pre-step,问“有没有插件要插手”。这些时刻叫钩子。
  2. 插件在自己的启动函数里,把一个处理函数注册到这个名字上(和第 01 章注册工具是同一回事)。处理函数返回 { kind: 'reject' }(不进入这一步)就是拦下;调用 next() 就是不管,交给后面照常处理;拿到 next() 的结果再改,就是改结果。
  3. 一条规矩:想改模型看到的内容,必须记进会话日志,不能偷偷改。

?先猜:想让 Agent 知道现在几点

模型不知道现在几点。你想让 Agent 每走一步之前,都告诉模型当前时间。在 DSH 里最省事的做法是?

答案是 B。DSH 里真有这么一个插件,叫报时插件(time-context)。它挂在“每一步开始前”这个时刻,往这一步的消息后面加一条“现在是几点、离上次过了多久”(默认十分钟最多报一次,可以配置成每步都报)。Agent 循环完全不知道它的存在。

点一个选项。

1Agent 循环在哪些时刻停下来

回到开头的问题:不改 Agent 循环,怎么让它多做一件事?答案分两半。前一半是:Agent 循环自己会在几个固定时刻停下来,问一句“有没有插件要插手”。

Agent 循环干活时,会在下面这几个时刻停下来,问一句“有没有插件要插手”。这些时刻就叫钩子(hook)。标了黄色小字的,只在特定情况下才会经过。右边绿色的,是真的挂在上面的插件。

你发来一句话,一个轮次开始
1这一步要不要走?agent/pre-step报时已归档的会话不许再跑对话太长先压缩
2发给模型之前agent/request只能换模型、换参数,不能改消息(后面“一条规矩”讲为什么)
3模型请求失败了只在失败时agent/request-error网络出错就重试太长就压缩后再试
4工具执行之前只在模型要用工具时tools/pre-execute按规则拒绝这次调用(比如实验性的自动审查)
5工具执行的时候只在模型要用工具时tools/execute跑太久就喊停
6工具执行之后只在模型要用工具时tools/post-execute输出太长只留开头结尾
还有工具结果要交给模型?回到 1,走下一步
7真的可以停了吗?agent/turn-stopping活没干完,不许停 比如让它查三个文件,它查了两个就想收工。这个钩子只是通知,没有 next():处理函数的参数里有 agent,就是当前在跑的这个 Agent,插件调用 agent.steer(消息),把一条消息排进下一步,循环看到下一步还有消息,就接着走
一个轮次结束,回复你

“停下来问一句”在代码里是什么样?拿第 00 章那个循环来看,第 1 个钩子就是开头多出来的两行:

Agent 循环(简化,接第 00 章)

while (true) {
  const 决定 = await 发出钩子('agent/pre-step', 这一步的消息, 默认处理)
  // 决定只有两种:{ kind: 'reject' } 或 { kind: 'enter', messages }
  if (决定.kind === 'reject') break        // reject:有插件拦下,这一步不走
  const 回复 = await ctx.llm.stream({ …, messages: [...会话, ...决定.messages] })  // enter:带上它的 messages 问模型
  …                                        // 往下和第 00 章一样
}

循环只喊一个名字 agent/pre-step,带上这一步的消息,再附一个默认处理:没有插件插手时,就由它交回 { kind: 'enter', messages },带上这一步的消息进入这一步。谁挂在这个名字上,它不知道,也不用知道。(真实代码里,“发出钩子”叫 dispatch.waterfall。)

超时、重试、压缩……这些听起来像 Agent 循环的本职工作,其实都是挂在钩子上的插件。不装它们,循环照样能跑。

2插件怎么插手

答案的后一半:插件在自己的启动函数里,把一个处理函数注册到钩子的名字上。Agent 循环每次喊这个名字,就调用这个函数,听它的决定。

先看一个完整的插件:报时插件。它的样子和第 01 章的 bash 工具一模一样,依赖加启动函数:

报时插件(简化过的真实代码)

export const name = 'time-context'
export const inject = ['agents', …]                 // 依赖
export function apply(ctx) {                        // 启动函数
  ctx.on('agent/pre-step', async (这一步, next) => {  // 注册:处理函数挂到这个名字上
    const 决定 = await next()
    if (决定.kind === 'reject') return 决定
    if (离上次报时不到 10 分钟) return 决定     // 默认十分钟最多报一次
    return { ...决定, messages: [...决定.messages, 现在几点] }  // 还是 enter,只是多了一条消息
  })
}

bash 把工具注册给工具服务,报时插件把处理函数注册给一个钩子,都是在启动函数里注册。所以规矩也一样:卸载报时插件,这个处理函数自动摘掉,循环照样跑,只是不再报时。

注意两样东西跑的时机不一样:

插件装上时(依赖齐了)apply 跑一次:ctx.on 把处理函数存起来,这时它还没被调用
第 1 步开始前Agent 循环喊 agent/pre-step,处理函数被调用
第 2 步开始前再喊一次,处理函数再被调用一次……

所以“现在几点”这种每一步都要重新算的东西,要写在处理函数里,不能写在 apply 里。写在 apply 里,就只在装上时算了一次。

再看处理函数本身:它拿到什么、能交回什么?这些不是插件自己说了算,而是调用它的那段代码定的。下面是 agent/pre-step 的契约。蓝色几行由 DSH 定,绿色一行由你定:

谁调用你
Agent 循环,每一步开始前(问模型之前)。
给你什么
这一步的信息 { messages, turn, step, agent, signal },外加 next。
注意 messages 是这一步新领到的消息:第一步是你刚说的话;工具跑完后的第二步,通常是空的。整段对话不在这里,它在会话日志里(下一章)。
你能交回什么
只有两种:{ kind: 'reject' },这一步不走;或 { kind: 'enter', messages },进入这一步,带上这些消息。没有第三种,比如“等 5 秒再走”。(想等,可以先 await 再交回这两种之一。)
调用方拿去做什么
reject:这一步不走,不问模型。enter:把 messages 记进会话日志,再从日志拼出发给模型的请求。(例外:没有任何新消息、这一轮本来就能结束时,循环直接收尾,不再问模型。)
你自己决定
拦不拦、加什么消息、多久加一次、自己记什么状态。

还有一个容易看错的地方:报时插件加的那条“现在几点”,在模型看来和你的话一样,都是 user 角色。区分它们靠每条消息上标的来源:你的话来源是 user,报时那条来源是 time-context。想判断“是不是用户亲口说的”,要看来源,不能看角色。

按这份契约,处理函数能做的就三种。左边是另一个插件的处理函数,右边把三种做法放在一起对照:

拦下 / 不管:归档拦截插件

ctx.on('agent/pre-step', (step, next) => {
  if (会话已归档) return { kind: 'reject' }
  return next()
})

会话已经归档(办完收起来了),就返回 { kind: 'reject' }:这一步不走,不会去问模型。没归档,就 return next():我不管,交给后面处理。

三种做法,对照着看

拦下:返回 { kind: 'reject' },通常也不再调用 next()。左边,会话已归档时。

不管:return next(),交给后面处理。左边,没归档时。

改结果:先 await next() 拿到结果,改完再交回。结果要是 reject,报时插件就原样交回:它只管加时间,不该把别人的拦截改成 enter。(技术上你能改,最终以你 return 的为准。)

代码都是简化过的真实代码(中文是为了好读)。这里有两个独立的决定,别混在一起:

把所有写法摆在一起(以 agent/pre-step 为例):

处理函数这样写后面的插件和默认处理这一步什么时候用
return next()交给下一个;更后面的跑不跑,看它们自己调不调 next()后面交回什么,就是什么和自己无关时。最常见
const d = await next(),改完 return同上以你最后 return 的为准。报时这类插件只管加消息,所以 d 是 reject 就原样交回,尊重后面的决定要在结果上加东西,比如报时插件
return { kind: 'reject' }不跑不进要拦下,比如归档拦截
return { kind: 'enter', messages },不调 next不跑进,你给的消息顶替这一步原本的新消息几乎不用:后面插件的意见全被跳过了
什么都不 return不跑拿不到决定,这一轮出错结束别这样写

写插件时,你不知道同一个钩子上还有没有别的插件,也不需要知道:next() 一定能调。后面没有插件时,它调的就是循环自己的默认处理,返回 { kind: 'enter', messages }。所以默认先调 next(),只有要拦下时才不调。

注意,reject 和 enter 只是 agent/pre-step 这一个钩子的约定。每个钩子能交回什么,由喊它的那段代码定,所以取值各不相同。同样写 ctx.on,挂在不同的钩子上,能做的事完全不同:

钩子谁喊它有没有 next处理函数交回什么
agent/pre-step 这一步要不要走Agent 循环有{ kind: 'reject' },或 { kind: 'enter', messages }
agent/request 发给模型之前Agent 循环有这次请求的配置:只能换模型、换参数,不能改消息(后面“一条规矩”讲为什么)
agent/request-error 模型请求失败了Agent 循环有{ kind: 'retry' } 重试,或 undefined 不重试
agent/turn-stopping 真的可以停了吗Agent 循环没有,只是通知不用交回,返回值没人看。想让它别停,调用 agent.steer(消息),把一条消息排进下一步
tools/pre-execute 工具执行之前工具服务有allow 放行、deny 拒绝(带 reason)、ask 先问用户、cancel 取消(第 05 章)
tools/execute 工具执行的时候工具服务有工具结果本身。超时插件就挂在这里
tools/post-execute 工具执行之后工具服务有accept 接受(可以换掉结果),或 block 拦下(带 feedback)
tools/result 工具结果出来了工具服务没有,只是旁观只能看,改不了结果;它出了错也不会影响工具结果,DSH 也不等它做完

取值不用背,记两条规律就够:有 next 的钩子,没意见时都写 return next();要插手时交回什么,查这个钩子的类型声明,也就是喊它的那段代码写下的契约。

所以判断一个钩子能做什么,不能看名字猜,要看三处:它的类型声明、发出它的那行代码、调用方拿返回值做了什么。

3好几个插件同时插手,怎么办

上面两个插件,其实挂在同一个钩子上(“这一步要不要走”)。它们是不同的人写的,互相不知道对方存在。那 Agent 循环停在这一刻时,先问谁?

答案藏在 next() 里:next 指的“后面”,就是同一个钩子上的下一个插件;最后一个插件之后,是 Agent 循环自己的默认处理(交回 enter,带上这一步的消息)。所以它们排成一串:前一个调用 next(),才轮到后一个。谁都不用认识谁,各管各的事,不归自己管就 next()。

这一串的先后是怎么排的?同一个钩子上,先注册的排在外面,先被调用;默认处理永远在最里面。插件谁先启动看依赖(第 01 章),所以别指望“我一定排在谁前面”。真要排到最前,注册时加一个选项:ctx.on('agent/pre-step', 处理函数, { prepend: true }),它会插到现有处理函数的最前面。

点“下一个画面”,看这一串怎么一个个往里调用、再一个个把结果交回来。勾上“会话已归档”,看拦下时会怎样。

报时插件排在最前:先 next(),拿到结果再加时间
归档拦截插件排第二:归档了就返回 reject,否则 next()
默认处理
Agent 循环自己的:交回 enter

    这一串排好以后,Agent 循环拿到最终的决定,才真正去问模型。回到开头的问题:报时就是这样加进去的。Agent 循环的代码,一行没改。

    !一条规矩:不能偷偷改模型看到的东西

    报时插件明明可以在“发给模型之前”把时间直接塞进请求,为什么要在“这一步开始前”往消息里加一条?

    ✗ 发请求前偷偷改

    模型看到了时间,但这件事哪儿都没记下来。以后重新打开这个会话,就算不出当时模型到底看到了什么。

    ✓ 往消息里加一条

    这条时间消息会记进会话日志。任何时候都能从日志里原样算出当时发给模型的内容,恢复会话、回放都靠它。

    “往消息里加一条”有唯一的正确姿势:把它放进交回去的决定里,由 Agent 循环去记进日志。下面两种看起来也在“加”,其实都不算数:

    还有一个边界:卸载报时插件,以后不再加时间;但以前已经记进日志的时间消息还在,卸载不会抹掉历史。

    所以“发给模型之前”这个钩子只许换模型、换参数,不许改消息。DSH 把这条规矩叫“模型看到的,必须记下来”。会话日志是怎么回事,下一章讲。

    ✓四道小题

    一个插件想拦下这一步,它的处理函数怎么写?

    想好了,看答案
    返回 { kind: 'reject' }。通常也不再调用 next(),这样排在它后面的插件和默认处理都不会被调用。注意,光是不调用 next() 还不够,要交回的是 { kind: 'reject' }。

    报时插件为什么要先调用 next(),再加时间?

    想好了,看答案
    因为它要先知道后面的处理结果。如果这一步已经被拒绝,就不用报时了,原样交回就行。

    一个插件只看 agent/pre-step 给的 messages,里面有“报告”两个字就加一条提示。你说“写个报告”,模型第一步调用工具,第二步直接回答。这个插件一共加了几次提示?

    想好了,看答案
    一次。处理函数被调用了两次(每步一次),但第二步新领到的消息是空的,你那句话已经在日志里,不在这一步的 messages 里。

    同一个钩子上依次是甲、乙、丙。甲先 await next(),拿到结果后在消息末尾加“甲”;乙不调用 next(),直接交回 { kind: 'enter', messages: ['乙'] };丙会加“丙”。最后这一步走不走?消息是什么?

    想好了,看答案
    进入这一步,消息是“乙、甲”。乙没调用 next(),所以丙和默认处理都没跑;但乙交回的是 enter,不是 reject。甲拿到乙的结果,再把“甲”加在后面。
    不改 Agent 循环,也能让它多做一件事:Agent 循环在几个固定时刻喊一个名字(钩子),插件在启动函数里把处理函数注册到这个名字上,两边只靠这个名字对上。处理函数拿到什么、能交回什么,由调用它的代码定;拦不拦、加什么,由插件定。调不调 next() 决定后面的插件跑不跑,交回什么决定这一步走不走。像“这一步要不要走”这样的钩子,同一个钩子上的几个插件靠 next() 串成一串,最后是 Agent 循环的默认处理。想改模型看到的内容,就把消息放进交回的决定,由循环记进会话日志。 下一章:会话日志。模型看到的一切,都是从它算出来的。