排查工具调用与结果回填
沿工具调用身份检查选择、参数、拦截、执行、返回和模型可见结果,区分展示失败与业务失败。
排查工具调用与结果回填
模型说“我检查过了”,不等于工具确实执行;工具执行结束,也不等于结果已经发回下一轮模型。把这几步分开,才能确定问题应该修在工具、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 包含模型、预设流、工具和订阅,直接运行不需要外部服务:
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 逐段核对
并行工具会交错结束,不能把“上一条开始”与“下一条结束”直接配对。用真实调用 ID 对应,再按任务或回合分组;跨运行重复的 ID 还需要加上运行身份。
工具失败如何继续
执行 npm run demo -- --fail-tool,工具故意抛出教学错误。实际 Agent 将其作为失败结果继续交给第二轮模拟响应,而非让整个脚本立即崩溃。报告中会有 toolErrors: 1,未闭合工具集合最终为空。
这只说明工具执行协议能够表达失败。模型是否能解释错误、改参数或停止,要在真实模型联调时验证;不能从预设的失败回复推断恢复能力。
对齐不同结果字段
工具的 content 是模型可读结果,details 可供宿主渲染,支持结构化输出的工具还可能带 structuredContent。同一结果在多个位置出现时,应由同一份业务数据构建,避免界面成功、机器结果失败。
本例只返回文本与 details,故意保持最小闭环;结构化结果契约的详细实现见结构化输出。如果目标版本修改了工具结果字段或事件结构,先核对导出类型,再改测试夹具。
不靠重新执行来证明过去发生了什么
重复调用一次工具只能观察现在的结果,不能证明上次已经执行。只读检查较容易重做,写文件、提交或发消息则可能重复副作用。先检查已有调用 ID、结果消息和外部状态;需要重执行时使用独立任务身份和幂等规则。
依据与版本
本地官方快照为 8efd314fa,相关包标记 1.3.0;配套代码另按已发布的 1.5.0 类型和运行行为核验。主分支文档持续变化,不能混作同一版本。