
工具调用消息有了 call_id,并不表示结果已经配好。重复 ID 会让两个调用争同一个结果;结果指向不存在的 ID,则成了孤儿;调用名称或结果字段缺失,也不该被一个「ID 找到了」掩盖。这里把调用与返回值放进本机写死的消息样例,用一段小校验器逐项检查。没有调用模型,也没有测模型本身会不会生成这些情况。
配对键只回答了一半
一条调用至少有标识和名称,返回值也得带回同一个标识。为了避开不同接口字段命名的差异,脚本使用一份归一化的桩结构:assistant 消息的 tool_calls 项写成 { call_id, name },结果消息写成 { role: 'tool', call_id, result }。这不是任何厂商的线上请求格式,也不声称所有接口都使用这些字段;只把需要检查的关联关系摊平。
唯一 ID 是必要条件。它让返回值能定位到一条调用,但名称仍决定这条调用究竟要交给哪个工具处理。返回值字段也必须存在;空值是否允许,应由应用自己的数据约定决定,不能跟字段完全缺失混为一谈。脚本以「字段存在」为准,所以 result: null 也算有结果字段。
错误输入会在哪里露出来
脚本没有连模型、工具服务或网络,只遍历消息数组并收集错误。判断顺序是先登记 assistant 的调用,再读 tool 结果:ID 为空就报告缺失;同一批里已有相同 ID 就报告重复;结果找不到先前的调用,就报告孤儿;最后再找没有结果的调用。名称和结果字段另作检查,不由 ID 是否命中代替。
有一个容易忽略的区别:同一个工具名称可以出现多次,名称不是关联键。示例里两次 lookup 分别拿到 c1、c2,都能各自配回结果;把 ID 重复了,即使名字不同也会失败。我不建议用 name 去配结果,因为并行调用同一工具时,它无法指出结果属于哪一次调用。
下面是脚本实际输出,正例是一条合法配对以及同名工具的两次调用;其余输入逐项触发对应错误。输出只证明校验器对这些固定样例的行为,不代表模型生成或任何服务端的拒绝行为。
node v26.8.1
本地桩校验器;输入为脚本内写死的消息,不调用模型或网络。
PASS valid pair; same name may be called twice
FAIL duplicate call_id
duplicate call_id: dup
FAIL missing call_id
message[0] call has missing call_id
FAIL missing result call_id
message[0] result has missing call_id
FAIL missing name
call c3 has missing name
FAIL orphan result
result references unknown call_id: ghost
FAIL duplicate result
duplicate result for call_id: c4
FAIL call without result
call c5 (lookup) has no result
FAIL missing result field
result for c6 has missing result field
校验边界在哪里
脚本按消息顺序处理,因此结果必须出现在它引用的调用之后。它能发现「结果在调用之前」这种顺序错误,但不会验证工具参数是否符合 schema、结果内容是否可信,也不检查调用名称是否在允许清单里。这些属于另外的边界,不能因为配对检查通过就视为安全。
重复 ID 的场景也值得单独留一条断言:若只把 ID 放进 Map,后一次写入会覆盖前一次,校验器可能只看到一个调用。实际脚本先检查 Map 是否已有该键,再登记,所以重复会显式报错。孤儿结果会报错;同一调用收到第二个结果也会报错。调用悬空时同样会留下错误,只做单向的「结果能不能找到调用」,会漏掉后两种情况。
边界因此可复核:每个有名调用只登记一次,结果必须指向已登记且尚未配对的 ID;处理结束时仍未配对的 ID 都是缺结果。验证代码只做结构层判断,调用内容及重试副作用仍得由其他规则约束。
越早校验越便宜:在消息进入历史前拒绝重复或缺失字段,日志还能指回产生问题的那条消息;等请求送出后才发现孤儿结果,排查就得回看消息组装和结果回填逻辑。边界检测不替代调用执行日志,但能把结构无效的对话挡在更窄的位置。
这份校验器适合放在消息历史交给后续逻辑之前,作为结构完整性检查。通过只代表这几项本地规则成立,不代表请求一定能被某个 API 接受。若系统采用其他字段名,先在适配层映射到同一套内部结构,再复用这类检查;不要把桩模型的字段当成通用协议规范。