07 · 工具调用
让 Agent 从"只会回答"升级到"会调用真实工具",并把工具结果送入聊天事件流。
课时资源
一、学习目标
本课所在阶段:第三阶段 · Agent 核心能力。
学完这一课后,你应该能够:
- 理解为什么 Chat Bot 不应该只会生成文本,还要能调用真实外部能力
- 看懂
ToolNode、bindTools(...)和tool.call事件在这条链路里的位置 - 明白工具结果是怎样从 LangGraph 流入前端消息列表的
- 知道这一课是在"有线程记忆"的基础上继续增加"能行动"的能力
二、问题背景
第六课已经让聊天应用开始具备"记住上下文"的能力,但如果你问它"现在几点"或者输入一个数学表达式,它还是可能只返回一段普通文本。
这说明系统还缺少一层关键能力:不是"会不会继续说",而是"会不会真的做事"。
如果没有这一课,应用会停留在一个很有限的状态:
- 模型可以根据上下文生成更连贯的文本
- 但它不能可靠地调用外部能力
- 前端也无法区分"模型在说话"和"模型刚刚调用过工具"
这一课真正要解决的,是先把最小工具调用闭环跑通:模型决定是否调用工具,工具执行后把结果回到聊天流,前端再把它渲染出来。
三、核心概念
这一课最重要的概念是:把聊天工作流从"纯文本回复"升级为"模型判断 -> 工具执行 -> 继续回复"。
当前实现不是规则匹配版假工具,而是已经切进了 LangGraph 的真实工具调用链路:
-
model.bindTools(tools)让模型在本轮对话中看见可用工具 -
ToolNode专门负责执行被模型选中的工具 -
tool.call事件 把工具调用结果从后端送到前端,而不是只留在 Agent 内部
本课使用的最小工具
-
current_time获取当前上海时区时间 -
calculator计算简单数学表达式
这两个工具都很小,但足够把整条链路讲清楚。
这一课相对第 06 课的核心增量
| 维度 | 第 06 课 | 第 07 课 |
|---|---|---|
| Agent 能力 | 记住上下文 | 记忆 + 工具调用 |
| LangGraph 图结构 | 只有 chatbot 节点 | chatbot + tools 节点 |
| 模型输出 | 只生成文本 | 可能发起 tool_calls |
| 前端事件流 | session.start + 文本事件 | 新增 tool.call |
| 消息结构 | 只有文本消息 | assistant 消息可附带 toolCalls |
这一课和参考项目的关系
参考项目同样使用 ToolNode 和工具事件流,但课程版刻意先用两个最小工具把链路讲清楚。这样你会更容易看懂:
- 模型为什么能自己决定要不要调用工具
- 工具执行后结果怎样回到聊天流
- 前端为什么需要知道"刚刚调用过什么工具"
四、整体流程
五、运行过程
1. 模型先看见可用工具
在 app/agent/chatbot.ts 里,模型不是直接 invoke(state.messages),而是:
const response = await model.bindTools(tools).invoke(state.messages);这一行意味着:模型本轮不仅能生成文本,还能返回结构化的 tool_calls。
2. LangGraph 用条件路由决定是否去执行工具
shouldContinue 会检查最近一条 AI 消息里有没有 tool_calls:
- 有:走到
tools - 没有:直接结束
这就是"模型先判断,要不要调用工具"的真正落点。
3. ToolNode 执行工具,不是聊天层手写调用
工具真正执行时,不是在 service 层写 if/else 去调用,而是交给 LangGraph 的 ToolNode(tools)。这能让工具调用继续留在工作流层,后面扩展更多工具时结构不会塌掉。
4. 后端把工具结果送回前端
streamChat(...) 在监听 app.streamEvents(...) 时,不只关心 on_chat_model_stream,还会处理:
-
on_chat_model_end记录模型刚刚准备调用哪些工具 -
on_tool_end拿到工具真正执行后的结果,并产出{ type: 'tool', toolCall }
5. 前端把工具记录挂到当前 assistant 消息上
page.tsx 里通过 activeAssistantId 知道当前这轮回复属于哪条 assistant 消息,然后在 tool.call 到来时调用:
setMessages((current) => attachToolCall(current, activeAssistantId, payload as ToolCallRecord));这样工具结果不会散落成独立消息,而是成为当前回复的补充信息。
六、关键代码解析
app/agent/chatbot.ts - 引入 ToolNode 和条件路由
本课最关键的文件。这里引入了 ToolNode、shouldContinue 条件路由,以及工具事件的提取逻辑。
关键代码:
const workflow = new StateGraph(MessagesAnnotation)
.addNode('chatbot', async (state) => {
const model = createModel();
const response = await model.bindTools(tools).invoke(state.messages);
return { messages: [response] };
})
.addNode('tools', new ToolNode(tools))代码解析:这里最关键的点是:
- 模型不是被动接收工具结果,而是自己决定要不要调用工具
- 工具执行被放在独立节点里,而不是塞进
chat.service.ts - 这已经是一个真正的 Agent 工作流,而不是"正则识别后手动调用函数"
app/agent/chatbot.ts - 缓存待完成的工具调用
关键代码:
const pendingToolCalls = new Map<string, ToolCallRecord>();代码解析:后面在 on_chat_model_end 里先记录工具调用元信息,在 on_tool_end 里再补上真正输出。这么做的原因是:模型发起工具调用和工具执行完成是两个事件,必须先把它们关联起来,前端才能拿到一条完整的工具调用记录。
app/agent/tools/current-time.ts - 定义时间工具
定义时间工具,展示一个真实工具最小需要哪些内容:名字、描述、参数 schema 和执行函数。
app/agent/tools/calculator.ts - 定义计算器工具
定义计算器工具,让你看到"文本提问 -> 结构化参数 -> 真实执行结果"的完整样子。
app/services/chat.service.ts - 把工具结果转成 SSE 事件
负责把 Agent 产生的工具事件转成 tool.call SSE 事件,继续送到前端。
关键代码:
if (item.type === 'tool') {
latestToolCall = item.toolCall;
if (item.toolCall) {
yield { event: 'tool.call', data: item.toolCall };
}
continue;
}代码解析:如果不把工具结果单独作为事件发给前端,前端就只能看到"最终回复文本",却不知道中间到底调用过什么工具。后面做更复杂的工具 UI、审计、调试时,这会是很大的缺口。
app/page.tsx - 把工具调用附着到 assistant 消息
新增 attachToolCall,把工具调用记录附着到当前 assistant 消息上。
关键代码:
setMessages((current) => attachToolCall(current, activeAssistantId, payload as ToolCallRecord));代码解析:这样工具结果不会散落成独立消息,而是成为当前回复的补充信息。
app/types/chat.ts - 定义工具调用数据结构
新增 ToolCallRecord,定义前后端共用的工具调用数据结构。
七、常见问题
为什么这一课不直接上统一工具配置?
因为你得先确认"真实工具调用本身能不能跑通"。如果连 ToolNode、tool.call 和前端附着逻辑都没看懂,直接上统一配置只会把问题叠在一起。
ToolCallRecord 为什么要放到 types/chat.ts?
因为它已经不是 Agent 内部私有数据了,而是要跨越后端和前端边界传递的结构。
工具结果为什么还要保留在 assistant 消息旁边?
因为当前主视图仍然是聊天视图。工具调用是这条回复的组成部分,不应该把它和主回复拆成两个互不相关的聊天单元。
八、练习题
- 解释
bindTools(...)、ToolNode、tool.call三者分别负责什么。 - 为什么工具调用不能只停留在 Agent 内部,而要进入前端事件流?
- 如果你要新增一个天气工具,最先应该补哪几个文件?
- 对比第 06 课和第 07 课,说明"记忆能力"和"行动能力"在系统里的落点有何不同。
九、总结
这一课真正建立的是:Agent 不再只会回答,而是开始具备调用真实外部能力的入口。
只要你已经能说清楚工具怎样被模型选中、怎样被 ToolNode 执行、以及结果怎样通过 tool.call 进入前端,这一课就掌握了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。