Vercel AI SDK 工具选择错误不是普通文本:补上失败终态与图像成本归因
2026/9/23 14:24:47 网站建设 项目流程

Vercel AI SDK 工具选择错误不是普通文本:补上失败终态与图像成本归因

核心判断:当模型被要求调用工具却返回普通文本时,运行时不能把它当作“模型回答成功”;当一次图像调用被网关拆成多个子请求时,也不能用父请求的一条账单记录代替真实成本。前者需要独立的 tool_choice_violation 终态,后者需要按子请求建立可追溯的成本账本。

这篇文章解决一个具体问题:AI 应用已经接入工具和图像生成后,为什么“请求成功”仍然可能代表业务失败或成本统计失真?读者可以用本文的状态模型、纯函数样例和验收清单,给现有 Agent Loop、流式 UI 或网关适配层补上两道门禁。

1. 两条版本变化

2026-08-30,Vercel AI SDK 发布了两条与应用工程直接相关的更新。ai@7.0.85 修正 Gateway 分段图像请求的成本求和,并暴露单次图像生成调用;ai@6.0.272 则把 generateText 在 required 或明确 selected tool choice 下收到不满足工具选择的响应,明确变成 ToolChoiceViolationError,同时保留规范化后的响应内容,让调用方可以选择是否恢复。

这里有三个容易被忽略的词:不满足、规范化、选择性恢复。它们意味着 SDK 不再鼓励调用方用一条“成功/失败”布尔值吞掉差异。模型可能返回了可读文字,但没有完成协议要求的工具动作;响应仍然有结构化内容,但内容不能未经检查直接当作下一步输入;恢复也不是默认动作,而是业务层在重新校验 Schema、权限和资源后作出的决定。

另一条更新解决的是费用粒度。父请求可以代表一次用户意图,但网关可能把一次图像生成拆成多个实际子请求,例如不同尺寸、不同质量或重试分支。每个子请求拥有自己的模型、usage、价格和结果状态。若只在父请求结束时记一笔总费用,系统就无法回答“哪一步最贵”“重试是否重复计费”“失败的子请求有没有产生费用”。

本文只依据 Vercel AI SDK 官方 Release 与项目知识库中的静态核验结果。没有在本机升级或运行 SDK,也没有连接真实 Provider。下面的 TypeScript 代码是框架无关的最小门禁样例,可以单独复制到 Node.js 环境验证数据流;它不宣称已经替代 SDK 内部实现。

2. 为什么工具选择违反必须是独立终态

一个典型 Agent Loop 大致如下:模型读取上下文,选择工具并生成参数,运行时校验参数,工具执行,结果回填上下文,再进入下一轮。只要模型选择了工具,调用方就会把这轮响应理解为一种协议消息,而不仅是一段自然语言。

现在考虑两种返回:

  1. 应用要求 weather 工具,模型给出 {toolCall: weather, args: {city: “嘉兴”}};
  2. 应用要求 weather 工具,模型却说“嘉兴今天多云,建议带伞”,没有任何工具调用。

第二种返回的文字可能非常合理,却没有经过天气工具的事实来源、权限和审计链路。如果 UI 把它渲染成绿色的“完成”,用户会误以为系统已经查过实时数据;如果 Runtime 把它静默改成普通文本,又会让后续重试、评测和成本统计失去依据。因此它至少要有独立失败状态,和普通模型错误、工具参数校验失败、审批后输入漂移区分开。

建议把一次运行建模为明确的状态机:

状态含义是否允许自动继续
streaming模型仍在输出增量否,等待完整响应
tool_call_ready已发现工具调用,参数尚待校验否,先过 Schema
executing_tool参数与权限通过,工具执行中否,等待结果或取消
tool_choice_violationrequired/selected 工具选择不满足默认否,需显式恢复策略
validation_failed工具参数不符合 Schema否,提示修正或重新生成
completed约定的文本或工具流程完成是,结束本轮
failed不可恢复的模型、网络或业务错误否,进入回滚/人工处理

tool_choice_violation 的关键不是命名,而是保留三个证据:原始请求要求了什么工具、规范化响应是什么、业务是否允许恢复。没有这三项,后续人工排查只能从聊天文本猜测。

3. 恢复不是“把文本当成功”

ToolChoiceViolationError 提供了选择性恢复的可能,但恢复路径必须比普通重试更严格。推荐顺序是:记录失败 → 判断策略 → 重新检查工具注册表 → 重新校验用户权限与资源 → 重新生成或请求补充信息 → 只在新一轮工具调用通过后继续。

