文章目录
-
- 1. 先分清四个角色,别把"点菜"当成"上菜"
-
- 1.1 工具定义 AgentTool:贴在门口的菜单
- 1.2 调用请求 ToolCall:模型的"口头承诺"
- 1.3 返回值 AgentToolResult:交上来的作业
- 1.4 结果消息 ToolResultMessage:贴上姓名贴的作业
- 2. 把工具注册进 Agent:以 read 为例
- 3. 从工具说明到执行结果,再回到模型
-
- 3.1 工具是怎么混进模型视野的
- 3.2 模型返回的不是答案,是"请求"
- 3.3 Agent 按名字找人,用校验过的参数干活
- 3.4 返回值怎么变成一条能对上号的消息
- 3.5 工具都干完了,为什么还要再问一次模型
- 4. 跑一把 Lab,验证工具真的在干活
- 5. 小结

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,
传送门https://blog.csdn.net/qq_74013365
先说个有点扎心的事实:很多人造的 Agent,是个嘴强王者。
你问它"帮我读一下 note.txt",它能给你从"文件系统的哲学意义"一路聊到"为什么换行符要分两套标准",聊得你热泪盈眶——但就是读不到文件。因为它没手。
模型这玩意儿就这样:上知天文下知地理,可惜是个云玩家。它知道全世界,就是碰不到你硬盘上的任何一个字节。今天要干的事,就是给它装一双手——工具调用。
先声明,我不是在骂模型。模型是真的强,强到能把一本十万字的文档总结成三句话,然后让你去猜它漏了哪九万九千字。
一句话流程:模型负责点单,Agent 负责传话,工具负责做饭。调用方还是老规矩,只喊一次 prompt()。
流程是真的简单,但别高兴太早——流程越简单的地方,坑往往越深。就像外卖 App 界面越简洁,你要等的配送时间越玄学。
工具
模型
Agent
调用方
工具
模型
Agent
调用方
#mermaid-svg-3Ae9sRDmioAvmMdR{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ae9sRDmioAvmMdR .error-icon{fill:#552222;}#mermaid-svg-3Ae9sRDmioAvmMdR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Ae9sRDmioAvmMdR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Ae9sRDmioAvmMdR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Ae9sRDmioAvmMdR .marker.cross{stroke:#333333;}#mermaid-svg-3Ae9sRDmioAvmMdR svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Ae9sRDmioAvmMdR p{margin:0;}#mermaid-svg-3Ae9sRDmioAvmMdR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Ae9sRDmioAvmMdR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Ae9sRDmioAvmMdR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3Ae9sRDmioAvmMdR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3Ae9sRDmioAvmMdR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3Ae9sRDmioAvmMdR .sequenceNumber{fill:white;}#mermaid-svg-3Ae9sRDmioAvmMdR #sequencenumber{fill:#333;}#mermaid-svg-3Ae9sRDmioAvmMdR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3Ae9sRDmioAvmMdR .messageText{fill:#333;stroke:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Ae9sRDmioAvmMdR .labelText,#mermaid-svg-3Ae9sRDmioAvmMdR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .loopText,#mermaid-svg-3Ae9sRDmioAvmMdR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Ae9sRDmioAvmMdR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3Ae9sRDmioAvmMdR .noteText,#mermaid-svg-3Ae9sRDmioAvmMdR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3Ae9sRDmioAvmMdR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Ae9sRDmioAvmMdR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Ae9sRDmioAvmMdR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Ae9sRDmioAvmMdR .actorPopupMenu{position:absolute;}#mermaid-svg-3Ae9sRDmioAvmMdR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3Ae9sRDmioAvmMdR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Ae9sRDmioAvmMdR .actor-man circle,#mermaid-svg-3Ae9sRDmioAvmMdR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3Ae9sRDmioAvmMdR :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
提交任务
输入 + 工具说明
工具名 + 参数 + 调用 ID
调用 execute()
返回执行结果
输入 + 工具调用 + 工具结果
根据工具结果回答
交付回答,结束本次处理
这张图翻译成人话就是:你进餐厅坐下,服务员(Agent)拿着菜单(工具说明)去问后厨(模型)想吃啥,后厨喊"我要一份 read,加个 path 参数",服务员就去让厨师(工具)做,做完端回来给后厨过目,后厨满意了,才把菜端给你。从头到尾只有你一个人点了单,剩下的全在后台偷偷忙活——这服务态度,海底捞看了都沉默。
1. 先分清四个角色,别把"点菜"当成"上菜"
很多人第一次接触工具调用,容易犯一个错误:模型一说"我要调工具",就觉得工具已经跑了。错!这中间隔着四层。谁负责什么,必须拎清楚,不然调 bug 的时候你会对着屏幕怀疑人生:这到底是模型没调,还是工具没跑,还是跑了没返回?
| 工具定义 AgentTool | 应用注册到 Agent 的工具对象 | 工具名、用途、参数格式,以及本地 execute() 函数 |
| 调用请求 ToolCall | 模型回复 | 指定工具名、参数和本次调用的 ID |
| 函数返回值 AgentToolResult | execute() | 给模型读取的 content 和给程序使用的 details |
| 结果消息 ToolResultMessage | Agent | 给返回值补上调用 ID、工具名和成功或失败标记,加入对话 |
这张表建议截图保存,面试前看一遍,能少掉一层头发。真的,我上次面试答工具调用,全靠这张表续命。
1.1 工具定义 AgentTool:贴在门口的菜单
菜单上写什么?菜名(name)、招牌介绍(description)、配料表(parameters),最后还挂着一个本地 execute()——相当于后厨的灶台:你看得见,但你自己进不去后厨。
// pi-ai:模型需要的工具说明
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
// …
}
// pi-agent-core:在工具说明上增加本地执行函数
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
label: string;
// …
execute: (
toolCallId: string,
params: Static<TParameters>,
// …
) => Promise<AgentToolResult<TDetails>>;
}
注意,菜单是给谁看的?给模型看的。模型根据菜名和介绍判断"这菜合不合口味",而 execute() 是给 Agent 用的。一个东西两头用,跟公司里那种"对外一套说辞、对内一套说辞"的周报,是一个道理。
1.2 调用请求 ToolCall:模型的"口头承诺"
模型说:“我要用 read 读 note.txt。”——这就是 ToolCall,包含工具名、参数、还有本次调用的 ID。
重点来了:这只是请求,文件还没打开。这就像你喊"明天开始减肥",承诺掷地有声,行动分文没有。模型也是,嘴上说得好好的,手是一点没动——哦对,它本来就没手。
1.3 返回值 AgentToolResult:交上来的作业
工具干完活,把结果交回来。content 是给模型看的正文,details 是给程序用的结构化数据。一份作业写两遍:一遍交老师批改,一遍自己存档。
export interface AgentToolResult<T> {
content: (TextContent | ImageContent)[];
details: T;
// …
}
没有附加信息的时候,details 可以为 undefined——翻译一下:没写存档,纯交作业。老师看了都得夸一句:行,至少交的是作业,不是白纸。
1.4 结果消息 ToolResultMessage:贴上姓名贴的作业
Agent 拿到返回值,补上调用 ID、工具名、成功失败标记,变成一条消息,才能进对话历史。
这操作像极了班主任:收作业先让你写名字,不然交上来的全是无名氏。到时候作业发下来,你都不知道哪份是你的。
2. 把工具注册进 Agent:以 read 为例
接入的入口是 initialState.tools。把符合 AgentTool 契约的对象丢进这个数组,Agent 就会自动把工具说明喂给模型,然后执行模型下达的调用请求。
先交代一下场景:当前目录下有个 note.txt,里面写着"本次验证代号:read-643219"。任务是让模型把这个代号读出来。这任务设计得很鸡贼:模型不调用工具,它就永远不知道答案——就像面试官把答案藏在桌子里,你不伸手,就永远拿不到 offer。
这配置就跟给手机装 App 一样:装上了,模型就"知道你有这个功能";没装,模型连你有这功能都不知道,只能靠脑补。脑补的结果嘛,大家都知道,模型脑补起来,能把 404 说成"文件去度假了"。
import { Agent } from "@earendil-works/pi-agent-core";
// read 是 Lab 中已创建并绑定本地执行环境的原生工具。
const agent = new Agent({
initialState: {
model,
systemPrompt: "严格按用户要求回答。读取文件必须调用 read,取得结果后只回复文件中的验证代号,不要重复读取。",
messages: [],
tools: [read],
},
streamFn: (model, context, options) =>
models.streamSimple(model, context, { …options, maxTokens: 256 }),
});
// 完整 Lab 通过 subscribe() 记录工具执行和消息结束事件。
await agent.prompt("请用 read 读取 note.txt,只回复其中的验证代号。");
系统提示词里写得很直白:“读取文件必须调用 read”。为什么?因为你不说,模型可能觉得它自己就能读文件——毕竟它平时就爱凭空发挥。哦不对,叫"发挥想象力"。
然后调用方就干一件事:await agent.prompt()。剩下的 Agent 全包了。你点完菜就坐着,菜怎么做的、谁做的、盘子谁洗的,都不用你管。这大概就是程序员能享受到的最高级别服务了。
3. 从工具说明到执行结果,再回到模型
前面章节的输入整理、历史合并、模型调用入口全都不动,工具调用发生在 runLoop() 内部。主路径长这样:
runLoop()
├─ streamAssistantResponse() → 第一次请求模型,得到包含工具调用的消息
├─ executeToolCalls() → 调度工具执行
│ ├─ prepareToolCall() → 找到工具,校验参数
│ ├─ executePreparedToolCall() → 调用本地 execute()
│ └─ createToolResultMessage() / emitToolResultMessage() → 形成并保存结果消息
└─ streamAssistantResponse() → 带上调用和结果,再次请求模型
3.1 工具是怎么混进模型视野的
createContextSnapshot() 做快照的时候,顺手把工具数组也复制了一份:
private createContextSnapshot(): AgentContext {
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
};
}
然后 streamAssistantResponse() 把工具和消息一起交给模型调用函数:
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// …
const response = await streamFunction(config.model, llmContext, {
…config,
apiKey: resolvedApiKey,
signal,
});
这里划个重点:发给模型的只是工具的"简历"——名字、介绍、参数结构,由 convertTools() 提取;真正的 execute() 函数留在本地。模型看到的是介绍信,动手的是你的代码。
这设计像极了相亲:递过去的是一份简历,真正跟你过日子的是简历背后那个人。模型以为自己是在跟 read 对话,其实对面是 Agent 的本地函数。网恋奔现现场。
3.2 模型返回的不是答案,是"请求"
第一次请求模型,回复的 content 里除了文字,可能多出这样一个内容块。这是 ToolCall 的核心定义:
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
// …
}
例如模型要求读取文件时,消息里会包含这样的数据,调用 ID 仅作示意:
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "note.txt" },
}
翻译一下:模型说"我想读这个文件"。它只是表达了意愿。注意,这条消息的 role 还是 assistant,会照常进入历史。此时模型的状态,就是那句经典名言:我只是问问,又没让你真干。
3.3 Agent 按名字找人,用校验过的参数干活
executeToolCalls() 开工。每个调用先进 prepareToolCall():先按 name 找工具,再准备参数:
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
// …
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);
// …
return {
kind: "prepared",
toolCall,
tool,
args: validatedArgs,
};
注意是按名字找,模型要是把 read 拼成 reed,直接查无此人。再校验参数,validateToolArguments() 用参数结构把输入过一遍,防止模型传一个不存在的路径。这一步像什么?像外卖平台:先看你点的店在不在配送范围,不在直接提示"超出配送范围",不会傻乎乎派个骑手去火星。
准备通过后,executePreparedToolCall() 调用工具的 execute()。下面用伪代码保留实际的调用名称和主要参数:
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args,
// …
);
// …
return { result, isError: false };
找到的是 read,参数是 { path: "note.txt" },具体怎么读,工具自己心里有数。你只需要知道:人家是专业的,别问,问就是底层实现。
这次执行得到的返回值长这样:
看到这里你可能想问:那 execute() 里面到底干了啥?答案是:不关你事。工具负责把活干完,你负责看结果。就像你去理发店,不会要求理发师现场直播他的剪刀是怎么动的。
{
content: [{ type: "text", text: "本次验证代号:read-643219" }],
details: undefined,
}
3.4 返回值怎么变成一条能对上号的消息
createToolResultMessage() 出场,把返回值加工成 toolResult 消息:toolCallId 直接复制模型请求的 id——“这条结果是谁要的”,一目了然。
return {
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
content: finalized.result.content ?? [],
details: finalized.result.details,
// …
isError: finalized.isError,
timestamp: Date.now(),
};
接着 emitToolResultMessage() 发事件:
await emit({ type: "message_start", message: toolResultMessage });
await emit({ type: "message_end", message: toolResultMessage });
第七章说过,message_end 会触发历史保存,所以工具结果也进了历史。此时历史长这样:
user 请用 read 读取 note.txt。
assistant toolCall: read({ path: "note.txt" }), id = call_1
toolResult 本次验证代号:read-643219, toolCallId = call_1
看这段历史,像不像传话游戏:你问 Agent,Agent 问模型,模型问工具,工具把答案递回来,模型再复述给你。中间谁都不能掉链子,掉了就是"我刚刚说的是那个……诶,我忘了我刚说啥了"。
3.5 工具都干完了,为什么还要再问一次模型
这是很多人第一次看会懵的地方:工具都执行完了,结果也拿到了,怎么又请求了一次模型?
答案很简单:工具只负责查资料,"写报告"的是模型。你让同事帮你查个数据,他查完回来,你不还得看完数据把话说完?总不能让数据自己变成一句话吧——它要是能,那模型早就集体失业了。
工具执行完成后,runLoop() 先把结果消息追加到本轮上下文:
toolResults.push(…executedToolBatch.messages);
// …
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
下面用伪代码串起工具正常执行时的循环:
let hasMoreToolCalls = true;
while (hasMoreToolCalls) {
const message = await streamAssistantResponse(…);
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(…);
// 将结果消息追加到 currentContext.messages 和 newMessages
hasMoreToolCalls = true; // 带上工具结果,再请求一次模型
}
}
第二次请求,模型看到的问题、调用、结果全都齐了,终于憋出最终答案,循环结束。要是它还想要工具,就继续循环——跟逛街的人一样,试了一件又一件,你也不知道它啥时候收手。
一次 prompt() 中的消息变化因此是:
| 第一次请求前 | [用户问题] | 模型决定调用工具 |
| 第一次模型回复完成 | [用户问题, 工具调用消息] | Agent 执行对应工具 |
| 工具结果追加后 | [用户问题, 工具调用消息, 工具结果] | 再次请求模型 |
| 最终回复完成 | [用户问题, 工具调用消息, 工具结果, 文本回答] | 结束 prompt() |
这里再敲一个黑板:一条 assistant 消息的 message_end,不代表整个任务结束。要看最终答案,得等 await agent.prompt() 全部跑完。别在模型说第一句话的时候就冲上去读结果——那相当于火锅刚下锅就问服务员"熟了吗",服务员只能回你一个礼貌而不失尴尬的微笑。
4. 跑一把 Lab,验证工具真的在干活
完整程序在 labs/08-tool-calling.ts。装依赖、跑起来,就两行:
npm install
node labs/08-tool-calling.ts
就这两行,程序员看了 DNA 都动了一下。Lab 会在临时目录里建一个 note.txt,里面写一个随机验证代号,然后让模型去读。模型不读文件就拿不到代号,作弊都作弊不了——跟开卷考试把答案锁保险柜里一个道理。
下面是一次真实调用的输出,后续运行的代号会变化:
model: qwen3.8-flash
input: 请用 read 读取 note.txt,只回复其中的验证代号。
request 1 roles: user
assistant stopReason: toolUse
tool_execution_start: read
read args: {"path":"note.txt"}
tool_execution_end: read
read result: 本次验证代号:read-643219
request 2 roles: user -> assistant -> toolResult
assistant stopReason: stop
final answer: read-643219
history roles: user -> assistant -> toolResult -> assistant
chapter 8 native read tool passed
输出里能看到完整的一条龙:模型第一次说 toolUse(我要用工具),read 执行开始、结束,然后第二次请求,模型拿到结果给出最终答案 read-643219。整个过程行云流水,比某些同事的周报都完整。
Lab 检查四组事实:
一次 prompt() 恰好发出两次模型请求,两次都携带工具说明;第二次请求包含完整的调用消息和结果消息。
只出现一次 read 执行的开始与结束事件,模型请求的文件是 note.txt;结果消息的 toolCallId 与调用 ID 相同,isError 为 false,返回文本与写入文件的内容完全一致。
先保存工具调用消息,再执行工具、保存工具结果,最后保存回答;历史恰好包含四条消息。
最终回复正常结束、没有继续请求工具,文字等于工具读出的验证代号。
四条断言,条条都像班主任抽查作业——而且真抽查,不是走形式。抽查完还告诉你:两次请求,一次不多一次不少,比你对象的夺命连环问还精确。
看完整个输出你会发现,模型全程表现得像个乖学生:老师说读文件,它就调用 read;拿到结果,就老老实实复述。要是现实中的学生都这么好带,老师能多活十年。
5. 小结
现在,Agent 从一次输入就能一路推着工具执行往下走:给模型看工具简历,按名字找到工具,校验参数,调 execute(),把结果贴回原调用,再请模型继续处理。
换成任何符合 AgentTool 契约的工具,走的都是同一套流程,变的只是具体干活的函数。read 只是个例子——你可以换成查天气、算账、发邮件,Agent 都是同一副姿势:找到、校验、执行、汇报。工具换了一个又一个,Agent 的姿势一直没变,像极了一个成熟的打工人。
最后说句掏心窝的:工具调用这事儿,说白了就是给一个只会说话的模型装了一双手。模型负责动嘴,工具负责动手,Agent 负责两头传话——还不收跑腿费。要是所有中间层都这么任劳任怨,这个世界能少一半的扯皮。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365
网硕互联帮助中心



评论前必须登录!
注册