跳到主要内容

工具执行、中断与 human-in-the-loop

30 秒导读: 在 Genkit 里,一个「工具」不是什么特殊物件,而是一个多了 interrupt 能力的 Action。本章讲两件事:(1) 一次工具调用如何被解析、并行执行、 拼回消息给模型;(2) Genkit 独有的中断/恢复机制——工具可以主动喊「停,我要问人」, 让整轮生成暂停,等外部(通常是人)给答复后再从暂停点续跑。

本章聚焦「单次工具如何被解析、执行、中断、恢复」。至于「何时进入这个工具循环、循环转几圈」, 是 第 3 章 generate 与工具循环 的职责——它是发动机,本章是发动机里 被反复调用的那个零件。


1. 这是什么(零基础也能懂)

一句话定义

工具(tool)= 一个你写的普通函数 + 一份「说明书」。 你把说明书交给模型,模型在对话中觉得 「这里该调用它」时,就吐出一个「请帮我用参数 X 调用工具 Y」的请求;Genkit 负责找到真函数、 带上参数跑一遍、把结果塞回给模型。

解决什么问题 / 给谁用

大语言模型只会「说话」,不会「做事」——它不能查数据库、不能发邮件、不能读今天的天气。工具就是给 模型接上手脚:你把 getWeather(city) 这个函数注册成工具,模型就能在回答「北京今天要带伞吗」时, 先请求调用 getWeather("北京"),拿到真实数据再作答。

一个最小例子(用起来什么样)

下面这段展示工具从「定义」到「被自动调用」的全流程:

// 示意,非源码:体现 defineTool + 自动工具循环的用法
const getWeather = ai.defineTool(
{
name: 'getWeather',
description: '查询某座城市的当前天气', // 这句会原样给模型看
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ tempC: z.number() }),
},
async ({ city }) => { // 这就是「真函数」
return { tempC: 22 }; // 真实实现里会去调天气 API
}
);

const { text } = await ai.generate({
prompt: '北京今天要带伞吗?',
tools: [getWeather], // 把工具交给模型
});
// generate 内部会:模型请求 getWeather → Genkit 执行 → 结果回灌 → 模型给出最终回答

一句话直觉

把工具想成餐厅的点餐单:description + inputSchema 是菜单上的菜名和「需要几人份」;模型是 顾客,看着菜单点菜(发出 toolRequest);Genkit 是服务员,把订单送进后厨(执行真函数)、再把菜 端回来(toolResponse)。而中断,就是后厨发现「这道菜要客人先确认忌口」——于是停下,让服务员 出去问人,问到了再回来接着做。

本节不出现底层细节。记住三个词就行:tool(工具)、toolRequest(模型的调用请求)、interrupt(中途暂停)


2. 顶层全景(它大概怎么转)

一次带工具的生成,数据在四个角色间流动:你的代码 → 注册表 → 模型 → 工具执行器

怎么读这张图:上半是「定义期」(把函数变成模型能看懂的说明书),
下半是「运行期」(模型点名 → 解析 → 执行 → 回灌)。中断是运行期的岔路。

┌─────────────────────── 定义期 ───────────────────────┐
│ 你的函数 + config │
│ │ defineTool() │
│ ▼ │
│ ① 注册成 tool Action ──► 存进注册表(/tool, /tool.v2)│
│ │ toToolDefinition() │
│ ▼ │
│ ② 生成「tool definition」(name+desc+JSON schema) │
└────────────────────────┬─────────────────────────────┘
│ 随请求发给模型

┌─────────────────────── 运行期 ───────────────────────┐
│ 模型返回 content 里带 toolRequest {name, input} │
│ │ resolveToolRequests() ← 本章主角 │
│ ▼ │
│ ③ 按 name 在 toolMap 里查到真 Action │
│ ④ 并行执行(Promise.all)每个 toolRequest │
│ │ │
│ ┌────┴─────────────┐ │
│ ▼ 正常返回 ▼ 工具调用了 interrupt() │
│ ⑤ 拼成 tool 消息 ⑥ 抛 ToolInterruptError │
│ (role:'tool') finishReason='interrupted' │
│ 回灌给模型继续 整轮停下,等 resume 续跑 │
└──────────────────────────────────────────────────────┘

