模型把数组写成了字符串:为什么只有一部分工具会报参数错误

你让 agent 用 clarify 一次问五个问题,它兴致勃勃地调用工具,然后收到一条类型错误;你换个模型重试,同样的提示换个写法就成了。更让人困惑的是,换成别的工具,一模一样的失误(把数组写成 JSON 字符串)却从不出错。
这类“时好时坏”的手感,多半不是模型的问题,而是工具调用走了两条不同的入口,其中一条会顺手修参数,另一条不会。2026-09-29 合并的补丁(PR #127781)把这两条路对齐了,下面说清它是怎么错的、修了什么、以及你在实测里还会遇到什么。
统一入口本来会替你修参数
Hermes 里所有注册表的工具调用都会经过同一个地方:
# model_tools.handle_function_call
args = coerce_tool_args(tool_name, args)
tools/arg_coercion.py 里的这段逻辑,是这个代码库中唯一负责“修参数类型”的地方。它会按工具自己的 JSON Schema 做修复:
"42"→42(字符串转数字)"true"→true(字符串转布尔)- JSON 编码的字符串 → 数组或对象(包括嵌套在容器里的情况)
- 单个值 → 单元素列表(schema 说要数组,模型给了标量)
它的策略是保守的:只在这一个修法有明确答案时才动手,有歧义就保留原值。这也是为什么它平时几乎不引人注意——修对了没人会说话,修不了才报错。
问题:内联工具根本不走这条路
有一批工具是“内联”的:它们由 agent 自己实现,注册在 agent/inline_tool_executors.py 的 INLINE_TOOL_EXECUTORS 表里,包括 clarify、todo_list、memory、session_search、read_terminal、drive_preview、delegate_task 等等。
问题在于,两条调度路径都是直接查这张表,而不是经过 handle_function_call:
- 顺序路径:
agent/tool_executor.py的_resolve_sequential_dispatch - 并发路径:
agent/agent_runtime_helpers.py的invoke_tool
于是这些工具拿到的是原始参数,从未被修过:
clarify收到一个以 JSON 字符串形式传来的questions数组,直接报错;todo_list遇到同一个错误,靠自己在内部塞了一段json.loads顶住。
一个靠共享层修、一个自己写补丁,第三个(memory、session_search)什么都不做——这就是“为什么只有一部分工具会报参数错误”的答案。
模型工具调用
├─ 普通工具 → handle_function_call → coerce_tool_args → 注册表工具
└─ 内联工具 → INLINE_TOOL_EXECUTORS → 工具拿到原始参数 ← 修复前的缺口
修法:在共享的那张表上包一层
补丁的思路很节制——不去改动两条调度路径,而是在它们共同读取的那张表上加一层包装:
# agent/inline_tool_executors.py
def _coerced(name: str, executor: InlineToolExecutor) -> InlineToolExecutor:
def _exec(agent, args: dict, ctx: InlineToolContext) -> Any:
return executor(agent, coerce_tool_args(name, args), ctx)
return _exec
INLINE_TOOL_EXECUTORS: Dict[str, InlineToolExecutor] = {
name: _coerced(name, executor) for name, executor in _RAW_INLINE_TOOL_EXECUTORS.items()
}
因为两条调度路径读的都是同一张表,一个包装就同时覆盖了顺序与并发两种执行方式。随后 tools/todo_tool.py 里那段手写的 json.loads 被删掉了——它的 schema 已经把 todos 声明成数组,共享修复能覆盖这个场景,留着反而是第二套真相。
顺带说明这个修复不覆盖什么:并发路径上的记忆 provider 工具没有注册表 schema,coerce_tool_args 拿不到类型信息,因此没有可修的对象——这和修复前完全一致,不是回归。修复只是让有 schema 的工具不再两套标准。
一个容易被忽略的细节:字段名也要对得上
coerce_tool_args 做的第一件事其实是“把参数名映射回注册表的原始名字”:
from tools.schema_sanitizer import unrename_tool_args
args = unrename_tool_args(schema.get("parameters"), args)
原因是对模型暴露的 schema 是消毒过的:provider 不接受的属性名会被重命名。模型看到的键名和注册表内部的键名不完全一样,所以修复必须先用注册表自己的 schema 把名字还原,再去按键做类型修复。顺序错了,修复就是对着一个不存在的字段白忙一场。
你怎么验证它真的修好了
补丁自带的验证命令可以直接跑(需要仓库检出):
scripts/run_tests.sh tests/tools/test_todo_tool_type_coercion.py tests/agent/ tests/tools/test_clarify_tool.py
日常使用上,你能观察到的变化是:同一个问法,在不同工具上的行为变得一致了。 以前“JSON 字符串当数组传”只有注册表工具会被原谅;现在 clarify、todo_list、memory、session_search 这些内联工具也一样。想自己复现,比较省事的办法是在会话里连续用 clarify 一次问多个问题,看它是否还能把多个问题一起抛出来——这正是修复前最先暴露的那个场景。
三条实践建议:
- 别把类型错误当成模型能力问题。 遇到“参数类型不对”时,先确认是不是某个工具收到了 JSON 字符串形态的数组;这轮修复之后这类噪音应当明显减少。
- 写 skill / 自定义提示时,按 schema 描述参数。 参数修复是兜底,不是设计目标;告诉模型“
questions是字符串数组”比指望它每次都写对更稳。 - 自己写的插件工具请照常声明 schema。 修复完全依赖 schema;没有 schema 的参数(如上面提到的记忆 provider 工具)永远拿不到自动修复。
这件事和我们之前写过的两篇是一脉相承的:结构化提问那篇 讲的是 clarify 一次问清多个问题能省下多少轮往返;工具 schema 瘦身那篇 讲的是把 schema 压小以省 token。而这一轮补的是第三块砖:参数在进入工具之前要有一致的修复与校验。想了解「待办」这条线,可以看 嵌套子任务那篇。
总结
- 注册表工具的参数修复只发生在
handle_function_call→coerce_tool_args;内联工具由两条调度路径直接查表,从未经过它。 - 于是同一类“JSON 字符串当数组传”的失误,
clarify直接报错、todo_list自己写json.loads兜住、memory与session_search什么都不做。 - 现在整张
INLINE_TOOL_EXECUTORS表被统一包装一层,顺序与并发两条路径同时受益;todo_list里的手写解析被删除。 - 修复依赖 schema,并且需要先把消毒过的字段名映射回注册表原名;没有 schema 的 provider 工具依然维持原行为。
- 你该做的:把类型错误当机制问题看待,写提示时按 schema 描述参数,自建工具记得声明 schema。
相关命令与配置可参考 工具与工具集文档 以及站点上的 hermes config。