11|进阶①:派小猫并行干活 + Agent 的自我认知

这是《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 的工具,长得和 grepread_file 一模一样------给它一个任务描述,它回一句确认。LLM 根本不知道这个「工具」背后是一整个 Agent 实例在跑。这就是透明性:能力被藏在了一个朴素工具的外壳里。

注意那条虚线:主 Agent 调完 spawn 立即拿到一句确认就接着干别的,三只小猫在后台并发跑,跑完各自把结果从 MessageBus 推回主对话流。这是「派活 → 立即脱手 → 结果异步回收」,不是「派一个等一个」。

1.2 SubagentManager:小猫的托管所

整套系统的核心是 SubagentManageragent/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
  // ...
}
  • _runningMap<taskId, Promise>------跟踪所有在跑的小猫,并发上限就卡在它的 size 上;
  • _statusesMap<taskId, SubagentStatus>------每只小猫的实时状态(跑到第几轮、调了什么工具、有没有报错);
  • _sessionTasksMap<sessionKey, Set<taskId>>------按会话分组,/stop 时好按会话批量收人。

那个 maxConcurrentSubagents = 3 不是拍脑袋。更高的并发 = 更多 LLM 调用 + 更多 token + 更难收拾的错误面。3 是「够用」和「可控」之间的平衡点:足够并行两三个独立子任务,又不至于把错误面铺得太大。

1.3 spawn:description 里那句「in the background」是关键

主 Agent 怎么派活?调 spawnagent/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 一份「体检报告」

myagent/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 既不能看也不能改 busproviderrunner_mcpServers
READ_ONLY 能看,不能改 subagentscurrentIterationexecConfig
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;

关键点:_activeTasksMap<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 进阶部分最值得抄的设计直觉。


这篇讲了什么?

  1. 子代理 = 把一整个 Agent 伪装成普通工具SubagentManagerspawn 派生独立的 AgentLoop(独立历史/工具表/Provider),后台异步跑,并发上限 3、超限不阻塞只如实报告;结果经 MessageBus.publishInbound() 注入主对话流,而 LLM 全程不知道自己调了一个完整 Agent 实例。
  2. 自我认知 = 给 Agent 装面镜子my 工具让它查模型/轮数/上下文余量/在跑的子代理数,还能在范围内自己调 maxIterations;最妙的是 AgentLoop 自己就是 RuntimeState,工具直接读实例属性------零同步成本,代价是一点有意为之的耦合。
  3. 取消闭环/stopAbortController.abort() 把信号一路传到所有 LLM 调用和子代理,信号异步传播但在下一个 await 点前必然生效。「知道自己在跑什么、也能停下来」,自我认知才算闭环。

下一篇预告 :Agent 现在工具丰富、还有了自知之明。但它能不能产出不只是文本的东西------比如把一个系统的架构,亲手画成一张 Draw.io 架构图?第 12 篇是一线 Prompt 工程实战:把「画图」拆成工具链 + 提示词,记录从 40% 可用率一路打磨到 90% 的四轮迭代,看看怎么把一个「老画歪」的能力调教成生产可用。

相关推荐
浪遏2 小时前
10|Langfuse 全链路追踪:怎么看清一个 Agent 到底在做什么
ai编程
浪遏2 小时前
12|进阶②:让 AI 画架构图——Prompt 工程一线实践
ai编程
JavaGuide2 小时前
阿里版 Claude Code 实测,表现怎么样?
ai编程·claude
全栈弄潮儿2 小时前
开始 AI 编程前,到底需要准备什么?
chatgpt·openai·ai编程
神奇霸王龙3 小时前
CC Switch 配置 Claude Desktop+ selltoken 中转 API 实测教程(2026 年 8 月更新)
人工智能·ai·aigc·agent·ai编程·claude
stormzhangV4 小时前
2026 年 8 月,我的最新 AI 装机单
人工智能·openai·ai编程
zhangfeng11334 小时前
免费 GPU 资源清单(按用途选)包括国产显卡 和 amd显卡 英伟达v100等
人工智能·ai编程·显卡
全栈弄潮儿4 小时前
AI 编程是什么?5 个真实开发场景带你快速入门
chatgpt·openai·ai编程