下面的纯函数只演示门禁顺序。它没有依赖 Vercel AI SDK,因此可以放在业务层、BFF 或前端状态适配层中,作为统一的状态转换合同。

typeToolChoice={mode:"required"|"selected";name?:string};typeNormalizedResponse={text?:string;toolCalls:Array<{name:string;args:unknown}>;};typeRunFailure={kind:"tool_choice_violation"|"validation_failed";requested:ToolChoice;response:NormalizedResponse;canRecover:boolean;};functionclassifyToolChoice(requested:ToolChoice,response:NormalizedResponse,registeredTools:Set<string>,):RunFailure|null{constcalls=response.toolCalls;constselectedOk=requested.mode!=="selected"||calls.some((call)=>call.name===requested.name);constrequiredOk=requested.mode!=="required"||calls.length>0;if(selectedOk&&requiredOk)returnnull;constknownCall=calls.every((call)=>registeredTools.has(call.name));return{kind:"tool_choice_violation",requested,response,canRecover:knownCall&&calls.length>0,};}

在真实系统中,registeredTools 还应带版本、租户、权限和资源范围,而不是只有工具名称。canRecover 也不等于“马上重试”:它只是把错误交给一个可审计的策略函数。

typeRecoveryDecision=|{action:"retry_tool_choice";reason:string}|{action:"ask_user";reason:string}|{action:"stop";reason:string};functiondecideRecovery(failure:RunFailure,opts:{retriesUsed:number;maxRetries:number;userCanApprove:boolean},):RecoveryDecision{if(failure.kind!=="tool_choice_violation"){return{action:"stop",reason:"交给对应错误处理器"};}if(!failure.canRecover){return{action:"ask_user",reason:"响应未包含可验证的已注册工具"};}if(opts.retriesUsed>=opts.maxRetries){return{action:"stop",reason:"达到重试上限"};}if(!opts.userCanApprove){return{action:"stop",reason:"当前会话没有恢复权限"};}return{action:"retry_tool_choice",reason:"重新校验后再生成工具调用"};}

这段逻辑有意把“问用户”和“停止”分开。对于写入、删除、发送消息、付款等副作用工具,恢复时应重新创建审批记录,不能沿用一次已经失效的批准。对于只读工具,也要限制重试次数、总耗时和预算,避免模型在错误状态下自旋。

4. 前端怎样呈现这个状态

流式 UI 不能只维护 isLoading 和 errorMessage 两个变量。至少要能展示当前运行 ID、工具选择要求、错误类型、规范化响应、是否需要用户补充、恢复按钮是否可用,以及恢复后产生的新运行 ID。

一个可落地的消息部分模型如下:

typeMessagePart=|{type:"text";text:string}|{type:"tool-call";name:string;args:unknown;status:"ready"|"running"|"done"}|{type:"tool-choice-violation";requested:ToolChoice;response:NormalizedResponse;recoverable:boolean}|{type:"error";code:string;retryable:boolean};

渲染时,tool-choice-violation 应明确告诉用户“本轮要求调用某工具,但模型没有按要求调用”,而不是把模型普通文本放在成功气泡里。若允许恢复,按钮文案应是“重新校验并尝试工具调用”,而不是含糊的“重试”。用户点击后,服务端重新检查工具版本、Schema、权限和资源;客户端只显示服务端确认的状态。

断线重连时,客户端还要按 runId 和序列号对账。一个旧运行的恢复结果不能覆盖新运行;重复收到同一错误事件不能让计数器增加两次;终态之后的迟到文本不能把失败运行改回完成。这些是 UI 与 Runtime 的共同合同,不是某一个组件的样式问题。

5. 图像请求为何必须按子请求记成本

图像生成的“一次请求”经常包含多种实际动作:网关解析参数、选择模型、发起一次或多次生成、下载或存储结果、重试失败分支、执行安全检查。官方这次修正的是 Gateway 分段图像请求的成本求和,并暴露单次图像生成调用。工程上可以把它理解为:费用账本的最小单位应接近真实 Provider 调用,而不是用户点击的按钮。

成本记录建议至少包含这些字段:

字段用途注意事项
runId关联一次 Agent 运行断线重试仍保持可追踪
parentRequestId关联用户意图或网关请求只用于聚合,不直接计费
subRequestId标识一次真实 Provider 调用必须幂等,重试生成新 ID
model记录实际模型不要只记录路由别名
usage输入/输出 token 或图像计量按 Provider 原始单位保存
unitPrice价格快照记录生效时间和币种
cost本次子请求金额由 usage 与价格快照计算
statussucceeded、failed、canceled失败不等于零成本
attempt第几次尝试用于发现重试放大

