第三阶段 · Agent 核心能力

08 · 统一工具配置

把工具系统从"能调用"推进到"能统一注册、前端可选、后端动态绑定"。

课时资源

一、学习目标

本课所在阶段:第三阶段 · Agent 核心能力

学完这一课后,你应该能够:

  • 理解为什么工具一旦变多,就不能继续把定义和执行逻辑散落在 chatbot.ts
  • 看懂 unified-tools.config.ts 如何成为工具系统的统一入口
  • 明白前端工具面板怎样把 toolIds 传给后端,并影响这一轮真正绑定到模型的工具
  • 知道"真实工具调用"和"工具系统工程化"之间的区别

二、问题背景

第七课已经解决了一个重要问题:Agent 能真实调用工具了。

但只要你继续往前走,很快就会遇到新的工程问题:

  • 工具一多,定义到底放哪里
  • 前端怎样表达"这一轮启用了哪些工具"
  • 后端怎样只绑定本轮需要的工具,而不是每次都把全部工具塞给模型

如果这些边界不提前收束,后面一旦继续加天气、搜索、文件系统、Canvas 等能力,chatbot.ts 会越来越重,前端也没法表达工具选择。

这一课真正要解决的是:把工具从"已经能调用"推进到"能管理、能选择、能扩展"。

三、核心概念

这一课最重要的概念有三个:

  1. 统一注册 所有工具先进入 toolDefinitions
  2. 前端选择 页面通过工具面板决定这一轮到底启用哪些工具
  3. 动态绑定 后端按本轮 toolIds 构造 LangChain tools,而不是永远绑定全部工具

这一课相对第 07 课的核心增量

维度第 07 课第 08 课
工具定义位置直接散落在 Agent 内收束到 unified-tools.config.ts
前端选择工具不支持支持工具面板和勾选状态
后端绑定工具固定工具数组toolIds 动态创建
工作流复用单一工具组合按工具组合缓存 app
页面状态只关心消息和会话新增 availableToolsselectedToolIds

本课建立的核心边界

  • 工具执行定义:服务端内部持有
  • 工具展示元数据:通过 API 给前端
  • 当前启用哪些工具:由页面状态决定
  • 本轮模型能用哪些工具:由后端动态绑定决定

这几个边界分开后,工具系统才算真正开始工程化。

四、整体流程

页面初始化与工具面板

按本轮选择动态绑定工具

五、运行过程

1. 页面首次加载时拿到可用工具列表

