第 7 步 · 技能系统

工具分组 + 按需加载 —— 从"全量发工具"到"先选技能、再加载工具"

配套可运行项目:f:\myagent\task7 | 前置:已完成 Step 6(对话历史)

本步的心智转变:把"整个工具箱"变成"先选抽屉、再开抽屉"

task 3~6 把所有工具一次性发给模型(tools 数组)。工具一多就出问题。

全量发工具有什么问题?浪费 token:每个工具的定义(名字、说明、参数 schema)都是请求的一部分,工具越多每次请求越贵;② 互相干扰:工具多了模型可能选错——问天气时调了计算工具;③ 安全面扩大:所有工具对模型可见,等于把所有"能力开关"都亮在它面前。技能的思路:给工具分组(技能),让模型先用一句话选组,选中后才把该组的工具发给它
本步要做的三件事 ① 新建 src/skills.tsSkill 接口 + skills 注册表(weather/calc 两个技能)+ select_skill 选择器工具 + loadSkillTools() 按需加载;② 改造 src/agent.ts:主循环变成两步走(第 1 轮只发选择器 → 选完技能后把工具加载进 currentTools),新增 select_skill 分支;③ main.tshistory.tsllm.tstools.ts 一行不改。
本步最大的坑:两个工具调用分支的区别 agent 里出现两种"工具调用":select_skill(选技能)普通工具(get_weather / calculate)。select_skill 来了是切换工具集,不是执行工具;而且切换后要 continue 而不是 break——必须给模型下一轮"用新工具"的机会。把这两个分支写混了(比如把技能名当成真实工具去 executeTool),模型就永远用不上工具。另一个隐蔽的坑:select_skill 的 description 里必须列出所有技能名——模型靠它才知道"有哪些抽屉"。
7

技能系统(按需加载:先选技能 → 再加载工具)

目标:模型第一轮只能看到 select_skill;选了 weather 后才看到 get_weather

7.1 新建 src/skills.ts:技能接口 + 注册表 + 选择器 + 按需加载

新文件,四个出口:Skill 接口、skills 注册表、skillSelectorTool 选择器、loadSkillTools() 加载函数。

src/skills.ts(本步新增)
为什么"选择"本身也做成一个工具? 让模型选技能有两种做法:让它用自然语言说"我要用天气技能",或者让它调用 select_skill 并填 JSON 参数。后者是协议里模型唯一可靠的"结构化输出"通道:skill_name 必须是一个合法技能名(enum 约束),程序解析 args.skill_name 即可,不需要读自然语言。这就是"选择即调用"——把决策变成协议能表达的动作。
toolNames 存名字而不是对象:靠名字约定 toolNames: ["get_weather"] 存的是字符串loadSkillToolstools.filter(t => skill.toolNames.includes(t.function.name)) 按名字筛;executeTool(tools.ts)也按名字分发。两个文件之间只靠"工具名"这一个约定连接,和 tools.ts 里 schema 的 function.name 保持一致——改名要三处同步(tools.ts 定义、skills.ts 引用、select_skill description)。
常见坑 ① 新增技能后忘了更新 select_skill 的 description 和 enum:模型根本不知道有这个技能、或者填了 enum 之外的参数被拒;② skills.find 没处理 undefined:技能名写错直接抛错,要返回空数组兜底;③ 工具定义改了 function.name 但 skills.ts 没同步:筛出来空数组,模型"选了技能却没工具"。

7.2 改造 src/agent.ts:两步走 + currentTools 状态

核心变化:① currentTools 变量——初始只有选择器,选完技能后换成该技能的工具;② 每轮请求只带 currentTools;③ 新增分支 A(select_skill:切换技能、不执行工具、continue)和分支 B(普通工具)。

