news 2026/9/13 22:19:31

Effect AI 工具调用 ID 的完整链路:从 HandlerContext 到 Toolkit.WithHandler.handle 的暴露与贯通

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Effect AI 工具调用 ID 的完整链路:从 HandlerContext 到 Toolkit.WithHandler.handle 的暴露与贯通

Effect AI 工具调用 ID 的完整链路:从 HandlerContext 到 Toolkit.WithHandler.handle 的暴露与贯通

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

导读:本篇文章以effect-smol仓库.changeset中的一次 patch 变更为主线,深入剖析 Effect 生态unstable/ai模块中tool call ID(工具调用 ID)从模型输出、审批流转到 handler 执行的全链路设计。读完本文,你将掌握HandlerContext.toolCallIdToolkit.WithHandler.handle第三参数、审批上下文NeedsApprovalContext的用法,并理解在多工具并发场景下如何借助 tool call ID 实现结果回填、审批配对与幂等追踪。

变更背景:一次 patch 说明

.repos/effect-smol/.changeset/pre/fix-ai-tool-call-id.md中记录了如下变更:

--- "effect": patch --- Expose the tool call ID to AI tool handlers and `Toolkit.WithHandler.handle` wrappers.

这是一个标准的 Changesets 预发布(pre模式,tag 为rc,见.repos/effect-smol/.changeset/pre.json)变更说明:它声明effect包打一个patch级别补丁,核心动作是把tool call ID 暴露给 AI 工具处理器(tool handlers)以及Toolkit.WithHandler.handle包装器

表面上这只是一行 release note,但它在整个unstable/ai模块中牵动了一条关键的数据链路。本文结合仓库源码,将这一变更拆解为四个可独立阅读的层次:tool call ID 的产生、handler 侧消费、WithHandler.handle包装器透传、以及审批流程中的配对使用。

tool call ID 是什么:Prompt.ToolCallPart.id

在 Effect 的 AI 模块中,模型的一次工具调用被建模为Prompt.ToolCallPart。在 Prompt.ts 中可以看到其核心定义:

export interface ToolCallPart extends BasePart<"tool-call", ToolCallPartOptions> { // 工具名称 readonly name: string // 工具调用参数(已编码) readonly params: unknown // 唯一标识符 readonly id: string }

id是模型(provider)在生成工具调用时赋予该次调用的唯一标识——例如 OpenAI 风格的工具调用 ID(形如call_xxx)。它是贯穿整个工具执行生命周期的主键:

