第一个智能体:跑通工具调用闭环
把模型请求、工具调用与结果回填组织成可验收的循环,并划清宿主与运行时的责任边界。
第一个智能体:跑通工具调用闭环
一个只会调 LLM 的程序,问不了你的数据库。这个缺口用工具补——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。
这段循环的形状很简单——发请求 → 收工具调用 → 执行工具 → 回填结果 → 再发请求。难的是它的边界:什么时候停、工具出错怎么办、模型卡在同一个调用里反复试探怎么办。
这篇用 Agent Core 把这个闭环跑通,并给它配上三份可复现的验收样本。读完你能写出一个带工具的智能体,说得出这次运行里发生了几个 turn、几次工具执行、最终是谁让它停的,并且能让取消在任何时刻打断运行而不留悬挂状态。
读它需要会用 TypeScript,并且已经能跑通一次模型调用(createModels() 或 ModelRuntime 都行)。你还要接受一个前提:智能体的运行不是"调一次函数等它返回",而是一串有明确语义的事件。
最终要跑通什么
- 一个带工具的智能体,能完整跑完"模型请求 → 工具执行 → 结果回填 → 再次请求"。
- 你能说出这次运行里发生了几个 turn、几次工具执行、最终是谁让它停的。
- 三类验收样本都能跑出预期结果:停止条件、工具抛错、重复调用。
- 取消能在任何时刻打断运行,且不留悬挂状态。
第 2 点是验收点。只看到"最后有回复"说明不了问题——那可能是模型自己停了,也可能是你的停止条件生效了,两者的行为完全不同。
先把事件序列背下来
Agent Core 的循环靠事件推进。下面是没有工具调用时一次 prompt() 的完整事件序列:
# 一次 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: [...] }带工具调用时,循环会继续:
# 第一个 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.subscribe() 的监听器按注册顺序被 await。agent_end 表示不会再有循环事件,但 await agent.waitForIdle() 和 await agent.prompt(...) 要等所有 agent_end 监听器跑完才 settle。所以监听器里的异步收尾工作是被计入这次运行的。
工具执行模式
工具调用有两种执行方式。
模式可以在两处设置:
- 全局:
new Agent({ toolExecution: "sequential" }) - 单个工具:
AgentTool上的executionMode: "sequential"
一条重要的覆盖规则:如果一批工具调用里有任意一个指向 executionMode: "sequential" 的工具,整批都按顺序执行,不管全局设置是什么。这不是 bug——它保证了有副作用的工具不会和别的工具并发。
terminate: true 是另一个开关。工具、被 beforeToolCall 拦截的结果、以及 afterToolCall 的返回值都可以带上它,含义是"提示这一批之后不要再自动发起下一次模型请求"。
注意它的判定条件:只有当这一批里所有已定稿的工具结果都设了 terminate: true 时,循环才真的提前停。 混合批次照常继续。
工具出错怎么回给模型
官方 Agent Core 文档的措辞很明确:工具失败时抛异常,不要把错误信息当普通内容返回。
// 工具内部的失败路径:抛异常而不是把错误文本当成正常结果返回
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 之前运行,正常响应、错误响应、被中止的响应都会调用它。
返回值有三种:
两个陷阱:
- 错误和中止的响应是硬退出,它们的 finishTurn 决策会被忽略。 所以谓词要自己过滤:
// finishTurn 是宿主对循环走向的正式控制点,返回 undefined 表示保持默认调度
agent.finishTurn = async ({ message }) => {
// 错误和中止是硬退出,这里返回 undefined 也轮不到它们,先显式挡掉
if (message.stopReason === "error" || message.stopReason === "aborted") return;
return shouldStop(message) ? { action: "end" } : undefined;
};finishTurn在下一次请求之后还会再跑。 无条件返回{ action: "continue" }会造成无限循环。
一个能离线跑的完整智能体
下面这个例子用 fauxProvider 作测试替身——它是一个内置的假 provider,可以脚本化地返回预设响应,不需要真实 API key,也就不会在你的练习目录里产生费用。
// 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();
}跑完你应该看到事件序列大致是,可以对照检查你的循环有没有多跑一轮:
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 之后立刻停。
// 计数放在 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 文件:
// 第一轮脚本化成一次注定失败的工具调用:路径后缀不对,工具会抛异常
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 拦住它:
// 用「工具名 + 参数」当 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,循环在这一批之后停止——不会再有第三次模型请求。这条样本同时验证了拦截和提前终止两个机制。
引导与后续输入
运行中的智能体可以接收两类排队输入。
// 工具还在跑的时候
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"。
谁负责什么
最后一行值得展开:工具是你写的代码,它在你的进程里跑,用的是你的用户权限。运行时只管编排,不管授权。 需要限制工具能碰什么,得由宿主自己在工具实现里做。
成功、失败、取消的可观察结果
取消的实现:
// 宿主自己准备一个 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,在长任务里验证取消后没有悬挂状态。