src/agent.ts(升级)
升级前:src/agent.ts(task 6,全量发工具)
为什么先选技能再加载工具(两步走)? 按需加载的本质就是"缩小模型的视野"。第一步只给模型选择器,它的"选择空间"只有技能名;选完之后,它这一轮以及后续轮次只能看到该技能的工具。三个收益同时到来:token 省了(没选的工具定义永远不发)、选错率降了(可见的工具少了)、安全面小了(没选的工具对模型不可见,也就无法被调用)。代价是多一次往返——但换来的是可扩展性:加第 10 个技能时,主循环一行都不用改。
currentTools 是"当前可见的工具菜单" currentTools 在每一轮都被传给 askLLM——模型每轮能"看到"什么,完全由这个变量决定。初始是 [skillSelectorTool];分支 A 里选完技能后变成 loadSkillTools(currentSkill)。注意:选完技能后选择器被替换掉了,如果想再切技能,模型需要先回答或由用户再提问触发新的一轮。这正是"抽屉"的比喻:打开 weather 抽屉后,工具箱收起来了。
常见坑 ① 分支 A 里调了 executeTool:select_skill 没有对应实现,会执行失败——它只负责切换状态;② 分支 A 末尾漏了 continue:会继续走分支 B,把 select_skill 当成真实工具执行;③ currentToolsconst:重新赋值直接报错(必须 let);④ 日志里 [当前工具] 每轮打印当前工具集,是观察"按需加载是否生效"的窗口——第一轮永远只有 select_skill

7.3 运行验收:天气问题走 select_skill → get_weather

npm run chat 后,重点观察 [当前工具] 这一行:第一轮只有 select_skill,选完技能后变成 get_weather

终端 · 运行命令
验收标准(来自课程大纲) ① 问天气时第一轮日志 [当前工具] select_skill,选完技能后变 get_weather(按需加载生效);② 跨轮记忆仍正常(历史是 task 6 的能力,本步沿用);③ 连续对话 + 截断正常;④ npm run agent 单次用法不变。四条都满足,task 7 通关。
常见坑 ① 第一轮直接出现 [调用工具] get_weather:说明模型"看到了"不该看到的工具——检查是否把全量 tools 传给了 askLLM(必须传 currentTools);② 选完技能后模型还在调 select_skill:正常,模型偶尔会再选一次,分支 A 已兜底(重复选择也安全);③ 日志显示 [当前工具] (空)loadSkillTools 返回了空数组——检查技能名与 toolNames 是否拼写一致。
?

字段来源速查表(task 7 新增:技能系统相关)

前几步的速查表在 agent-tutorial0123.html / task4.html / task5.html / task6.html。这里只列 task 7 新增/涉及的名字。判断口诀不变:上网络的键名 = 协议定;只在你代码里出现的 = 你定,但要对齐。

字段 / 值能不能改谁规定的说明
Skill 接口字段 name / description / toolNames自己起你自己技能 = 名字 + 给模型看的说明 + 包含的工具名列表
skills 注册表数组自己起你自己新增技能 = 加一条 + 工具已在 tools.ts + 同步 select_skill 的 description/enum
工具名 select_skill 与参数 skill_name自己起你自己模型通过 JSON 参数填技能名做选择;改名要三处同步
skillSelectorTooltype / function / name / parameters 键名不能改OpenAI 兼容协议与 task 3 工具 schema 相同的结构,只是内容换成了"选择器"
loadSkillTools(skillName)自己起你自己按技能名从全量 tools 筛选;返回空数组 = 未知技能
currentTools 变量自己起你自己当前发给模型/唯一可见的工具集;初始 [skillSelectorTool],选完技能后替换
分支 A(select_skill)vs 分支 B(真实工具)自己起你自己选择只切换状态不执行工具,且末尾 continue 给模型下一轮机会
消息 role / tool_call_id / 截断相关不能改协议 / task 6本步沿用 task 6,见 task6.html
一句话记住 task 7 "选择即调用,视野即安全"。技能 = 工具分组;把"选技能"本身做成工具(select_skill);currentTools 决定模型每轮"看得到什么"——初始只有选择器,选完技能才按需加载该技能的工具。新增技能只需在 skills.ts 加一条 + 同步 select_skill 的说明,主循环一行不改。
← Home