  • 模型发出工具调用时携带id
  • 工具 handler 执行完成后,结果 part 使用同一个id回填(见下文"结果回填"一节);
  • 需要人工审批时,审批请求 part 通过toolCallId指向被审批的那次调用。

因此,工具调用 ID 是"模型发起的调用"与"代码侧执行结果"之间唯一稳定的关联键,在多工具并发、流式输出、审批延迟等场景下不可或缺。

变更的第一落点:HandlerContext.toolCallId

本次变更的第一项内容是"Expose the tool call ID to AI tool handlers"。其实现载体是HandlerContext接口,定义于 Tool.ts:

export interface HandlerContext<Tool extends Tool.Any> { /** * The unique identifier of the tool call, when available. */ readonly toolCallId?: string | undefined /** * Emit a preliminary result during long-running tool calls. */ readonly preliminary: (result: Tool.Success<Tool>) => Effect.Effect<void> }

也就是说,工具 handler 的函数签名从此扩展为接收两个参数——解码后的参数对象,以及携带toolCallIdpreliminary的执行上下文。在HandlersFrom类型(Toolkit.ts)中,handler 的完整形态为:

( params: Tool.Parameters<Tools[Name]>, context: HandlerContext<Tools[Name]> ) => Effect.Effect< Tool.Success<Tools[Name]>, Tool.Failure<Tools[Name]> | AiError.AiError | AiError.AiErrorReason, Tool.HandlerServices<Tools[Name]> >

注意toolCallId在 handler 侧是可选的(?: string | undefined),因为它仅在调用方确实传入时才会出现;而preliminary则用于长耗时工具调用中流式推送中间结果。

在 handler 中的典型用法:

toolkit.toLayer({ queryDatabase: (params, context) => Effect.gen(function*() { // 将工具调用 ID 写入日志 / trace,用于关联模型侧请求 yield* Effect.log(`executing ${params.sql}`, { toolCallId: context.toolCallId }) // ... 执行查询并返回结果 }) })

这一能力让 handler 内部的日志、指标、外部 API 回调能够携带与模型侧完全一致的调用 ID,从而在观测系统里把"模型决策"与"代码执行"串联成一条完整链路。

变更的第二落点:Toolkit.WithHandler.handle包装器

第二项内容是"Toolkit.WithHandler.handlewrappers"。WithHandler是注册了 handler 之后的 toolkit 形态,其handle方法签名定义于 Toolkit.ts:

readonly handle: <Name extends keyof Tools>( name: Name, // 工具名称 params: Tool.ParametersEncoded<Tools[Name]>, // 待解码的编码参数 toolCallId?: string // 工具调用 ID(可选) ) => Effect.Effect< Stream.Stream< Tool.HandlerResult<Tools[Name]>, Tool.HandlerError<Tools[Name]>, Tool.HandlerServices<Tools[Name]> >, AiError.AiError >

handle是执行一次工具调用的统一入口:按名称查找工具 → 用 Schema 解码参数 → 构造HandlerContext→ 调用 handler,并以Stream形式流式返回结果(含 preliminary 结果与最终结果)。第三个参数toolCallId使调用方(无论来自LanguageModel内部还是用户手动驱动)都能显式传入调用 ID。

handle的底层实现(Toolkit.ts)展示了toolCallId是如何被组装进 handler 上下文的:

const handle = Effect.fnUntraced(function*(name: string, params: unknown, toolCallId?: string) { const tool = Object.hasOwn(tools, name) ? tools[name] : undefined yield* Effect.annotateCurrentSpan({ tool: name, parameters: params }) // 工具不存在 → ToolNotFoundError if (Predicate.isUndefined(tool)) { /* ...AiError.make... */ } // 获取缓存的 schema / handler const schemas = getSchemas(tool) // 解码参数,失败 → ToolParameterValidationError const decodedParams = yield* schemas.decodeParameters(params).pipe(/* ... */) // 构造 HandlerContext:toolCallId 直接来自第三个参数 const queue = yield* Queue.make<{ /* ... */ }, Cause.Done>() const context: HandlerContext<any> = { toolCallId, preliminary: (result) => Effect.asVoid(Queue.offer(queue, { result, isFailure: false, preliminary: true })) } const fiber = yield* schemas.handler(decodedParams, context).pipe(/* ... */) // ...将 handler 结果(含 preliminary 与最终结果)推入队列并流式返回 })

可以看到,handle内部把传入的toolCallId原样塞入context.toolCallId,再交给schemas.handler(decodedParams, context)执行——这就是变更声明中"暴露给 handler 和 handle 包装器"的代码级落点。此外,Effect.annotateCurrentSpan会把工具名与参数写入当前 span,配合context.toolCallId,可在追踪系统中完整还原一次工具调用的上下文。

完整调用链:LanguageModel 如何把part.id一路传到 handler

WithHandler.handletoolCallId参数并非摆设——LanguageModel.generateText内部正是以"model 输出的part.id"作为调用 ID 来驱动工具执行的。在 LanguageModel.ts 的流式执行路径中:

yield* toolkit.handle(part.name, part.params as any, part.id).pipe( Stream.unwrap, Stream.runForEach((result) => { const toolResultPart = Response.makePart("tool-result", { id: part.id, // 结果 part 使用与调用 part 相同的 id name: part.name, providerExecuted: false, ...result }) return Queue.offer(queue, toolResultPart) }) )

在非流式 / 工具解析路径(LanguageModel.ts)中同样如此:

if (approvedToolCallIds.has(toolCall.id)) { return toolkit.handle(toolCall.name, toolCall.params as any, toolCall.id).pipe( Stream.unwrap, Stream.map( (result) => Response.makePart("tool-result", { id: toolCall.id, name: toolCall.name, providerExecuted: false, ...result }) ) ) }

由此可以梳理出完整的 ID 贯通链路:

模型流式输出 tool-call part(id = "call_xxx") │ ▼ LanguageModel 内部解析 part │ ├─ 无需审批:toolkit.handle(part.name, part.params, part.id) └─ 需要审批:发出 tool-approval-request(toolCallId = part.id) │ ▼ Toolkit.handle 收到 toolCallId,构造 HandlerContext { toolCallId, preliminary } │ ▼ tool handler 通过 (params, context) => 使用 context.toolCallId │ ▼ handler 结果以 tool-result part 回填,id 仍为原调用 ID

这条链路保证了:无论是否经过审批、无论以流式还是批量方式执行,handler 侧看到的context.toolCallId与最终tool-resultpart 的id、以及模型侧tool-callpart 的id始终是同一个值。

toolCallId 在审批流程中的角色:NeedsApprovalContextToolApprovalRequestPart

tool call ID 在"人工审批"机制中扮演着更关键的角色。Effect 的 AI 模块允许为工具配置needsApproval,它可以是静态布尔值,也可以是基于参数与上下文动态决策的函数。动态决策时,函数会收到NeedsApprovalContext(Tool.ts):

export interface NeedsApprovalContext { /** * The unique identifier of the tool call. */ readonly toolCallId: string /** * The conversation messages leading up to this tool call. */ readonly messages: ReadonlyArray<Prompt.Message> }

在 LanguageModel.ts 中,动态审批决策被这样调用:

const result = tool.needsApproval(params, { toolCallId: toolCall.id, messages })

即:动态审批函数不仅能看到本次调用的参数与对话上下文,还能拿到调用 ID,从而可以对某些特定调用(例如重复出现的写操作)做差异化审批。

当需要审批时,模块会生成一个审批请求 part。Prompt.ToolApprovalRequestPart(Prompt.ts)包含两个 ID:

export interface ToolApprovalRequestPart extends BasePart<"tool-approval-request", ToolApprovalRequestPartOptions> { /** * Unique identifier for this approval flow. */ readonly approvalId: string /** * The tool call ID requiring approval. */ readonly toolCallId: string }

approvalId标识"这一次审批流程",toolCallId则指回"被审批的那次工具调用"。在collectToolApprovals(LanguageModel.ts)中,模块正是用Map<string, ...>toolCallId为键,把审批请求、审批响应、原始 tool-call、以及已存在的 tool-result 关联起来;随后用approvedToolCallIds(Set)与deniedByToolCallId(Map)来分派"批准执行 / 拒绝执行"两种分支(LanguageModel.ts)。

被拒绝的调用不会进入 handler,而是生成一个特殊的失败结果:

if (deniedByToolCallId.has(toolCall.id)) { const denial = deniedByToolCallId.get(toolCall.id)! return Stream.succeed( Response.makePart("tool-result", { id: toolCall.id, name: toolCall.name, providerExecuted: false, isFailure: true, result: { type: "execution-denied", reason: denial.reason }, encodedResult: { type: "execution-denied", reason: denial.reason }, preliminary: false }) ) }

即使 handler 完全没有执行,tool-resultpart 的id依然与原始调用 ID 保持一致,模型侧可以据此理解"哪一次调用被拒绝"。

测试如何验证 ID 的贯通

仓库的测试套件对该行为有大量直接断言。在 Tool.test.ts 中,测试先构造const toolCallId = "tool-123",再让 mock 语言模型返回一个带该 ID 的tool-callpart:

const response = yield* LanguageModel.generateText({ prompt: "Test", toolkit }).pipe( TestUtils.withLanguageModel({ generateText: [{ type: "tool-call", id: toolCallId, name: toolName, params: { testParam: "test-param" } }] }), Effect.provide(handlers) ) deepStrictEqual(response.toolResults, [ Response.makePart("tool-result", { id: toolCallId, // 结果 ID 与调用 ID 一致 isFailure: false, name: toolName, result: toolResult, encodedResult: toolResult, providerExecuted: false, preliminary: false }) ])

该文件共 1330 行,几乎每个用例都以const toolCallId = "tool-123"开头,覆盖了成功、失败(failureMode: "return""error")、AiErrorReason包装、审批与拒绝等各类分支,但所有用例都断言同一件事:从模型发出的tool-callpart 到最终返回的tool-resultpart,id始终等于toolCallId。这正是本次 patch 变更所保证的 ID 贯通性在测试层的体现。

实践价值:toolCallId 的三个典型用法

结合上述源码链路,context.toolCallId在真实应用中可以直接用于以下场景:

  1. 端到端追踪:把context.toolCallId写入结构化日志或 trace span(配合handle内部的Effect.annotateCurrentSpan),即可在观测平台中把模型决策、审批过程与工具执行结果串成一条完整调用链,快速定位"模型调了哪个工具、执行结果如何"。
  2. 幂等与去重:在重试、超时重放或多轮会话中,以toolCallId作为唯一键做去重,避免同一个工具调用被执行两次(例如支付、发消息等不可安全重放的副作用操作)。
  3. 审批状态机:当 handler 逻辑与审批逻辑分离(例如由 UI 或 Agent 框架层驱动审批)时,ToolApprovalRequestPart.toolCallIdHandlerContext.toolCallId的一致性,保证"批准的是哪次调用"与"最终执行的是哪次调用"严格对应,杜绝串号。

需要注意,本次变更的toolCallId在 handler 侧是可选字段(?: string | undefined),这意味着手动通过Toolkit.WithHandler.handle(name, params)直接调用而不传 ID 时,handler 需要自行处理undefined的情况(例如回退到自动生成的 ID);而在LanguageModel.generateText驱动的正常路径下,该字段必定存在。

小结

.changeset/pre/fix-ai-tool-call-id.md这一行 patch 说明出发,可以看到 Effectunstable/ai模块中工具调用 ID 的完整生命周期:

  • 模型侧:Prompt.ToolCallPart.id产生调用 ID;
  • 执行入口:Toolkit.WithHandler.handle(name, params, toolCallId?)接受并透传 ID(Toolkit.ts);
  • Handler 侧:HandlerContext.toolCallId让工具处理器感知本次调用的 ID(Tool.ts);
  • 审批侧:NeedsApprovalContext.toolCallIdToolApprovalRequestPart.toolCallId支撑审批配对(Prompt.ts);
  • 结果侧:tool-resultpart 以同一 ID 回填,保证模型上下文中的调用与结果严格对应。

这一 patch 虽小,却补上了 AI 工具执行链路中"身份贯通"的关键一环,为日志追踪、幂等控制与审批状态机提供了可靠的实现基础。若需继续深入,可阅读 Tool.ts、Toolkit.ts 与 LanguageModel.ts 的完整实现,以及 Tool.test.ts 中 1330 行覆盖各类分支的测试用例。

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 22:17:50

单元测试实践指南:框架选型与设计模式

1. 单元测试的本质与价值单元测试是软件开发过程中针对程序最小可测试单元&#xff08;通常是函数或方法&#xff09;进行的验证工作。它就像给代码装上了一个显微镜&#xff0c;能够精确捕捉到每个独立单元的行为是否符合预期。在实际项目中&#xff0c;我发现很多团队对单元测…

作者头像 李华
网站建设 2026/9/13 22:13:01

双层规划与雨流计数法在电力系统优化中的应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 22:12:58

脑电伪迹识别:从原理到临床实操的全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华