尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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.toolCallId、Toolkit.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 BaseParttool-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.tsexport interface HandlerContextTool 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.SuccessTool) Effect.Effectvoid }也就是说工具 handler 的函数签名从此扩展为接收两个参数——解码后的参数对象以及携带toolCallId与preliminary的执行上下文。在HandlersFrom类型Toolkit.ts中handler 的完整形态为( params: Tool.ParametersTools[Name], context: HandlerContextTools[Name] ) Effect.Effect Tool.SuccessTools[Name], Tool.FailureTools[Name] | AiError.AiError | AiError.AiErrorReason, Tool.HandlerServicesTools[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.tsreadonly handle: Name extends keyof Tools( name: Name, // 工具名称 params: Tool.ParametersEncodedTools[Name], // 待解码的编码参数 toolCallId?: string // 工具调用 ID可选 ) Effect.Effect Stream.Stream Tool.HandlerResultTools[Name], Tool.HandlerErrorTools[Name], Tool.HandlerServicesTools[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(/* ... */) // 构造 HandlerContexttoolCallId 直接来自第三个参数 const queue yield* Queue.make{ /* ... */ }, Cause.Done() const context: HandlerContextany { 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一路传到 handlerWithHandler.handle的toolCallId参数并非摆设——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 partid call_xxx │ ▼ LanguageModel 内部解析 part │ ├─ 无需审批toolkit.handle(part.name, part.params, part.id) └─ 需要审批发出 tool-approval-requesttoolCallId 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 在审批流程中的角色NeedsApprovalContext与ToolApprovalRequestParttool call ID 在人工审批机制中扮演着更关键的角色。Effect 的 AI 模块允许为工具配置needsApproval它可以是静态布尔值也可以是基于参数与上下文动态决策的函数。动态决策时函数会收到NeedsApprovalContextTool.tsexport interface NeedsApprovalContext { /** * The unique identifier of the tool call. */ readonly toolCallId: string /** * The conversation messages leading up to this tool call. */ readonly messages: ReadonlyArrayPrompt.Message }在 LanguageModel.ts 中动态审批决策被这样调用const result tool.needsApproval(params, { toolCallId: toolCall.id, messages })即动态审批函数不仅能看到本次调用的参数与对话上下文还能拿到调用 ID从而可以对某些特定调用例如重复出现的写操作做差异化审批。当需要审批时模块会生成一个审批请求 part。Prompt.ToolApprovalRequestPartPrompt.ts包含两个 IDexport interface ToolApprovalRequestPart extends BaseParttool-approval-request, ToolApprovalRequestPartOptions { /** * Unique identifier for this approval flow. */ readonly approvalId: string /** * The tool call ID requiring approval. */ readonly toolCallId: string }approvalId标识这一次审批流程toolCallId则指回被审批的那次工具调用。在collectToolApprovalsLanguageModel.ts中模块正是用Mapstring, ...以toolCallId为键把审批请求、审批响应、原始 tool-call、以及已存在的 tool-result 关联起来随后用approvedToolCallIdsSet与deniedByToolCallIdMap来分派批准执行 / 拒绝执行两种分支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-callpartconst 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-resultpartid始终等于toolCallId。这正是本次 patch 变更所保证的 ID 贯通性在测试层的体现。实践价值toolCallId 的三个典型用法结合上述源码链路context.toolCallId在真实应用中可以直接用于以下场景端到端追踪把context.toolCallId写入结构化日志或 trace span配合handle内部的Effect.annotateCurrentSpan即可在观测平台中把模型决策、审批过程与工具执行结果串成一条完整调用链快速定位模型调了哪个工具、执行结果如何。幂等与去重在重试、超时重放或多轮会话中以toolCallId作为唯一键做去重避免同一个工具调用被执行两次例如支付、发消息等不可安全重放的副作用操作。审批状态机当 handler 逻辑与审批逻辑分离例如由 UI 或 Agent 框架层驱动审批时ToolApprovalRequestPart.toolCallId与HandlerContext.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?)接受并透传 IDToolkit.tsHandler 侧HandlerContext.toolCallId让工具处理器感知本次调用的 IDTool.ts审批侧NeedsApprovalContext.toolCallId与ToolApprovalRequestPart.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),仅供参考
返回列表