各部件的一句话职责:

部件干什么在哪(相对克隆根)
defineTool把函数 + config 注册成 tool Actionjs/ai/src/tool.ts:336
toToolDefinition把 tool Action 压成给模型看的 definitionjs/ai/src/tool.ts:260
interrupt(ctx 里的函数)工具内主动暂停,抛出中断js/ai/src/tool.ts:532
resolveToolRequests解析一整轮的 toolRequest,并行执行、拼回消息js/ai/src/generate/resolve-tool-requests.ts:186
respond / restart为被中断的调用构造「答复」或「重跑」js/ai/src/tool.ts:356:371
resolveResumeOption下一次 generate 时消化 resume,续跑中断js/ai/src/generate/resolve-tool-requests.ts:354

主线走一遍(高层):模型说「调 getWeather」→ resolveToolRequests 按名字查到函数 → 并行跑 → 若正常,打包成 role:'tool' 的消息回灌,循环继续;若工具喊了 interrupt(),整轮以 finishReason='interrupted' 停下,把「待处理」状态记在消息里,等你下次带着 resume 再来。


3. 核心原理(逐个机制,由浅入深)

3.1 defineTool:函数怎么变成「模型能看懂的工具」

它要解决的小问题: 你手里是一个 TypeScript 函数,模型那边只认「一段文字说明 + 一份 JSON schema」。 中间要有人做翻译和登记。

思路: defineTool 干两件事——把你的函数包成一个带 type: 'tool' 元数据的 Action 存进注册表; 需要发给模型时,再用 toToolDefinition 把这个 Action「拍扁」成模型认识的 definition。

一个反直觉的细节:注册两次。 非 multipart 的工具会同时注册成 tooltool.v2 两份:

// js/ai/src/tool.ts:341-348 defineTool
const a = tool(config, fn);
delete a.__action.metadata.dynamic;
registry.registerAction(config.multipart ? 'tool.v2' : 'tool', a);
if (!config.multipart) {
// 非 multipart 工具额外再注册一个 v2 版本
registry.registerAction('tool.v2', basicToolV2(config, fn as ToolFn<I, O>));
}

tool.v2(multipart)是新一代格式,允许工具不仅返回结构化 output,还能返回 content(图片等 Part[])和 metadata——见 MultipartToolResponseSchema(js/ai/src/parts.ts:208)。双注册是为了向后 兼容:老代码按 tool 查,新执行路径优先按 tool.v2 查。basicToolV2(js/ai/src/tool.ts:602)本质是 把老式 ToolFn 的返回值包一层 { output: ... },升格成 multipart 形状。

翻译成 definition: toToolDefinition 把 Action 上的 Zod schema 转成 JSON schema,并把带 / 前缀的 完整名字裁成短名给模型:

// js/ai/src/tool.ts:263-280 toToolDefinition
const originalName = tool.__action.name;
let name = originalName;
if (originalName.includes('/')) {
name = originalName.substring(originalName.lastIndexOf('/') + 1); // /tool/getWeather → getWeather
}
const out: ToolDefinition = {
name,
description: tool.__action.description || '',
outputSchema: toJsonSchema({ schema: tool.__action.outputSchema ?? z.void(), ... })!,
inputSchema: toJsonSchema({ schema: tool.__action.inputSchema ?? z.void(), ... })!,
};

注意最后(js/ai/src/tool.ts:286-288):如果短名和原名不同,会把 originalName 塞进 out.metadata.originalName——这样模型回传短名时,还能反查回注册表里的完整键。

查表反查: 模型只会说短名,lookupToolByName(js/ai/src/tool.ts:241)于是挨个前缀试探,直到命中:

尝试的 key覆盖的情况
name已是完整键
/tool/${name}老式工具
/tool.v2/${name}multipart 工具
/prompt/${name}被当工具用的 prompt
/dynamic-action-provider/${name}动态注入的工具

