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

第一个智能体:跑通工具调用闭环

把模型请求、工具调用与结果回填组织成可验收的循环,并划清宿主与运行时的责任边界。

第一个智能体:跑通工具调用闭环

一个只会调 LLM 的程序,问不了你的数据库。这个缺口用工具补——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。

这段循环的形状很简单——发请求 → 收工具调用 → 执行工具 → 回填结果 → 再发请求。难的是它的边界:什么时候停、工具出错怎么办、模型卡在同一个调用里反复试探怎么办。

这篇用 Agent Core 把这个闭环跑通,并给它配上三份可复现的验收样本。读完你能写出一个带工具的智能体,说得出这次运行里发生了几个 turn、几次工具执行、最终是谁让它停的,并且能让取消在任何时刻打断运行而不留悬挂状态。

读它需要会用 TypeScript,并且已经能跑通一次模型调用(createModels() 或 ModelRuntime 都行)。你还要接受一个前提:智能体的运行不是"调一次函数等它返回",而是一串有明确语义的事件。

最终要跑通什么

  1. 一个带工具的智能体,能完整跑完"模型请求 → 工具执行 → 结果回填 → 再次请求"。
  2. 你能说出这次运行里发生了几个 turn、几次工具执行、最终是谁让它停的。
  3. 三类验收样本都能跑出预期结果:停止条件、工具抛错、重复调用。
  4. 取消能在任何时刻打断运行,且不留悬挂状态。

第 2 点是验收点。只看到"最后有回复"说明不了问题——那可能是模型自己停了,也可能是你的停止条件生效了,两者的行为完全不同。

先把事件序列背下来

Agent Core 的循环靠事件推进。下面是没有工具调用时一次 prompt() 的完整事件序列:

text
# 一次 prompt() 的事件序列,缩进表示嵌套层级
prompt("Hello")
├─ agent_start
├─ turn_start
├─ message_start   { message: userMessage }      // 你的输入
├─ message_end     { message: userMessage }
├─ message_start   { message: assistantMessage } // 模型开始响应
├─ message_update  { message: partial... }       // 流式分片
├─ message_end     { message: assistantMessage } // 完整回复
├─ turn_end        { message, toolResults: [] }
└─ agent_end       { messages: [...] }

带工具调用时,循环会继续:

text
# 第一个 turn 结束(turn_end)之后循环不会停,而是接着开第二个 turn
prompt("Read config.json")
├─ agent_start
├─ turn_start
├─ message_start/end  { userMessage }
├─ message_start      { assistantMessage with toolCall }
├─ message_update...
├─ message_end        { assistantMessage }
├─ tool_execution_start  { toolCallId, toolName, args }
├─ tool_execution_update { partialResult }           // 工具自己流式上报时才有
├─ tool_execution_end    { toolCallId, result }
├─ message_start/end  { toolResultMessage }
├─ turn_end           { message, toolResults: [toolResult] }
│
├─ turn_start                                        // 下一轮
├─ message_start      { assistantMessage }           // 模型看到工具结果后回答
├─ message_update...
├─ message_end
├─ turn_end
└─ agent_end

这里有一个关键分界:turn 和 agent 不是一回事。

概念含义
一个 turn
一次助手回复 + 那次回复产生的所有工具调用与工具结果
一个 agent run
从你提交消息到循环真正结束,可能包含多个 turn

模型调用工具时会停下等结果,拿到结果后再发起下一次请求——这就是第二个 turn。循环的持续条件是"还有工具调用要处理"或"队列里有新消息"。

还有一个必须记住的细节:Agent.subscribe() 的监听器按注册顺序被 await。agent_end 表示不会再有循环事件,但 await agent.waitForIdle() 和 await agent.prompt(...) 要等所有 agent_end 监听器跑完才 settle。所以监听器里的异步收尾工作是被计入这次运行的。

工具执行模式

工具调用有两种执行方式。

模式行为
parallel(默认)
预检阶段串行跑参数校验和 beforeToolCall,然后并发执行允许的工具;每个工具一完成就发 tool_execution_end;工具结果消息和 turn_end.toolResults 仍按助手消息里的原始顺序排列
sequential
一次一个地准备、执行、收尾工具调用

