全部文章

从不完整 JSON 到安全重试:流式 Agent 工具调用的防护

复盘 Agent 工具参数被截断后变成空对象的问题,在原始流、结构校验和工具执行之间建立可观察、可重试的边界。

一次 Agentic EDA 的工具调用失败,表面上是工具抱怨缺少必填参数。顺着调用链继续看,才发现工具收到的是 {},而模型原始输出并不是什么都没有。

原始参数已经包含了一部分内容,只是末尾缺少闭合括号。适配器无法解析时,把结果替换成了空对象。经过这次转换,最有价值的错误信息消失了:工具只能说“参数不对”,无法说明参数是在何处丢失的。

这次排查能确认的是原始参数不完整,以及空对象来自适配层的回退行为。它没有证明模型为什么没有给出完整内容,也不能据此认定网络中断或 token 用尽。修复首先针对能确定的边界:不让损坏的参数进入工具执行。

保留流里的原始参数

流式工具调用通常包含开始事件、多个参数增量,以及结束事件。事件名称因 SDK 而异,但有一个共同需求:按工具调用 ID 分开收集原文,避免并行调用互相混入。

下面是合成的事件示意,工具名与参数均用于演示:

call-a start: inspect_board
call-a delta: {"boardId":"demo",
call-a delta: "checks":["clearance"]
call-a end

拼接后的字符串缺少最外层的 }。如果只检查适配器最终给出的对象,就无法区分“模型真的输出了空对象”和“解析失败被替换成空对象”。

我在这一层更关心三个字段:调用 ID、工具名、原始参数是否通过解析。原文可作为受控的调试证据保留,但不应该无条件写进长期日志;工具参数里可能带有文件内容或业务数据。常规监控可以记录长度、摘要、错误位置和关联请求 ID,需要查看原文时再使用受限的诊断渠道。

三道检查各自解决一个问题

JSON 语法正确,不代表输入一定是业务对象。null、数字和数组都可能是合法 JSON。MDN 对 JSON.parse 的说明也明确区分了解析结果与语法错误。

因此我把分发前的检查拆成三个阶段:

  1. 语法检查:能否完整解析原始字符串。
  2. 结构检查:是否为对象、必填字段和类型是否符合工具 schema。
  3. 业务检查:对象引用是否存在、操作是否允许、当前状态能否执行。

下面的独立示例只演示前两层,实际工具还需要业务校验:

class MalformedToolArguments extends Error {}
class InvalidToolInput extends Error {}

function parseToolInput(raw: string): { boardId: string } {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    throw new MalformedToolArguments('工具参数不是完整 JSON');
  }
  if (
    typeof value !== 'object' || value === null || Array.isArray(value) ||
    !('boardId' in value) || typeof value.boardId !== 'string' ||
    value.boardId.trim() === ''
  ) {
    throw new InvalidToolInput('boardId 必须是非空字符串');
  }
  return { boardId: value.boardId };
}

这里没有尝试自动补括号。补全可能把一个未完成的嵌套结构改成另一种含义;对于会修改设计的工具,这种“修好语法”的做法尤其难以保证语义正确。让模型重新生成明确的参数,比静默猜测更容易追踪。

重试应该发生在副作用之前

参数还没有通过检查时,可以将这轮生成判定为失败,带着简短错误信息重新请求。需要设定明确的次数上限,持续失败后把错误返回给用户,不能在后台无限循环。

另一个问题是重试范围。如果一轮模型响应里有多个工具调用,不能执行到第三个才发现它的 JSON 损坏,然后重新执行整轮。前两个工具可能已经写入文件。更稳妥的边界,是先收集并校验本批待执行参数,再进行分发;已经执行的工具需要独立记录结果。

以下几种失败不能混为一类:

情况 处理重点
参数不完整,工具尚未执行 有上限地重新生成参数
参数完整,但业务校验拒绝 把具体原因交回规划过程,避免原样重试
工具执行超时,写入状态未知 先查询结果或使用幂等标识,再决定是否重试
上游明确拒绝请求 保留状态码和原因,停止假装成功

尤其不能把“没有收到成功响应”等同于“没有产生副作用”。对于创建、删除和导出等操作,幂等键、操作记录和结果查询往往比重试次数更重要。

测试要证明工具没有被误调用

历史修复中有两组很有价值的用例:第一次输出不完整,下一次恢复;每一次都不完整,最终达到上限。前者检查恢复路径,后者检查终止条件。

我还会观察工具执行次数。只断言最终错误类型还不够,损坏参数对应的工具必须一次也没有执行。对于多工具响应,需要加入“一个合法、一个损坏”的场景,检查是否在校验完成之前产生了写入。

另外应覆盖合法的空对象、合法但类型错误的 JSON、上游错误事件,以及已执行工具后的不确定响应。这些情况可能显示相似的用户提示,却需要完全不同的恢复方式。

这次防护改善的是错误定位和执行边界,没有解决所有生成失败,也没有查明所有上游中断原因。但它让后续排查保留了原始证据,并让工具在输入明确之前保持不执行。这比把所有异常都压成一个“参数缺失”更有助于维护 Agent。