page.tsxuseEffect 里请求 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()}`,
  },
];

代码解析:

  1. 每个工具的定义包含了模型需要的全部信息:namedescriptionschema
  2. handler 是真正执行时调用的函数,与定义分开
  3. enabled 字段让系统能快速启用或禁用某个工具,而不需要删除代码
  4. 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 }));
}

代码解析:前端只需要知道"展示什么",不应该知道 schemahandler 这些服务端执行细节。所以这一层把"可展示元数据"和"可执行定义"明确拆开了。

  1. 只返回前端需要的字段:idnamedescriptionicon
  2. 自动过滤掉 enabled: false 的工具
  3. 后续如果需要权限控制,可以在这里添加用户级别的过滤逻辑

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;
}

代码解析:这里的关键不是字符串拼接本身,而是:工作流实例现在已经和"启用哪些工具"绑定在一起了。不同工具组合对应不同工作流实例,缓存之后才能避免重复构建。

  1. getToolKey 对工具 ID 进行去重和排序,确保 ['a', 'b']['b', 'a'] 生成相同的 key
  2. __no_tools__ 作为空工具列表的特殊 key,对应纯聊天模式
  3. 缓存避免了每次请求都重新创建 StateGraphcompile,提升性能

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 });
}

代码解析:

  1. 工作流不再是全局单例,而是根据 toolIds 动态创建
  2. toModelMessages 是本课新增:它负责把从 checkpointer 恢复的消息(可能是 StoredMessage 格式)统一转换成 BaseMessage[],确保模型能正确处理历史消息
  3. tools.length > 0 的判断决定是否绑定工具
  4. 即使没有工具,ToolNode 也会被加入工作流(只是工具列表为空),保持工作流结构统一
  5. 这为后续新增工具提供了稳定的扩展点

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);

代码解析:这组代码说明本课前端新增的不是一个装饰性面板,而是真正把用户选择送进了后端。

  1. 前端直接发送 selectedToolIds 数组
  2. 后端进行类型安全过滤,确保只保留字符串类型的 ID
  3. undefined 会被 createLangChainTools 解释为"使用全部工具"
  4. 空数组 [] 会被解释为"不使用任何工具"

app/api/chat/route.ts - 返回可用工具列表

GET /api/chat 时返回可用工具列表,供前端初始化工具面板。


app/components/ToolPanel.tsx - 工具选择面板

本课新增组件。它负责展示、勾选、全部启用和全部关闭。


app/components/ChatInput.tsx - 集成工具面板

集成 ToolPanel,在输入框左侧展示工具面板,成为用户选择工具的入口。


app/page.tsx - 前端工具选择状态

新增 availableToolsselectedToolIds 两份状态,并在发送消息时把选择结果提交给后端。


app/utils/tool-selection.ts - 前端工具选择逻辑

把"默认全选"和"切换单个工具"这些纯前端逻辑从页面里拆出来,保持状态更新更清晰。

七、常见问题

第八课和第七课最大的区别是什么?

第七课解决"真实工具调用能不能跑起来",第八课解决"工具系统能不能工程化管理"。

第七课的工具定义是写死在 chatbot.ts 里的,每次请求都绑定相同的工具列表。第八课把工具定义抽离成配置文件,让前端可以选择性地启用工具,后端按需动态绑定。

为什么工具面板不把选中状态放在自己内部?

因为消息发送逻辑在页面层,后端请求也在页面层。如果选中状态只存在 ToolPanel.tsxChatInput.tsx 里,页面层发送请求时就拿不到统一状态。

当前设计中,page.tsx 维护 selectedToolIds 状态,然后把它传给 ChatInput,再传给 ToolPanel。这样确保了"发送消息"和"工具选择"使用同一份数据。

为什么 current_time 现在带了一个 request 参数?

这是当前兼容层下更稳妥的做法。与零参数工具相比,显式参数 schema 更容易被模型正确调用。

在 DashScope OpenAI 兼容模式下,零参数工具的调用稳定性不如带参数工具。因此 current_time 工具接受一个 request 参数,描述用户的原始问题。

如果以后再加数据库,这一课会被推翻吗?

不会。数据库解决的是持久化问题,这一课解决的是工具系统边界问题,两者是不同演进线。

工具配置与数据存储是正交的:toolDefinitions 决定"有哪些工具可用",数据库决定"工具结果和会话历史存在哪里"。后续接入数据库时,只需要把 MemorySaver 替换成数据库 checkpointer,工具配置层不需要改动。

为什么工具选择默认是全选?

这是为了让学员先看到"配置驱动 + 前端筛选"的完整链路。默认全选意味着初始状态下工具行为与第七课一致,然后用户可以逐步关闭工具来观察差异。

如果这一轮把所有工具都关闭,后端应该发生什么?

后端会检测到 tools.length === 0,然后只调用 model.invoke(modelMessages) 而不绑定任何工具。模型会像第六课那样只生成文本,不会发起工具调用。

你可以通过工具面板点击"全部关闭"来验证这个行为。

八、练习题

  1. 说明 toolDefinitionsgetAvailableToolOptions() 为什么不应该合并成一个函数。
  2. 假设你要新增一个天气工具,最少需要修改哪几个文件?
  3. 为什么 selectedToolIds 更适合放在 page.tsx,而不是 ToolPanel.tsx
  4. 如果这一轮把所有工具都关闭,后端应该发生什么?你可以自己试着验证。
  5. 解释 getToolKey 为什么要对工具 ID 进行去重和排序。

九、总结

这一课真正建立的是:统一工具注册表、前端工具选择、后端动态绑定工具。

到这里为止,第三阶段的三节课已经形成一条连续主线:

  1. 第 06 课解决线程记忆
  2. 第 07 课解决真实工具调用
  3. 第 08 课解决工具系统工程化

如果你已经能说清楚工具为什么要统一配置、前端面板怎样影响后端、后端又怎样按 toolIds 真正筛选工具,这一课就掌握了。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page