记录一次智能体运行,读懂执行事件
从真实 Agent 循环采集事件摘要,区分执行阶段、会话收束与应用等待返回。
记录一次智能体运行,读懂执行事件
界面显示“检查完成”,只能说明界面给出了这句话。要判断智能体是否真正执行了工具、结果有没有回到模型,需要检查运行轨迹。
这篇先建立一条最小证据链:预设模型流发起一次工具调用,真实 Agent 执行只读检查,再把结果交给第二轮模拟响应。记录的是事件摘要,不保存整个提示词和源码。
本系列的独立示例位于仓库 columns/coding-agent/examples/debugging-observability/,固定使用 pi-agent-core、pi-ai、pi-schema、pi-telemetry 1.5.0。先在该目录执行 npm install,下文命令也从该目录运行。示例不需要模型密钥,不读取个人配置。
先跑一条能够解释的轨迹
npm run demo
npm run demo -- --fail-tool正常路径应有两次 turn_start、一次工具开始和结束,以及两次请求摘要;第二次摘要中的 toolResults 为 1。失败路径仍会进入第二轮,因为工具错误本身也是需要交给模型的信息。
这里的模型回复来自预设流,真实运行的是 Agent 控制循环和工具,不是远程模型。它能验证事件与结果回填机制,不能证明模型会自主选择合适工具。
三个完成边界不能混用
SDK 订阅得到内存对象,JSON/RPC 得到序列化事件,它们不能盲目用同一套消息累加方式处理。底层 Agent 不承诺生成会话层事件,所以示例使用自己的应用结束标记,并明确记录来源。
只保留能定位问题的字段
下面的记录器保存事件类型、序号、相对时间和工具调用身份。它不复制事件中出现的任意字段,避免把提示词、工具参数或错误正文顺手带入日志。
export function object(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export interface EventRecord {
seq: number
atMs: number
type: string
turn: number
toolCallId?: string
toolName?: string
isError?: boolean
}
const knownTypes = new Set([
"agent_start",
"turn_start",
"message_start",
"message_update",
"message_end",
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
"turn_end",
"agent_end",
"agent_settled",
"lesson_run_end",
])
export class Recorder {
private readonly started: number
private seq = 0
private turn = 0
private readonly records: EventRecord[] = []
readonly counts: Record<string, number> = {}
readonly pendingTools = new Set<string>()
dropped = 0
toolErrors = 0
finalBoundary: "none" | "low-level-end" | "session-settled" | "application-end" = "none"
constructor(
readonly runId: string,
private readonly limit = 200,
private readonly clock = () => performance.now(),
) {
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new Error("limit 必须是正整数")
}
this.started = clock()
}
accept(raw: unknown): void {
const event = object(raw)
if (typeof event.type !== "string" || !knownTypes.has(event.type)) {
return
}
if (event.type === "agent_start") {
this.finalBoundary = "none"
}
if (event.type === "turn_start") {
this.turn += 1
}
this.counts[event.type] = (this.counts[event.type] ?? 0) + 1
const id = typeof event.toolCallId === "string" ? event.toolCallId.slice(0, 128) : undefined
if (event.type === "tool_execution_start" && id) {
// 避免异常流使 pending 集合无限增长。达到上限就拒绝继续投影。
if (this.pendingTools.size >= this.limit && !this.pendingTools.has(id)) {
throw new Error("未完成工具数量超过上限")
}
this.pendingTools.add(id)
}
if (event.type === "tool_execution_end" && event.isError === true) {
this.toolErrors += 1
}
if (event.type === "tool_execution_end" && id) {
this.pendingTools.delete(id)
}
if (event.type === "agent_end") {
this.finalBoundary = "low-level-end"
} else if (event.type === "agent_settled") {
this.finalBoundary = "session-settled"
} else if (event.type === "lesson_run_end") {
this.finalBoundary = "application-end"
}
this.records.push({
seq: ++this.seq,
atMs: Math.max(0, this.clock() - this.started),
type: event.type,
turn: this.turn,
toolCallId: id,
toolName: typeof event.toolName === "string" ? event.toolName.slice(0, 80) : undefined,
isError: typeof event.isError === "boolean" ? event.isError : undefined,
})
if (this.records.length > this.limit) {
this.records.shift()
this.dropped += 1
}
}
snapshot() {
return {
runId: this.runId,
toolErrors: this.toolErrors,
counts: { ...this.counts },
dropped: this.dropped,
finalBoundary: this.finalBoundary,
pendingTools: [...this.pendingTools],
events: this.records.map((record) => ({ ...record })),
}
}
}runId 和回合序号由示例定义,toolCallId 来自实际工具事件。RPC 命令 ID 用于关联命令响应,普通会话事件未必带同一个 ID;不能拿最近一个命令 ID 给所有并发事件强行归属。
尾部记录最多 200 条,丢弃量单独计数。总事件计数和工具错误数不从尾部重新推导,否则较早的失败会被“忘掉”。未完成工具集合也有上限,超限会拒绝继续投影;这适合严格离线检查。接入生产订阅时,应在观察边界处理记录器错误,不能让日志故障终止业务。
有序不等于完整
开始后缺少对应结束,可能是任务仍在运行,也可能是采集被截断。pendingTools 只表达观察到的未闭合调用,不能直接断言工具进程仍活着。
atMs 使用本机单调时钟,表示采集端收到事件的相对时间,包含派发、缓冲和传输影响。它适合定位间隔,不是 Provider 内部精确执行时间。不同进程的相对时钟也不能直接相减。
示例脚本只向自己的 stdout 输出报告;接到 Pi JSON/RPC 模式时,协议 stdout 只能写协议记录,调试日志应走 stderr 或独立收集通道。下一篇会沿着同一条轨迹检查模型实际收到了什么。
依据与版本
本地官方快照为 8efd314fa,相关包标记 1.3.0;配套代码另按已发布的 1.5.0 类型和运行行为核验。主分支文档持续变化,不能混作同一版本。