这是《Agent全栈开发实战》的第 11 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例拆解 harness 设计。
前面几篇我们把心脏、手脚、眼睛、记忆、韧性都讲透了------单个 Agent 已经能稳稳干活。这篇往「进阶」迈两步:一是让主 Agent 派一群小猫并行干活 (子代理系统),二是让 Agent 认识自己------知道我在用什么模型、跑到第几轮了、上下文还剩多少、有几只小猫在外面跑,甚至能在安全边界内自己调参数、自己被叫停。本篇独立可读,不需要前面的铺垫也能看懂。
0. 两只手不够使,脑子也得清醒
先说一个真实的卡顿。
你让 catbuddy 同时分析三个开源仓库的架构,做个对比。主 Agent 老老实实地一个一个来:搜完 repo A,再搜 repo B,再搜 repo C。每个仓库要调三四次工具,三个加起来十几轮迭代。等你看到对比总结的时候,两分钟过去了------而这三件事本来毫无依赖,完全可以同时干。
这是第一个痛点:单个 Agent 的循环是严格串行的,哪怕任务天然能并行。
再说第二个。你问它「你现在用的什么模型?最多还能跑几轮?」------大多数 AI 助手这时候只能编一个数字,或者老实说「我不确定」。因为 LLM 根本不知道自己被装在一个什么样的运行时里。它是个被蒙着眼睛干活的工人,连自己手上的工具清单都说不全。
这一篇就是给 Agent 补这两块短板:多开几只手 (子代理),再给它装一面镜子(自我认知)。这俩看着不相干,其实是一回事------都是让 Agent 从「被动的工具调用器」变成「知道自己在干嘛、还能调度自己」的主动玩家。
1. 子代理:把一个完整 Agent,伪装成一个普通工具
1.1 单个 Agent 的两个天花板
为什么需要子代理?因为单个 Agent 撞了两堵墙:
- 串行瓶颈:Agent Loop 的每一轮------LLM 思考 → 调工具 → 再思考 → 再调工具------是顺序执行的。三个独立子任务,也只能排队。
- 上下文污染:每个子任务的工具结果(grep 吐 8000 字符、web_search 返回一堆链接、read_file 拍来整个文件)全塞进同一个消息数组。三个任务搅在一起,LLM 很难分清「这段输出到底属于哪个任务」。
子代理(Sub-Agent,由主 Agent 临时派生出来、独立干一件事的小型 Agent)一招解决俩问题:独立上下文 + 并行执行。
每只「小猫」都有自己的一套内脏,和主 Agent 完全隔离:
- 独立的消息历史------只装它自己那一个任务的对话,互不串味;
- 独立的工具注册表(ToolRegistry)------它自己的一套工具;
- 独立的 Provider / Runner------自己去调 LLM,自己跑循环。
最妙的设计在这里:对 LLM 来说,派一只小猫就是调一个普通工具 。主 Agent 看到的是一个叫 spawn 的工具,长得和 grep、read_file 一模一样------给它一个任务描述,它回一句确认。LLM 根本不知道这个「工具」背后是一整个 Agent 实例在跑。这就是透明性:能力被藏在了一个朴素工具的外壳里。

