流式工具调用被截断后,为何必须拒绝执行
CodeSmith v0.5.0 针对输出达到 token 上限的情况,拒绝修复和执行被截断的工具调用,防止残缺 JSON 被补全后误执行。文章还介绍流式处理的终止保证,并给出对应版本、源码路径与新增测试信息。
完整中文译文
流式输出断了,工具调用就一定会报错吗?CodeSmith 的这次修复揭示了更危险的情况:模型只写了一半参数,框架却把它补成合法 JSON,再交给 shell 执行。程序没有崩溃,执行的却可能是模型没写完的命令。
原文围绕 CodeSmith v0.5.0(commit 3a74c82f)展开:先禁止修补被截断的工具参数,再要求流结束时拿出明确证据。对接过模型流式 API、做过 Agent 工具调度的开发者,值得检查自己的代码里有没有同样的默认假设。
JSON 能修好,不代表命令可以执行
CodeSmith 原来有一套五级参数修复流程,包括补齐括号、删除尾逗号,把接近合法的 JSON 救回来。模型完整输出了内容,只是格式写错时,这种宽容处理能减少失败。
但输出撞上 token 上限,情况就变了。
例如,收到的参数停在 {"command": "rm -rf /tmp/leg。补上引号和右花括号,确实能得到一个可执行命令。可模型原本可能想写的是 /tmp/legacy-cleanup,后面也可能还有其他内容。解析器无法知道。
格式合法只能证明参数能被解析,不能证明模型已经表达完执行意图。
因此,P0-2 把修复行为变成显式策略:RepairAllowed 保留原来的完整修复流程;StrictOnly 只做严格解析,失败就返回 None。对于被输出上限截断的轮次,必须使用后者。测试用同一段残缺输入验证两者的区别:前者能救回,后者必须拒绝。
具体判断分两步。先规范化 stop_reason,命中 length 或 max_tokens,就认定该轮被输出上限截断;流处理器再严格解析工具参数,失败时把它标记为 Value::Null。调度阶段同时看到这两个条件,才拦截调用。
这不是整轮作废。同一轮里参数解析完整的其他调用仍可执行;没有出现这两种结束原因时,仍走原来的宽容路径。另外还预留了供应商豁免表,用来处理错误上报 length 的情况,但当前表是空的,作者选择等实际证据出现后再添加。
拦截位置也有讲究:它发生在批量调度阶段、循环保护器记账之前。模型没有写完的调用,不计作一次执行尝试,也不进入错误升级熔断统计;如果已经提前启动了后台任务,则立即取消。
反馈给模型的信息包含三件事:明确说明 NOT executed 并附上 stop_reason,要求完整重发该调用,建议参数过长时缩短或拆分。三个 shell 工具的说明也同步写入了这份约定。
连接结束,为什么还要证明它真的结束了
前面的拦截依赖一个前提:引擎能拿到可信的 stop_reason。P0-3 发现,这个前提并不成立。
原文分析的中间层是 rig-core 0.39。在 OpenAI-compatible 路径中,线上返回的 finish_reason 被压缩成 crate 私有的二值枚举,[DONE] 则被跳过;在 Anthropic 路径中,读取到的 stop_reason 字符串也被丢弃了。
更麻烦的是,SSE 连接静默断开时,适配器会补出 MessageDelta 和 MessageStop。底层内容没传完,上层却收到了正常结束通知。
P0-3 于是选择公开 API 中还能观察到的 Final 作为终止证据。按原文描述,rig 只有在 SSE 循环无错误关闭时才会生成这个载荷。如果流结束却从未出现 Final,适配器就必须向上返回可重试的连接中断错误,不能伪造 MessageStop。
引擎也增加了一层对应防御:其他 LlmClient 实现如果耗尽流却没发出 MessageStop,同样按中断处理,删除原来默认视作完成的兜底逻辑。
中断之后,再区分两种状态:
Empty表示没有收到任何内容,按原文方案透明重发。Partial表示已经收到部分内容,展示现有输出,不自动重试。
作者给出的理由是,部分输出已经产生计费,重试可能重复付费,还会丢掉半轮内容。不过,「没有收到内容就没有计费」并不能仅凭客户端状态证明,原文也没有展开供应商差异。
这里还有一个维护隐患:引擎靠错误文本把故障归入可重试的 Network 类别,两条错误字符串被测试逐字固定。由于测试的依赖图中没有 providers crate,只能复制字符串并注明保持同步。错误提示改几个字,就可能让自动重试退化成硬失败。
拿不到结束原因,就用哪些证据推导
中间层吞掉了真实结束原因,截断拦截就无法在实际请求中触发。P0-3 的补救是根据可见信息推导 stop_reason。
下面这段代码解决的是:在拿不到线上原值时,为调度器生成一个可用的结束原因。
pub(crate) fn derive_stop_reason(saw_tool_use: bool, usage: &Usage, max_tokens: u32) -> String {
if max_tokens > 0 && usage.output_tokens >= max_tokens {
"max_tokens".to_string()
} else if saw_tool_use {
"tool_use".to_string()
} else {
"end_turn".to_string()
}
}
要看第一条分支:max_tokens > 0,且 usage.output_tokens >= max_tokens,就优先返回 max_tokens,即使这一轮出现过工具调用。
后面的顺序也明确:观察到工具块就返回 tool_use,否则返回 end_turn。这让此前拿不到 max_tokens 的 rig 路径终于能触发 P0-2 的拦截。
作者认为,输出 token 达到请求上限是截断的有力证据。恰好自然结束在上限位置,也可能误判;但因为最终还要检查工具参数是否解析失败,参数完整的调用仍能通过。
同一次提交还处理了两个周边问题。一是 HTTP/2 回退:部分 CDN 节点通过 ALPN 协商到 HTTP/2 后,在协议层失败。实现遍历整个错误链,用包括 http2、goaway、preface 在内的五种标记匹配;由于没有直接依赖 h2 crate,无法做结构化匹配。命中一次后设置持久标记,后续请求直接使用 HTTP/1.1。
二是重试提示的结构化,包含尝试次数、倒计时秒数和原因。原文说明这部分采用进程级全局状态,目的是避免子代理后台重试干扰前台状态栏,但没有展开完整的隔离实现。
我的判断
这套改动最有价值的地方,是把「内容完整」「参数合法」「传输完成」拆开判断。对接模型时,这三件事很容易被一个成功返回混在一起,最后让执行层承担歧义。
有副作用的工具调用,应当先确认输出完整,再考虑容忍格式错误。
相比所有解析失败都重试,作者的方案保留了完整调用的执行机会;相比一律禁用 JSON 修复,它又保留了普通格式错误的容错能力。代价也清楚:残缺调用必须完整重发,长参数可能多花一轮;Partial 不自动重试,则把恢复工作留给了后续交互。
但这些仍有适用前提。用 token 用量推导结束原因,依赖供应商准确返回 usage,也依赖它的计数与请求上限可比较。作者关于截断总会停在上限的判断,没有提供跨供应商对照数据,不能直接当成通用协议保证。
Final 也是特定中间层版本提供的证据。它能弥补这里的适配缺口,却不能替代对底层协议的理解。HTTP/1.1 回放被作者描述为代价很小,但原文没有说明重发前是否可能已经生成内容、是否额外计费,也没有给出对照数据。
两次提交在同一天的一小时内落地,共修改 20 个文件,增加 1418 行、删除 55 行,新增 22 个测试。这说明作者做了成套修复,但原文没有给出线上故障率或性能变化,不能据此推断收益幅度。
能拿来做什么
- 如果你在 Node 服务里组装流式工具参数,把缓冲区严格解析与结束状态一起保存。确认撞上输出上限且参数残缺时,阻止执行,并明确要求模型完整重发。
- 给 Agent 的流处理补上「没有终止事件就耗尽」这一分支。适配器和引擎都应识别它,界面也应区分已完成与只收到部分内容。
- 检查模型 SDK 是否保留
finish_reason、stop_reason、usage 和终止事件。必须推导时,记录推导依据,并验证当前供应商的返回行为。 - 围绕执行边界做测试:同一残缺参数在
RepairAllowed与StrictOnly下结果应不同;静默断流不能被当成完成;提前启动的任务在拦截后必须取消。