模式可以在两处设置:

  • 全局:new Agent({ toolExecution: "sequential" })
  • 单个工具:AgentTool 上的 executionMode: "sequential"

一条重要的覆盖规则:如果一批工具调用里有任意一个指向 executionMode: "sequential" 的工具,整批都按顺序执行,不管全局设置是什么。这不是 bug——它保证了有副作用的工具不会和别的工具并发。

terminate: true 是另一个开关。工具、被 beforeToolCall 拦截的结果、以及 afterToolCall 的返回值都可以带上它,含义是"提示这一批之后不要再自动发起下一次模型请求"。

注意它的判定条件:只有当这一批里所有已定稿的工具结果都设了 terminate: true 时,循环才真的提前停。 混合批次照常继续。

工具出错怎么回给模型

官方 Agent Core 文档的措辞很明确:工具失败时抛异常,不要把错误信息当普通内容返回。

typescript
// 工具内部的失败路径:抛异常而不是把错误文本当成正常结果返回
execute: async (toolCallId, params, signal, onUpdate) => {
  if (!existsSync(params.path)) {
    throw new Error(`File not found: ${params.path}`);
  }
  // 成功时才返回 content,运行时只在这里补 details
  return { content: [{ type: "text", text: "..." }] };
}

抛出的异常会被运行时捕获,然后作为 isError: true 的工具结果报告给模型。这样模型看到的是一个"这次调用失败了,原因是 X",而不是一次成功但内容奇怪的调用——模型据此可以修正参数重试。

反过来,如果你把错误写进 content 里返回,模型会把它当成正常结果,循环也就不会自然纠错。

停止条件:finishTurn

finishTurn 是宿主控制循环走向的正式接口。它在助手消息和所有工具结果定稿之后、turn_end 之前运行,正常响应、错误响应、被中止的响应都会调用它。

返回值有三种:

返回值含义
undefined
保持默认调度
{ action: "end" }
紧接着 turn_end 之后立刻结束这次运行,不再轮询 steering / follow-up 队列,也不再准备下一次请求
{ action: "continue" }
保证会有一次下一次 provider 请求。如果工具结果、steering 或 follow-up 已经安排了这次请求,就由它们满足,不额外发请求;否则循环用当前上下文再发一次

两个陷阱:

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

一个能离线跑的完整智能体

下面这个例子用 fauxProvider 作测试替身——它是一个内置的假 provider,可以脚本化地返回预设响应,不需要真实 API key,也就不会在你的练习目录里产生费用。

typescript
// loop.ts
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core";
import {
	createModels,
	fauxAssistantMessage,
	fauxProvider,
	fauxText,
	fauxToolCall,
	Type,
} from "@earendil-works/pi-ai";

const readFileParameters = Type.Object({
	path: Type.String({ description: "相对工作目录的文件路径" }),
});

const readFileTool: AgentTool<typeof readFileParameters> = {
	name: "read_file",
	label: "读取文件",
	description: "读取工作目录下的文本文件",
	parameters: readFileParameters,
	execute: async (_toolCallId, params) => {
		// 失败要抛异常,运行时才会把它作为 isError: true 回给模型
		if (!params.path.endsWith(".md")) {
			throw new Error(`只支持读取 .md 文件,收到:${params.path}`);
		}
		return {
			content: [{ type: "text", text: `# ${params.path}\n(示例内容)` }],
			details: { path: params.path },
		};
	},
};

const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const model = faux.getModel();

// 第一轮:模型要求读文件
faux.setResponses([
	fauxAssistantMessage([fauxToolCall("read_file", { path: "README.md" })], { stopReason: "toolUse" }),
]);
// 第二轮:模型看到工具结果后给出最终回答
faux.appendResponses([fauxAssistantMessage([fauxText("README.md 的内容是……")])]);

const agent = new Agent({
	initialState: {
		systemPrompt: "你是一个仓库助手,需要文件内容时调用 read_file。",
		model,
		tools: [readFileTool],
	},
	streamFn: models.streamSimple.bind(models),
	toolExecution: "parallel",
});

const events: string[] = [];
const unsubscribe = agent.subscribe((event) => {
	events.push(event.type);
	if (event.type === "tool_execution_end") {
		console.error(`[工具] ${event.toolName} isError=${event.isError}`);
	}
});

