1. 多 Skills 打架这件事,我踩过不止一次
如果你正在用 LangChain 搭 Agent,并且给同一个 Agent 挂了三个以上的 Skills,大概率见过这些症状:模型一会儿按技术文档的格式输出,一会儿又切成测试用例的模板;工具调用时明明该读文件,它偏偏去调搜索;上下文窗口被一堆 Skill 提示词塞满,真正对话没几轮就开始丢历史。这不是模型变笨了,而是多 Skills 共享同一个上下文导致的指令污染。
LangChain 的 Skills 机制本质是把专业化提示词和知识注入 Agent 上下文。挂一个 Skill 时行为清晰,挂四个就变成四套工作流在同一个脑子里抢方向盘。我试过用提示词里加优先级说明来压制,效果有限,因为模型在工具选择阶段依然会被不同 Skill 推荐的工具体系干扰。
SubAgent 模式解决的就是这个问题:主 Agent 只负责路由判断,每个子 Agent 只挂自己需要的 Skills,上下文互相隔离。子 Agent 内部的工具调用过程对主 Agent 不可见,只返回最终文本结果。这样主 Agent 的对话历史不会被十几轮工具调用日志挤占,Token 消耗也大幅下降。
这篇内容面向已经在写 LangChain Agent、但被多 Skills 冲突卡住的开发者。我会用 TaoToken 作为统一的 Key 和 API 通道,把主 Agent 和子 Agent 的模型调用收敛到一个入口,然后给出可复制的config.toml与settings.json配置骨架,最后用一次路由验证确认子 Agent 隔离生效。整套流程可以在本地复现。
2. 为什么用 TaoToken 统一 Key,而不是每个 Agent 各配一套
SubAgent 架构下,你会同时跑主 Agent 和多个子 Agent。如果每个 Agent 各自读环境变量、各自配 base_url 和 api_key,会出现三个麻烦:一是密钥散落在多个文件里,轮换时容易漏改;二是不同 Agent 可能指向不同通道,排查问题时无法判断是模型行为差异还是通道差异;三是子 Agent 动态 import 时如果环境变量没加载,会直接报鉴权失败。
TaoToken 在这里的角色是统一 Key 与 API 通道。你只需要在 TaoToken 控制台创建一个 API Key,然后在配置层把它注入给所有 Agent。主 Agent 和子 Agent 共用同一个 base_url 和 key,模型调用链路一致,出问题时只需要看一处日志。
需要提前准备的东西:
- 一个 TaoToken 账号,在控制台创建一个 API Key
- 本地 Node.js 环境(建议 18 以上),因为要用到动态
import() - 一个 LangChain 项目骨架,能跑通单 Agent 的基础调用
TaoToken 的 API 入口是https://taotoken.net/api,这个地址在配置里会作为base_url使用。控制台创建 Key 的入口在 console,Key 管理在 api-keys。如果你还没决定用哪个模型,可以先去 模型对话 试一下调用效果,确认通道可用再写进配置。
注意:不要把 API Key 硬编码进 Agent 源码。SubAgent 会动态 import 多个模块,硬编码会导致密钥出现在多个文件里,后续维护成本很高。
3. 可复制的配置骨架:config.toml 与 settings.json
这一节给出两个配置文件。config.toml负责声明模型通道和默认参数,settings.json负责声明 Agent 与 Skills 的映射关系。两者配合,主 Agent 和子 Agent 都从同一份配置读取 Key。
3.1 config.toml:统一模型通道
# config.toml # TaoToken 统一 Key 与 API 通道配置 [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" # 从环境变量读取,不写死 [models.default] model = "claude-sonnet-4-5" temperature = 0.3 max_tokens = 4096 [models.router] # 主 Agent 用于路由判断,温度调低让决策更稳定 model = "claude-sonnet-4-5" temperature = 0.1 max_tokens = 1024 [subagent] max_history_length = 15 # 传给子 Agent 的历史消息窗口 default_timeout_ms = 60000这里的关键点是api_key_env。所有 Agent 都通过环境变量拿 Key,而不是各自读不同的配置项。启动前执行:
export TAOTOKEN_API_KEY="你的_TaoToken_Key"如果你在 Windows PowerShell 下:
$env:TAOTOKEN_API_KEY="你的_TaoToken_Key"3.2 settings.json:Agent 与 Skills 映射
{ "agents": { "main-assistant": { "role": "router", "model": "models.router", "subagents": ["chart-agent", "code-assistant-agent", "env-resolver-agent"], "skills": [] }, "chart-agent": { "role": "subagent", "model": "models.default", "skills": ["mermaid"], "description": "负责生成流程图、架构图等图表内容" }, "code-assistant-agent": { "role": "subagent", "model": "models.default", "skills": ["git-clone", "dev-design"], "description": "负责代码分析、仓库操作与技术设计" }, "env-resolver-agent": { "role": "subagent", "model": "models.default", "skills": ["env-resolver"], "description": "负责环境排查与依赖问题定位" } }, "subagent_registry": { "chart-agent": { "import_path": "@/agents/chart-agent", "name": "图表生成助手" }, "code-assistant-agent": { "import_path": "@/agents/code-assistant-agent", "name": "代码助手" }, "env-resolver-agent": { "import_path": "@/agents/env-resolver-agent", "name": "环境排查助手" } } }注意main-assistant的skills是空数组。这是 SubAgent 模式的核心:主 Agent 不挂任何 Skill,只保留路由能力。每个子 Agent 的skills只包含自己需要的项,互不重叠。
3.3 子 Agent 注册表:动态 import 打破循环依赖
子 Agent 模块会导入@/agents/tools,而注册表也在该目录下,直接静态导入会形成循环依赖。用动态import()惰性加载解决:
// src/agents/tools/subagentTools.js const SUBAGENT_REGISTRY = { 'chart-agent': { import: () => import('@/agents/chart-agent'), name: '图表生成助手', description: '生成流程图、架构图,输入为结构化描述,输出为 mermaid 代码' }, 'code-assistant-agent': { import: () => import('@/agents/code-assistant-agent'), name: '代码助手', description: '分析代码仓库、执行 git 操作、输出技术设计文档' }, 'env-resolver-agent': { import: () => import('@/agents/env-resolver-agent'), name: '环境排查助手', description: '定位依赖冲突、环境变量缺失、版本不匹配问题' } }; export function getSubagentTools({ include, exclude, userId, sessionId, messages, maxHistoryLength = 15 }) { let names = Object.keys(SUBAGENT_REGISTRY); if (include) names = names.filter(n => include.includes(n)); if (exclude) names = names.filter(n => !exclude.includes(n)); return names.map(agentName => ({ name: 'call_agent', description: `委派任务给子 Agent。可选值:${names.join(', ')}`, schema: { type: 'object', properties: { agentName: { type: 'string', enum: names }, description: { type: 'string', description: '任务描述' }, includeHistory: { type: 'boolean', default: true } }, required: ['agentName', 'description'] }, func: async ({ agentName, description, includeHistory }) => { const entry = SUBAGENT_REGISTRY[agentName]; if (!entry) return `未知子 Agent: ${agentName}`; try { const mod = await entry.import(); const agent = await mod.default.createAgent(); const input = includeHistory ? [...messages.slice(-maxHistoryLength), `[委派任务] ${description}`] : [description]; const result = await agent.invoke({ messages: input }); return result.messages.at(-1).content; } catch (error) { return `子 Agent 调用失败: ${error.message}`; } } })); }这段代码里有两个设计点值得说明。第一,call_agent采用 Single Dispatch 模式,一个工具加agentName参数选择子 Agent,比每个子 Agent 一个独立工具更好管理。第二,catch块把错误转成文本返回给主 Agent,子 Agent 崩溃不会阻塞主流程,主 Agent 收到失败信息后可以用自身工具补救。
4. 验证请求:确认路由与隔离生效
配置写完后,需要验证三件事:主 Agent 是否按预期委派、子 Agent 是否只加载自己的 Skills、主 Agent 上下文是否只增加一条 ToolMessage。
4.1 启动与基础调用
export TAOTOKEN_API_KEY="你的_TaoToken_Key" node src/main.js在代码里发起一次会触发委派的请求:
// src/main.js import { createMainAgent } from '@/agents/main-assistant'; const agent = await createMainAgent(); const result = await agent.invoke({ messages: [ { role: 'user', content: '帮我画一个 SubAgent 调用流程图,然后检查一下我的 Node 版本是否满足要求' } ] }); console.log(result.messages.at(-1).content);这条请求同时涉及图表和环境排查,预期主 Agent 会在单轮中调用两次call_agent,分别委派给chart-agent和env-resolver-agent。
4.2 观察路由日志
在call_agent的func里加一行日志:
console.log(`[route] 委派给 ${agentName}, 任务: ${description.slice(0, 50)}`);预期输出类似:
[route] 委派给 chart-agent, 任务: 画一个 SubAgent 调用流程图 [route] 委派给 env-resolver-agent, 任务: 检查 Node 版本是否满足要求如果只看到一次委派,说明主 Agent 把两个任务合并处理了,需要检查models.router的 temperature 是否过高,或者call_agent的 description 是否足够清晰。
4.3 确认上下文隔离
在call_agent返回前打印主 Agent 收到的消息条数:
const before = messages.length; // ... 执行子 Agent const after = messages.length; console.log(`[context] 主 Agent 消息数: ${before} -> ${after}`);预期after - before等于 1,即只增加一条 ToolMessage。如果子 Agent 内部的工具调用过程泄漏到主 Agent,这个差值会明显大于 1。这正是 SubAgent 隔离带来的 Token 节省:子 Agent 内部跑十几次工具调用,主 Agent 只看到一条最终结果。
4.4 用模型对话快速验证通道
如果你在配置阶段想先确认 TaoToken 通道本身可用,可以打开 模型对话 发一条测试消息。通道正常后再回到本地跑 Agent,能排除掉鉴权类问题。
5. 本篇常见错排查
5.1 子 Agent 报鉴权失败
现象:主 Agent 正常,委派后返回子 Agent 调用失败: 401。
原因通常是子 Agent 动态 import 时环境变量未加载,或者子 Agent 内部自己读了一个不存在的配置项。排查步骤:在call_agent的func开头打印process.env.TAOTOKEN_API_KEY是否存在。如果为空,检查启动命令是否在同一 shell 里 export。如果存在但仍 401,检查config.toml里的base_url是否写成了https://taotoken.net/api,不要多加路径后缀。
5.2 主 Agent 不委派,自己硬答
现象:请求里明确提到图表,但主 Agent 没有调用call_agent,直接输出了一段文字描述。
原因有两个方向。一是call_agent的 description 没有说清楚子 Agent 的能力边界,主 Agent 判断自己也能做。解决方法是把每个子 Agent 的 description 写具体,比如「生成 mermaid 格式的流程图代码」比「负责图表」更有效。二是models.router的 temperature 偏高,路由决策不稳定。把 temperature 降到 0.1 再试。
5.3 循环依赖导致启动报错
现象:ReferenceError: Cannot access 'SUBAGENT_REGISTRY' before initialization。
这是静态导入顺序问题。确认子 Agent 注册表里用的是() => import(...)而不是顶部import。动态 import 是惰性的,只有call_agent被调用时才加载子 Agent 模块,从而打破循环。
5.4 子 Agent 返回内容被截断
现象:子 Agent 返回的文本不完整,主 Agent 拿到的结果缺尾巴。
检查config.toml里models.default的max_tokens。子 Agent 内部可能有多轮工具调用,最终结果加上中间推理会消耗较多 token。如果max_tokens设得太小,最终文本会被截断。建议子 Agent 用 4096 起步,主 Agent 路由用 1024 即可。
5.5 新增子 Agent 后主 Agent 不认识
现象:在注册表里加了新子 Agent,但主 Agent 的call_agent枚举里没有它。
原因是call_agent的schema.enum是在getSubagentTools调用时生成的。如果你在 Agent 创建之后才改注册表,需要重启进程。另外确认新子 Agent 同时注册到了settings.json的subagent_registry和代码里的SUBAGENT_REGISTRY,两处缺一不可。
6. 把 Key 收敛到一处,把职责拆到多个 Agent
SubAgent 模式的价值不在于多了一层调用,而在于职责隔离。主 Agent 不挂 Skill,只做路由;每个子 Agent 只挂自己的 Skills,上下文互不污染;子 Agent 内部的工具调用过程对主 Agent 不可见,主 Agent 的对话历史保持干净。这套结构在 Skills 数量增长时优势会越来越明显。
TaoToken 在这里承担的是统一 Key 与 API 通道的角色。主 Agent 和所有子 Agent 共用同一个base_url和api_key,配置只写一份,轮换只改一处。如果你准备把这套结构用到长期编码或 Agent 工作流里,可以看一下 Coding Plan,它更适合持续性的 Agent 调用场景。接入细节和参数说明在 接入文档 里,Key 的创建和管理在 api-keys。
最后留一个实操建议:新增子 Agent 时,先在settings.json里把skills数组写清楚,再写 Agent 文件,最后注册到SUBAGENT_REGISTRY。顺序反了容易出现「Agent 能跑但主 Agent 不委派」的情况,因为call_agent的枚举依赖注册表。