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

看懂智能体怎样执行一个任务

通过执行事件跟踪模型请求、工具执行与结果回填,理解双层循环的结构、拦截语义与批次终止规则。

看懂智能体怎样执行一个任务

接入读取工具后,一次 prompt() 不再只是等待一段文字。模型可能先请求读取文件,程序完成读取,再把结果发给模型;模型看到结果后才生成解释。

本篇沿用工具接入里的 runtime.ts、read-tool.ts 和练习文件,通过事件把过程显示出来,不要求你先手写一个运行时。

本文的循环结构核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/agent-loop.ts。

先预测一次运行

text
用户:读取 sample.ts,解释 first([])
  → 模型请求 read_lesson_file
  → 程序读取文件
  → 文件文本成为工具结果
  → 模型收到工具结果,解释返回值

这里通常有两次模型请求,但只有一次用户提交。一次运行中可以包含多轮模型与工具交互,不能把它们都称为"一次调用"而忽略具体对象。

双层循环的真实结构

底层循环的骨架是两层 while。外层处理"智能体本来要停了,但队列里还有活"的情况;内层处理工具调用和引导消息。

typescript
// 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 });
}

内层循环的一次迭代,顺序是这样的:

text
prepareNextTurn(可选)
  → 轮询 steering 队列
  → turn_start
  → 把准备好的消息与排队消息逐条发出
  → prepareRequest(可选)
  → streamAssistantResponse        ← 一次模型请求
  → 若是 error / aborted,直接收尾退出
  → executeToolCalls                ← 执行这批工具调用
  → 把工具结果写回 context 与 newMessages
  → finishTurn(可选)
  → turn_end
  → 轮询 steering,决定内层是否继续

几个关键函数的名字和职责:

函数职责
streamAssistantResponse
发一次模型请求,把 AgentMessage[] 转成 Message[] 后流式接收
prepareToolCall
校验参数、跑 beforeToolCall,决定是执行还是立即返回错误
executePreparedToolCall
调用工具的 execute,捕获异常,测量耗时
createToolResultMessage
把工具结果包装成 role: "toolResult" 的消息

把关键事件打印出来

创建 src/execution.ts:

typescript
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()
}

运行:

bash
node --env-file=.env --import tsx src/execution.ts

下面是预期的关键事件顺序示意,不是某次真实服务的完整日志:

text
turn_start
message_end user
message_end assistant
工具开始 → 工具结束
message_end toolResult
turn_end 工具结果数量为 1
turn_start
message_end assistant
turn_end 工具结果数量为 0

实际运行还会有增量、开始和结束等其他事件,也可能包含多个工具。这里仅打印理解闭环所需的部分。

工具结果以什么形状回到模型

createToolResultMessage 是结果回填的唯一入口,它决定了工具结果消息的字段:

typescript
// 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(),
	};
}

字段可以这样对照:

字段作用
role
固定是 "toolResult",这是模型协议里的四种角色之一
toolCallId
指向发起这次调用的那个 tool call,同一批可能有多个调用
toolName
工具名,便于排查
content
发给模型的文本或图片;?? [] 兜底防止 null 进入历史
details
程序侧的任意结构,不发给模型
usage
工具自身消耗的用量
isError
这次调用是否失败
durationMs?
工具执行耗时,工具没真跑时不存在这个字段
timestamp
创建时刻的毫秒时间戳

toolCallId 存在的理由是:同一轮可能发出多个调用,不能仅靠工具名称猜测结果属于哪个请求。

durationMs 用的是单调时钟,在 executePreparedToolCall 里测量:

typescript
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 } 会阻止执行:

typescript
// 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。

拦截的用途是防止危险动作,或者防止模型在同一个参数上反复试探:

typescript
// 用「工具名 + 参数」当 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 提示"这批之后不要再自动发一次请求"。但它的生效条件常被误解。

判定函数只有一行:

typescript
// 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 之前运行。返回值有三种:

返回值含义
undefined
保持默认调度
{ action: "end" }
turn_end 之后立刻结束这次运行,不再轮询队列、不再准备请求
{ action: "continue" }
保证会有一次下一次 provider 请求

本例同时设置时间和轮次限制。30 秒定时器覆盖单次等待过久;finishTurn 在一轮助手与工具结果定稿后检查是否继续。二者解决的问题不同。

两个陷阱:

  1. 错误和中止的响应是硬退出,它们的 finishTurn 决策会被忽略。 所以谓词要自己过滤:
typescript
agent.finishTurn = async ({ message }) => {
  // 错误和中止是硬退出,这里返回 undefined 也轮不到它们,先显式挡掉
  if (message.stopReason === "error" || message.stopReason === "aborted") return
  return shouldStop(message) ? { action: "end" } : undefined
}
  1. finishTurn 在下一次请求之后还会再跑。 无条件返回 { action: "continue" } 会造成无限循环。

轮次上限设为四轮;第四轮仍有工具调用时,在该批工具结束后停止后续请求,并报告任务尚未确认完成。这不是工具执行前审批,也不会撤回已经完成的副作用。需要阻止某个动作时,应在执行前处理——也就是 beforeToolCall。

如果模型在第四轮已经给出文本答案,本例不会仅因轮数达到四而判为超限。错误与中止也不是成功结束:printReply() 会检查最终助手状态。

做一次失败观察

暂时移走 fixtures/sample.ts,观察失败结果是否回到模型。再恢复文件重试。重点是区分"工具失败""模型解释工具失败"和"模型请求本身失败",而不是要求每次都得到完全相同的事件次数。

当你能根据日志说出请求过什么工具、结果是否成功、模型是否继续、运行因何结束,就已经建立了后续调试需要的基本观察方式。更深入的请求准备、队列和并发机制放在运行机制专题。

参考资料