注意那条虚线:主 Agent 调完 spawn 立即拿到一句确认就接着干别的,三只小猫在后台并发跑,跑完各自把结果从 MessageBus 推回主对话流。这是「派活 → 立即脱手 → 结果异步回收」,不是「派一个等一个」。
1.2 SubagentManager:小猫的托管所
整套系统的核心是 SubagentManager(agent/subagent.ts)。一句话概括它的活:接住主 Agent 的 spawn 调用 → 创建独立的 AgentRunner → 后台执行 → 把结果注入回主对话流。
它靠三个 Map 把一切管起来:
javascript
export class SubagentManager {
readonly runner: AgentRunner
private readonly _running = new Map<string, Promise<void>>()
private readonly _statuses = new Map<string, SubagentStatus>()
private readonly _sessionTasks = new Map<string, Set<string>>()
maxConcurrentSubagents = 3
// ...
}
_running:Map<taskId, Promise>------跟踪所有在跑的小猫,并发上限就卡在它的 size 上;_statuses:Map<taskId, SubagentStatus>------每只小猫的实时状态(跑到第几轮、调了什么工具、有没有报错);_sessionTasks:Map<sessionKey, Set<taskId>>------按会话分组,/stop时好按会话批量收人。
那个 maxConcurrentSubagents = 3 不是拍脑袋。更高的并发 = 更多 LLM 调用 + 更多 token + 更难收拾的错误面。3 是「够用」和「可控」之间的平衡点:足够并行两三个独立子任务,又不至于把错误面铺得太大。
1.3 spawn:description 里那句「in the background」是关键
主 Agent 怎么派活?调 spawn(agent/tools/spawn.ts)。工具定义本身平平无奇,但有一句话很要紧:
javascript
description:
'Spawn a subagent to handle a task in the background. '
+ 'Use for complex or time-consuming tasks that can run independently.',
in the background------这是写给 LLM 看的行为契约。它告诉模型:调完我立刻 就返回,不会卡住你。果然,spawn 一执行,主 Agent 立即收到一句确认:
javascript
return `Subagent [${displayLabel}] started (id: ${taskId}). I'll notify you when it completes.`
拿到这句话,主 Agent 就能接着连续 spawn 第二只、第三只,或者干点别的------三只小猫在后台并发跑,跑完了自己会回来报到。这才有了 1.1 节那张图里的「同时分析 A/B/C」。
1.4 每只小猫的内脏:精简、受限、隔离
子代理不是简单复用主 Agent 的环境,而是 _runSubagent 里现搭一套。和主 Agent 比,有三处刻意的不同:
javascript
const result = await runWithFileStates(fileStates, () =>
this.runner.run({
context, // system 极简:当前时间 + 工作目录
tools, // _buildTools():内置工具,但排除 generate_image
model: this.model,
maxIterations: this.maxIterations,
concurrentTools: true,
contextWindowTokens: 128_000,
providerRetryMode: 'standard',
hook: new SubagentHook(status), // 把每轮状态同步回 _statuses
// ...
}),
)
- System Prompt 极简。主 Agent 的系统提示塞了用户画像、项目上下文、Skill 清单,动辄几千 token。子代理只给「当前时间 + 工作目录」------任务单一明确,没必要背那么多包袱。
- 工具集受限 。
_buildTools()注册所有内置工具,但if (tool.name === 'generate_image') continue------排除画图。小猫的活是搜索、读文件、做分析,不画图;少一个工具还能省点 tool definition 的 token。 - FileStates 独立 。每只小猫有自己的
FileStates实例(基于AsyncLocalStorage),文件读取的去重缓存不会和主 Agent 或别的小猫串味。
1.5 并发满了怎么办?不阻塞,直接如实报告
spawn() 第一行就是并发检查,这里藏着一个很「Agent」的设计哲学:
javascript
async spawn(opts: { /* ... */ }): Promise<string> {
if (this.getRunningCount() >= this.maxConcurrentSubagents) {
return (
`Cannot spawn subagent: concurrency limit reached `
+ `(${this.getRunningCount()}/${this.maxConcurrentSubagents} running).`
)
}
// ...
}
已经有 3 只在跑?spawn 不阻塞等待 ,直接甩回一句「并发已满」。为什么不等?因为 spawn 跑在主 Agent 的工具调用流程里------它要是阻塞了,整个 Agent Loop 就跟着卡死。现在它如实报告状态,决策权交还给 LLM:是等一会儿再派,还是换个策略先干别的,主 Agent 自己看着办。
代码只负责诚实地报告状态,决策权留给 LLM。 这条原则在整个 harness 里反复出现。
收尾也干净。runPromise.finally() 里把 _running、_statuses、_sessionTasks 三个 Map 对应的条目全删掉------无论小猫是正常完成还是中途崩了,都不会留下「尸体还占着并发名额」的脏状态。
1.6 结果怎么回到主对话流:一句话带过 MessageBus
小猫跑完了,结果怎么回到主 Agent 的对话里?难点在于:小猫跑完那一刻,主 Agent 可能正忙着别的,甚至已经空闲(IDLE)了------你没法走「正常的响应返回」那条路。
catbuddy 的做法是把结果包成一条 InboundMessage,走 MessageBus.publishInbound() 注入:
javascript
const msg: InboundMessage = {
channel: 'system',
senderId: 'subagent',
chatId: `${origin.channel}:${origin.chatId}`,
content, // 渲染好的「任务X完成」文本
sessionKeyOverride: origin.sessionKey,
metadata: { injected_event: 'subagent_result', subagent_task_id: taskId },
// ...
}
this.bus.publishInbound(msg)
MessageBus 是 catbuddy 的消息总线(多生产者、单消费者的一条阻塞队列)------它的完整设计我们留到第 14 篇 细讲,这里只需要记住一件事:子代理把结果当成「一条新进来的消息」publish 进总线,主 Agent 的循环在下一次取消息时自然就读到它 ,于是「任务 X 完成了,结果是......」就成了主对话流里的下一条工具结果,主 Agent 接着往下推理。那个 injected_event: 'subagent_result' 标记,则让前端能把「小猫注入的结果」和「用户发的消息」区分开,UI 上做差异化展示。
对主 Agent 来说,整个过程就像:「我调了个工具 → 它说在后台跑了 → 过了一会儿,结果作为一条消息回来了。」它从头到尾不知道自己刚才调度的是一整个 Agent 实例。透明性,闭环。
1.7 小猫的实时状态:10 行 Hook 就够了
主 Agent(或 UI)想知道「现在每只小猫跑到哪了」,靠的是 SubagentHook------一个只有 10 行有效代码的 Hook(Hook 是 catbuddy 在循环关键节点开的「扩展口」,不碰核心代码就能插逻辑,第 09 篇讲过):
javascript
class SubagentHook extends AgentHook {
override async afterIteration(context: AgentHookContext): Promise<void> {
this.status.iteration = context.iteration
this.status.toolEvents = [...context.toolEvents]
if (context.error) this.status.error = context.error
}
}
每跑完一轮,afterIteration 就把这轮的状态(第几轮、调了哪些工具、有没有报错)同步回那只小猫的 SubagentStatus 对象,也就是 _statuses Map 里。这意味着外部随时能读到每只小猫的实时进度,不用等它跑完 。它的价值不在代码量,而在它证明了 Hook 系统的设计是对的------9 个 Hook 插槽,子代理只动了其中一个,没往核心循环里塞任何 if (isSubagent)。
2. 自我认知:让 Agent 照见自己的运行时
子代理在跑,主 Agent 怎么知道「现在有几只小猫」?更广义地问:Agent 能感知自己的运行状态吗 ------当前模型、第几轮、上下文还剩多少 token?catbuddy 的回答是 my 工具。
2.1 my 工具:给 Agent 一份「体检报告」
my(agent/tools/self.ts)让 Agent 查询、并在配置允许时有限地修改 自己的运行时状态。它有两个 action:check(查)和 set(改)。
无参数 check 返回一份「体检报告」:
javascript
return [
`model: ${runtime.model}`,
`max_iterations: ${runtime.maxIterations}`,
`current_iteration: ${runtime.currentIteration}`,
`context_window_tokens: ${runtime.contextWindowTokens}`,
`tools: ${runtime.toolNames.join(', ')}`,
`model_preset: ${runtime.modelPreset ?? '(none)'}`,
`running_subagents: ${runtime.subagents?.getRunningCount() ?? 0}`,
].join('\n')
于是用户问「你现在什么模型、跑到第几轮、有几只小猫在外面跑」,Agent 不用编------直接调 my,照着报告念。看最后一行:running_subagents 正是从上半篇的 SubagentManager.getRunningCount() 读出来的。两块拼上了:子代理让它多干活, my 让它知道自己多干了多少活。
2.2 一个反直觉的内部设计:AgentLoop 本身就是 RuntimeState
my 工具读的状态,定义在一个叫 RuntimeState 的接口里(runtime_state.ts,全文就 23 行):
javascript
export interface RuntimeState {
readonly model: string
readonly maxIterations: number
readonly currentIteration: number
readonly toolNames: string[]
readonly contextWindowTokens: number
readonly subagents: SubagentManager | null
// ...
setRuntimeValue?(key: string, value: unknown): string
}
有意思的地方来了。一般人会再建一个 RuntimeStateManager 对象来维护这些状态,然后想办法让它和 AgentLoop 保持同步。catbuddy 没有。它让 AgentLoop 自己就 implements RuntimeState:
javascript
export class AgentLoop implements RuntimeState {
// ...
get toolNames(): string[] { return this.tools.toolNames }
get runtimeVars(): Record<string, unknown> { return this._runtimeVars }
}
注册 my 工具时传进去的 this,就是这个 AgentLoop 实例本身:
javascript
const allowSet = opts.config.tools?.my?.allowSet ?? false
this.tools.register(createMyTool(this, { modifyAllowed: allowSet }))
所以 my 工具是直接读 AgentLoop 实例的属性 ------没有中间状态对象,自然就没有状态同步问题(你不可能和你自己不同步)。代价是:my 工具的实现和 AgentLoop 有了直接依赖,耦合上了。这是个有意为之的权衡------用「一点耦合」换「零同步成本」。对一个状态变化频繁、又必须实时准确的运行时来说,这笔买卖很划算。

