数据截至 (上游 commit 1eb2bb1ec895)
Hooks 与 Context JSAPI:V8 桥与边界注入
30 秒导读: Yao 让你写两个 TypeScript 函数
Create和Next,分别插在"调 LLM 之前"和"拿到 LLM 结果之后"。它们就是 index 里说的"笼子的边界"——请求进出模型都要先过你这道关。钩子里能拿到一个ctx对象,上面挂了发流式消息、调 LLM、调工具、调别的 agent 的方法。这个ctx不是普通 JS 对象,而是 Go 用 V8 桥"喂"进 JavaScript 的一层壳。本章讲这两件事:钩子怎么拦截,ctx怎么桥。
本章位置:主管道的时序在 02-pipeline 已经走过一遍;这里只聚焦"钩子这一环里发生了什么"。memory 的读写细节归 06-memory-sandbox,ctx.agent.Call/All 的委派语义归 05-multiagent-stack(本章只点名它存在)。
1. 这是什么(零基础也能懂)
一句话定义: 钩子(Hook)= 你写在 src/index.ts 里、由 Yao 在固定时机自动调用的 TypeScript 函数;Create 在调模型前跑,Next 在调模型后跑。
它解决什么问题。 一个纯 LLM agent 是个黑盒:消息进去,回答出来,你插不上手。真实业务要在中间做很多事——
- 调模型前:改写用户输入、换个模型、塞入检索到的资料、或者干脆判断"这个请求不该我处理,转给别的 agent"。
- 调模型后:校验模型输出、把结果落库、决定"要不要再问一轮"、或者把结果二次委派出去。
钩 子就是官方给你的这两个"插入点"。没有钩子,agent 只是个 LLM 代理;有了钩子,它才是个可编程的管道。
一个最小钩子长什么样。 下面是一个 src/index.ts,只做一件事:在用户消息前面加一句系统提示,然后照常让 LLM 处理。
// src/index.ts —— 示意,展示钩子的形状
function Create(context, messages, options) {
// context 就是本章主角 ctx;messages 是完整历史;options 是调用参数
context.Send("正在思考…"); // 直接往前端推一条流式消息
return { // 返回 HookCreateResponse
messages: [
{ role: "system", content: "你只用中文回答。" },
...messages,
],
};
}
function Next(context, payload, options) {
// payload.completion 是 LLM 刚生成的回答
return; // 返回 undefined = 什么都不改,走标准返回
}
一句话直觉: 把 agent 想成一条流水线,Create 是"进料口的质检+改料员",Next 是"出料口的质检+分拣员"。两道关中间夹着 LLM(或不夹,见 §3)。
2. 顶层全景(钩子在管道里的位置)
一次请求从进入 Assistant.Stream 到返回,钩子卡在两个位置。下图从上到下是时间顺序,方框里带 file:line 的是真实调用点。
用户输入 messages
│
▼
┌───────────────────────────────────────────────┐
│ ① Create 钩子 agent.go:225 HookScript.Create │ ← 调 LLM 之前
│ · 可改写 messages / 换 connector / 调温度 │
│ · 可返回 Delegate → 提前路由,跳过 LLM │ ──┐ 命中就直接
└───────────────────────────────────────────────┘ │ 走委派、返回
│ (未委派) │
▼ │
┌───────────────────────────────────────────────┐ │
│ ② LLM / CLI 执行 agent.go:283 (Prompts||MCP) │ │
│ 三选一:LLM 流式 / CLI 沙箱 / 纯钩子(不调) │ │
└───────────────────────────────────────────────┘ │
│ │
▼ │
┌───────────────────────────────────────────────┐ │
│ ③ Next 钩子 agent.go:512 HookScript.Next │ │
│ · 拿到 completion + tools 结果 │ │
│ · 可返回 Delegate(再委派)/ Data(自定义) │ │
└───────────────────────────────────────────────┘ │
│ │
▼ processNextResponse (next.go:11) │
最终 Response ◄──────────────────────────────────────┘
三个 部件各干什么:
| 部件 | 职责 | 源码锚点 |
|---|---|---|
Create 钩子 | LLM 前:改写输入、调参、提前委派 | agent/assistant/hook/create.go:13 Script.Create |
Next 钩子 | LLM 后:后处理、再委派、返回自定义数据 | agent/assistant/hook/next.go:13 Script.Next |
ctx 对象 | 钩子手里的"遥控器",桥到 Go 能力面 | agent/context/jsapi.go:20 Context.NewObject |
主线走一遍(高层): 输入 →Create 有机会拦下或改料 → 若未提前委派,进 LLM/CLI/空转 → Next 有机会改判 →processNextResponse 根据 Next 的返回决定"标准返回 / 委派 / 自定义数据"→ 出 Response。
3. 核心原理
3.1 Create 钩子:进料口的三种权力
它要解决的小问题: 在模型看到输入之前,把最后一道人为逻辑塞进去。
签名与职责。 Create 收三样东西,返回一个 HookCreateResponse(可为空):
Create(ctx, messages, options) → HookCreateResponse | undefined
Go 侧的封装在 hook/create.go:13 Script.Create:它把 options 转成 map 传给 JS(ToMap()),执行 JS 的 Create 方法,再把返回值 JSON 化回 HookCreateResponse(create.go:83 getHookCreateResponse)。
返回值能改三层东西(字段定义见 agent/context/types.go:411 HookCreateResponse):
| 想改什么 | 返回字段 | 生效方式 |
|---|---|---|
| 发给模型的消息 | messages | 直接替换本轮 messages |
| 换模型 | connector | 调用级覆盖,写回 options(create.go:75 applyOptionsAdjustments) |
| 会话字段(locale/theme/route/metadata) | 同名字段 | 写回 ctx(create.go:44 applyContextAdjustments) |
| 生成参数 | temperature/max_tokens/… | 传给 LLM 连接器 |
| 提前路由 | delegate | 跳过 LLM,直接转别的 agent |
注意 applyContextAdjustments 里明确写着 AssistantID 不可覆盖——它在初始化时定死,是这个笼子的身份,钩子不能自己改名。
最关键的一手:Delegate 提前路由。 如果 Create 返回里带了 delegate,管 道会跳过 LLM 调用和 Next 钩子,直接把请求转给目标 agent。这就是"笼子边界"最硬的体现:某些请求根本不该进这只笼子,Create 在门口就把它转走。真实判断点在 agent/assistant/agent.go:247:
// agent.go:246 —— Create 返回带 Delegate 时的提前路由
if createResponse != nil && createResponse.Delegate != nil {
delegateResponse, err := ast.handleDelegation(ctx, createResponse.Delegate, streamHandler)
// …
return delegateResponse, nil // 直接返回,不碰 LLM、不跑 Next
}
DelegateConfig 只需三个字段:目标 agent_id、要发的 messages、可选 options(types.go:487)。委派的具体栈语义(父子 Stack、A2A)见 05-multiagent-stack。