3.2 resolveToolRequests:把模型的请求变成真实执行

它要解决的小问题: 模型一轮可能一次点好几个工具(content 里有多个 toolRequest)。要并行跑完、 把每个结果对号入座,再打包成一条 role:'tool' 的消息喂回模型。

思路:三步走。 先建名字 → Action 的映射表,再并行遍历每个请求,最后按「有没有中断」决定返回什么。

第一步,toToolMap(js/ai/src/generate/resolve-tool-requests.ts:44)把工具列表压成 短名 → Action 的字典,同时用 assertValidToolNames 保证没有重名(重名直接抛 INVALID_ARGUMENT)。

第二步,resolveToolRequests 并行处理 content 里的每个 part:

// js/ai/src/generate/resolve-tool-requests.ts:205-230(节选)
await Promise.all(
revisedModelMessage.content.map(async (part, i) => {
if (!part.toolRequest) return; // 跳过非工具请求的 part
const { response, interrupt } = await resolveToolRequest(rawRequest, part, toolMap, middleware);
if (response) {
responseParts.push(response!);
revisedModelMessage.content.splice(i, 1, toPendingOutput(part, response));
}
if (interrupt) {
revisedModelMessage.content.splice(i, 1, interrupt);
hasInterrupts = true;
}
})
);

单个请求的解析在 resolveToolRequest(:90)里:按 nametoolMap 查工具(查不到抛 NOT_FOUND)、 过一遍中间件链(dispatch,:109)、再真正执行(executeTool,:127)。executeTool 会区分 tool.v2(取 output/content/metadata 三件套)和老式工具(只有 output)。

第三步,返回值有三种可能,这决定了工具循环下一步走哪:

情况返回循环怎么走
有任一中断{ revisedModelMessage }停!整轮以 finishReason:'interrupted' 结束
无中断、有响应{ toolMessage }(role:'tool')回灌给模型,继续下一圈
无中断、无响应{}无事发生

这三种正好对应 第 3 章generateHelper 的分支 (js/ai/src/generate/action.ts:524-539):拿到 revisedModelMessage 就短路返回中断响应,拿到 toolMessage 就 append 进历史、递归再转一圈。

巧妙处 toPendingOutput: 即使工具正常返回,原来的 toolRequest part 也不会被丢弃,而是被换成一个 带 metadata.pendingOutput(:77)的版本——把结果「暂存」在请求上。这为「一轮里既有中断、又有已完成 工具」的混合场景铺路:已完成的挂着 pendingOutput,下次 resume 时直接取出,不必重跑(见 3.4)。

3.3 interrupt():工具怎么主动喊「停」

它要解决的小问题: 有些工具不该默默执行——「转账 500 元」得先让人确认。工具需要一个办法说: 「我不返回结果,我要把控制权交回去,等外部拍板。」

思路:用异常做控制流。 工具实现函数的 ctx 里注入了一个 interrupt 函数,一旦调用,它抛出一个 ToolInterruptError;这个异常被 resolveToolRequest 专门捕获,翻译成「中断」而非「失败」。

// js/ai/src/tool.ts:532-543 interruptTool 返回的就是注入 ctx 的那个 interrupt
return (metadata?: Record<string, any>): never => {
if (registry) assertUnstable(registry, 'beta', 'Tool interrupts are a beta feature.');
if (metadata) setCustomMetadataAttributes({ interrupt: JSON.stringify(metadata) });
throw new ToolInterruptError(metadata); // 关键:抛异常,函数永不正常返回(返回类型 never)
};

工具体里怎么触发?看 basicTool(js/ai/src/tool.ts:585-596):每次执行都先造好 interrupt 塞进 ctx,你在函数里 throw 或调用它即可。若你根本没提供实现函数,工具默认就中断(return interrupt())—— 这正是「纯人在环路确认」型工具的用法。

捕获与翻译: 异常在 resolveToolRequest 的 try/catch 里被拦下,变成一个特殊的 interrupt part:

// js/ai/src/generate/resolve-tool-requests.ts:161-177(节选)
} catch (e) {
if (e instanceof ToolInterruptError || (e as Error).name === 'ToolInterruptError') {
const ie = e as ToolInterruptError;
return {
interrupt: {
toolRequest: part.toolRequest,
metadata: { ...part.metadata, interrupt: ie.metadata || true }, // 打上 interrupt 标记
},
};
}
throw e; // 其它异常照常往上抛,不当中断处理
}

原来的 toolRequest 被原样保留,只是在 metadata 上多了一枚 interrupt 印章(值是你传给 interrupt() 的 metadata,或 true)。这枚印章就是后面「恢复」时的锚点。

interrupt() 助手 = 没有实现的工具。 顶层的 interrupt(config)(js/ai/src/tool.ts:486)是个语法糖: 它定义一个实现函数只做一件事——立刻中断的工具,并把 metadata.tool.restartable 设为 falserequestMetadata 让你在中断时附带上下文(比如「请确认转账 ¥500」),支持静态对象或按输入动态计算。

别把 interrupt 和「工具失败」搞混:

interrupt工具抛普通 Error
异常类型ToolInterruptError任意 Error
被谁处理框架捕获,转成中断 part原样上抛,整个 generate 报错
语义「暂停,等外部」「出错了」
finishReason'interrupted'抛异常

3.4 respond / restart + resume:怎么从暂停点续跑

它要解决的小问题: 生成已经以 finishReason:'interrupted' 停下,历史里那条模型消息里挂着一个 带 interrupt 印章的 toolRequest。现在人确认了,怎么让对话从这里接着走,而不是重头再来?

思路:两种续法。 对每个被中断的工具调用,你可以二选一:

方式含义用哪个 API
respond「别执行了,这是答案」,手动塞一个结果给模型tool.respond(interrupt, data) → 进 resume.respond[]
restart「现在可以真跑了」,重新触发这个工具tool.restart(interrupt) → 进 resume.restart[]

respond/restart 只是构造消息 part,不执行任何东西。respond(js/ai/src/tool.ts:356)会拿 outputSchema 校验你给的数据,再造出一个 toolResponse part;restart(:371)造一个复用原 ref、可选替换输入(replaceInput,给「用户改了参数再确认」的场景)的新 toolRequest part。二者底层是 独立工具函数 respondTool(:452)/ restartTool(:414)。

你把它们塞进下一次 generate 的 resume 参数:

// 示意,非源码:恢复一次被中断的生成
const resumed = await ai.generate({
messages: history, // 含那条挂着 interrupt 的模型消息
tools: [transferMoney],
resume: {
respond: [confirmTool.respond(theInterrupt, { ok: true })], // 或
restart: [transferMoney.restart(theInterrupt)],
},
});

resume 的 schema 明确是 { respond?: ToolResponsePart[], restart?: ToolRequestPart[], metadata? } (js/ai/src/model-types.ts:398-404)。

消化 resume 的是 resolveResumeOption(js/ai/src/generate/resolve-tool-requests.ts:354)。 它在 generate 真正调模型之前跑(js/ai/src/generate/action.ts:378),对历史里最后那条模型消息的每个 toolRequest,交给 resolveResumedToolRequest(:269)按优先级匹配:

怎么读:自上而下第一个命中的分支胜出。

对每个被中断的 toolRequest:

├─① metadata.pendingOutput 存在?
│ 是 → 直接取出暂存结果当 toolResponse(不重跑) [:279]

├─② resume.respond[] 里有对应(name+ref)的答复?
│ 是 → 用它当 toolResponse,把 interrupt 改写成 resolvedInterrupt [:298]

├─③ resume.restart[] 里有对应的重启请求?
│ 是 → 真正执行该工具(resolveToolRequest)
│ ├─ 又中断 → 冒泡出新的 interrupt [:330]
│ └─ 正常 → 结果当 toolResponse [:332]

└─ 都没有 → 抛 INVALID_ARGUMENT:「这个中断你没在 resume 里处理」 [:347]

对应关系靠 findCorrespondingToolResponse / findCorrespondingToolRequest(:245:257)——它们按 name ref 双重匹配,ref 是模型给每个调用的唯一编号,保证「同名工具的多次调用」不会串味。

