工具系统:让AI真正“动手”做事

——CogitoAgent开发实战(二)

📖 本文是专栏的第二篇。上一篇我们让AI学会了“思考”——它有了状态机,能持续运转,能被打断,能主动等待。但一个只会“想”不会“做”的AI,就像一个满腹经纶却手脚不便的学者,终究是纸上谈兵。这一篇,我们来给AI装上一双手。

演示


📌 从一个思想实验开始

假设你要指挥一个盲人帮你做事。

你看得见,他看不见。他力气很大,但完全不知道周围有什么。你想让他帮你拿桌上的杯子,你会怎么说?

你可能会说:“向前走三步,然后向右摸一下。”

但这里有个问题:你说的话,他必须能听懂。 你说的“向前”“三步”“向右”,得是他能理解的指令。

指挥AI也是同样的问题。AI能“看”到的只有文字,它能“做”的也只有“输出文字”这一件事。那怎么才能让它帮你操作文件、打开网页、启动软件呢?

思路:我们和AI约定一套“暗号”。AI输出特定的文字格式,程序看到这个格式,就知道“哦,AI想让我执行某个操作”,然后程序去执行,再把结果用文字告诉AI。

这套“暗号”,就是工具调用协议。
效果


一、设计“暗号”:AI必须能学会,程序必须能读懂

1.1 我们先想想,这套暗号要满足什么条件?

条件 为什么
AI能学会 我们不能给AI重新训练,只能通过提示词教它。格式必须简单,给几个例子它就能照葫芦画瓢
程序能读懂 程序需要用正则表达式来解析,格式要规整,不能有歧义
人能看懂 调试的时候,我们要能一眼看出AI想干什么
支持多个操作 AI可能一次想做几件事,比如“先看看目录里有什么,再读那个文件”

这四个条件,听起来不多,但很多格式都满足不了。

1.2 我们来试试几种格式,看看会有什么问题

方案一:自然语言

AI说:“帮我列出src目录下的内容。”

问题:程序怎么知道这是一条指令,而不是普通对话?而且“帮我列出src目录下的内容”有无数种说法,程序不可能全部识别。

❌ 不行。

方案二:纯函数调用

AI说:“ls(‘src’)”

问题:这个格式很简洁,AI也容易学。但如果AI一次想做两件事呢?它可能会说:“ls(‘src’) ls(‘dist’)”

两件事之间没有分隔符,程序不知道从哪里切分。

❌ 不行。

方案三:JSON

AI说:{“tool”: “ls”, “args”: [“src”]}

问题:JSON对格式要求非常严格。多一个逗号、少一个引号,解析就失败。大模型天生不擅长精确输出,很容易出错。而且JSON占用的token多,浪费。

❌ 不太行。

方案四:带标记的调用(CogitoAgent的选择)

AI说:[TOOL] ls(“src”) [/TOOL]

这个方案的优点:

  • 有明确的开始标记 [TOOL] 和结束标记 [/TOOL],多个调用不会混在一起
  • 中间的内容就是普通的函数调用格式,AI熟悉
  • 正则表达式 \[TOOL\](.*?)\[\/TOOL\] 就能轻松提取

✅ 可行。

1.3 为什么这个格式AI能学会?

大模型在训练的时候,见过海量的代码。函数调用 ls(“src”) 这样的形式,在代码里太常见了。加上 [TOOL] 标记,只是给这段代码加了个“包装”,不改变核心结构。

我们只需要在系统提示词里给它看几个例子:

## 输出格式
当你想使用工具时,请按此格式输出:
[TOOL] 工具名称("参数1", "参数2") [/TOOL]

## 示例
[TOOL] ls("src") [/TOOL]
[TOOL] read("README.md") [/TOOL]
[TOOL] search("人工智能") [/TOOL]

这就是“少样本学习”——给几个例子,模型就能举一反三。


二、解析暗号:从“AI说了什么”到“程序要做什么”

AI输出了一段文字,里面可能夹杂着对话和工具调用。比如:

让我看看src目录里有什么。
[TOOL] ls("src") [/TOOL]
嗯,有个agent文件夹,我再看看里面。
[TOOL] ls("src/agent") [/TOOL]

程序需要把两件事分开:

  1. 普通对话(“让我看看src目录里有什么。”)→ 直接显示给用户
  2. 工具调用([TOOL] ls(“src”) [/TOOL])→ 执行操作

2.1 第一步:提取所有工具调用

我们需要一个正则表达式,能从文字中匹配出 [TOOL] ... [/TOOL] 这样的片段。

先别急着看最终代码,我们来一步步构造这个正则表达式。

第1版:匹配开始和结束标记

\[TOOL\] 匹配 [TOOL],注意 [] 在正则里有特殊含义(表示字符集),所以需要加反斜杠转义,写成 \[TOOL\]