try {
	await agent.prompt("读一下 README.md");
	console.error("[事件]", events.join(" -> "));
	const last = agent.state.messages.at(-1);
	if (last?.role === "assistant") {
		console.log(last.content.map((b) => (b.type === "text" ? b.text : "")).join(""));
	}
} finally {
	unsubscribe();
	agent.abort();
	await agent.waitForIdle();
}

跑完你应该看到事件序列大致是,可以对照检查你的循环有没有多跑一轮:

text
agent_start -> turn_start -> message_start -> message_end -> message_start
-> message_update -> message_end -> tool_execution_start -> tool_execution_end
-> message_start -> message_end -> turn_end -> turn_start -> message_start
-> message_update -> message_end -> turn_end -> agent_end

三类验收样本

验收样本的价值在于:它们把"循环会停"从一句期望变成一条可复现的断言。 下面三个都可以离线跑。

样本一:停止条件

模型拿到工具结果后还想继续时,finishTurn 返回 { action: "end" },循环在 turn_end 之后立刻停。

typescript
// 计数放在 Agent 外部:finishTurn 每次调用都要能读到上一轮的累计值
let turnCount = 0;

agent.finishTurn = async ({ message }) => {
	// 错误和中止是硬退出,决策会被忽略,这里先挡掉
	if (message.stopReason === "error" || message.stopReason === "aborted") return;

	turnCount += 1;
	// 跑满两轮就收工:条件有界,所以不会造成无限继续
	return turnCount >= 2 ? { action: "end" } : undefined;
};

预期结果:agent_end 之后不再有 turn_start;agent.state.messages 里最后一条是助手消息,不是工具结果。

样本二:工具抛错

用上面的 read_file 工具,让模型去读一个 .txt 文件:

typescript
// 第一轮脚本化成一次注定失败的工具调用:路径后缀不对,工具会抛异常
faux.setResponses([
	fauxAssistantMessage([fauxToolCall("read_file", { path: "notes.txt" })], { stopReason: "toolUse" }),
]);
// 第二轮模拟模型读到错误后自己纠正,这里直接给出最终文本
faux.appendResponses([
	fauxAssistantMessage([fauxText("这个文件不是 .md,我换一个。")]),
]);

预期结果:

  • tool_execution_end 事件的 isError 为 true。
  • 工具结果消息的内容里带着那句错误文本。
  • 循环没有停——模型拿到错误后可以自己纠正。
  • 想看模型怎么纠正,就把第二次响应也脚本化成另一个工具调用,第三次再给文本回答。

对照实验:如果你把 throw new Error(...) 换成 return { content: [{ type: "text", text: "只支持 .md" }], details: {} },isError 会是 false,模型会以为读取成功。这是同一段逻辑里最容易写错的一处。

样本三:重复调用

模型在同一个参数上反复试探时,用 beforeToolCall 拦住它:

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) {
		// block 之后结果按错误处理;terminate 提示这一批之后不要再发请求
		return { block: true, reason: "同一参数调用次数过多", terminate: true };
	}
	// 返回 undefined 表示放行,钩子跑在参数校验之后、执行之前
	return undefined;
};

预期结果:第三次相同调用被拦下,tool_execution_end 的 isError 为 true,并且因为这一批唯一的工具结果带 terminate: true,循环在这一批之后停止——不会再有第三次模型请求。这条样本同时验证了拦截和提前终止两个机制。

引导与后续输入

运行中的智能体可以接收两类排队输入。

方法何时进入
steer()
当前助手轮次之后进入——"这一轮工具跑完了,但接着来"
followUp()
智能体把待办工作都做完之后进入
typescript
// 工具还在跑的时候
agent.steer({ role: "user", content: "停,先看 config.json。", timestamp: Date.now() });

// 等智能体做完手上的事
agent.followUp({ role: "user", content: "做完再总结一下。", timestamp: Date.now() });

投递顺序是确定的:检测到 steering 消息时,当前助手消息的所有工具调用都已完成,steering 消息被注入,下一轮模型才响应。follow-up 消息只在既没有工具调用、也没有 steering 消息时才被检查——有任何一种在排队,它就继续等。

两种队列各自有投递模式,"one-at-a-time"(默认,每轮只投一条)和 "all"(一次性全投)。

