工具分组 + 按需加载 —— 从"全量发工具"到"先选技能、再加载工具"
task 3~6 把所有工具一次性发给模型(tools 数组)。工具一多就出问题。
src/skills.ts:Skill 接口 + skills 注册表(weather/calc 两个技能)+ select_skill 选择器工具 + loadSkillTools() 按需加载;② 改造 src/agent.ts:主循环变成两步走(第 1 轮只发选择器 → 选完技能后把工具加载进 currentTools),新增 select_skill 分支;③ main.ts、history.ts、llm.ts、tools.ts 一行不改。continue 而不是 break——必须给模型下一轮"用新工具"的机会。把这两个分支写混了(比如把技能名当成真实工具去 executeTool),模型就永远用不上工具。另一个隐蔽的坑:select_skill 的 description 里必须列出所有技能名——模型靠它才知道"有哪些抽屉"。目标:模型第一轮只能看到 select_skill;选了 weather 后才看到 get_weather。
src/skills.ts:技能接口 + 注册表 + 选择器 + 按需加载新文件,四个出口:Skill 接口、skills 注册表、skillSelectorTool 选择器、loadSkillTools() 加载函数。
skill_name 必须是一个合法技能名(enum 约束),程序解析 args.skill_name 即可,不需要读自然语言。这就是"选择即调用"——把决策变成协议能表达的动作。toolNames 存名字而不是对象:靠名字约定
toolNames: ["get_weather"] 存的是字符串。loadSkillTools 用 tools.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 没同步:筛出来空数组,模型"选了技能却没工具"。src/agent.ts:两步走 + currentTools 状态核心变化:① currentTools 变量——初始只有选择器,选完技能后换成该技能的工具;② 每轮请求只带 currentTools;③ 新增分支 A(select_skill:切换技能、不执行工具、continue)和分支 B(普通工具)。
currentTools 是"当前可见的工具菜单"
currentTools 在每一轮都被传给 askLLM——模型每轮能"看到"什么,完全由这个变量决定。初始是 [skillSelectorTool];分支 A 里选完技能后变成 loadSkillTools(currentSkill)。注意:选完技能后选择器被替换掉了,如果想再切技能,模型需要先回答或由用户再提问触发新的一轮。这正是"抽屉"的比喻:打开 weather 抽屉后,工具箱收起来了。executeTool:select_skill 没有对应实现,会执行失败——它只负责切换状态;② 分支 A 末尾漏了 continue:会继续走分支 B,把 select_skill 当成真实工具执行;③ currentTools 用 const:重新赋值直接报错(必须 let);④ 日志里 [当前工具] 每轮打印当前工具集,是观察"按需加载是否生效"的窗口——第一轮永远只有 select_skill。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 是否拼写一致。前几步的速查表在 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 参数填技能名做选择;改名要三处同步 |
skillSelectorTool 的 type / 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 |
select_skill);currentTools 决定模型每轮"看得到什么"——初始只有选择器,选完技能才按需加载该技能的工具。新增技能只需在 skills.ts 加一条 + 同步 select_skill 的说明,主循环一行不改。