父请求的总成本是子请求账本的派生值。若网关收到四个图像子请求,就应该能逐项列出四条记录,再汇总成一个总数。这样才能区分:第一张图成功、第二张图超时、第三张图重试后成功,以及第四张图在安全检查阶段被拒绝等情况。

6. 可复现的成本账本样例

下面的代码故意不调用网络,只验证“按子请求幂等写入、再汇总”的核心规则。它可在 Node.js 22 或 Bun 1.3 环境运行。

typeUsage={inputTokens?:number;outputTokens?:number;images?:number};typeCostEntry={parentRequestId:string;subRequestId:string;model:string;usage:Usage;cost:number;status:"succeeded"|"failed"|"canceled";attempt:number;};classCostLedger{privaterows=newMap<string,CostEntry>();append(row:CostEntry):void{constold=this.rows.get(row.subRequestId);if(old&&JSON.stringify(old)!==JSON.stringify(row)){thrownewError("sub_request_conflict:"+row.subRequestId);}this.rows.set(row.subRequestId,row);}byParent(parentRequestId:string):CostEntry[]{return[...this.rows.values()].filter((row)=>row.parentRequestId===parentRequestId);}total(parentRequestId:string):number{constraw=this.byParent(parentRequestId).reduce((sum,row)=>sum+row.cost,0);returnMath.round(raw*1000)/1000;}}constledger=newCostLedger();ledger.append({parentRequestId:"p-100",subRequestId:"p-100-1",model:"image-model-a",usage:{images:1},cost:0.126,status:"succeeded",attempt:1});ledger.append({parentRequestId:"p-100",subRequestId:"p-100-2",model:"image-model-a",usage:{images:1},cost:0.204,status:"failed",attempt:1});ledger.append({parentRequestId:"p-100",subRequestId:"p-100-3",model:"image-model-a",usage:{images:1},cost:0.204,status:"succeeded",attempt:2});ledger.append({parentRequestId:"p-100",subRequestId:"p-100-2",model:"image-model-a",usage:{images:1},cost:0.204,status:"failed",attempt:1});console.log({rows:ledger.byParent("p-100").length,total:ledger.total("p-100")});

预期输出为:

{ rows: 3, total: 0.534 }

为什么不是 0.330?因为失败的第二个子请求已经实际消耗了资源,不能因为最终状态是 failed 就强行记为零。真正计费规则要以 Provider 返回的 usage 和价格有效期为准;这里的金额只是门禁样例。生产实现还要处理币种、税费、价格变更、舍入规则和账单对账。

幂等检查同样重要。网关超时后,调用方可能重放“写入成本”事件。如果 subRequestId 相同且内容一致,第二次写入应该被视为重放;如果同一 ID 携带不同模型或金额,则必须报警并阻止覆盖。没有这道门禁,成本看板会因为网络重试随机膨胀。

7. 观测字段怎样与错误终态对齐

建议把工具选择错误和图像成本放进同一条 Trace,但不要混成一条指标。一次 Trace 可以包含:

  • run.started:记录用户任务、模型路由和预算;
  • model.response.normalized:保存规范化响应摘要和工具选择要求;
  • tool_choice.violation:记录错误类型、可恢复性和策略决定;
  • tool.retry.started:记录重新校验后的新尝试;
  • image.subrequest.completed:每个子请求一条事件,包含 usage、cost、状态;
  • run.completed 或 run.failed:汇总业务结果与总成本。

指标也要分开:工具选择违反率反映协议遵从性,工具参数校验失败率反映 Schema 或提示设计,图像子请求平均成本反映路由和质量策略,重试放大率反映故障与幂等设计。只看 HTTP 200 比例和父请求平均成本,会掩盖这些差异。

日志中不要写入完整用户提示、图片原始内容、访问令牌或环境变量。可以保存哈希、字段级摘要和脱敏后的错误。对外部工具尤其要记录调用者身份、工具版本、审批 ID 和资源范围,方便在出现越权或账单争议时回放。

8. 四阶段迁移路线

如果现有应用只有 success / error 两种状态,不建议一次性重写所有前端。可以按下面四个有依赖关系的阶段迁移。

阶段一:冻结协议与版本