2.3 改,但只能改三个:三层安全模型
check 随便看,set 可就管得严了。my 工具默认 allowSet = false------只读,连改的口子都不开(你可不想 Agent 在不受控的情况下把自己切到别的模型)。即便开了,能改的也只有三个 key,由三个集合层层把关:
javascript
const BLOCKED = new Set([
'bus', 'provider', '_running', 'tools', '_runtimeVars',
'runner', 'sessions', 'consolidator', 'dream', // ... 共 20+ 个内部对象
])
const READ_ONLY = new Set(['subagents', 'currentIteration', 'execConfig', 'webConfig'])
const RESTRICTED: Record<string, { type: 'number' | 'string'; min?: number; max?: number }> = {
maxIterations: { type: 'number', min: 1, max: 100 },
contextWindowTokens: { type: 'number', min: 4096, max: 1_000_000 },
model: { type: 'string' },
}
| 安全等级 | 含义 | 示例 |
|---|---|---|
| BLOCKED | 既不能看也不能改 | bus、provider、runner、_mcpServers |
| READ_ONLY | 能看,不能改 | subagents、currentIteration、execConfig |
| RESTRICTED | 能看,能改(带范围校验) | maxIterations(1-100)、contextWindowTokens(4096-1M)、model |
为什么 bus 被 BLOCKED?因为 MessageBus 是整个系统的脊椎神经,Agent 要是能窥到它的内部队列,就可能推断出别的会话的内容、甚至尝试消费不属于自己的消息。为什么 currentIteration 只读?Agent 知道自己在第几轮有助于决策,但不能自己重置计数器------否则循环终止逻辑就乱套了。
这套设计有点卡夫卡的味道:Agent 知道自己在一个循环里,知道循环的上限,但不能重置计数器,只能申请把上限调高。
2.4 自适应:跑到第 40 轮,自己把上限提到 70
set 真正落地在 AgentLoop.setRuntimeValue,三段分支,每段都是「类型校验 + 范围限制 + 副作用传播」:
javascript
setRuntimeValue(key: string, value: unknown): string {
if (key === "maxIterations") {
const n = Number(value);
if (Number.isNaN(n) || n < 1 || n > 100) return "Error: maxIterations out of range (1-100)";
this.maxIterations = n;
this.subagents?.setProvider(this.provider, this.model); // 子代理也继承迭代限制
return `maxIterations set to ${n}`;
}
// contextWindowTokens / model 两段类似...
this._runtimeVars[key] = value; // 兜底:写进「便签簿」
return `${key} stored in runtime scratchpad`;
}
注意最后那一段兜底------任何不在前三段的 key,都写进 _runtimeVars 这个「便签簿」(一个 Record<string, unknown>,重启即失,适合 Agent 在当前会话里跨轮传点临时状态)。这是一种可扩展设计:将来想加新的可改参数,不用动 self.ts 的安全集合,在 setRuntimeValue 里加个 if 分支就行。
这个能力解锁了一个很爽的场景------Agent 自适应:
javascript
Agent: [调 my.check()] 当前 current_iteration: 40 / max_iterations: 50,running_subagents: 2
Agent: 我只剩 10 轮,这个重构怕是不够。让我把上限提一下。
Agent: [调 my.set({ key: 'maxIterations', value: 70 })] → maxIterations set to 70
Agent: 上限已到 70,继续。
Agent 没等用户去手动改配置------它自己发现 迭代快耗尽,自己决定 延长,在安全边界内(最多 100)执行。从「被动的工具调用器」到「主动的任务管理者」,差的就是这点自我认知。
3. 会话取消:知道自己在跑什么,也能停下来
自我认知如果只能「看」和「改参数」,还差一块:能不能停? /stop 就是这块拼图。
它的三层命令路由(前缀检测 → 命令分发 → 影响扩散)我们在第 03 篇 已经讲过,这里不重复路由细节 ,只补最关键的一环:取消信号到底怎么传播到所有正在跑的 LLM 调用和子代理。
3.1 每个 turn 一个 AbortController
catbuddy 用 Web 标准的 AbortController(一个能向下游广播「取消」信号的对象)来做硬中断。每开一个 turn,_dispatch 就建一个 controller,把它的 signal 挂到这条消息上:
javascript
const controller = new AbortController();
const existing = this._activeTasks.get(key) ?? [];
existing.push(controller);
this._activeTasks.set(key, existing);
msg._abortSignal = controller.signal;
关键点:_activeTasks 是 Map<string, AbortController[]>------一个会话可以同时挂多个 controller (主 Agent 一个,每只子代理各一个)。这个 signal 会一路往下传:AgentLoop 把它塞进 runner.run({ abortSignal }),runner 再把它交给 Provider 的流式调用 provider.chatStream({ signal })。也就是说,一根信号线从循环顶层直通到正在 await 的那个 LLM 网络请求。
3.2 cancelSession:一把全 abort
/stop 最终落到 cancelSession,它把这个会话名下所有 controller 一次性 abort 掉:
javascript
async cancelSession(sessionKey: string): Promise<number> {
const controllers = this._activeTasks.get(sessionKey);
if (!controllers) return 0;
let cancelled = 0;
for (const ctrl of controllers) {
if (!ctrl.signal.aborted) { ctrl.abort(); cancelled++; }
}
this._activeTasks.delete(sessionKey);
return cancelled;
}
主 Agent 的推理、外面跑着的每只子代理、心跳任务......全在这一把里被 abort。注意:这不是 「请 Agent 行行好停一下」的软请求,而是硬中断 。abort() 会立刻让下游的 LLM 调用抛 AbortError;而且 Provider 层的重试逻辑见到 AbortError 会直接抛出、绝不重试------你都喊停了,它不会还偷偷再试一次。
3.3 信号是异步传播的,但在下一个 await 点前必然生效
这里有个容易踩的认知坑:abort() 是异步生效 的。调用 abort() 的那一瞬间,正在 CPU 上跑的同步代码并不会被「掐断」------JavaScript 是单线程的,没人能从外面打断一段正在执行的同步逻辑。
但这不影响正确性。因为 Agent 干的活几乎全是 I/O:调 LLM、读文件、跑命令,处处是 await。信号会在下一个 await 点之前必然生效 ------要么正在 await 的那个网络请求被 abort 而抛错,要么循环走到下一个检查点(比如 runner 里的 if (spec.abortSignal?.aborted))时发现信号已亮,于是停下。对一个本质上「等 I/O」的循环来说,「下一个 await 点」永远近在眼前,所以体感上就是「秒停」。