重启后怎么真跑? 关键在运行期的 resumed 标志。toRunOptions(:71)把 metadata.resumed 提出来放进 ToolRunOptions.resumed(js/ai/src/tool.ts:152);工具执行时 recordResumedMetadata(:566)把它记进 追踪。于是你的工具函数能通过 ctx.resumed 知道「这是确认后的第二次调用」,从而走不同逻辑(比如第一次 interrupt() 求确认,第二次 resumed 为真就真转账)。

收尾: 全部中断都解决后,resolveResumeOption 把这些 toolResponse 打包成一条 role:'tool' 消息(:426)append 进历史,清掉 resume 字段,然后让 generate 带着完整的 请求-响应对去调模型(:434)。如果重启过程中又冒出新中断,则返回 interruptedResponse (finishReason:'interrupted',:405)——注意此时不会调模型,因为重启中再中断的语义还没完全支持 (源码里 js/ai/src/generate/action.ts:382-390 直接抛错说明了这点限制)。


4. 深入实现:一次「确认后转账」的完整时序

把 3.3 和 3.4 串起来,看一个人在环路的真实来回。假设 transferMoney 第一次调用时 interrupt() 求确认:

时间线(→ 表示一次 ai.generate 调用):

第 1 次 generate ────────────────────────────────────────────
模型: "调用 transferMoney({amount:500})" (content 里一个 toolRequest)
│ resolveToolRequests → resolveToolRequest → executeTool
│ 工具体检测到未 resumed,调 interrupt({need:'confirm'})
│ 抛 ToolInterruptError,被 catch 成 interrupt part

返回: finishReason='interrupted'
历史里那条模型消息的 toolRequest 挂上 metadata.interrupt={need:'confirm'}

……(你的应用把「确认转账 ¥500?」展示给用户,用户点了「确认」)……

第 2 次 generate(带 resume.restart=[transferMoney.restart(itp)]) ──
│ 先跑 resolveResumeOption(在调模型之前)
│ resolveResumedToolRequest 命中「③ restart」分支
│ → 真正执行 transferMoney,这次 ctx.resumed 为真 → 真转账,返回 {done:true}
│ 打包成 role:'tool' 消息 append 进历史,清掉 resume

带着「请求+响应」完整历史调模型 → 模型: "已为你转账 ¥500 ✅"

两个执行入口值得对照:

函数时机干的事
resolveToolRequests模型刚返回解析这一轮新产生的 toolRequest
resolveResumeOption下次 generate 调模型前消化 resume,补齐上一轮遗留的中断
resolveRestartedTools内部辅助(:444)单独收割历史里标了 resumed 的重启请求

三者共用同一个底层 resolveToolRequest,区别只在「喂给它的是新请求还是历史里的重启请求」。


5. 巧妙之处(可借鉴的技术)

  • 用异常做控制流,但只认自家异常。 interrupt()throw ToolInterruptError 暂停,catch 时严格 instanceof 判别(还兜底判 name,:164),别的异常一律放行。这让「暂停」和「失败」在同一 try/catch 里干净分流。js/ai/src/generate/resolve-tool-requests.ts:161-178

  • 中断不丢信息:原请求原样留、只盖印章。 中断后 toolRequest 本体不变,只在 metadata 上叠加 interrupt 标记;恢复时又把它换成 resolvedInterrupt——历史因此完整可追溯,谁中断过、谁已解决一目了然。 js/ai/src/generate/resolve-tool-requests.ts:172:306

  • pendingOutput:已完成的工具不重跑。 一轮里若有的工具完成、有的中断,完成的把结果暂存进 pendingOutput;下次 resume 时 resolveResumedToolRequest 第一分支直接取出,避免副作用重复执行。 js/ai/src/generate/resolve-tool-requests.ts:77:279

  • name+ref 双键匹配。 同一个工具被模型一轮调用多次时,靠模型分配的 ref 区分,respond/restart 才能精确对号入座,不会张冠李戴。js/ai/src/generate/resolve-tool-requests.ts:245-267

  • 双注册向后兼容。 tooltool.v2 并存,老 API 无痛升级到能返回富媒体的 multipart 工具。 js/ai/src/tool.ts:343-347


