工具系统:让AI真正“动手”做事 ——CogitoAgent开发实战(二)
工具系统:让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]
程序需要把两件事分开:
- 普通对话(“让我看看src目录里有什么。”)→ 直接显示给用户
- 工具调用(
[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 的下一步决策依赖上一步的结果。
举个例子:
- AI 调用
ls(“src”),想知道 src 目录下有什么 - 程序执行,发现有
agent/和api/两个文件夹 - AI 想继续探索
agent/里面有什么 - 但如果 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.js,path.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}` };
}
}
注意三点:
- 所有路径都要基于
getBasePath(),防止越权 - 用
try-catch捕获错误,返回统一格式 - 成功时返回的
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';
然后在 executeTool 的 switch 里加一个 case:
case 'rename':
return await rename(args[0], args[1]);
6.5 第五步:告诉 AI 这个新工具的存在
在 prompt.js 的 buildSystemPrompt 函数里,找到工具列表,加上一行:
## 可用工具
- 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 支持一下开源项目!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)