effect-smol 修复记录:Anthropic 非流式工具调用caller元数据导致Expected JSON value报错的根因与修复
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
本篇文章以 effect-smol 仓库中的 changeset 文档 fix-anthropic-caller-toolid.md 为核心,深入剖析一次针对@effect/ai-anthropic的真实缺陷修复:非流式(non-streaming)Anthropic 响应在工具调用携带caller元数据时会抛出Expected JSON value解码错误。读完本文,你将理解ProviderMetadata的 JSON 值约束、undefined与null在 Effect Schema 解码中的本质差异,以及 streaming 与 non-streaming 两条代码路径在修复前后如何保持一致。
变更一览:一次 patch 级缺陷修复
该 changeset 的完整内容如下(位于仓库 .repos/effect-smol/.changeset/fix-anthropic-caller-toolid.md):
--- "@effect/ai-anthropic": patch --- Fix non-streaming Anthropic responses throwing when a tool call carries `caller` metadata. The mapper emitted `caller.toolId: undefined`, but `ProviderMetadata` is `Record(String, NullOr(Json))` and `undefined` is not a valid Json value, so decoding the model's own response threw `Expected JSON value`. Emit `null` instead, matching the streaming mappers.关键信息可以拆解为:
- 变更包:
@effect/ai-anthropic,变更级别为patch,即向后兼容的缺陷修复,不引入破坏性 API 变更; - 问题触发条件:非流式响应的工具调用(
tool call)中携带caller元数据; - 报错现象:
Expected JSON value,发生在解码模型自身响应(即"回环"解码)时; - 根因:mapper 在组装元数据时输出了
caller.toolId: undefined,而undefined不是合法的 JSON 值; - 修复方式:将
undefined改为输出null,与流式(streaming)路径中已有的 mapper 保持一致。
注意:本文分析的原始 changeset 同时存在于pre目录的预发布变更集中(.repos/effect-smol/.changeset/pre/fix-anthropic-caller-toolid.md),并在 packages/ai/anthropic/CHANGELOG.md 中留下了对应发布记录。这也解释了为什么该修复同时出现在两个 changeset 位置——一个是正式变更集,一个是预发布批次。
ProviderMetadata的类型约束:Record(String, NullOr(Json))
理解这次修复的核心,首先要搞清楚ProviderMetadata的数据契约。
ProviderMetadata是 effect-smol 的 AI 包中承载"供应商特有元数据"的结构,它被定义为Record(String, NullOr(Json))。以@effect/ai-anthropic为例,工具调用部分(tool call)的元数据接口就继承自ProviderMetadata:
- AnthropicLanguageModel.ts 中,
ToolCallPartMetadata extends ProviderMetadata,其下定义了caller字段:readonly caller?: { readonly type: string readonly toolId?: string | null } - 同样在 OpenAiLanguageModel.ts 等其它供应商实现中,
ToolCallPartMetadata也继承自同一ProviderMetadata基类型。
Record(String, NullOr(Json))这条契约传递了两个硬性约束:
- 键必须是字符串:
caller、toolId这类键名必须可编码为字符串键; - 值必须是 JSON 值或
null:即NullOr(Json)。所谓 JSON 值,指的是字符串、数字、布尔值、对象、数组以及null;undefined不在其中。
也就是说,只要把toolId: undefined写入这个 Record,就已经违反了类型契约。而更致命的是,当这个"污染"后的响应被重新解码(decode)时,Schema 解码器会因undefined不是合法 JSON 值而抛出Expected JSON value——这正是 changeset 中描述的报错路径:"decoding the model's own response threwExpected JSON value"。
这里的"解码模型自身响应"值得展开:effect-smol 在把 Anthropic 原始响应映射为统一响应结构的同时,还会把metadata携带到响应 parts 中;当上层框架(例如Effect AI的会话/工具编排)对这些 parts 再次进行 Schema 校验或持久化时,就会触发解码。任何混入的undefined都会在此刻引爆Expected JSON value。
缺陷根因:non-streaming mapper 输出了undefined
本次缺陷发生在**非流式(non-streaming)**响应映射路径上。在该路径中,Anthropic 的原始tool_usecontent block 被映射为统一的tool-callpart 时,代码会尝试提取caller信息:
const caller = (part as any).caller const callerInfo = Predicate.isNotNullish(caller) ? { type: caller.type, toolId: "tool_id" in caller ? caller.tool_id : null } : undefined注意第 3 行:toolId: "tool_id" in caller ? caller.tool_id : null。当caller对象存在但其中没有tool_id字段时(例如caller.type === "direct"的场景),"tool_id" in caller为false,此时会显式返回null——这部分逻辑本身是正确的。
问题出在更早的源头:caller.tool_id本身可能是undefined。具体来说,如果调用方通过 provider options 以编程方式发起工具调用,并传入了一个caller对象,其中type存在而toolId字段缺失或为undefined(对应仓库中 AnthropicLanguageModel.ts 里options.caller.type === "code_execution_20250825" && Predicate.isNotNullish(options.caller.toolId)这类守卫的相反分支),那么从模型返回的part.caller.tool_id就会是undefined。
此时第 3 行中"tool_id" in caller判断为true(键存在),于是toolId直接取到undefined,并把它写进了metadata:
parts.push({ type: "tool-call", id: part.id, name: toolName, params, ...(Predicate.isNotUndefined(callerInfo) ? { metadata: { anthropic: { caller: callerInfo } } } : undefined) })最终metadata.anthropic.caller.toolId的值为undefined。这一行在构建 JSON 时会被 JSON.stringify 直接丢弃,但在 effect-smol 的响应 part 结构中,metadata是结构化数据对象,不是先序列化再传输的字符串——它会被ProviderMetadata的 Schema 直接解码,于是undefined触发Expected JSON value。
修复方式:undefined归一到null
修复本身非常轻量:确保caller.toolId的输出值永远是null而不是undefined,与流式(streaming)mapper 保持一致。
在流式路径中,caller信息的提取本来就正确地做了归一化。例如处理预置内容块(pre-populated content blocks)的代码:
const callerInfo = Predicate.isNotUndefined(part.caller) ? { type: part.caller.type, toolId: "tool_id" in part.caller ? part.caller.tool_id : null } : undefined以及流式content_block_start/content_block_stop路径中对contentBlock.caller的透传:
...(Predicate.isNotUndefined(contentBlock.caller) ? { metadata: { anthropic: { caller: contentBlock.caller } } } : undefined)这些流式路径使用的同样是Record(String, NullOr(Json))契约,但流式路径从未输出过undefined——要么不携带caller(整个 metadata 缺席),要么携带时toolId已确保为null。修复后的 non-streaming 路径与此对齐:当tool_id缺失时明确产出null,保证两种模式行为一致。
从代码结构看,修复点集中在 AnthropicLanguageModel.ts 中 non-streaming 响应处理分支(case "tool_use"分支)对callerInfo的构造逻辑,使toolId的取值从"可能为undefined"变为"必然为null或字符串"。具体实现位置随版本演进可能略有不同,但语义完全对应 changeset 的描述。
为什么是null而不是其它值
有人可能会问:既然undefined不合法,为什么不直接省略toolId字段,或者省略整个caller?
原因在于信息语义的差异:
null表达的是"键存在,但值显式为空/未知"——它告诉下游消费者"这个工具调用确实带有caller元数据,只是当前没有关联的toolId";- 省略字段则表达"不存在该元数据",下游若按"有
caller必有toolId"做非空断言会拿到undefined,可能再次触发类似问题; - 省略整个
caller则丢失了type(如direct或code_execution_20250825)信息,破坏了对工具调用来源类型的追踪。
同时,null恰好是 JSON 规范中的一等公民,也是NullOr(Json)联合类型的合法成员,解码器可以零成本接受它。这正是 changeset 强调"Emitnullinstead, matching the streaming mappers"的原因:统一协议边界上的表示,比局部规避报错更有价值。
修复的影响面与验证思路
从变更级别patch可以看出,这是一次向后兼容的修复:对外 API 签名不变,只修正了内部映射输出的取值边界,不会破坏依赖旧行为的调用方。受影响的功能点包括:
- 使用
@effect/ai-anthropic的非流式工具调用,尤其是携带caller元数据的场景(例如通过 provider options 指定caller.type与caller.toolId的编程式调用); - 上游使用
metadata.anthropic.caller做工具来源追踪、审计或持久化的应用——修复后它们读到的toolId要么是字符串、要么是null,不会出现undefined。
验证该修复是否生效,可以从两个层面进行:
- 回归验证:构造一个携带
caller元数据(toolId缺失)的非流式工具调用响应,经@effect/ai-anthropic映射后,断言metadata.anthropic.caller.toolId === null,并断言该 part 可以正常通过ProviderMetadata的 Schema 解码而不抛Expected JSON value; - 一致性验证:对同一输入分别走流式与非流式两条路径,比较产物 part 的
metadata结构是否一致——这正是本次修复追求的最终目标。
如果你正在基于 effect-smol 构建工具调用链路,建议在自己的非流式调用集成测试中加入一条"工具调用携带caller元数据"的用例,避免此类边界值问题在升级依赖后以隐蔽方式回归。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考