最小动作是把 AI SDK 版本、工具选择模式、工具 Schema、价格表版本写入配置;为 tool_choice_violation、validation_failed、failed 建立互不重叠的错误码。过关证据是同一份响应在服务端日志、Trace 和 UI 中得到一致分类。适用边界是单模型、少量工具的应用;多租户系统还要把租户权限加入分类输入。

阶段二:补齐恢复门禁

最小动作是实现 classify → decide → revalidate → retry/ask/stop 链路,并给恢复设置最大次数、时间和成本预算。过关证据是一个包含“普通文本返回、未知工具、已知工具、审批后参数漂移”的测试矩阵,每个案例都有预期终态。适用边界是恢复不会自动执行不可逆副作用;支付、删除和发布类工具仍需人工确认。

阶段三:建立子请求账本

最小动作是为每个真实 Provider 调用生成稳定的 subRequestId,保存模型、usage、价格快照、状态和 attempt;父请求总成本只能由账本聚合。过关证据是重复事件不增加行数、相同 ID 内容冲突会报警、失败和取消路径都有成本状态。适用边界是价格、税费和币种规则仍需与财务系统对账。

阶段四:接入回归评测

最小动作是从线上失败样本建立小型评测集,比较版本升级前后的工具选择违反率、恢复成功率、图像成本分布和重试放大率。过关证据是 CI 或定期任务能输出差异,并能定位到运行 ID 和子请求。适用边界是静态指标不能证明答案质量;仍需人工审阅事实、引用和用户体验。

9. 回滚、异常和未验证边界

升级 SDK 前,先把错误码和账本字段做向后兼容。若新版本的错误对象无法被旧客户端识别,服务端可以暂时映射成通用失败,但必须在日志中保留原始 ToolChoiceViolationError 类型;不能把它降级成成功文本。回滚时保留已经写入的子请求账本,禁止删除账单记录后重新计算。

需要特别测试的异常包括:模型同时返回普通文本和错误工具调用、工具名称大小写或别名不一致、网关只返回部分 usage、价格表在请求中途切换、重试跨越不同 Provider、客户端重复点击恢复按钮、用户在恢复过程中撤销权限,以及图像子请求成功但结果上传失败。每一种情况都应有确定的终态和补偿动作。

本文没有验证 Vercel AI SDK 在你项目中的具体 Provider 行为,也没有证明所有图像模型都按同一单位计费。ai@7.0.85 与 ai@6.0.272 的官方 Release 是事实来源;“UI 需要独立状态”“成本应按子请求归因”“恢复前重校验”是基于这些变化做出的工程判断。接入前仍要锁定实际 SDK、Provider、价格页和运行时版本,跑一遍自己的回归矩阵。

10. 发布前验收清单

  • 文章只回答一个问题:工具选择错误和图像成本为什么不能走普通成功路径;
  • 版本事实对应 ai@7.0.85、ai@6.0.272 官方 Release;
  • tool_choice_violation、参数校验失败、普通模型错误和完成态互不混淆;
  • 恢复前重新校验工具注册表、Schema、权限、资源和预算;
  • 子请求账本具备幂等键、冲突检测、usage、价格快照、状态与 attempt;
  • 失败子请求不被静默记为零成本,父请求总额由子请求聚合;
  • 示例代码可在 Node.js 22/Bun 1.3 中独立运行,输出为 { rows: 3, total: 0.534 };
  • 未把静态核验写成 SDK 已在本机实测;
  • 封面与第 2、6 节知识图均为独立 PNG,分别从本地上传到两个平台;
  • CSDN 与掘金平台实际正文计数均不少于 5000 字,预览无 Markdown 标记残留、粘连表格或代码块损坏。

来源与证据

  1. Vercel AI SDK ai@7.0.85 Release
  2. Vercel AI SDK ai@6.0.272 Release
  3. AI SDK Tools and Tool Calling
  4. AI SDK Core Reference
  5. [[…/…/02-技术沉淀/01-AI工程/AI应用工程知识地图|AI 应用工程知识地图]](2026-08-31 更新)
  6. [[…/…/02-技术沉淀/01-主题笔记/02-前端框架与原理/AI应用前端与生成式UI状态设计|AI 应用前端与生成式 UI 状态设计]](2026-08-31 更新)

[!warning] 验证边界
本文的版本事实来自官方 Release 静态核验;示例账本是本地纯逻辑演示;未在本机升级或运行 Vercel AI SDK,未连接真实 Provider,也未对真实账单金额作承诺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询