08 · 统一工具配置
把工具系统从"能调用"推进到"能统一注册、前端可选、后端动态绑定"。
课时资源
一、学习目标
本课所在阶段:第三阶段 · Agent 核心能力。
学完这一课后,你应该能够:
- 理解为什么工具一旦变多,就不能继续把定义和执行逻辑散落在
chatbot.ts里 - 看懂
unified-tools.config.ts如何成为工具系统的统一入口 - 明白前端工具面板怎样把
toolIds传给后端,并影响这一轮真正绑定到模型的工具 - 知道"真实工具调用"和"工具系统工程化"之间的区别
二、问题背景
第七课已经解决了一个重要问题:Agent 能真实调用工具了。
但只要你继续往前走,很快就会遇到新的工程问题:
- 工具一多,定义到底放哪里
- 前端怎样表达"这一轮启用了哪些工具"
- 后端怎样只绑定本轮需要的工具,而不是每次都把全部工具塞给模型
如果这些边界不提前收束,后面一旦继续加天气、搜索、文件系统、Canvas 等能力,chatbot.ts 会越来越重,前端也没法表达工具选择。
这一课真正要解决的是:把工具从"已经能调用"推进到"能管理、能选择、能扩展"。
三、核心概念
这一课最重要的概念有三个:
- 统一注册
所有工具先进入
toolDefinitions - 前端选择 页面通过工具面板决定这一轮到底启用哪些工具
- 动态绑定
后端按本轮
toolIds构造 LangChain tools,而不是永远绑定全部工具
这一课相对第 07 课的核心增量
| 维度 | 第 07 课 | 第 08 课 |
|---|---|---|
| 工具定义位置 | 直接散落在 Agent 内 | 收束到 unified-tools.config.ts |
| 前端选择工具 | 不支持 | 支持工具面板和勾选状态 |
| 后端绑定工具 | 固定工具数组 | 按 toolIds 动态创建 |
| 工作流复用 | 单一工具组合 | 按工具组合缓存 app |
| 页面状态 | 只关心消息和会话 | 新增 availableTools、selectedToolIds |
本课建立的核心边界
- 工具执行定义:服务端内部持有
- 工具展示元数据:通过 API 给前端
- 当前启用哪些工具:由页面状态决定
- 本轮模型能用哪些工具:由后端动态绑定决定
这几个边界分开后,工具系统才算真正开始工程化。
四、整体流程
页面初始化与工具面板
按本轮选择动态绑定工具
五、运行过程
1. 页面首次加载时拿到可用工具列表
page.tsx 在 useEffect 里请求 GET /api/chat。这次 GET 不只返回 sessions,还会额外返回:
tools: getAvailableToolOptions()前端拿到后,一方面保存到 availableTools,另一方面用 getDefaultSelectedToolIds(tools) 默认全选。
2. 工具面板集成在输入框内
ToolPanel.tsx 被集成到 ChatInput.tsx 的工具栏左侧,形成一个紧凑的工具选择入口:
tools由页面传入selectedToolIds由页面传入onToggle/onSelectAll/onClearAll也都由页面传入
这意味着"当前这一轮到底启用了哪些工具"仍然是页面级状态,工具面板本身是一个受控组件。
3. 发送消息时把 toolIds 一起带给后端
这一课的请求体已经从:
{ "message": "...", "threadId": "..." }升级为:
{ "message": "...", "threadId": "...", "toolIds": ["current_time", "calculator"] }也就是说,工具选择不再只是页面展示,而是真正进入了后端调用链路。
4. 后端根据本轮选择动态创建工具
parseChatRequest 解析出 toolIds 后,streamChat(userMessage, threadId, toolIds) 会把它传到 Agent 层。
接着 app/agent/chatbot.ts 里会执行:
const tools = createLangChainTools(toolIds);这一步决定了:模型本轮真正能看到哪些工具。
5. 相同工具组合会复用已有工作流
这一课没有每次请求都重新建一套 LangGraph app,而是按工具组合做缓存:
const appCache = new Map<string, ReturnType<typeof buildChatApp>>();这样同一组工具不会反复重建工作流,结构上也更接近可扩展系统。
6. 无工具时的工作流
当前端把所有工具都关闭时,buildChatApp 会检测到 tools.length === 0,然后只调用 model.invoke(modelMessages) 而不绑定任何工具。这说明系统已经支持"纯聊天模式"和"工具增强模式"之间的平滑切换。
六、关键代码解析
app/agent/config/unified-tools.config.ts - 统一工具注册表
这是本课最重要的文件。它统一定义工具元数据、参数 schema、执行 handler,并提供前端可消费的工具选项。
关键代码:
export const toolDefinitions: ToolDefinition[] = [
{
id: 'current_time',
name: 'current_time',
description: '获取当前上海时区时间',
icon: '🕒',
enabled: true,
schema: z.object({
request: z.string().describe('用户关于时间的提问,例如 现在几点了'),
}),
handler: async () => `当前时间: ${getCurrentTime()}`,
},
];代码解析:
- 每个工具的定义包含了模型需要的全部信息:
name、description、schema handler是真正执行时调用的函数,与定义分开enabled字段让系统能快速启用或禁用某个工具,而不需要删除代码icon用于前端展示,让工具面板更友好
app/agent/config/unified-tools.config.ts - 前端可消费的工具选项
关键代码:
export function getAvailableToolOptions(): ToolOption[] {
return toolDefinitions
.filter((tool) => tool.enabled)
.map(({ id, name, description, icon }) => ({ id, name, description, icon }));
}代码解析:前端只需要知道"展示什么",不应该知道 schema 和 handler 这些服务端执行细节。所以这一层把"可展示元数据"和"可执行定义"明确拆开了。
- 只返回前端需要的字段:
id、name、description、icon - 自动过滤掉
enabled: false的工具 - 后续如果需要权限控制,可以在这里添加用户级别的过滤逻辑
app/agent/chatbot.ts - 按工具组合缓存 app
根据 toolIds 动态构建工作流,并缓存不同工具组合对应的 app。
关键代码:
function getToolKey(toolIds?: string[]) {
if (!toolIds || toolIds.length === 0) return '__no_tools__';
return [...new Set(toolIds)].sort().join(','); // 去重并排序,保证组合唯一性
}
const appCache = new Map<string, ReturnType<typeof buildChatApp>>();
function getChatApp(toolIds?: string[]) {
const key = getToolKey(toolIds);
const cachedApp = appCache.get(key);
if (cachedApp) return cachedApp;
const nextApp = buildChatApp(toolIds);
appCache.set(key, nextApp);
return nextApp;
}代码解析:这里的关键不是字符串拼接本身,而是:工作流实例现在已经和"启用哪些工具"绑定在一起了。不同工具组合对应不同工作流实例,缓存之后才能避免重复构建。
getToolKey对工具 ID 进行去重和排序,确保['a', 'b']和['b', 'a']生成相同的 key__no_tools__作为空工具列表的特殊 key,对应纯聊天模式- 缓存避免了每次请求都重新创建
StateGraph和compile,提升性能
app/agent/chatbot.ts - 动态工作流构建
关键代码:
function buildChatApp(toolIds?: string[]) {
const tools = createLangChainTools(toolIds);
const workflow = new StateGraph(MessagesAnnotation)
.addNode('chatbot', async (state) => {
const model = createModel();
const modelMessages = toModelMessages(state.messages);
const response = tools.length > 0
? await model.bindTools(tools).invoke(modelMessages)
: await model.invoke(modelMessages);
return { messages: [response] };
})
.addNode('tools', new ToolNode(tools))
.addEdge(START, 'chatbot')
.addConditionalEdges('chatbot', shouldContinue, {
tools: 'tools',
[END]: END,
})
.addEdge('tools', 'chatbot');
return workflow.compile({ checkpointer });
}代码解析:
- 工作流不再是全局单例,而是根据
toolIds动态创建 toModelMessages是本课新增:它负责把从 checkpointer 恢复的消息(可能是StoredMessage格式)统一转换成BaseMessage[],确保模型能正确处理历史消息tools.length > 0的判断决定是否绑定工具- 即使没有工具,
ToolNode也会被加入工作流(只是工具列表为空),保持工作流结构统一 - 这为后续新增工具提供了稳定的扩展点
app/services/chat.service.ts - 解析前端传来的 toolIds
负责解析前端传来的 toolIds,再把它们继续传进 Agent。
关键代码:
// page.tsx 发送消息
body: JSON.stringify({ message: content, threadId, toolIds: selectedToolIds })
// chat.service.ts 解析请求
const toolIds = Array.isArray(body.toolIds)
? body.toolIds.filter((id): id is string => typeof id === 'string')
: undefined;
// chatbot.ts 使用工具 IDs
const tools = createLangChainTools(toolIds);代码解析:这组代码说明本课前端新增的不是一个装饰性面板,而是真正把用户选择送进了后端。
- 前端直接发送
selectedToolIds数组 - 后端进行类型安全过滤,确保只保留字符串类型的 ID
undefined会被createLangChainTools解释为"使用全部工具"- 空数组
[]会被解释为"不使用任何工具"
app/api/chat/route.ts - 返回可用工具列表
在 GET /api/chat 时返回可用工具列表,供前端初始化工具面板。
app/components/ToolPanel.tsx - 工具选择面板
本课新增组件。它负责展示、勾选、全部启用和全部关闭。
app/components/ChatInput.tsx - 集成工具面板
集成 ToolPanel,在输入框左侧展示工具面板,成为用户选择工具的入口。
app/page.tsx - 前端工具选择状态
新增 availableTools 和 selectedToolIds 两份状态,并在发送消息时把选择结果提交给后端。
app/utils/tool-selection.ts - 前端工具选择逻辑
把"默认全选"和"切换单个工具"这些纯前端逻辑从页面里拆出来,保持状态更新更清晰。
七、常见问题
第八课和第七课最大的区别是什么?
第七课解决"真实工具调用能不能跑起来",第八课解决"工具系统能不能工程化管理"。
第七课的工具定义是写死在 chatbot.ts 里的,每次请求都绑定相同的工具列表。第八课把工具定义抽离成配置文件,让前端可以选择性地启用工具,后端按需动态绑定。
为什么工具面板不把选中状态放在自己内部?
因为消息发送逻辑在页面层,后端请求也在页面层。如果选中状态只存在 ToolPanel.tsx 或 ChatInput.tsx 里,页面层发送请求时就拿不到统一状态。
当前设计中,page.tsx 维护 selectedToolIds 状态,然后把它传给 ChatInput,再传给 ToolPanel。这样确保了"发送消息"和"工具选择"使用同一份数据。
为什么 current_time 现在带了一个 request 参数?
这是当前兼容层下更稳妥的做法。与零参数工具相比,显式参数 schema 更容易被模型正确调用。
在 DashScope OpenAI 兼容模式下,零参数工具的调用稳定性不如带参数工具。因此 current_time 工具接受一个 request 参数,描述用户的原始问题。
如果以后再加数据库,这一课会被推翻吗?
不会。数据库解决的是持久化问题,这一课解决的是工具系统边界问题,两者是不同演进线。
工具配置与数据存储是正交的:toolDefinitions 决定"有哪些工具可用",数据库决定"工具结果和会话历史存在哪里"。后续接入数据库时,只需要把 MemorySaver 替换成数据库 checkpointer,工具配置层不需要改动。
为什么工具选择默认是全选?
这是为了让学员先看到"配置驱动 + 前端筛选"的完整链路。默认全选意味着初始状态下工具行为与第七课一致,然后用户可以逐步关闭工具来观察差异。
如果这一轮把所有工具都关闭,后端应该发生什么?
后端会检测到 tools.length === 0,然后只调用 model.invoke(modelMessages) 而不绑定任何工具。模型会像第六课那样只生成文本,不会发起工具调用。
你可以通过工具面板点击"全部关闭"来验证这个行为。
八、练习题
- 说明
toolDefinitions和getAvailableToolOptions()为什么不应该合并成一个函数。 - 假设你要新增一个天气工具,最少需要修改哪几个文件?
- 为什么
selectedToolIds更适合放在page.tsx,而不是ToolPanel.tsx? - 如果这一轮把所有工具都关闭,后端应该发生什么?你可以自己试着验证。
- 解释
getToolKey为什么要对工具 ID 进行去重和排序。
九、总结
这一课真正建立的是:统一工具注册表、前端工具选择、后端动态绑定工具。
到这里为止,第三阶段的三节课已经形成一条连续主线:
- 第 06 课解决线程记忆
- 第 07 课解决真实工具调用
- 第 08 课解决工具系统工程化
如果你已经能说清楚工具为什么要统一配置、前端面板怎样影响后端、后端又怎样按 toolIds 真正筛选工具,这一课就掌握了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。