在 SDK 层对应的是 session.steer(text) 和 session.followUp(text),两者都返回 "queued" 或 "handled"。

谁负责什么

职责由谁承担说明
系统提示、工具清单、模型选择
宿主
通过 initialState 或运行时赋值
什么时候停
宿主
finishTurn 返回 { action: "end" }
哪些工具调用可以被拦下
宿主
beforeToolCall 返回 { block: true, reason }
工具结果要不要改写
宿主
afterToolCall 可覆盖 content / details / isError
每次请求前重建上下文
宿主
prepareRequest
自定义消息类型如何转成 LLM 消息
宿主
convertToLlm
循环怎么跑、事件怎么发
运行时
Agent Core
参数校验、工具执行、结果回填
运行时
运行时捕获异常并标 isError: true
工具实际碰到的文件、子进程、网络
宿主进程
工具在宿主进程里执行,继承宿主权限

最后一行值得展开:工具是你写的代码,它在你的进程里跑,用的是你的用户权限。运行时只管编排,不管授权。 需要限制工具能碰什么,得由宿主自己在工具实现里做。

成功、失败、取消的可观察结果

场景判据你应该看到
正常完成
agent_end 出现
最后一个 turn 的 turn_end.toolResults 里有工具结果,state.messages 最后是助手消息
模型自己决定不用工具
turn_end.toolResults 为空
只有一个 turn,直接 turn_end → agent_end
被 finishTurn 停掉
turn_end 后紧跟 agent_end
中间没有新的 turn_start
工具失败
tool_execution_end.isError === true
工具结果消息带错误文本,循环继续
工具被拦截
tool_execution_end.isError === true
beforeToolCall 的 reason 出现在结果里
模型请求失败
助手消息 stopReason === "error"
turn_end 后直接 agent_end,不再有下一轮
被取消
助手消息 stopReason === "aborted"
同样直接结束;agent.abort() 后 waitForIdle() 会 resolve

取消的实现:

typescript
// 宿主自己准备一个 AbortController,超时和用户取消都走同一条路
const controller = new AbortController();
// 交给宿主自己的超时逻辑
setTimeout(() => controller.abort(), 30_000);

try {
	// prompt 会一直等到这次运行真正结束,中途的取消由 agent.abort() 触发
	await agent.prompt("做一个很长的任务");
} finally {
	agent.abort();          // 停掉当前操作
	await agent.waitForIdle(); // 等到真正空闲:只调 abort 不等,状态可能还没落定
	unsubscribe();
}

abort() 停掉当前操作,waitForIdle() 等它收尾。这两个要一起用:只调 abort() 不等待,状态可能在你的下一次读取时还没落定。

你现在能做什么

  • 用 fauxProvider 写一个不花钱的带工具智能体,并把完整事件序列打印出来对照本文的图。
  • 给智能体装一个 finishTurn,用有界的外部计数做终止条件,并验证 agent_end 之后不再有 turn_start。
  • 让一个工具在参数不合法时抛异常,确认 tool_execution_end.isError 为 true 且循环继续;再把同一处改成返回文本,确认 isError 变成 false。
  • 用 beforeToolCall 统计同一参数的调用次数,拦住第三次并让它提前终止。
  • 把 abort() 和 waitForIdle() 放进 finally,在长任务里验证取消后没有悬挂状态。

排障速查

现象检查点处理
工具报错了但模型当成功
工具实现
错误要 throw,不要写进 content 返回
模型在同一个调用上打转
重复调用
用 beforeToolCall 计数并 block
循环停不下来
finishTurn
检查是不是无条件返回了 { action: "continue" }
工具之间互相干扰
执行模式
有副作用的工具设 executionMode: "sequential",它会让整批串行
提前终止没生效
terminate
只有整批工具结果都带 terminate: true 才停
finishTurn 不生效
响应类型
错误和中止响应是硬退出,决策被忽略
事件少了几条
订阅时机
subscribe() 要在 prompt() 之前;监听器是按顺序 await 的
收到 steering 却没生效
投递时机
steering 在当前助手轮次之后注入,不是立刻打断工具
取消后状态还在变
等待
abort() 之后必须 await waitForIdle()
离线跑不起来
测试替身
fauxProvider 的响应队列空了会返回 "No more faux responses queued"

参考资料