\[\/TOOL\] 匹配 [/TOOL],中间的 / 也需要转义吗?在 JavaScript 正则里,/ 没有特殊含义,但为了对称,也转义一下:\[\/TOOL\]

中间的内容用什么匹配?用 .*?,意思是“匹配任意字符,尽可能少地匹配”(非贪婪模式)。

所以第1版:\[TOOL\](.*?)\[\/TOOL\]

第2版:考虑空格

AI可能会写成 [TOOL] ls(“src”) [/TOOL],在 ]ls 之间有空格。我们的正则需要容忍这些空格。

\s* 表示“零个或多个空白字符”。

第2版:\[TOOL\]\s*(.*?)\s*\[\/TOOL\]

第3版:提取工具名和参数

上面我们只提取了 [TOOL][/TOOL] 之间的全部内容(比如 ls(“src”))。但我们还想单独拿到工具名 ls 和参数 “src”

怎么分开?加括号。

(\w+) 匹配工具名。\w 表示字母、数字、下划线,+ 表示“一个或多个”。

\( 匹配左括号,([^)]*) 匹配括号内的参数([^)] 表示“不是右括号的字符”,* 表示“零个或多个”),\) 匹配右括号。

最终版本:

/\[TOOL\]\s*(\w+)\s*\(([^)]*)\)\s*\[\/TOOL\]/g

拆解一下:

部分 含义
\[TOOL\] 匹配开始标记 [TOOL]
\s* 零个或多个空格
(\w+) 捕获组1:工具名(如 ls
\s* 零个或多个空格
\( 匹配左括号
([^)]*) 捕获组2:参数内容(如 “src”
\) 匹配右括号
\s* 零个或多个空格
\[\/TOOL\] 匹配结束标记 [/TOOL]
g 全局匹配(找出所有,不只是第一个)

2.2 参数解析:处理引号和逗号

捕获到的参数部分是一个字符串,比如 “src”, “dist”。我们需要把它拆成数组 [“src”, “dist”]

直觉告诉我们可以用 split(‘,’) 按逗号分割。但问题来了:参数本身如果包含逗号怎么办?

比如搜索工具:search(“苹果, 香蕉, 橙子”),这里的逗号是搜索词的一部分,不是多个参数的分隔符。

CogitoAgent 的工具调用规定:参数之间用逗号分隔,但如果参数用引号包裹,引号内的逗号不作为分隔符

实现思路:按逗号分割,然后去掉引号。

function parseArgs(argsStr) {
  const result = [];
  const parts = argsStr.split(',');
  for (const part of parts) {
    const trimmed = part.trim();
    // 如果被引号包裹,去掉首尾的引号
    if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
        (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
      result.push(trimmed.slice(1, -1));
    } else {
      result.push(trimmed);
    }
  }
  return result;
}

注意:这个实现有个边界问题——如果参数本身包含转义引号(比如 “he said \“hello\””),会解析错误。但对于当前的需求,已经够用了。后面需要再改进。

2.3 提取函数的最终形态

function parseAllToolCalls(text) {
  const results = [];
  const regex = /\[TOOL\]\s*(\w+)\s*\(([^)]*)\)\s*\[\/TOOL\]/g;
  let match;
  while ((match = regex.exec(text)) !== null) {
    results.push({
      tool: match[1],
      args: parseArgs(match[2])
    });
  }
  return results;
}

举个例子:

输入:

[TOOL] ls("src") [/TOOL] 和 [TOOL] read("README.md") [/TOOL]

输出:

[
  { tool: "ls", args: ["src"] },
  { tool: "read", args: ["README.md"] }
]

三、分发执行:找到对应的“动作”

现在我们有了工具名和参数,接下来要做的,就是找到真正干活的函数

3.1 一个直观的想法:switch语句

async function executeTool(tool, args) {
  switch (tool) {
    case 'ls':
      return await ls(args[0]);
    case 'read':
      return await read(args[0]);
    case 'copy':
      return await copy(args[0], args[1]);
    case 'search':
      // 搜索要把所有参数用逗号拼起来,因为搜索词本身可能含逗号
      return await search(args.join(','));
    case 'browse':
      return await browse(args[0]);
    case 'fetchPage':
      return await fetchPage(args[0]);
    case 'listApps':
      return await listApps();
    case 'openApp':
      return await openApp(args[0]);
    case 'closeApp':
      return await closeApp(args[0]);
    default:
      return { success: false, error: `未知工具: ${tool}` };
  }
}

这里有一个细节值得注意:为什么不同工具的参数处理方式不一样?

