工具系统让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’)”两件事之间没有分隔符程序不知道从哪里切分。❌ 不行。方案三JSONAI说{“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 的工具调用规定参数之间用逗号分隔但如果参数用引号包裹引号内的逗号不作为分隔符。实现思路按逗号分割然后去掉引号。functionparseArgs(argsStr){constresult[];constpartsargsStr.split(,);for(constpartofparts){consttrimmedpart.trim();// 如果被引号包裹去掉首尾的引号if((trimmed.startsWith()trimmed.endsWith())||(trimmed.startsWith()trimmed.endsWith())){result.push(trimmed.slice(1,-1));}else{result.push(trimmed);}}returnresult;}注意这个实现有个边界问题——如果参数本身包含转义引号比如“he said \“hello\””会解析错误。但对于当前的需求已经够用了。后面需要再改进。2.3 提取函数的最终形态functionparseAllToolCalls(text){constresults[];constregex/\[TOOL\]\s*(\w)\s*\(([^)]*)\)\s*\[\/TOOL\]/g;letmatch;while((matchregex.exec(text))!null){results.push({tool:match[1],args:parseArgs(match[2])});}returnresults;}举个例子输入[TOOL] ls(src) [/TOOL] 和 [TOOL] read(README.md) [/TOOL]输出[{tool:ls,args:[src]},{tool:read,args:[README.md]}]三、分发执行找到对应的“动作”现在我们有了工具名和参数接下来要做的就是找到真正干活的函数。3.1 一个直观的想法switch语句asyncfunctionexecuteTool(tool,args){switch(tool){casels:returnawaitls(args[0]);caseread:returnawaitread(args[0]);casecopy:returnawaitcopy(args[0],args[1]);casesearch:// 搜索要把所有参数用逗号拼起来因为搜索词本身可能含逗号returnawaitsearch(args.join(,));casebrowse:returnawaitbrowse(args[0]);casefetchPage:returnawaitfetchPage(args[0]);caselistApps:returnawaitlistApps();caseopenApp:returnawaitopenApp(args[0]);casecloseApp:returnawaitcloseApp(args[0]);default:return{success:false,error:未知工具:${tool}};}}这里有一个细节值得注意为什么不同工具的参数处理方式不一样工具参数处理原因lsargs[0]只有一个参数直接取第一个copyargs[0], args[1]两个参数源路径和目标路径searchargs.join(‘,’)搜索词可能包含逗号用户输入时逗号是合法的你看search的处理方式就不一样。如果我们用对象映射{ ls, read, copy }[tool]那种“优雅”的方式就没法针对每个工具定制参数处理逻辑。switch虽然代码长一点但灵活性更高。3.2 所有工具必须遵守的“契约”为了让executeTool能统一处理每个工具函数必须遵守同样的规则// 契约// 1. 必须是异步函数返回 Promise// 2. 必须返回 { success: boolean, data?: any, error?: string }asyncfunctionsomeTool(param1,param2){try{constresultawaitdoSomething(param1,param2);return{success:true,data:result};}catch(error){return{success:false,error:error.message};}}这样设计的好处是什么好处一调用方不用每个工具都写 try-catch// 如果不统一格式每个调用都要这样try{constresultawaitls(src);// 处理 result}catch(error){// 处理错误}// 有了统一格式就这样constresultawaitls(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 没有“内存”它唯一的信息来源是对话历史。所以我们把结果写成文字添加到对话历史里。// 执行完工具后constresultawaitexecuteTool(tool,args);// 把结果写成一段文字附在 AI 原来的回复后面consttoolResultMessagefullResponse\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,createweb.js联网能力search,browse,fetchPagesystem.js系统控制listApps,openApp,closeApp为什么这么分修改时只关注一个文件要改文件操作功能只需要打开file.js新增一类工具很方便想加一个“图像处理”类别新建image.js就行依赖隔离system.js用到了child_process调用系统命令但file.js不需要知道这个5.2 统一入口index.js虽然工具分散在不同文件里但Agent.js只想从一个地方导入它们。所以我们建一个index.js作为“总出口”// tools/index.jsimport{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;functiongetBasePath(){constcfgloadConfig();returncfg.workspace||D:\\;}export{getBasePath};为什么把它单独拎出来因为这个函数会被所有文件操作工具用到。每个文件工具在执行前都需要知道“工作区根目录在哪里”才能把相对路径转成绝对路径。把它单独放一个文件避免了循环依赖file.js导入path.jspath.js不依赖其他工具文件。六、手把手添加一个新工具假设我们需要一个rename工具用来重命名文件或文件夹。我们一步步来。6.1 第一步在对应的分类文件里写函数rename属于文件操作所以在file.js里添加/** * 重命名文件或目录 * param {string} oldPath - 当前的路径 * param {string} newPath - 新的路径 * returns {PromiseObject} 结果对象 */asyncfunctionrename(oldPath,newPath){// 1. 获取工作区根目录constbasePathgetBasePath();// 2. 把相对路径转成绝对路径constfullOldPathpath.isAbsolute(oldPath)?oldPath:path.join(basePath,oldPath);constfullNewPathpath.isAbsolute(newPath)?newPath:path.join(basePath,newPath);// 3. 执行重命名try{awaitfs.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.jsimport{ls,read,copy,mkdir,create,rename}from./file.js;export{// ... 其他rename};6.4 第四步在Agent.js里添加执行分支先在文件顶部导入import{ls,read,copy,mkdir,create,rename,// 加上 renamesearch,browse,fetchPage,listApps,openApp,closeApp,getBasePath}from./tools/index.js;然后在executeTool的switch里加一个casecaserename:returnawaitrename(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 支持一下开源项目 https://gitee.com/cnt-code/cogito-agent
工具系统:让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’)”两件事之间没有分隔符程序不知道从哪里切分。❌ 不行。方案三JSONAI说{“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 的工具调用规定参数之间用逗号分隔但如果参数用引号包裹引号内的逗号不作为分隔符。实现思路按逗号分割然后去掉引号。functionparseArgs(argsStr){constresult[];constpartsargsStr.split(,);for(constpartofparts){consttrimmedpart.trim();// 如果被引号包裹去掉首尾的引号if((trimmed.startsWith()trimmed.endsWith())||(trimmed.startsWith()trimmed.endsWith())){result.push(trimmed.slice(1,-1));}else{result.push(trimmed);}}returnresult;}注意这个实现有个边界问题——如果参数本身包含转义引号比如“he said \“hello\””会解析错误。但对于当前的需求已经够用了。后面需要再改进。2.3 提取函数的最终形态functionparseAllToolCalls(text){constresults[];constregex/\[TOOL\]\s*(\w)\s*\(([^)]*)\)\s*\[\/TOOL\]/g;letmatch;while((matchregex.exec(text))!null){results.push({tool:match[1],args:parseArgs(match[2])});}returnresults;}举个例子输入[TOOL] ls(src) [/TOOL] 和 [TOOL] read(README.md) [/TOOL]输出[{tool:ls,args:[src]},{tool:read,args:[README.md]}]三、分发执行找到对应的“动作”现在我们有了工具名和参数接下来要做的就是找到真正干活的函数。3.1 一个直观的想法switch语句asyncfunctionexecuteTool(tool,args){switch(tool){casels:returnawaitls(args[0]);caseread:returnawaitread(args[0]);casecopy:returnawaitcopy(args[0],args[1]);casesearch:// 搜索要把所有参数用逗号拼起来因为搜索词本身可能含逗号returnawaitsearch(args.join(,));casebrowse:returnawaitbrowse(args[0]);casefetchPage:returnawaitfetchPage(args[0]);caselistApps:returnawaitlistApps();caseopenApp:returnawaitopenApp(args[0]);casecloseApp:returnawaitcloseApp(args[0]);default:return{success:false,error:未知工具:${tool}};}}这里有一个细节值得注意为什么不同工具的参数处理方式不一样工具参数处理原因lsargs[0]只有一个参数直接取第一个copyargs[0], args[1]两个参数源路径和目标路径searchargs.join(‘,’)搜索词可能包含逗号用户输入时逗号是合法的你看search的处理方式就不一样。如果我们用对象映射{ ls, read, copy }[tool]那种“优雅”的方式就没法针对每个工具定制参数处理逻辑。switch虽然代码长一点但灵活性更高。3.2 所有工具必须遵守的“契约”为了让executeTool能统一处理每个工具函数必须遵守同样的规则// 契约// 1. 必须是异步函数返回 Promise// 2. 必须返回 { success: boolean, data?: any, error?: string }asyncfunctionsomeTool(param1,param2){try{constresultawaitdoSomething(param1,param2);return{success:true,data:result};}catch(error){return{success:false,error:error.message};}}这样设计的好处是什么好处一调用方不用每个工具都写 try-catch// 如果不统一格式每个调用都要这样try{constresultawaitls(src);// 处理 result}catch(error){// 处理错误}// 有了统一格式就这样constresultawaitls(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 没有“内存”它唯一的信息来源是对话历史。所以我们把结果写成文字添加到对话历史里。// 执行完工具后constresultawaitexecuteTool(tool,args);// 把结果写成一段文字附在 AI 原来的回复后面consttoolResultMessagefullResponse\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,createweb.js联网能力search,browse,fetchPagesystem.js系统控制listApps,openApp,closeApp为什么这么分修改时只关注一个文件要改文件操作功能只需要打开file.js新增一类工具很方便想加一个“图像处理”类别新建image.js就行依赖隔离system.js用到了child_process调用系统命令但file.js不需要知道这个5.2 统一入口index.js虽然工具分散在不同文件里但Agent.js只想从一个地方导入它们。所以我们建一个index.js作为“总出口”// tools/index.jsimport{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;functiongetBasePath(){constcfgloadConfig();returncfg.workspace||D:\\;}export{getBasePath};为什么把它单独拎出来因为这个函数会被所有文件操作工具用到。每个文件工具在执行前都需要知道“工作区根目录在哪里”才能把相对路径转成绝对路径。把它单独放一个文件避免了循环依赖file.js导入path.jspath.js不依赖其他工具文件。六、手把手添加一个新工具假设我们需要一个rename工具用来重命名文件或文件夹。我们一步步来。6.1 第一步在对应的分类文件里写函数rename属于文件操作所以在file.js里添加/** * 重命名文件或目录 * param {string} oldPath - 当前的路径 * param {string} newPath - 新的路径 * returns {PromiseObject} 结果对象 */asyncfunctionrename(oldPath,newPath){// 1. 获取工作区根目录constbasePathgetBasePath();// 2. 把相对路径转成绝对路径constfullOldPathpath.isAbsolute(oldPath)?oldPath:path.join(basePath,oldPath);constfullNewPathpath.isAbsolute(newPath)?newPath:path.join(basePath,newPath);// 3. 执行重命名try{awaitfs.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.jsimport{ls,read,copy,mkdir,create,rename}from./file.js;export{// ... 其他rename};6.4 第四步在Agent.js里添加执行分支先在文件顶部导入import{ls,read,copy,mkdir,create,rename,// 加上 renamesearch,browse,fetchPage,listApps,openApp,closeApp,getBasePath}from./tools/index.js;然后在executeTool的switch里加一个casecaserename:returnawaitrename(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 支持一下开源项目 https://gitee.com/cnt-code/cogito-agent