从零打造CyberClaw:AI 学会了“喊人干活“
【从0开发类OpenClaw】Day 10:AI 学会了"喊人干活"——子智能体委派机制
从零写一个类 OpenClaw 的 AI 助手框架,全程记录踩坑。今天是第十天:把 Phase 5 的 C 阶段子智能体落地——主 agent 可以把任务委派给另一个智能体,在完全独立的上下文里执行,再把结果拿回来汇报。核心是三个隔离(新线程、只传任务、工具白名单)+ 三道保险(防递归、超时断线、出错不炸)。同一天还用刚做完的技能体系做了一次"自举"验收:agent 按 code-review 技能评审代码、指出真实缺陷。后端测试从 445 涨到 462,2 个 commit。
前情回顾:Day 9 把插件/技能机制收掉了,Phase 5 还剩 C(子智能体)、D(记忆深化)。今天做 C。主题一句话:当主智能体遇到"专业但杂"的任务,与其亲自干(污染自己的对话记忆、上下文爆棚),不如喊另一个智能体在独立房间干完,只听汇报。
今天的剧情线:C1 委派服务(新抽屉 + 预算)→ 避坑:循环依赖 → C2 工具注册(老板桌上的电话)→ 双包类型冲突 → 白名单三态语义 → 防递归 → E2E-6 实测。
第一幕:先讲清楚"为什么要子智能体"
想象一个场景:用户正在跟"法律文书智能体"聊天,突然让它翻译一篇长文。
如果主 agent 亲自干:
- 翻译过程的工具调用、中间思考会写进当前对话的记忆(thread 状态)——历史记录一团乱
- 长任务挤占上下文额度,可能把更重要的对话历史挤掉
子智能体的思路:开一个全新的"抽屉"(thread_id),让目标智能体在里面干完,只把结论文本拿回来。父会话零污染,子任务上下文完全干净。
抽屉(thread_id)是 langgraph checkpointer 的记忆单元:不同的 thread_id = 完全独立的消息历史。整个 C 阶段的隔离魔法都建立在"换个抽屉号"上。
第二幕:C1 委派服务——spawn 的四步流程
SubagentService.spawn(input, parent)(subagent.service.ts)是总指挥:
async spawn(input, parent) {
// ① 查岗:目标 agent 存在?启用?不存在 → "[工具错误] 目标不存在"(绝不硬闯)
// ② 算白名单:父请求 tools ∪ allowSubagentTools,强制剔除 subagent(防递归)
// ③ 开新抽屉:thread_id = `${parent.threadId}:sub:${randomUUID().slice(0,8)}`
// ④ 装闹钟:SUBAGENT_TIMEOUT_SEC(默认120s) 到点 abort;父信号断线也传导过来
const built = await parent.chatService.buildAgent(targetId, {
toolNames, // 子 agent 只能带白名单里的工具
extraSystemPrompt: SUBAGENT_SYSTEM_PROMPT, // "你是子任务执行者,只汇报结果"
withSubagent: false, // 子 agent 工具包里没有 subagent(防递归)
});
const stream = parent.chatService.streamChat(built, [userMsg], signal, subThreadId, true);
return await this.collect(stream); // 非流式聚合最终文本
}
上下文隔离是怎么做到的
子任务只传两样东西:task + 可选 context。绝不自动带父会话历史——父对话里可能有隐私,而且子 agent 根本用不上老板的闲聊。这条在 spec 里是硬规则(防上下文泄露 + 防 token 浪费)。
预算三件套
- 超时:120 秒闹钟,到点
abort强制收工 - 断线传导:父请求的
signal接进子任务的 AbortController——用户关页面,子任务立刻停,不留孤儿 - 输出截断:子 agent 说太多?聚合时 4000 字截断并标注
collect:怎么"偷听"子 agent 说话
streamChat 是 SSE 事件流。collect 遍历它,只挑两种东西:choices[].delta.content(子 agent 说的每个字,拼起来)+ tool_start(数它调了几次工具)。返回 { text, toolCalls, iterations }。
避坑:循环依赖——两只手互相指
写代码时撞上一个经典的架构死锁:员工干活需要"老板的干活能力"(buildAgent/streamChat 都在 ChatService 里),而老板建员工需要 SubagentService——两个类互相注入,Nest 直接循环依赖报错。
解法(spec 原设计):SubagentService 不注入 ChatService,而是让调用方把 ChatService 作为参数递进来:
// spawn 的 parent 参数里带着 chatService —— 像老板秘书递名片,员工不用认识老板本人
export interface SubagentParent {
threadId: string;
agentId: string;
chatService: ChatService;
signal?: AbortSignal;
}
依赖方向变成单向:ChatService → SubagentService,没有环。
第三幕:C2 工具注册——老板桌上的"电话"
子智能体对主 agent 来说只是一个工具(subagent),问题是怎么让工具在每次对话里都可用,还要能拿到"当前是哪个会话在调用"这个上下文。
为什么不能像普通工具那样注册
普通工具走 config.tools + 执行器表——执行器签名是 (args, signal),没有父线程上下文。但 subagent 必须知道"我现在在哪个抽屉里"才能开子抽屉。
解法:extraTools + 运行时偷看 RunnableConfig
subagent 工具不走 config.tools,而是走 agent-core 的 extraTools(构建 agent 时直接塞编译好的工具对象)。关键在于:langgraph 调用工具时会把运行配置(RunnableConfig)透传给工具函数——里面藏着 thread_id(当前抽屉号)和 signal(中止信号):
// subagent.tool.ts
func: async (input, _runManager, config) => {
const threadId = config?.configurable?.thread_id; // ← 从运行配置里偷看父抽屉号
const signal = config?.configurable?.signal; // ← 父中止信号
if (!threadId) return '[工具错误] subagent 缺少父线程上下文';
const result = await subagentService.spawn(input, { threadId, agentId, chatService, signal });
return result.text; // ← 返回字符串 = langgraph 自动包成 ToolMessage 回给父模型
}
"偷看"为什么可行?因为 agent.stream 调用时传了 configurable: { thread_id },langgraph 会把它一路透传到工具执行——之前 A 阶段已经靠这个机制拿到了 signal,thread_id 是同一条通道。
坑④:ESM/CJS 双包类型冲突
extraTools 的类型(StructuredToolInterface)来自 @langchain/core——但 agent-core 是 ESM 包、api 是 CJS 包,两边 import 的 @langchain/core 在 TypeScript 眼里是两个不同的模块实例,类型直接不兼容:
Type 'StructuredToolInterface' is not assignable to type
'StructuredToolInterface' ... Two different types with this name exist, but they are unrelated.
运行时其实是同一个包(Node 模块缓存共享),纯编译期幻觉。解法沿用项目先例(checkpointer 的 MemorySaver 同款):断言收口:
extraTools: extraTools as unknown as Parameters<typeof createLangchainAgent>[0]['extraTools'],
第四幕:白名单三态 + 防递归——权限与安全
allowSubagentTools 的三种语义
ClawAgent.allowSubagentTools?: string[](智能体配置里新增字段),三态:
不配置 (undefined) → 子 agent 不限制工具(本地信任模型)
配置 [] → 子 agent 什么工具都不给(纯文字任务)
配置 ["translate"] → 白名单上限:父请求的工具 ∩ 白名单
注意实现细节:undefined 和 [] 必须区分——前者"全量",后者"禁止",写错一个就是安全漏洞或功能失效(单测专门盖了这两个用例)。
防递归:员工没有电话
如果允许套娃,A 喊 B、B 喊 C、C 喊 A……无限循环烧钱。解法简单粗暴:spawn 给子 agent 构建时传 withSubagent: false——子 agent 的工具包里根本没有 subagent 这个工具。想喊人?没电话。
防爆:出错变成文本,不变成异常
子任务里任何错误(目标不存在、模型 401、超时)都被 spawn catch 成一句文本([工具错误] ...)返回——父 agent 收到的是文字,不是异常。父模型看到错误还能组织语言解释,整个对话不会崩。
第五幕:E2E-6 实测——委派链路真跑通了
让主 agent(法律文书智能体)委派翻译任务给代码助手,观察 SSE 事件序列:
tool_start: subagent ← 老板拨号了
(安静期,子 agent 在自己的抽屉里干活:内部调了 translate)
tool_end: subagent ok=true ← 员工的汇报以 ToolMessage 回传
content: "已通过 subagent 工具委派给代码助手。译文:你好,世界……"
主 agent 的最终回答完整转述了子任务结果(译文 + 含义说明),还自己总结了一句"证明子代理委派链路工作正常"。汇报机制验证无误:子 agent 的输出 → 工具返回值 → langgraph 包成 ToolMessage → 父模型下一轮看到 → 组织语言回复用户,全程没有跨线程读记忆的黑魔法。
验证:445 → 462(后端)
- C1 委派服务 8 用例:聚合/统计、目标校验、独立子线程格式、上下文隔离、超时中止、错误回退
- C2 工具 5 + 白名单/防递归 3:schema 结构、thread_id 提取、signal 传导、防递归、三态语义
- E2E-6 ✅ 跨 agent 委派全链路(含子 agent 内部再调 translate)
今天的成果
- 子智能体 = 新抽屉 + 只传任务 + 白名单 + 三道保险——四个隔离/防护维度全部有单测背书
- 工具上下文难题的解法:extraTools + 运行时偷看 RunnableConfig(thread_id/signal 同通道透传)
- 循环依赖用"parent 传参"化解,依赖单向化
- 汇报走标准的 ToolMessage 回传——子任务对主 agent 来说就是一次普通工具调用
- 三态白名单语义写清楚:undefined=全量 / [] =禁止 / 非空=白名单
明天干点啥(Phase 5 继续:D 阶段记忆深化)
Phase 5 只剩 D(记忆深化:embedding 语义检索 + dreaming 后台整理)和 E(验收合入 dev)。D 阶段依赖 embedding 端点,无端点时走"与现状一致"的降级路线——先按降级路径实现,配了端点就生效,是最稳的推进方式。到此,我们的CyberClaw也基本实现了openclaw的大部分主要核心功能。
更多推荐



所有评论(0)