跳到主内容
创建时间:最后更新:作者:白川

排查工具调用与结果回填

沿工具调用身份检查选择、参数、拦截、执行、返回和模型可见结果,区分展示失败与业务失败。

排查工具调用与结果回填

模型说“我检查过了”,不等于工具确实执行;工具执行结束,也不等于结果已经发回下一轮模型。把这几步分开,才能确定问题应该修在工具、Agent 循环还是界面。

本文沿同一个 inspect-1 调用追踪,使用真实 Agent 循环执行一次只读检查。模型流预先给出工具调用,确保每次都能复现同一条路径。

本系列的独立示例位于仓库 columns/coding-agent/examples/debugging-observability/,固定使用 pi-agent-core、pi-ai、pi-schema、pi-telemetry 1.5.0。先在该目录执行 npm install,下文命令也从该目录运行。示例不需要模型密钥,不读取个人配置。

最小闭环完整代码

src/mock-agent.ts 包含模型、预设流、工具和订阅,直接运行不需要外部服务:

ts
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { Agent, type StreamFn } from "@earendil-works/pi-agent-core"
import {
  type AssistantMessage,
  createAssistantMessageEventStream,
  type Model,
} from "@earendil-works/pi-ai"
import { obj } from "@earendil-works/pi-schema"
import { Recorder } from "./recorder.js"
import { requestSummary } from "./request-summary.js"

const model: Model<"openai-responses"> = {
  id: "lesson-mock",
  name: "教学模拟模型",
  api: "openai-responses",
  provider: "openai",
  baseUrl: "https://example.invalid",
  reasoning: false,
  input: ["text"],
  contextWindow: 8192,
  maxTokens: 1024,
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}

export async function runMock(failTool = false) {
  const recorder = new Recorder("lesson-run")
  const requests: ReturnType<typeof requestSummary>[] = []
  let calls = 0
  let toolExecutions = 0
  const streamFn: StreamFn = (_model, context) => {
    requests.push(requestSummary(context))
    const first = calls++ === 0
    const message: AssistantMessage = {
      role: "assistant",
      api: "openai-responses",
      provider: "openai",
      model: model.id,
      timestamp: Date.now(),
      stopReason: first ? "toolUse" : "stop",
      content: first
        ? [{ type: "toolCall", id: "inspect-1", name: "lesson_inspect", arguments: {} }]
        : [{ type: "text", text: failTool ? "检查失败,请核对文件" : "检查完成" }],
      usage: {
        input: 0,
        output: 0,
        cacheRead: 0,
        cacheWrite: 0,
        totalTokens: 0,
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
      },
    }
    const stream = createAssistantMessageEventStream()
    stream.push({ type: "done", reason: first ? "toolUse" : "stop", message })
    stream.end()
    return stream
  }
  const agent = new Agent({
    streamFn,
    initialState: {
      model,
      tools: [
        {
          name: "lesson_inspect",
          label: "检查教学文件",
          description: "返回教学文件中的命名函数匹配",
          parameters: obj({}),
          async execute() {
            toolExecutions += 1
            if (failTool) {
              throw new Error("教学工具失败")
            }
            const source = await readFile(
              fileURLToPath(new URL("../fixtures/sample.ts", import.meta.url)),
              "utf8",
            )
            const names = [...source.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)].map(
              (match) => match[1],
            )
            return {
              content: [{ type: "text", text: JSON.stringify({ names }) }],
              details: { names },
            }
          },
        },
      ],
    },
  })
  const unsubscribe = agent.subscribe((event) => {
    recorder.accept(event)
  })
  try {
    await agent.prompt("检查教学文件")
    // 这是应用等待 prompt 返回的边界,不冒充 coding-agent 的 agent_settled。
    recorder.accept({ type: "lesson_run_end" })
    return {
      timeline: recorder.snapshot(),
      requests,
      toolExecutions,
      messages: agent.state.messages,
    }
  } finally {
    unsubscribe()
  }
}

正常流程中,工具从示例包的 fixture 文件读取内容,匹配到 greet。这是受限正则匹配,不是完整 TypeScript 语义分析,也不能说明代码没有安全问题。

第二次 streamFn 调用的摘要包含一个工具结果,证明真实循环进行了回填;测试还检查了结果文本中的函数名。预设的最终回复只写“检查完成”,不根据工具内容推理,所以不能用最终回复检验模型理解能力。

沿同一个调用 ID 逐段核对

观察到什么先检查哪里
没有工具调用消息
模型选择、活跃工具集合、任务与描述
有调用、没有执行开始
工具名解析、参数 Schema、事件拦截、权限决策
有开始、结束时 isError 为 true
业务异常、取消、资源访问、工具内部请求
执行结束但没有对应 toolResult
结果转换、消息追加、异常中断
toolResult 存在但下一轮请求缺失
上下文转换、过滤、会话分支或恢复
请求里有结果但 UI 没显示
客户端事件处理与渲染

并行工具会交错结束,不能把“上一条开始”与“下一条结束”直接配对。用真实调用 ID 对应,再按任务或回合分组;跨运行重复的 ID 还需要加上运行身份。

工具失败如何继续

执行 npm run demo -- --fail-tool,工具故意抛出教学错误。实际 Agent 将其作为失败结果继续交给第二轮模拟响应,而非让整个脚本立即崩溃。报告中会有 toolErrors: 1,未闭合工具集合最终为空。

这只说明工具执行协议能够表达失败。模型是否能解释错误、改参数或停止,要在真实模型联调时验证;不能从预设的失败回复推断恢复能力。

对齐不同结果字段

工具的 content 是模型可读结果,details 可供宿主渲染,支持结构化输出的工具还可能带 structuredContent。同一结果在多个位置出现时,应由同一份业务数据构建,避免界面成功、机器结果失败。

本例只返回文本与 details,故意保持最小闭环;结构化结果契约的详细实现见结构化输出。如果目标版本修改了工具结果字段或事件结构,先核对导出类型,再改测试夹具。

不靠重新执行来证明过去发生了什么

重复调用一次工具只能观察现在的结果,不能证明上次已经执行。只读检查较容易重做,写文件、提交或发消息则可能重复副作用。先检查已有调用 ID、结果消息和外部状态;需要重执行时使用独立任务身份和幂等规则。

依据与版本

本地官方快照为 8efd314fa,相关包标记 1.3.0;配套代码另按已发布的 1.5.0 类型和运行行为核验。主分支文档持续变化,不能混作同一版本。