自己动手写Agent Harness【subagent】:从spawn到fork的子代理机制实现与TaoToken统一Key接入
1. 从单 Agent 到会派活为什么需要 subagent 机制单 Agent 跑复杂任务时最典型的症状是上下文越滚越乱。你让它「先读 package.json 再读 README 再对比版本」它会把三次文件读取的原始内容全部塞进同一条会话历史模型每一轮都要重新消化这些噪声。任务再大一点中途想换个思路只能把整条会话推倒重来。Agent Harness 里的 subagent子代理机制解决的就是这件事。它不是「更多 Agent」而是两件具体的事把任务拆出去把上下文隔离。拆出去主会话不被大任务的中间过程撑爆隔离子代理的草稿进不了主会话的真相。子代理的派生路径有两条语义正好相反。spawn 从零新开一个子会话子代理没有任何父会话记忆适合完全独立、可拆出去的子任务。fork 从父会话的某个切点复制前缀记忆子代理基于已有上下文继续干适合「我已经掌握 X 了接着处理 Y」这类任务。这篇文章面向自建多代理编排的开发者目标是在本地跑通一个可扩展的 subagent 调度骨架。我会给出可复制的目录结构、spawn/fork 的配置片段、一次端到端验证动作并说明如何通过 TaoToken 统一 Key 为子代理调用统一鉴权入口。整套代码零依赖Node 直接跑不需要任何 API Key 就能看到完整链路。先看目录结构这是后面所有操作的基础first-agent/ ├── step5-subagent/ │ ├── index.js # 主入口spawn/fork 演示 │ ├── test.js # 三个断言锁死隔离行为 │ ├── PRACTICE.md # 本机实测输出记录 │ └── lib/ │ ├── session-log.js # SessionLogspawn/fork 原语 │ ├── react-loop.js # ReactLoop单 loop 执行器 │ ├── tool-registry.js # ToolRegistry工具注册表 │ ├── tool-pipeline.js # ToolPipeline工具调用流水线 │ └── mock-llm.js # MockLLM确定性模型替身这个结构的关键在于subagent 没有引入任何新对象。spawn 和 fork 是 SessionLog 上的两个方法子代理的执行复用已有的 ReactLoop、ToolRegistry、ToolPipeline。你之前为单 Agent 建好的工具流水线子代理天然继承。2. TaoToken 前置给子代理调用统一鉴权入口子代理跑起来之后每个子代理都要调模型。如果 spawn 出五个子代理每个都配一套 Key、一套 Base URL管理成本会迅速失控。TaoToken 的价值在这里它提供一个统一的 API 通道主 Agent 和所有子代理共用同一个鉴权入口你只需要维护一份 Key。TaoToken 是一个大模型 API 聚合网关能做什么把不同厂商的模型调用收敛到一套 OpenAI 兼容接口上适合谁适合需要多模型切换、多子代理并发调用、又不想在每个子代理里重复配置鉴权的开发者。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。在 subagent 场景里统一 Key 的意义被放大。spawn 出来的子代理是「无记忆」的它从零开始只看到自己的 system prompt 和父 Agent 写给它的任务描述。如果每个子代理还要额外携带一套鉴权配置任务描述就得被迫塞进这些无关信息违背了「prompt 自包含」的初衷。把鉴权收敛到环境变量或统一配置层子代理的任务描述就能保持干净。具体做法是主进程启动时从环境变量读取一次 Key 和 Base URL所有子代理的 LLM 实例都从同一个配置工厂创建。这样 spawn 和 fork 的 execute 内部不需要关心鉴权只关心「派什么任务」。你需要先在 TaoToken 控制台创建一个 API Key。访问 https://taotoken.net/api-keys 生成 Key然后配置到本地环境。Windows 用 setmacOS/Linux 用 export# macOS / Linux export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api # Windows PowerShell $env:TAOTOKEN_API_KEYsk-你的key $env:TAOTOKEN_BASE_URLhttps://taotoken.net/api配置好之后子代理的 LLM 实例统一从这两个环境变量构造。下面这段是配置工厂放在 lib/llm-factory.js 里// lib/llm-factory.js // 统一鉴权入口主 Agent 和所有子代理共用同一份 Key 与 Base URL export function createLLM({ model gpt-4o-mini, temperature 0 } {}) { const apiKey process.env.TAOTOKEN_API_KEY const baseURL process.env.TAOTOKEN_BASE_URL || https://taotoken.net/api if (!apiKey) { throw new Error(缺少 TAOTOKEN_API_KEY请先配置环境变量) } return { model, temperature, apiKey, baseURL, async complete(messages, tools) { const resp await fetch(${baseURL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model, temperature, messages, tools }), }) if (!resp.ok) { const text await resp.text() throw new Error(LLM 请求失败 ${resp.status}: ${text}) } return resp.json() }, } }这段代码的关键点是spawn 和 fork 的子代理都调用 createLLM()拿到的是同一套鉴权配置。你换模型只改一个参数换 Key 只改一个环境变量所有子代理同步生效。如果你打算长期跑多代理编排、需要 Coding Plan 这类按量或包月方案可以看 https://taotoken.net/coding-plan 。如果只是想先验证模型对话是否通用 https://taotoken.net/models 直接试。3. 可复制配置spawn 与 fork 的完整实现片段这一节给出可以直接复制运行的配置和代码。核心是 SessionLog 上的两个原语以及把它们包装成工具的两个配置片段。先看 SessionLog 的 spawn 和 fork 实现。spawn 一行fork 三行语义正好相反// lib/session-log.js export class SessionLog { constructor() { this.events [] this.boundaries [] } // spawn从零新开一个子会话。与父会话零共享——这就是「上下文隔离」的实现。 spawn() { return new SessionLog() } // fork从指定 boundary默认最后一次 turn/start切子会话继承前缀记忆。 fork(seq) { const cut seq ?? this.boundaries.at(-1) if (cut undefined) return this.spawn() const child new SessionLog() child.events this.events.slice(0, cut 1).map((e) Object.freeze({ ...e })) child.boundaries this.boundaries.filter((b) b cut) return child } append(event) { this.events.push(event) if (event.type turn/start) this.boundaries.push(this.events.length - 1) } }spawn 返回一个全新的空 SessionLog父会话的事件一概不复制。fork 从最后一次 boundary 处切开复制 events[0..cut] 进子会话boundary 之后的消息不继承。接下来是把这两个原语包装成工具。这是核心主张子代理不是一个新对象它就是「工具 内部多一个 loop」。配置片段如下// step5-subagent/index.js节选 export function makeSubagentTools({ mainLog, childLLM }) { const llm childLLM ?? createLLM() const spawn_subagent { name: spawn_subagent, description: 从零新开一个子会话子代理没有任何父会话记忆独立完成一个子任务并回传结果。适合完全独立、可拆出去的任务。, parameters: { type: object, properties: { prompt: { type: string, description: 给子代理的自包含任务描述。子代理没有父会话记忆prompt 必须把任务背景说全。, }, }, required: [prompt], }, async execute(args) { return await runSubagent({ llm, prompt: args.prompt, inherited: null, name: spawn_subagent, }) }, } const fork_subagent { name: fork_subagent, description: 从父会话最后一次 turn/start boundary 切一个子会话子代理继承到该 boundary 的前缀记忆基于已有上下文继续完成子任务并回传结果。, parameters: { type: object, properties: { prompt: { type: string, description: 给子代理的子任务描述。子代理能看到继承来的前缀记忆。, }, }, required: [prompt], }, async execute(args) { const inherited mainLog.fork() return await runSubagent({ llm, prompt: args.prompt, inherited, name: fork_subagent, }) }, } return [spawn_subagent, fork_subagent] }两个工具长得几乎一样唯一的分歧在 execute 里spawn 传 inherited: nullfork 传 mainLog.fork()。剩下都交给同一个 runSubagent。runSubagent 是「子代理即工具」的实现它拿子会话建一个子 ReactLoop跑一个完整 turn把最终文本打包成字符串 return 出去// step5-subagent/index.js节选 async function runSubagent({ llm, prompt, inherited, name }) { const child inherited ?? new SessionLog() const childRegistry new ToolRegistry() childRegistry.register(read_file) // 子代理手里有普通工具没有派活工具 const childPipeline new ToolPipeline(childRegistry) const childLoop new ReactLoop(llm, childRegistry, childPipeline, child, SUBAGENT_SYSTEM) const mode inherited ? fork·继承父前缀记忆 : spawn·全新会话 console.log(\n┌─ ${name} 开始${mode}) const finalText await childLoop.turn(prompt) const derived child.deriveMessages() console.log( 子会话派生消息模型看到 ${derived.length} 条) for (const m of derived) { if (m.role toolResult) console.log( [toolResult:${m.toolName}] ${String(m.content).slice(0, 40)}) else if (m.toolCall) console.log( [assistant/toolCall] ${m.toolCall.name}) else console.log( [${m.role}] ${String(m.content).slice(0, 40)}) } console.log(└─ ${name} 结束子会话共 ${child.events.length} 条事件) return 【${name}】${finalText}子会话 ${child.events.length} 条事件 }这个 return 值会走上一条既有的路ToolPipeline 把工具返回值转成 toolResult写进主会话日志主模型下一轮读得到。子代理机制没有造任何新管道。如果你用 Claude Code 的 subagent 配置格式对应的 settings 片段是这样注意路径与原文一致{ subagents: { spawn_subagent: { description: 从零新开子会话无父记忆适合独立子任务, inheritsParentContext: false, model: gpt-4o-mini, baseURL: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY }, fork_subagent: { description: 继承父会话前缀记忆基于已有上下文继续, inheritsParentContext: true, model: gpt-4o-mini, baseURL: https://taotoken.net/api, apiKeyEnv: TAOTOKEN_API_KEY } } }三件套在这里对齐Base URL 是 https://taotoken.net/api Key 从 TAOTOKEN_API_KEY 环境变量读Model ID 是 gpt-4o-mini。spawn 和 fork 共用同一套只是 inheritsParentContext 不同。4. 验证请求一次端到端跑通 spawn 与 fork配置写好了现在跑一遍验证。整个过程不需要真实 API Key用 MockLLM 就能看到完整链路。先跑主入口$ node step5-subagent/index.js step5-subagent子代理机制spawn / fork 主题让单 agent 会派活 —— 拆任务出去 把上下文隔离 [user] 把『读文件 package.json』这个子任务派给 spawn 子代理 ┌─ spawn_subagent 开始spawn·全新会话 [user] 读文件 package.json [assistant] 工具 read_file 返回了{ name: first-agent, version: 0.1.0, private… 子会话派生消息模型看到 4 条 [user] 读文件 package.json [assistant/toolCall] read_file [toolResult:read_file] { name: first-agent, version: [assistant] 工具 read_file 返回了{ name: first-agen └─ spawn_subagent 结束子会话共 5 条事件 [assistant] 主 agent 总结子代理 spawn_subagent 完成任务回传 → 【spawn_subagent】工具 read_file 返回了{ name: first-agent, … [user] 我已经掌握 package.json 了把『读文件 README.md』交给 fork 子代理继承我的记忆 ┌─ fork_subagent 开始fork·继承父前缀记忆 [user] 读文件 README.md [assistant] 工具 read_file 返回了# first-agent —— 动手开发你的第一个 agent系列配套工程 子会话派生消息模型看到 8 条 [user] 把『读文件 package.json』这个子任务派给 spawn 子代理 [assistant/toolCall] spawn_subagent [toolResult:spawn_subagent] 【spawn_subagent】工具 read_file 返回了{ na [assistant] 主 agent 总结子代理 spawn_subagent 完成任务回传 → [user] 读文件 README.md [assistant/toolCall] read_file [toolResult:read_file] # first-agent —— 动手开发你的第一个 agent系列配套工程 [assistant] 工具 read_file 返回了# first-agent —— 动手开发你的 └─ fork_subagent 结束子会话共 11 条事件 [assistant] 主 agent 总结子代理 fork_subagent 完成任务回传 → 【fork_subagent】工具 read_file 返回了# first-agent —— 动手开发你的第一个 a…两段演示一个看 spawn 一个看 fork。第一段主 Agent 把「读 package.json」派给 spawn 子代理子会话派生消息 4 条全是本次子任务的内容父会话的历史一条都没进来。第二段是 fork子会话派生消息变成 8 条前 4 条是父会话第一轮的记忆后 4 条才是自己的子任务。fork 把切点之前的记忆带来了。再看主会话日志这是最该看的一张父会话是干净的—— 主会话日志append-only共 10 条事件—— #0 [boundary] turn/start #1 [user] 把『读文件 package.json』这个子任务派给 spawn 子代理 #2 [assistant/toolCall] spawn_subagent({prompt:读文件 package.json}) #3 [toolResult:spawn_subagent] 【spawn_subagent】工具 read_file 返回了{ name: first-agent, … #4 [assistant] 主 agent 总结子代理 spawn_subagent 完成任务回传 → … #5 [boundary] turn/start #6 [user] 我已经掌握 package.json 了把『读文件 README.md』交给 fork 子代理继承我的记忆 #7 [assistant/toolCall] fork_subagent({prompt:读文件 README.md}) #8 [toolResult:fork_subagent] 【fork_subagent】工具 read_file 返回了# first-agent —— 动手开发你的第一个 a… #9 [assistant] 主 agent 总结子代理 fork_subagent 完成任务回传 → …10 条事件两个 toolResult#3、#8是子代理的结论摘要两个主模型总结#4、#9。read_file 的原始结果只活在子会话里父会话一行都没有。这就是「父会话不被污染」在日志层面的直接证据。最后跑测试三个断言锁死三个行为$ node step5-subagent/test.js step5-subagent 测试 ✓ ① spawn 子会话不继承父会话任何消息父 2 条 / 子 0 条 ✓ ② fork 子会话继承前缀记忆子含父前 2 条且后续不污染父会话 ✓ ③ 子代理产出作为 toolResult 回传主会话主会话日志记录了它无 read_file、无子会话内部 user 全部断言通过 ✔三个断言分别验证spawn 空会话、fork 继承前缀且不污染父会话、子代理产出作为 toolResult 回传主会话。本机全过。如果你要接真实模型验证把 createLLM() 的返回值传给 makeSubagentTools然后跑同样的流程。真实模型天生会决定调哪个工具不需要 MockLLM 的确定性规则。验证模型对话是否通可以用 https://taotoken.net/models 直接试。5. 本篇常见错排查401、local proxy failed 与 reading choices跑 subagent 时最容易踩的坑集中在鉴权和响应解析上。这一节对照真实报错逐个排查。报错一401 UnauthorizedError: LLM 请求失败 401: {error:{message:Invalid API key,type:invalid_request_error}}这个报错说明 Key 没读到或读错了。排查顺序先确认环境变量是否在当前 shell 生效echo $TAOTOKEN_API_KEYWindows 用echo %TAOTOKEN_API_KEY%看有没有值。如果为空说明 export 只在另一个终端窗口执行过当前进程读不到。子代理是主进程内 new 出来的它继承的是主进程的环境变量所以必须在启动主进程的那个 shell 里配置。第二个可能Key 复制时带了空格或换行。TaoToken 控制台生成的 Key 以 sk- 开头复制时注意别把末尾的换行带进去。可以在代码里加一句 trimconst apiKey (process.env.TAOTOKEN_API_KEY || ).trim()第三个可能Base URL 写错了。注意 API 端点是 https://taotoken.net/api 请求路径拼成${baseURL}/v1/chat/completions。如果你把 baseURL 写成带 /v1 的形式就会拼成 /v1/v1/chat/completions返回 404 而不是 401但同样调不通。报错二local proxy failedError: local proxy failed: connect ECONNREFUSED 127.0.0.1:7890这个报错说明你的运行环境里配置了本地代理但代理进程没启动。子代理的 fetch 请求走了系统代理设置连不上就报 ECONNREFUSED。排查检查环境变量 HTTP_PROXY / HTTPS_PROXY 是否指向了一个没启动的本地端口。如果不需要代理清掉这两个变量# macOS / Linux unset HTTP_PROXY HTTPS_PROXY # Windows PowerShell Remove-Item Env:HTTP_PROXY Remove-Item Env:HTTPS_PROXY注意这里说的是清理本地代理配置不是让你去配代理。TaoToken 的 API 端点直接可达不需要任何中间层。报错三reading choicesTypeError: Cannot read properties of undefined (reading choices)这个报错说明 resp.json() 返回的结构里没有 choices 字段。原因通常是请求体格式不对或者模型名写错了。排查先打印原始响应看看返回了什么const data await resp.json() if (!data.choices) { console.error(响应结构异常:, JSON.stringify(data).slice(0, 200)) throw new Error(响应缺少 choices 字段) } return data常见原因是 model 参数传了一个 TaoToken 不支持的模型 ID。去 https://taotoken.net/models 确认可用的 Model ID填到 createLLM 的 model 参数里。报错四OAuth token expiredError: OAuth token expired, please re-authenticate如果你用 Claude Code 或 Codex 作为子代理后端可能会遇到这个。它说明外部 harness 的 OAuth 凭证过期了。子代理把一轮委派丢给外部进程时外部进程的鉴权独立于 TaoToken 的 Key。排查确认外部 harness 自己的登录状态重新走一次它的认证流程。如果你希望统一用 TaoToken 的 Key 管理所有调用就不要把子代理后端指向需要独立 OAuth 的外部进程直接用 createLLM() 走 TaoToken 通道。报错五子代理结果为空【spawn_subagent】子会话 0 条事件子会话 0 条事件说明子 loop 没跑起来。排查确认 runSubagent 里 childLoop.turn(prompt) 被调用了且 prompt 非空。另一个可能是 SUBAGENT_SYSTEM 没传进去子 loop 缺少系统提示词。检查 ReactLoop 构造函数参数顺序new ReactLoop(llm, registry, pipeline, session, systemPrompt)。排查完这些你的 subagent 骨架应该能稳定跑通。如果要在生产环境用记得给每个子代理设独立预算maxSteps、token 预算、超时回收超出即终止并标记结果状态防止子代理失控拖垮整次任务。6. 把子代理接进你的编排骨架到这里spawn 和 fork 两条派生路径都跑通了。回到最初的问题Agent Harness 缺的那格「会派活」现在补上了。两个原语spawn 从零开子会话fork 继承前缀切子会话加起来不到十行。一个判断子代理不是「更多 Agent」是「把任务拆出去 把上下文隔离」。子代理即工具结果回传走的是你早就建好的工具流水线没造任何新管道。接下来可以做的扩展方向有三个。第一并发扇出把 runSubagent 的同步 await 换成任务队列一批子任务交给调度器多个子代理并发跑全部返回后统一收结果再汇总。第二递归派活把子工具注册表也传给子会话子代理就能继续往下派实现套娃。第三进程级隔离把子代理放进独立子进程worker_threads / child_process.fork自带资源与权限沙箱子会话独立落盘做跨会话持久隔离。鉴权层面所有子代理共用 TaoToken 的统一 Key 和 API 通道。主进程启动时读一次环境变量createLLM() 工厂给每个子代理发同一套配置。换模型改一个参数换 Key 改一个环境变量spawn 和 fork 同步生效。需要生成新 Key 去 https://taotoken.net/api-keys 接入细节看 https://taotoken.net/doc 长期跑多代理编排考虑 https://taotoken.net/coding-plan 。最后留一个实用技巧调试子代理时把 child.deriveMessages() 打印出来。它展示的是子代理「眼里的世界」——spawn 只有自己的任务fork 还有继承来的前缀。这一行日志能帮你快速判断隔离是否符合预期比读代码快得多。