工具 参数处理 原因
ls args[0] 只有一个参数,直接取第一个
copy args[0], args[1] 两个参数:源路径和目标路径
search args.join(‘,’) 搜索词可能包含逗号,用户输入时逗号是合法的

你看,search 的处理方式就不一样。如果我们用对象映射({ ls, read, copy }[tool])那种“优雅”的方式,就没法针对每个工具定制参数处理逻辑。switch 虽然代码长一点,但灵活性更高。

3.2 所有工具必须遵守的“契约”

为了让 executeTool 能统一处理,每个工具函数必须遵守同样的规则:

// 契约:
// 1. 必须是异步函数(返回 Promise)
// 2. 必须返回 { success: boolean, data?: any, error?: string }

async function someTool(param1, param2) {
  try {
    const result = await doSomething(param1, param2);
    return { success: true, data: result };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

这样设计的好处是什么?

好处一:调用方不用每个工具都写 try-catch

// 如果不统一格式,每个调用都要这样:
try {
  const result = await ls('src');
  // 处理 result
} catch (error) {
  // 处理错误
}

// 有了统一格式,就这样:
const result = await ls('src');
if (result.success) {
  // 处理 result.data
} else {
  // 处理 result.error
}

好处二:AI 能理解返回结果

AI 看到 { success: false, error: “文件不存在” },就知道操作失败了,可以采取别的策略。

好处三:扩展新工具时,照着模板写就行了,不用思考“怎么返回”。

演示


四、反馈闭环:让AI“看到”自己做了什么

4.1 一个关键问题

AI 执行完工具之后,程序拿到了结果。但这还不够——AI 自己也要知道结果

为什么?因为 AI 的下一步决策依赖上一步的结果。

举个例子:

  1. AI 调用 ls(“src”),想知道 src 目录下有什么
  2. 程序执行,发现有 agent/api/ 两个文件夹
  3. AI 想继续探索 agent/ 里面有什么
  4. 但如果 AI 不知道第2步的结果,它就没法决定下一步

这就是“反馈闭环”:AI 做什么 → 程序执行 → 结果告诉 AI → AI 决定下一步

4.2 怎么把结果告诉 AI?

我们不能直接在程序里“塞”给 AI 一个变量。AI 没有“内存”,它唯一的信息来源是对话历史

所以,我们把结果写成文字,添加到对话历史里。

// 执行完工具后
const result = await executeTool(tool, args);

// 把结果写成一段文字,附在 AI 原来的回复后面
const toolResultMessage = fullResponse + `\n\n[工具结果]: ${JSON.stringify(result.data)}`;

// 添加到历史
addAssistantMessage(toolResultMessage);

注意:我们保存的是 fullResponse + 工具结果,而不是只保存工具结果。

为什么?因为 fullResponse 包含了 AI 的原始输出(其中包含 [TOOL] 调用)。把两者放在一起,AI 下次看到历史时,就能理解“我上次说要调用这个工具,得到了这个结果”。

4.3 举个例子

AI 第一次回复的内容(fullResponse)是:

让我看看src目录里有什么。[TOOL] ls("src") [/TOOL]

程序执行 ls(“src”) 后,得到结果 [“agent”, “api”, “config.js”]

程序构造的新消息是:

让我看看src目录里有什么。[TOOL] ls("src") [/TOOL]

[工具结果]: ["agent", "api", "config.js"]

然后 AI 下一次思考时,看到的历史里就有这条消息。它会知道:哦,我之前看了 src 目录,里面有 agent、api 和 config.js,那我现在可以去看 agent 里面有什么了。

于是 AI 输出:

发现有 agent 文件夹,我再看看里面。[TOOL] ls("src/agent") [/TOOL]

闭环完成了。


五、组织结构:代码怎么放才不乱?

随着工具越来越多,我们不能把所有工具函数都塞在一个文件里。需要按功能分类。

5.1 按职责分文件

CogitoAgent 的工具分成三类:

文件 职责 工具
file.js 文件操作 ls, read, copy, mkdir, create
web.js 联网能力 search, browse, fetchPage
system.js 系统控制 listApps, openApp, closeApp

为什么这么分?

  • 修改时只关注一个文件:要改文件操作功能,只需要打开 file.js
  • 新增一类工具很方便:想加一个“图像处理”类别,新建 image.js 就行
  • 依赖隔离system.js 用到了 child_process(调用系统命令),但 file.js 不需要知道这个

5.2 统一入口:index.js

虽然工具分散在不同文件里,但 Agent.js 只想从一个地方导入它们。所以我们建一个 index.js 作为“总出口”:

// tools/index.js
import { getBasePath } from './path.js';
import { ls, read, copy, mkdir, create } from './file.js';
import { search, browse, fetchPage } from './web.js';
import { listApps, openApp, closeApp } from './system.js';

export {
  getBasePath,
  ls, read, copy, mkdir, create,
  search, browse, fetchPage,
  listApps, openApp, closeApp
};

这样 Agent.js 只需要写:

import { ls, read, copy, search, listApps, getBasePath } from './tools/index.js';

不用关心这些函数原来在哪个文件里。

5.3 工作区路径:一个特殊的“工具”

path.js 里只有一个函数:getBasePath()

import { loadConfig } from '../../config.js';

function getBasePath() {
  const cfg = loadConfig();
  return cfg.workspace || 'D:\\';
}

export { getBasePath };

为什么把它单独拎出来?因为这个函数会被所有文件操作工具用到。每个文件工具在执行前,都需要知道“工作区根目录在哪里”,才能把相对路径转成绝对路径。

把它单独放一个文件,避免了循环依赖(file.js 导入 path.jspath.js 不依赖其他工具文件)。


六、手把手:添加一个新工具

假设我们需要一个 rename 工具,用来重命名文件或文件夹。我们一步步来。

6.1 第一步:在对应的分类文件里写函数

rename 属于文件操作,所以在 file.js 里添加:

/**
 * 重命名文件或目录
 * @param {string} oldPath - 当前的路径
 * @param {string} newPath - 新的路径
 * @returns {Promise<Object>} 结果对象
 */
async function rename(oldPath, newPath) {
  // 1. 获取工作区根目录
  const basePath = getBasePath();
  
  // 2. 把相对路径转成绝对路径
  const fullOldPath = path.isAbsolute(oldPath) ? oldPath : path.join(basePath, oldPath);
  const fullNewPath = path.isAbsolute(newPath) ? newPath : path.join(basePath, newPath);
  
  // 3. 执行重命名
  try {
    await fs.rename(fullOldPath, fullNewPath);
    return { success: true, data: `重命名完成: ${oldPath}${newPath}` };
  } catch (error) {
    return { success: false, error: `重命名失败: ${error.message}` };
  }
}

注意三点:

  1. 所有路径都要基于 getBasePath(),防止越权
  2. try-catch 捕获错误,返回统一格式
  3. 成功时返回的 data 是字符串,便于 AI 理解

6.2 第二步:在 file.js 的导出列表里加上它

// file.js 最后
export { ls, read, copy, mkdir, create, rename };

6.3 第三步:在 index.js 里重新导出

// tools/index.js
import { ls, read, copy, mkdir, create, rename } from './file.js';

export {
  // ... 其他
  rename
};

6.4 第四步:在 Agent.js 里添加执行分支

先在文件顶部导入:

import {
  ls, read, copy, mkdir, create, rename,  // 加上 rename
  search, browse, fetchPage,
  listApps, openApp, closeApp,
  getBasePath
} from './tools/index.js';

然后在 executeToolswitch 里加一个 case

case 'rename':
  return await rename(args[0], args[1]);

6.5 第五步:告诉 AI 这个新工具的存在

prompt.jsbuildSystemPrompt 函数里,找到工具列表,加上一行:

## 可用工具
- ls(path) - 列出目录内容
- read(path) - 读取文件内容
- copy(src, dest) - 复制文件
- rename(oldPath, newPath) - 重命名文件或目录  // 新加的

6.6 完成

五步之后,AI 就知道了 rename 的存在。下次它需要重命名时,就会输出:

[TOOL] rename("old.txt", "new.txt") [/TOOL]

程序会解析并执行,然后把结果告诉 AI。


七、小结:从暗号到动作的完整路径

这一篇,我们走通了从“AI 输出文字”到“程序执行操作”的完整路径:

AI 输出 “[TOOL] ls(“src”) [/TOOL]”
        ↓
正则解析 → { tool: “ls”, args: [“src”] }
        ↓
executeTool switch 分发 → 调用 ls(“src”)
        ↓
ls 函数读取目录 → { success: true, data: […] }
        ↓
结果写回对话历史 → AI 下次能看到
        ↓
终端显示执行结果(灰色小框)

核心要点回顾

问题 答案
AI 怎么告诉程序想做什么? [TOOL] 标记协议
程序怎么解析? 正则表达式提取工具名和参数
程序怎么知道谁执行? switch 语句分发
工具函数要长什么样? 遵守 { success, data/error } 契约
结果怎么让 AI 知道? 写回对话历史
怎么加新工具? 五步:写函数 → 导出 → 注册 → 加提示词

下一篇预告:文件管理详解

我们将深入 file.js,看看:

  • 如何安全地读取文件(二进制检测、自动截断)
  • 如何防止 AI 越权访问(路径归一化 + 边界检查)
  • ls 的格式化输出是怎么做出目录树效果的

如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!

👉 https://gitee.com/cnt-code/cogito-agent 👈

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