ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自己动手写Agent Harness【subagent】:从spawn到fork的子代理机制实现与TaoToken统一Key接入

自己动手写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 # SessionLog:spawn/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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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 用 set,macOS/Linux 用 export:

# macOS / Linux export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" # Windows PowerShell $env:TAOTOKEN_API_KEY="sk-你的key" $env:TAOTOKEN_BASE_URL="https://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: null,fork 传 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 Unauthorized

Error: LLM 请求失败 401: {"error":{"message":"Invalid API key","type":"invalid_request_error"}}

这个报错说明 Key 没读到或读错了。排查顺序:先确认环境变量是否在当前 shell 生效,echo $TAOTOKEN_API_KEY(Windows 用echo %TAOTOKEN_API_KEY%)看有没有值。如果为空,说明 export 只在另一个终端窗口执行过,当前进程读不到。子代理是主进程内 new 出来的,它继承的是主进程的环境变量,所以必须在启动主进程的那个 shell 里配置。

第二个可能:Key 复制时带了空格或换行。TaoToken 控制台生成的 Key 以 sk- 开头,复制时注意别把末尾的换行带进去。可以在代码里加一句 trim:

const 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 failed

Error: 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 'choices'

TypeError: 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 expired

Error: 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 还有继承来的前缀。这一行日志能帮你快速判断隔离是否符合预期,比读代码快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进