看懂智能体怎样执行一个任务
通过执行事件跟踪模型请求、工具执行与结果回填,理解双层循环的结构、拦截语义与批次终止规则。
看懂智能体怎样执行一个任务
接入读取工具后,一次 prompt() 不再只是等待一段文字。模型可能先请求读取文件,程序完成读取,再把结果发给模型;模型看到结果后才生成解释。
本篇沿用工具接入里的 runtime.ts、read-tool.ts 和练习文件,通过事件把过程显示出来,不要求你先手写一个运行时。
本文的循环结构核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/agent-loop.ts。
先预测一次运行
用户:读取 sample.ts,解释 first([])
→ 模型请求 read_lesson_file
→ 程序读取文件
→ 文件文本成为工具结果
→ 模型收到工具结果,解释返回值这里通常有两次模型请求,但只有一次用户提交。一次运行中可以包含多轮模型与工具交互,不能把它们都称为"一次调用"而忽略具体对象。
双层循环的真实结构
底层循环的骨架是两层 while。外层处理"智能体本来要停了,但队列里还有活"的情况;内层处理工具调用和引导消息。
// packages/agent/src/agent-loop.ts
async function runLoop(...) {
let currentContext = initialContext;
let config = initialConfig;
let lastCompletedTurn: PrepareNextTurnContext | undefined;
let explicitContinuation = false;
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
// ...
}
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
explicitContinuation = false;
pendingMessages = followUpMessages;
continue;
}
// ...
break;
}
await emit({ type: "agent_end", messages: newMessages });
}内层循环的一次迭代,顺序是这样的:
prepareNextTurn(可选)
→ 轮询 steering 队列
→ turn_start
→ 把准备好的消息与排队消息逐条发出
→ prepareRequest(可选)
→ streamAssistantResponse ← 一次模型请求
→ 若是 error / aborted,直接收尾退出
→ executeToolCalls ← 执行这批工具调用
→ 把工具结果写回 context 与 newMessages
→ finishTurn(可选)
→ turn_end
→ 轮询 steering,决定内层是否继续几个关键函数的名字和职责:
把关键事件打印出来
创建 src/execution.ts:
import { readLessonFile } from "./read-tool.js"
import { createLabAgent, printReply } from "./runtime.js"
const agent = createLabAgent("读取练习文件后解释代码,不编造文件内容。", [readLessonFile])
let turns = 0
let limited = false
agent.finishTurn = async ({ message }) => {
turns += 1
if (turns >= 4 && message.content.some((item) => item.type === "toolCall")) {
limited = true
return { action: "end" }
}
}
const unsubscribe = agent.subscribe((event) => {
if (event.type === "turn_start") {
console.error("[turn_start]")
}
if (event.type === "message_end") {
console.error("[message_end]", event.message.role)
}
if (event.type === "tool_execution_start") {
console.error("[tool_start]", event.toolName, event.toolCallId)
}
if (event.type === "tool_execution_end") {
console.error("[tool_end]", event.isError)
}
if (event.type === "turn_end") {
console.error("[turn_end]", event.toolResults.length)
}
})
const timer = setTimeout(() => agent.abort(), 30_000)
try {
await agent.prompt("读取 sample.ts,解释 first([]) 的结果及原因。")
if (limited) {
throw new Error("达到运行轮次上限,任务尚未确认完成")
}
printReply(agent)
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
unsubscribe()
}运行:
node --env-file=.env --import tsx src/execution.ts下面是预期的关键事件顺序示意,不是某次真实服务的完整日志:
turn_start
message_end user
message_end assistant
工具开始 → 工具结束
message_end toolResult
turn_end 工具结果数量为 1
turn_start
message_end assistant
turn_end 工具结果数量为 0实际运行还会有增量、开始和结束等其他事件,也可能包含多个工具。这里仅打印理解闭环所需的部分。
工具结果以什么形状回到模型
createToolResultMessage 是结果回填的唯一入口,它决定了工具结果消息的字段:
// packages/agent/src/agent-loop.ts
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
return {
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
// Untyped tools (JS extensions) can return results without content; normalize
// so the null never enters session history or provider payloads.
content: finalized.result.content ?? [],
details: finalized.result.details,
usage: finalized.result.usage,
isError: finalized.isError,
...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
timestamp: Date.now(),
};
}字段可以这样对照:
toolCallId 存在的理由是:同一轮可能发出多个调用,不能仅靠工具名称猜测结果属于哪个请求。
durationMs 用的是单调时钟,在 executePreparedToolCall 里测量:
const startedAt = performance.now();
const elapsed = () => Math.round(performance.now() - startedAt);被 beforeToolCall 拦截的调用不会有 durationMs——工具压根没跑过。这正好可以用来区分"跑了但很快"和"根本没跑"。
为什么工具结果要回到模型
工具函数只知道怎样读取文件,不负责解释 first([])。模型只有收到读取结果,才有根据继续回答。
运行时负责组织这些消息,你不需要在 tool_execution_end 回调里再手动调用一次 prompt()。这样做会制造重入或重复执行,把已有循环又套上一层循环——运行中的 prompt() 会直接抛 "Agent is already processing a prompt."。
工具执行结束也不等于用户任务完成。它可能只是收集证据的中间步骤;最终解释是否正确,还要对照源码检查。
拦截:让工具不执行
beforeToolCall 在参数校验之后、工具执行之前运行。返回 { block: true } 会阻止执行:
// packages/agent/src/agent-loop.ts
if (beforeResult?.block) {
const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
if (beforeResult.terminate === true) {
result.terminate = true;
}
return {
kind: "immediate",
result,
isError: true,
};
}这段代码说明三件事:
第一,工具一次都不会执行。返回的是 kind: "immediate",executePreparedToolCall 根本不会被调用。
第二,写回一条 isError: true 的错误结果。模型看到的是"这次调用被拒绝了,原因是 X",可以据此调整。
第三,默认文案是 "Tool execution was blocked"。beforeResult.reason 为空时用这句默认的。想自定义就传 reason。
拦截的用途是防止危险动作,或者防止模型在同一个参数上反复试探:
// 用「工具名 + 参数」当 key 统计次数:同一参数反复试探才会被识别出来
const callCounts = new Map<string, number>()
agent.beforeToolCall = async ({ toolCall, args }) => {
const key = `${toolCall.name}:${JSON.stringify(args)}`
const count = (callCounts.get(key) ?? 0) + 1
callCounts.set(key, count)
// 超过阈值就挡下:execute 一次都不会跑
if (count > 2) {
return { block: true, reason: "同一参数调用次数过多", terminate: true }
}
// 返回 undefined 表示放行,钩子跑在参数校验之后、执行之前
return undefined
}注意这个钩子拿到的是已校验的参数(args: unknown),不是模型原始发来的 JSON。
批次终止:一个结果不够
工具结果上的 terminate 提示"这批之后不要再自动发一次请求"。但它的生效条件常被误解。
判定函数只有一行:
// packages/agent/src/agent-loop.ts
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}every 意味着这一批里每个已定稿的工具结果都必须标了 terminate: true。混合批次照常继续。
源码注释反复强调同一句话:"Early termination only happens when every finalized tool result in the batch sets this to true."
这带来一个实际的注意点:并行模式下,如果一个工具标了 terminate 而另一个没标,这一批不会终止。想终止,就得让这批所有工具都标。上面那个重复调用拦截的例子正好满足——它每次只挡一个调用,但拦下的结果带了 terminate: true。
不过在这个例子里,更稳的做法是同时用 finishTurn 控制轮次上限,因为 terminate 的语义是"批次级"的。
给循环设置结束边界
finishTurn 在助手消息和所有工具结果定稿之后、turn_end 之前运行。返回值有三种:
本例同时设置时间和轮次限制。30 秒定时器覆盖单次等待过久;finishTurn 在一轮助手与工具结果定稿后检查是否继续。二者解决的问题不同。
两个陷阱:
- 错误和中止的响应是硬退出,它们的
finishTurn决策会被忽略。 所以谓词要自己过滤:
agent.finishTurn = async ({ message }) => {
// 错误和中止是硬退出,这里返回 undefined 也轮不到它们,先显式挡掉
if (message.stopReason === "error" || message.stopReason === "aborted") return
return shouldStop(message) ? { action: "end" } : undefined
}finishTurn在下一次请求之后还会再跑。 无条件返回{ action: "continue" }会造成无限循环。
轮次上限设为四轮;第四轮仍有工具调用时,在该批工具结束后停止后续请求,并报告任务尚未确认完成。这不是工具执行前审批,也不会撤回已经完成的副作用。需要阻止某个动作时,应在执行前处理——也就是 beforeToolCall。
如果模型在第四轮已经给出文本答案,本例不会仅因轮数达到四而判为超限。错误与中止也不是成功结束:printReply() 会检查最终助手状态。
做一次失败观察
暂时移走 fixtures/sample.ts,观察失败结果是否回到模型。再恢复文件重试。重点是区分"工具失败""模型解释工具失败"和"模型请求本身失败",而不是要求每次都得到完全相同的事件次数。
当你能根据日志说出请求过什么工具、结果是否成功、模型是否继续、运行因何结束,就已经建立了后续调试需要的基本观察方式。更深入的请求准备、队列和并发机制放在运行机制专题。