到这里,闭环成了:my 工具让 Agent 知道「我是谁、跑到哪了、还剩多少」, AbortController 让它(以及外部)能干脆地「停下来」。 知道自己在跑什么,也能停下来------这才是完整的自我认知。值得点一句:自我认知 (看状态)和自我控制 里的「取消」是两条独立的线------前者靠 Agent 配合(它得自己调 my),后者不依赖 Agent 配合(外部直接 abort,它想赖着不停也不行)。看得见,更要拽得住。
4. 复盘:这两块为什么是「一回事」
回头看,子代理和自我认知,骨子里是同一种设计取向:
- 子代理把「一整个 Agent」藏进
spawn这个普通工具的外壳里------LLM 用起来零负担,能力却翻了倍(透明性)。 my工具把「运行时状态」摊开给 Agent 看,却用三层安全集合死死管住它能改什么(暴露接口,约束能力)。AbortController让外部能一把叫停全场,不需要 Agent 点头(控制权不下放)。
三者共享一句话:该暴露的大方暴露,该约束的寸步不让。 Agent 能看很多东西,但只能改三个经过严格校验的参数;外部能取消任何任务,但碰不到 Agent 的内部状态。这种「给足信息、收紧权限」的分寸感,正是 harness 进阶部分最值得抄的设计直觉。
这篇讲了什么?
- 子代理 = 把一整个 Agent 伪装成普通工具 :
SubagentManager用spawn派生独立的 AgentLoop(独立历史/工具表/Provider),后台异步跑,并发上限 3、超限不阻塞只如实报告;结果经MessageBus.publishInbound()注入主对话流,而 LLM 全程不知道自己调了一个完整 Agent 实例。 - 自我认知 = 给 Agent 装面镜子 :
my工具让它查模型/轮数/上下文余量/在跑的子代理数,还能在范围内自己调maxIterations;最妙的是AgentLoop自己就是RuntimeState,工具直接读实例属性------零同步成本,代价是一点有意为之的耦合。 - 取消闭环 :
/stop经AbortController.abort()把信号一路传到所有 LLM 调用和子代理,信号异步传播但在下一个await点前必然生效。「知道自己在跑什么、也能停下来」,自我认知才算闭环。
下一篇预告 :Agent 现在工具丰富、还有了自知之明。但它能不能产出不只是文本的东西------比如把一个系统的架构,亲手画成一张 Draw.io 架构图?第 12 篇是一线 Prompt 工程实战:把「画图」拆成工具链 + 提示词,记录从 40% 可用率一路打磨到 90% 的四轮迭代,看看怎么把一个「老画歪」的能力调教成生产可用。