6. 边界与局限(诚实)

  • 中断/恢复是 beta。 interruptrespondrestart 都会 assertUnstable(registry, 'beta', ...), 非 beta 环境调用会报错。js/ai/src/tool.ts:358:373:535

  • 重启中再中断,不支持。 resume 一个 restart 时若工具中断,resolveResumeOption 返回 interruptedResponse不调模型;上层 generateHelper 遇到「restart 又触发 interrupt」会直接抛 FAILED_PRECONDITION。源码注释明说这块「太复杂,暂未支持」。 js/ai/src/generate/resolve-tool-requests.ts:403-413js/ai/src/generate/action.ts:379-390

  • 所有中断都必须显式处理。 resume 时任一被中断的 toolRequest 若在 respond/restart 里都找不到对应 项,直接抛 INVALID_ARGUMENT——不能「漏答」。js/ai/src/generate/resolve-tool-requests.ts:347

  • resume 有前置条件。 只有当上一条消息是带至少一个 toolRequest 的 model 消息时才能 resume, 否则抛 FAILED_PRECONDITIONjs/ai/src/generate/resolve-tool-requests.ts:370-379

  • interrupt() 助手造的工具默认 restartable:false 纯确认型中断工具通常走 respond 而非 restartjs/ai/src/tool.ts:496


7. 横向对比 / 与同组章节的边界

  • 本章 vs 第 3 章 generate 与工具循环: 第 3 章是「发动机」,决定 何时进循环、转几圈(maxTurns)、returnToolRequests 时是否根本不自动执行;本章是「零件」, 管单次请求怎么解析、执行、中断、恢复。二者的接缝就是 resolveToolRequests / resolveResumeOption 的调用点(js/ai/src/generate/action.ts:378:524)。

  • 本章 vs 第 1 章 Action 与注册表: 工具本质是 Action,defineTool 最终 落到 action()。第 1 章讲通用 Action 原语与注册表机制,本章讲「Action 的 tool 子类型」多出来的 interrupt/Resumable 能力。

  • 本章 vs 第 5 章 Dotprompt 与结构化输出: prompt 也能被 ref.asTool() 当工具用(resolveTools,js/ai/src/tool.ts:227);把 prompt 当工具的机制在第 5 章。


8. 代码地图(导航索引)

主题文件(相对克隆根)符号
定义工具(注册 tool + tool.v2)js/ai/src/tool.ts:336defineTool
底层造工具 Actionjs/ai/src/tool.ts:559:575:617tool / basicTool / multipartTool
压成模型看的 definitionjs/ai/src/tool.ts:260toToolDefinition
名字反查工具js/ai/src/tool.ts:241:210lookupToolByName / resolveTools
工具内的暂停函数js/ai/src/tool.ts:532interruptTool / ToolFnOptions.interrupt
中断异常js/ai/src/tool.ts:521ToolInterruptError
无实现即中断的助手js/ai/src/tool.ts:486:508interrupt / defineInterrupt
恢复接口(respond/restart)js/ai/src/tool.ts:46:356:371Resumable / respond / restart
构造恢复用的 partjs/ai/src/tool.ts:414:452restartTool / respondTool
解析一轮 toolRequestjs/ai/src/generate/resolve-tool-requests.ts:186resolveToolRequests
解析单个 + 捕获中断js/ai/src/generate/resolve-tool-requests.ts:90resolveToolRequest
暂存已完成结果js/ai/src/generate/resolve-tool-requests.ts:77toPendingOutput
消化 resume(续跑)js/ai/src/generate/resolve-tool-requests.ts:354:269resolveResumeOption / resolveResumedToolRequest
resume 参数 schemajs/ai/src/model-types.ts:398GenerateActionOptions.resume
multipart 响应形状js/ai/src/parts.ts:208MultipartToolResponseSchema
工具循环里的调用点js/ai/src/generate/action.ts:378:524generateHelper