跳到主要内容

数据截至 (上游 commit d87b272aec54)

多智能体:子代理、工作流编排、团队与竞技场

30 秒导读: 01 主循环讲的是「一个 agent 怎么把一次输入跑完」。本章讲的是同一个循环被复制成很多份之后发生的事:谁来生一个新 agent、新 agent 能用哪些工具、它在哪个目录里干活、结果怎么回来、几个 agent 之间怎么互相说话。Qwen Code 为此做了四套东西——子代理、工作流、团队、竞技场。


1. 这是什么(零基础也能懂)

一句话定义: 让主 agent 不再自己硬扛,而是把活派给另一个(或一群)带独立上下文的 agent,自己只收结果。

为什么需要。 一个 agent 的上下文窗口是有限的。让它自己去 grep 三十个文件、读五千行代码,窗口很快被垃圾塞满,后面真正要写代码时反而没空间了。

派一个子代理出去,子代理烧的是它自己的窗口,回来只给你一段几百字的结论。这是「把噪音关在别人家里」。

四种形态,解决四类不同的问题:

形态白话谁发起结果怎么回来
子代理(subagent)派个临时工,干完就消失主 agent 调 agent 工具一段最终文本,当作工具结果回灌
工作流(workflow)写一段 JS 脚本当指挥,脚本里循环/并发地喊 agent主 agent 调 workflow 工具脚本的返回值
团队(team)一群常驻同事,有信箱、有共享任务板leader 调 team_create + agent(name:)队友主动 send_message 给 leader
竞技场(arena)几个不同模型做同一道题,各占一个 git worktree,最后比 diff用户每个 agent 一份 diff + 摘要,人来选

一句话直觉: 子代理像外包一次性任务;工作流像写一个 CI 流水线,每个 step 是一个 agent;团队像开一个共享看板的小组;竞技场像同一道题让四个候选人各写一版,你挑一版合并

用起来什么样。 子代理的定义就是一个带 YAML frontmatter 的 Markdown 文件,丢进 .qwen/agents/ 就能用。下面是这个仓库自带的真实文件(.qwen/agents/test-engineer.md;description 与正文已截断,tools 是完整列表):

---
name: test-engineer
description: Test engineer agent for bug reproduction and verification. ...
model: inherit
tools:
- read_file
- edit
- write_file
- glob
- grep_search
- run_shell_command
- skill
- web_fetch
---

You are a test engineer. ...

然后主 agent 在对话里这样调它(工具参数形状见 packages/core/src/tools/agent/agent.ts:172AgentParams):

{
"description": "Reproduce issue 4410",
"prompt": "Reproduce the bug described in docs/issues/4410.md end-to-end...",
"subagent_type": "test-engineer",
"run_in_background": true,
"isolation": "worktree"
}

本节不出现底层代码。往下看之前你只要记住:四种形态共用同一个推理循环,区别只在「壳」


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是「谁调谁」;最底下那一层是所有形态共享的同一段代码。

┌─────────────────── 主会话(leader) ───────────────────┐
│ agent 工具 workflow 工具 team_create │
└────┬──────────────────┬──────────────────┬───────────┘
│ │ │
┌─────▼──────┐ ┌───────▼────────┐ ┌─────▼──────┐ ┌──────────┐
│ 子代理 │ │ 工作流编排器 │ │ 团队 │ │ 竞技场 │
│ 一次性/后台 │ │ JS 沙箱 + 限流 │ │ 信箱+任务板 │ │ 多模型赛马│
└─────┬──────┘ └───────┬────────┘ └─────┬──────┘ └────┬─────┘
│ │ │ │
│ AgentHeadless │ AgentHeadless │ AgentInteractive
└──────────────────┴──────────┬───────┴───────────────┘

┌───────────▼────────────┐
│ AgentCore │
│ createChat / prepareTools
│ / runReasoningLoop │
└────────────────────────┘

部件一句话职责:

部件干什么文件
AgentCore真正的推理循环:建 chat、备工具、转圈调模型跑工具packages/core/src/agents/runtime/agent-core.ts:261
AgentHeadless一次性壳:execute() 跑完就死,产出一段最终文本packages/core/src/agents/runtime/agent-headless.ts:139
AgentInteractive常驻壳:有消息队列,IDLE 后还能收新消息packages/core/src/agents/runtime/agent-interactive.ts:55
SubagentManager从磁盘/内置读 agent 定义,转成运行时配置并造 AgentHeadlesspackages/core/src/subagents/subagent-manager.ts:99
AgentTool模型可见的 agent 工具:选类型、选前台/后台、可选 worktree 隔离packages/core/src/tools/agent/agent.ts:414
WorkflowOrchestrator在 vm 沙箱里跑 JS 脚本,脚本每次 agent() 都过限流+计数+预算三道闸packages/core/src/agents/runtime/workflow-orchestrator.ts:1551
TeamManager队友生命周期、消息优先级投递、空闲自动认领任务packages/core/src/agents/team/TeamManager.ts:136
ArenaManager给每个模型开一个 git worktree,并行跑,收 diff 做摘要packages/core/src/agents/arena/ArenaManager.ts:90
GitWorktreeService所有「换个目录干活」的底座packages/core/src/services/gitWorktreeService.ts:239

主线走一遍(以最常见的子代理为例,高层):

模型输出 agent 工具调用
→ AgentTool.validateToolParams 校验类型存在
→ SubagentManager.loadSubagent 按 session>project>user>builtin 找定义
→ createApprovalModeOverride 造一个「原型委托」的子 Config
→ (可选) 开一个 git worktree,把 Config 的 cwd 全部改指到它
→ SubagentManager.createAgentHeadless 造 AgentHeadless
→ AgentHeadless.execute → AgentCore.runReasoningLoop 转圈
→ getFinalText() 作为工具结果回灌给主 agent
→ finally:dispose 释放 per-agent hook / MCP;清理或保留 worktree

3. 子代理:定义、加载、隔离

这一节讲最基础的一种:派一个临时工

3.1 定义就是一个 Markdown 文件

SubagentConfig(packages/core/src/subagents/types.ts:51)是所有形态的公共载体。frontmatter 里能写的关键字段:

字段作用缺省行为
name / description名字和「什么时候用我」——description 会被拼进 agent 工具的描述里给模型看必填
tools允许用的工具白名单省略 = 继承全部
disallowedTools黑名单,在白名单之后生效,支持 mcp__server 这种服务器级通配
modelinherit / fast / model-id / authType:model-idinherit
approvalModedefault / plan / auto-edit / yolo / bubble见 §3.4
background恒定后台运行,与工具参数 run_in_background 取或false
maxTurns轮数上限,压过老的 runConfig.max_turns
mcpServers / hooks每个 agent 私有的 MCP 服务器与钩子

注意 permissionMode 这一列。 它不是 Qwen 自己的字段,而是为了让 .claude/agents/*.md 文件原样丢进来也能解析:claudePermissionModeToApprovalMode(packages/core/src/subagents/agent-frontmatter-schema.ts:78)把 Claude 的六个值映射成 Qwen 的 approvalMode

映射表里有一处刻意的不对称,值得记:

Claude permissionModeQwen approvalMode为什么
acceptEdits / autoauto-edit语义对齐
bypassPermissionsyolo这才是「全放行」
dontAskdefaultdontAsk 在 Claude 里是拒绝一切要弹窗的调用,是限制性的;映射到 auto-edit(自动批准)会把限制变成放行

解析姿态是宽容的:非法的可选字段被丢成 undefined 而不是抛错。parseAgentMcpServers(同文件 :139)和 parseAgentHooks(:174)都只做「形状对不对」的浅校验,深层的 {type, command, ...} 判别式留给运行时的 MCP loader / SessionHooksManager。理由写在注释里:一段写坏的 mcpServers 不该把整个 agent 干掉。

两个解析器都用 Object.create(null) 建结果对象,这样 YAML 里一个字面量 __proto__ 键落进来只是普通属性,不会触发 Object.prototype 的 setter。

3.2 加载:五个层级,先到先得

SubagentLevel(types.ts:39)有五档,listSubagents(subagent-manager.ts:399)按顺序扫,名字重复时先出现的赢:

session → project(.qwen/agents/) → user(~/.qwen/agents/) → builtin → extension
(SDK 注入) 项目级 用户级 内置 扩展提供

两个特例:

  • SDK 模式(config.getSdkMode())只认 session 级,磁盘上的一律不读(:406)。
  • 安全模式(config.isSafeMode())只留 builtin;即使调用方显式要 project,也会被强行改写成 builtin 并打一条 debug(:433-449)。

内置 agent 由 BuiltinAgentRegistry(packages/core/src/subagents/builtin-agents.ts:23)硬编码,一共三个:general-purpose(默认,DEFAULT_BUILTIN_SUBAGENT_TYPE,:17)、Explore(只读搜索专家,model: 'fast',工具白名单里没有任何写工具)、statusline-setup

名字不是随便取的。SubagentValidator.validateName(packages/core/src/subagents/validation.ts:92)禁掉了一串保留字(validation.ts:130):self system user model tool config default main。其中 main 的理由很具体——它是 /stats 归因流水线用来标记「主对话」的哨兵值,一个叫 main 的子代理会被静默并进主对话那一桶。

3.3 spawn 时的四层隔离

这是子代理里工程含量最高的部分。一个子代理必须与父会话隔开,但又不能重建整个世界。Qwen 的做法是原型委托 + 定点覆盖

父 Config
│ Object.create(父)

① approval override ← getApprovalMode() 改成子代理解析出的模式
│ Object.create(①)

② subagent context ← 触发独立 FileReadCache;合并 per-agent MCP

├─ 重建 ToolRegistry ← 让 Edit/Write/Read 的 this.config 指到②而非父
└─ (可选) worktree ← targetDir/cwd/getProjectRoot/... 全部改指

第一层:审批模式覆盖。 createApprovalModeOverride(packages/core/src/tools/agent/agent.ts:372)用 Object.create(base) 造一个不改父对象的壳。

第二层:文件读缓存隔离。 注释(agent.ts:1969-1976)说得很直白:哪怕审批模式和父完全一样,也必须新建一个 Config。因为 Config.getFileReadCache() 是按实例惰性初始化的,共用父实例就等于父读过的文件能替子代理「过掉」写前必读的强制检查。

第三层:工具注册表重建。 光换 Config 不够——工具实例在构造时就捕获了 this.config,父缓存里的 EditTool 仍然指着父。rebuildToolRegistryOnOverride(tools/agent/agent.ts:445)在 override 上重跑 createToolRegistry,然后把已发现的 MCP 工具拷贝过来(而不是重新发现,因为发现很贵)。

重建过一次要打标记,否则壳套壳会重复重建。标记是一个 Symbol.for('qwen-code:tool-registry-rebuilt')(tools/agent/agent.ts:413-416)。用 Symbol 而不是字符串键,是因为 Symbol 查找会沿原型链走,所以下游的壳能自动发现「祖先里已经有人重建过了」(hasRebuiltToolRegistry,tools/agent/agent.ts:426)。

第四层(可选):git worktree。 见 §5.1。

per-agent 的 mcpServers 会强制打破上面的跳过优化。原因写在 buildSubagentContextOverride(subagent-manager.ts:922-932):不重建的话,已存在的注册表里的 McpClientManager 解析的是父的服务器列表,永远看不到合并后的覆盖表,后面的发现循环就静默变成空转。

per-agent 服务器的发现用 Promise.allSettled 并发(:955),而不是串行:一个卡住的 stdio 命令不该让 spawn 时间变成所有服务器超时之和;失败的只记 warn,不阻断其他服务器的工具落盘。

3.4 权限模式:父强则父赢,bubble 是第五种

resolveSubagentApprovalMode(agent.ts:226)的三条规则:

  1. 父是宽松模式(yolo/auto-edit/auto)时,父赢——子代理必须能自己跑,不能退化成「每个工具调用都弹窗」,那在无头场景下等于挂死。
  2. 否则用 agent 定义里写的模式;但特权模式需要可信目录——不可信目录里的 agent 定义想给自己开 yolo/auto-edit/auto,一律驳回并退回父模式(:278-285)。注释点名 auto 也算特权:它的 LLM 分类器能自动批准 shell / 网络调用,让一个不可信仓库自己授权等于白送。
  3. 都没写:plan 模式保持 plan;可信目录默认给 auto-edit(子代理需要自主性)。

bubble(BUBBLE_APPROVAL_MODE,packages/core/src/subagents/types.ts:29)是只有子代理能用的第五种,刻意没有加进全局 ApprovalMode 枚举——加进去会让它出现在会话级的模式选择器里,而它在那儿没有意义。

它的行为是:跑起来像 default(工具调用要确认),但如果这个 agent 是在交互式会话里后台跑,需要确认时不会被自动拒绝,而是把确认冒泡到父会话的 UI 排队。非交互会话、前台运行都退化成普通 default

3.5 工具黑名单:递归防护写死在循环里

EXCLUDED_TOOLS_FOR_SUBAGENTS(packages/core/src/agents/runtime/agent-core.ts:123)是一张任何子代理都拿不到的工具清单:

被砍掉的工具理由
agent防无限递归生 agent
workflow防 O(k^n) 扇出:被 workflow 生出来的子代理再调 workflow
cron_create/list/delete定时任务属于控制面,不给临时工
enter_worktree / exit_worktreeworktree 状态属于父会话,子代理不许自己进出
team_create/deletetask_*send_message团队控制面

队友有一张单独的表 EXCLUDED_TOOLS_FOR_TEAMMATES(agent-core.ts:150):队友需要 send_messagetask_create/task_update/task_list 才能干活,所以那几个放行(task_stop 仍然被砍),但 team_create/team_delete 依旧只有 leader 能用。workflow 在两张表里都在——注释说明了原因:队友身份通过 AsyncLocalStorage 传播到它生出来的任何东西,少这一条就等于把扇出炸弹重新装回去。

prepareTools(agent-core.ts:474)里有一个容易看漏的分支差异:

  • 通配 tools: ['*'] 或没写 → 拿全部工具(含 deferred/按需披露的),再过黑名单。子代理是一次性的,没有主会话那种「省 token 才延迟披露」的生命周期,藏 schema 只会静默弄坏已有配置。
  • 显式白名单 → 白名单也要过完整黑名单,而不是只过递归防护。这样控制面工具不会因为用户在白名单里手写了 cron_create 就漏进去。
  • 直接传进来的内联 FunctionDeclaration[] → 只过 recursionGuardOnly(只有 agent)。这是给 fork 用的,见下节。

4. fork:把自己复制一份

它要解决的小问题: 普通子代理从零上下文起步,你得给它写一大段简报。有时候你只想说「按刚才聊的,去把这件事做了」——让它继承整段对话

思路。 fork 不是「一个特殊的 agent 定义」,而是一个伪类型:subagent_type: "fork"FORK_AGENT(packages/core/src/tools/agent/fork-subagent.ts:30)是个 session 级的合成配置,approvalMode 写死成 bubble——detached 的 fork 没有内联 UI,default 会把每个确认自动拒掉。

关键设计:它刻意不出现在工具 schema 的枚举里。 updateDescriptionAndSchema(agent.ts:633-645)只把真实可加载的 agent 名字塞进 subagent_type 的 enum。注释解释了原因:把 fork 当作一个随手可选项挂出来之后,模型开始拿它跑需要结果的活(比如 review agent),而 fork 是 fire-and-forget、结果不回来的。现在 fork 只能显式写字符串或走 /fork 命令,校验层放行(agent.ts:685)但不推荐。

历史怎么接。 createForkSubagent(agent.ts:1086)要把父的历史改造成「以 model 消息结尾」,否则 agent-headless 再发一条 user 的 task_prompt 就会出现连续两条 user 消息。buildForkedMessages(fork-subagent.ts:119)负责这一步:

父历史最后一条是 model 且带 functionCall
→ 给每个未闭合的 functionCall 补一个占位 functionResponse
("Fork started — processing in background")
→ 占位响应 + 指令文本合成一条 user 消息(避免连续 user)
→ 再追加一条 model 的 "Understood. Executing directive now."
→ task_prompt 退化成一个触发词 "Begin."

为什么 fork 保留 agent 工具声明。 为了和父的请求逐字节相同从而共享 DashScope 的 prompt 缓存,fork 直接拿父 getGenerationConfig() 里的 systemInstruction 和工具声明原样用(agent.ts:1153-1184)。既然工具声明不能改,递归防护就只能换个地方做:用 AsyncLocalStorage 打标记。runInForkContext(fork-subagent.ts:61)在 dispatch 时标记当前异步帧,AgentTool.execute 一进来就查 isInForkExecution()(agent.ts:1744)并直接返回错误。

注释还说明了为什么不能靠扫历史检测:嵌套 AgentToolthis.config 是主进程的 Config,getHistory() 返回的是父对话而不是 fork 子对话,根本认不出嵌套。

fork 的轮数上限硬编码 200(FORK_DEFAULT_MAX_TURNS,fork-subagent.ts:46)——没人 await 的后台活,不设上限就是静默烧 token。

fork 的指令外面还包了一段强约束的 boilerplate(buildChildMessage,fork-subagent.ts:182):禁止再生 agent、禁止在工具调用之间输出文本、报告必须以 Scope: 开头、500 词以内。


5. 隔离与后台:在哪干、什么时候干

5.1 worktree 隔离:换一棵工作树

isolation: 'worktree' 会在 <repoRoot>/.qwen/worktrees/agent-<7hex>/ 开一棵新工作树(slug 由 generateAgentWorktreeSlug 生成,packages/core/src/services/gitWorktreeService.ts:153;分支名是 worktree-<slug>,:29)。

分三个阶段,顺序是有讲究的:

① provision 开 worktree ─┐ 必须在造 agent Config 之前
│ 否则工具注册时拿到的还是父目录
② rebind 改 Config ───┤ targetDir / cwd / getTargetDir /
│ getProjectRoot / FileDiscoveryService /
│ WorkspaceContext 全部改指
③ notice 改 prompt ───┘ 告诉模型「你在 worktree 里,路径要翻译」

第二阶段(agent.ts:2004-2023)同时覆盖了字段(ov.targetDir)和方法(ov.getTargetDir)。注释解释了为什么两者都要:JS 里给一个 getter 赋值不会自动变成字段遮蔽,只覆盖方法的话,像 getProjectRoot/getFileService 内部那种直接读 this.targetDir 的调用点仍然会沿原型链拿到父的值。

第三阶段的 notice(buildWorktreeNotice,fork-subagent.ts:168)传的是父 agent 的 getTargetDir(),不是仓库顶层。第 5 轮 review 抓到的:模型的心智地图是父的 cwd——父在 packages/core/ 下跑时说的 ./foo,拿仓库根去翻译就错了。

收尾策略是「有产出就留,没产出就删」。 cleanupWorktreeIsolation(agent.ts:1593)并发跑两个检查:hasWorktreeChanges(工作区脏不脏)和 hasUnmergedWorktreeCommits(有没有没并回去的提交)。任何一个为真就保留,并把路径和分支名拼进工具结果。两个检查都是fail-closed:抛异常时一律当作「有变更」,宁可留垃圾也不删掉用户的活。

还有一处细节:如果目录已经删了但分支因为有未合并提交而保留下来,返回值里只给 branch,不给 path(agent.ts:1654-1673)——报一个已经不存在的路径等于骗父 agent 说「你可以去那儿看」。

隔离本身有前置条件(validateToolParams,agent.ts:697-711):必须显式指定 subagent_type,且不能是 fork(fork 复用父的对话和工作树,隔离没意义)。运行时还会拒绝嵌套 worktree(agent.ts:1853)和父工作树有未提交改动的情况——后者的理由是子代理会看到一个陈旧的 HEAD。

5.2 后台任务:结果怎么找回来

BackgroundTaskRegistry(packages/core/src/agents/background-tasks.ts:373)同时装两类条目,靠 isBackgrounded 区分:

isBackgrounded: trueisBackgrounded: false
生命周期跨轮次存活只活到父的这一次工具调用返回
结果通道终态时发 <task-notification> XML普通工具结果
无头模式计入 hasUnfinalizedTasks(),让循环等它不参与

并发上限默认 10(DEFAULT_MAX_CONCURRENT_BACKGROUND_AGENTS,:42),可用 QWEN_CODE_MAX_BACKGROUND_AGENTS 覆盖。assertCanStartBackgroundAgent(:392)在两处调用:一次是 register() 里的权威竞态守卫,一次是 AgentTool.execute 开头的预检(agent.ts:1801)——预检不是冗余,它让失败发生在开 worktree、跑 hook、造子代理之前

终态条目最多留 32 条(MAX_RETAINED_TERMINAL_AGENTS,:101)。

BackgroundAgentResumeService(packages/core/src/agents/background-agent-resume.ts:377)负责把上次会话里暂停的后台 agent 从 JSONL transcript 里捞回来续跑。

5.3 定时:cron 与 wakeup

CronScheduler(packages/core/src/services/cronScheduler.ts:185)提供 cron_create / cron_list / cron_delete 三个工具(它们全在子代理黑名单里)。几个硬约束:

  • 最多 50 个 job(MAX_JOBS,:29)。
  • 周期性 job 创建满 7 天后自动过期(RECURRING_MAX_AGE_MS,:33),过期那次仍然会触发一次再删——覆盖「这周每小时看一下我的 PR」这类需求,同时给遗忘的排程封顶。
  • 触发时间加抖动:周期性最多取周期的 10%(封顶 15 分钟),一次性最多提前 90 秒。
  • durable: true 才落盘到 ~/.qwen/tmp/<project-hash>/ 并跨重启存活;默认只在内存里。
  • wakeup(自定速的 /loop)另有一套:延迟被夹到 [60, 3600] 秒(:46),不占 MAX_JOBS,永不持久化。

6. 工作流编排:用一段 JS 当指挥

它要解决的小问题: 「先并发查 8 个方向,再把结果汇总给一个 agent 写报告」——这种拓扑靠模型一句一句 call 工具去凑,既慢又不可靠。

思路:让模型写一段 JavaScript,脚本里能调 agent(prompt, opts)parallel([...thunks])pipeline(items, ...stages)phase(title)log(msg),脚本的返回值就是工作流的结果。

6.1 沙箱:脚本跑在 vm 里,而且刻意残废

createWorkflowSandbox(packages/core/src/agents/runtime/workflow-sandbox.ts:666)用 Node 的 vm.createContext 建一个隔离 realm。安全上最核心的一条:绝不把宿主 realm 的对象递过边界

原因是原型链逃逸:一个宿主 Promise 或宿主 Error 都能被顺藤摸瓜拿到宿主的 Function 构造器——

agent("x").constructor.constructor("return process")() // 走返回的宿主 Promise
try { throw new Error() } catch(e) { e.constructor.constructor(...)() } // 走宿主 Error
globalThis.constructor.constructor("return process")() // 走宿主 Object.prototype

做法是:先在 globalThis 上放一个只含函数和字符串的 bridge,init 脚本的第一件事就是 delete globalThis.__workflowBridge,然后在 vm realm 内部重建所有全局对象(workflow-sandbox.ts:727-792)。bridge 和容器都被 Object.setPrototypeOf(x, null) 斩断原型链(:743)。

沙箱还刻意砍掉了两个东西:Math.random()Date.now() 都会抛异常(:766-772)。理由不是安全而是可重放——见 §6.4。

其他硬边界:同步执行 30 秒 vm timeout(:1139),另有一层 Promise.race 的整体墙钟(vm timeout 覆盖不到 await);日志和 phase 各封顶 10000 条(:375)。

6.2 三道闸门:计数、并发、预算

所有 agent() 调用——顺序的、parallel() 里的、pipeline() 里的——都走同一个 countedDispatch(packages/core/src/agents/runtime/workflow-orchestrator.ts:1607)。这是「扇出绕不过上限」的结构性保证。

countedDispatch(prompt, opts)
① journal 缓存查询 ← 命中直接返回,不花 token、不占名额
② 预算闸(入口) ← budget.remaining() <= 0 → 抛 WorkflowBudgetExceededError
③ agent 计数 ← agentCount++ > maxAgents → 抛
④ limiter.run(…)
└ 预算闸(拿到槽位时再查一次)
└ 真正 dispatch → AgentHeadless
⑤ 结果写 journal + emit 给 UI

三个上限的取值:

闸门默认env 覆盖硬顶
每次运行的 agent() 总数1000QWEN_CODE_MAX_WORKFLOW_AGENTS10000
同时在飞的 agent 数max(1, min(16, cpus-2))QWEN_CODE_MAX_WORKFLOW_CONCURRENCY64
每次运行的输出 token无上限(null)QWEN_CODE_MAX_TOKENS_PER_WORKFLOW1 亿

对应 resolveMaxAgentsPerRun(:71)、resolveConcurrencyLimit(:117)、resolveMaxTokensPerWorkflow(packages/core/src/agents/runtime/workflow-budget.ts:71)。三个函数的姿态一致:非整数或 <1 的覆盖值记 warn 后退回默认;超硬顶的夹住而不是拒绝。硬顶存在的意义写得很直白——防手滑,一个 =999999999 不该静默解除限制。

有两处顺序细节值得单独拎出来:

① 限流器卡在「叶子」而不是「编排」上(:1292-1301)。如果并发窗口卡在 thunk 层,一个 pipeline 的 stage 内部再 parallel() 就会自锁:外层占满所有槽位,等着内层拿槽位,而槽位永远不会释放。只让叶子 agent() 抢槽位就没这个问题。

② 预算闸要查两次。 入口查一次不够:parallel([N 个 thunk]) 会在一个微任务批里同步完成 N 次检查,那时 spent 还是 0,N 个全过。所以拿到槽位时再查一次(:1444),让排队的 dispatch 能看见已完成的兄弟花掉的 token。这把超支上界从 (N-1) × 单次 压回文档承诺的 (并发窗口-1) × 单次

③ 预算闸在计数之前。 反过来的话,预算耗尽后每次调用仍然 agentCount++,最终触发 agent 计数上限,报出去的终态错误就变成「超过 N 次 agent 调用」——把真正的原因(预算耗尽)盖掉了(:1378-1387)。

工作流子代理还有自己的地板配置:50 轮 / 10 分钟(:150),以及一张无论 agentType 是什么都强制生效的黑名单 WORKFLOW_SUBAGENT_DISALLOWED_TOOLS = [send_message, exit_plan_mode](:161)。这两个工具会打破「最终文本就是返回值」的契约:send_message 会把答案送给用户而不是脚本。

同样的约束在系统提示里也写了一遍(workflow-prompts.ts:30),两层防御:

你的最终文本会被逐字当作字符串返回给调用脚本——它是返回值,不是给人看的消息。

agent({schema}) 指定了 JSON Schema 时换成另一段提示(workflow-prompts.ts:50),要求必须通过 structured_output 工具交付,纯文本答案会被丢弃;连续两次校验失败就终止。

6.3 停滞看门狗:分清「卡住」和「慢」

agent() 可能永远挂着——模型打转、provider 流断在半路、工具不返回。子代理自己的 10 分钟上限太粗。

workflow-stall.ts 的看门狗更细:60 秒内没有任何可观察进展就 abort 这次尝试,最多重试 3 次(DEFAULT_STALL_MS :44MAX_STALL_ATTEMPTS :47)。

关键在「进展」的定义(:16-22):任何 reasoning-loop 事件(轮次开始、流式文本、token 用量、工具调用/结果)都算进展;而且工具执行期间计时器是挂起的——一个跑 90 秒的 shell build 不能被判成停滞。计时器只累计「既不产出、也没有工具在跑」的墙钟时间。

区分「停滞中止」和「父级取消」靠 watchdog.stalled() 加父 signal 的 aborted 标志:前者重试,后者直接往上传。

schema 模式还白捡一个救援:如果停滞发生在子代理已经交出合法 structured_output 之后,单次 dispatch 会在检查终止模式之前先把 payload 返回,外层看到的是成功,不会重试。

6.4 重放:改一行脚本,只重跑改动之后的部分

它要解决的小问题: 一个跑了 40 个 agent 的工作流,在第 38 步炸了。重跑一次要再烧 37 个 agent 的钱。

思路:滚动前缀哈希。 每次 dispatch 的 key 是 v2:sha256(前一个 key ‖ prompt ‖ 规范化 opts)——链式的(deriveAgentKey,packages/core/src/agents/runtime/workflow-journal.ts:120)。

调用#1 ─key1─┐
调用#2 ─key2─┤ key_n = H(key_{n-1}, prompt_n, opts_n)
调用#3 ─key3─┤
↑ 改这里 → key3 变 → key4 变 → key5 变 … (从改动点往后全部失效)

链的种子不是空串,而是本次运行 args 的哈希(deriveArgsSeed,:130)。这样换了 args 的重放会落进一个完全不相交的 key 空间,每次调用都 miss、都重跑,而不是静默重放上一次的结果。

规范化 opts 只保留影响 dispatch 的四个字段:schema / model / isolation / agentType,对象键递归排序(canonicalizeAgentOpts,:74)。labelphasestallMs 这些装饰性的改动不会破缓存。

hadMiss 标志保证「第一次 miss 之后,后缀全部走实跑」(workflow-orchestrator.ts:1621)——不允许缓存跳着命中。

现在回头看 §6.1 里那两个被砍掉的全局:Date.now()Math.random() 抛异常,正是重放正确性的前提。脚本必须确定性,否则 agent() 的调用序列不稳定,key 链就无从谈起。Math.random 的错误信息还顺手教了替代写法:「要 N 个独立样本,把索引写进 agent 的 label 或 prompt 里」。

journal 是 JSONL 追加写(WorkflowJournal,:170),写失败只记 warn 不阻断 dispatch——热路径上不 await。

配套的两个登记处:WorkflowRunRegistry(packages/core/src/agents/workflow-run-registry.ts:285,内存,驱动 UI 的 pill 和 /workflows 面板,刻意不发任何模型可见的通知,因为 WorkflowTool 自己已经返回结果了)和 workflow-snapshot.ts(终态时落盘 <projectDir>/workflows/<runId>.json,最多留 30 份,让重启后还能看历史)。

保存好的工作流放在 .qwen/workflows/<name>.js(项目级优先于用户级),名字要匹配 ^[a-z][a-z0-9-]{0,40}$(workflow-saved.ts:34)——因为这个名字同时当文件名和斜杠命令名(deep-research.js/deep-research)。嵌套只允许一层:嵌套沙箱创建时不带 workflow 实现,第二层调用直接抛。


7. 团队:一群常驻同事

和子代理的根本区别: 子代理用 AgentHeadless(跑完就死),队友用 AgentInteractive(空转到 IDLE 还能收新消息)。completeOnIdle 对队友刻意设为 false(agent-types.ts:170-181),这样它们能继续接后续消息、继续自动认领任务;leader 的等待循环依赖这一点。

7.1 生成队友

TeamManager.spawnTeammate(packages/core/src/agents/team/TeamManager.ts:229)最多 10 个(MAX_TEAMMATES,team/types.ts:178)。

第一个细节在开头:名额是同步预占的(:263-270)。注释说明了没有它会怎样——N 个并发 spawn 全都通过了上限检查,然后第一个 await loadSubagent,回来时 N 个一起 push,直接冲破上限。

第二个细节是团队工具会被强行塞回去(:320-348)。当 agentType 指向一个工具受限的 agent 定义时,send_message / task_list / task_update / task_create 会被补进白名单;更关键的是它们还要从 disallowedTools摘掉——因为黑名单在白名单之后生效,只补白名单不够。一个显式 disallow 了 send_message 的 agent 定义,否则会 spawn 成功但完全无法汇报。

队友的系统提示 = (agent 定义的提示 或 用户提示)+ buildTeammatePromptAddendum(team/promptAddendum.ts:28)。addendum 是五条硬规矩:先 task_list 找活 → 干活 → send_message(to: "leader") 汇报(这是 leader 唯一能看到你输出的途径,文本输出对别人不可见) → 标完成 → 再看任务板。最后一条:不许生子代理,只有 leader 能。

leader 不加任何团队指令——它从工具可用性自己推断(promptAddendum.ts:10-12)。

7.2 消息:优先级、防伪、背压

队友身份靠 AsyncLocalStorage 传播(team/identity.ts:25),所以 SendMessageTaskUpdate 这些工具不用改签名就知道「现在是谁在调我」。

投递有三档优先级(TeamManager.ts:99):

优先级来源
0SHUTDOWN
1LEADER(leader 发来的)
2PEER(队友之间)

每个队友的待投递队列封顶 50 条(MAX_PENDING_MESSAGES,:149),满了就报错让发送方等——背压。

队友 → leader 的消息要防伪。 消息在 leader 的对话里被包进 <teammate_message> 标签(LEADER_ENVELOPE_TAG,:119)。防伪不靠 nonce,而是结构性的:escapeEnvelopeTags(:817)把队友正文里任何一份该定界符的拷贝转义掉。标签字面量只定义一次,就是为了防止改名时转义正则和定界符漂移——一漂移就静默停止防护。

结构化的控制消息(shutdown、计划审批、任务分派)走文件信箱:~/.qwen/teams/<team>/inboxes/<agent>.json(team/mailbox.ts)。并发用两层锁:进程内 per-inbox Mutex + 跨进程 proper-lockfile(10 次重试、5–100ms 带抖动的指数退避)。进程内那层的作用是别让同进程的写者一起去踩文件锁——Windows 上较慢的 fs 系统调用会让并发写者耗尽重试预算然后抛 ELOCKED

7.3 任务板

任务是一堆独立 JSON 文件:~/.qwen/tasks/<team>/<id>.json(team/tasks.ts)。同样两层锁,文件锁重试次数提到 30——注释指名了最尖锐的场景:scanIdleAgentsForTasks 让最多 10 个认领者同时扑向同一个 pending 任务文件。

两个容量守卫:metadata 单个封顶 32KB(:43,因为 listTasks 会并行读每一个任务文件),并行读封顶 16 个 fd(:49,防 EMFILE)。

RECIPROCAL_CALLER = '__reciprocal__'(:149)是一个哨兵调用者名:A.blocks=[B] 需要镜像成 B.blockedBy=[A],这个内部写入必须绕过归属检查。用哨兵而不是空字符串,是为了让「刻意绕过」在日志里可 grep;而且它永远不会和真实身份撞——agent 名字被规范化成 [a-z0-9-],下划线出不来。

leader 空闲时会扫一遍(scanIdleAgentsForTasks,:1535),把 pending 任务派给 IDLE 的队友(tryAutoClaimTask,:1467)。

7.4 审批冒泡到 leader

队友是在同一个 Node 进程里跑的,所以它的 CoreToolScheduler 判定「这个工具要人批」时,TOOL_WAITING_APPROVAL 事件会经过 leaderPermissionBridge(team/leaderPermissionBridge.ts)转到 leader 的 UI:

队友 CoreToolScheduler
→ AgentEventType.TOOL_WAITING_APPROVAL
→ TeamManager.setupEventBridge (TeamManager.ts:1185)
→ forwardApproval(name, color, details) (bridge:97)
├ 有 leader 注册 → 进 leader 的确认队列,标题带 [队友名] 徽章
└ 没有(无头) → 返回 false,改发 TEAMMATE_APPROVAL_REQUEST 团队事件

wrapConfirmWithBadge(bridge:121)有个不显然的必要性:agent 事件边界会把 onConfirm 回调剥掉,所以包装器的 onConfirm 必须委托给事件里带的 respond 回调,直接用 original.onConfirm 会抛。

7.5 执行后端

Backend 是一层抽象,理论上有三种:in-process / tmux / iterm2(backends/types.ts:28)。

现实是:detectBackend(backends/detect.ts:34)只返回 InProcessBackend,tmux / iTerm 的自动探测逻辑整段被注释掉留在文件里(:47-88)。用户显式要 tmux 时也只会拿到一条 warning 加 in-process。TmuxBackend.ts(813 行)和 ITermBackend.ts(431 行)仍在仓库里,但不接入口。

InProcessBackend(backends/InProcessBackend.ts:45)给每个 agent 一份自己的 ToolRegistry,用 Map 按 agentId 存。注释记录了改成 Map 之前的 bug:扁平数组导致 stopAgent 无法单独释放,每次 spawn-then-stop 都在共享的 SkillManager 上积一个陈旧监听器。


8. 竞技场:多模型赛马

要解决的问题: 同一道题,哪个模型做得更好?让它们同时做,各自在独立的 git worktree 里,最后比 diff。

ArenaManager.start(packages/core/src/agents/arena/ArenaManager.ts:289)的流程:

校验参数(至少 2 个模型,最多 5 个 → ARENA_MAX_AGENTS, arena/types.ts:15)
→ git 可用性 + 是不是 repo(快速失败,在任何 UI 输出之前)
→ initializeBackend
→ setupWorktrees 每个 modelId 一棵工作树 (:775)
→ runAgents (:841)
→ collectResults 收 diff
→ 侧路查询生成「这一版的思路」摘要(20s 超时,diff/transcript 各截 6000 字符)

摘要走 runSideQuery,失败时退回 buildFallbackApproachSummary;diff 统计由 summarizeUnifiedDiff(arena/diff-summary.ts:17)解析 unified diff 得到每文件的增删行数。

竞技场里的 agent 需要知道自己在竞技场里。做法是往上下文里插一条 system-reminder(getArenaSystemReminder,packages/core/src/core/prompts.ts:908),内容只有一句:「Arena 会话已激活,详情读 <configFilePath>,此消息仅供内部使用,不要在回复里提及」——把细节推到文件里而不是撑在上下文里(这个「按需下钻」的套路见 05 上下文工程)。

如果 agent 跑在子进程里,状态回传走文件 IPC:ArenaAgentClient(arena/ArenaAgentClient.ts:40)靠 ARENA_AGENT_ID / ARENA_SESSION_ID / ARENA_SESSION_DIR 三个环境变量自激活(缺一个就返回 null),状态写 <sessionDir>/agents/<safeAgentId>.json,控制信号读 <sessionDir>/control/<safeAgentId>.json

终止模式里的 SHUTDOWN(agent-types.ts:115)就是给竞技场/团队优雅收场用的。遥测里它被归到 cancelled 而不是 failed(agent.ts:767),免得正常的会话结束把子代理错误率拉高。


9. 巧妙之处(可以偷走的技术)

① 用 Symbol.for 做原型链可见的幂等标记。 判断「祖先里有没有人已经重建过工具注册表」不需要手动走原型链——Symbol 属性查找天然走链。用 Symbol.for 而不是 Symbol() 是为了扛住打包去重,两次独立 import 拿到同一个 Symbol 身份(tools/agent/agent.ts:409-427)。

② 用 AsyncLocalStorage 做递归防护,而不是删工具。 fork 必须保留 agent 工具声明来共享 prompt 缓存,所以防护改成标记异步帧(fork-subagent.ts:59-67)。同一招也用在深度计数(runWithAgentContext 自增 depth,agents/runtime/agent-context.ts:46)和队友身份传播(team/identity.ts:91)上。

getCurrentAgentDepth() 的歧义被写进文档而不是被掩盖。 它对「没有 agent 帧」和「顶层帧 depth=0」都返回 0;注释明确要求调用方先用 getCurrentAgentId()(只有前者返回 null)区分(agent-context.ts:70-85)。这是一个「与其加一个 nullable 返回值,不如把消歧模式钉在注释里」的取舍。

④ 滚动前缀哈希 + 砍掉不确定性 = 便宜的重放。 见 §6.4。真正的洞察是:重放正确性的前提不是 journal,而是脚本必须确定性,所以 Date.now/Math.random 被主动砍掉。

⑤ 限流器卡在叶子而不是编排层。 一句话避免了嵌套扇出的自锁(workflow-orchestrator.ts:1575-1584)。

⑥ 定界符转义而不是 nonce。 队友消息的防伪靠转义正文里的定界符拷贝,不需要密钥管理;代价是定界符字面量必须单点定义(TeamManager.ts:111-120)。

⑦ 清理路径一律 fail-closed。 worktree 的两个检查抛异常时都返回「有变更」,宁可留下垃圾目录也不删掉用户可能需要的产出(agent.ts:1613-1626)。

⑧ 硬顶(hard ceiling)配在 env 覆盖之上。 三个 env 旋钮各有一个 10×~20× 于默认值的绝对上限,超了就夹住并 warn。这些注释明说了目的:防手滑,不是防攻击。


10. 边界与局限

只有 in-process 后端是活的。 tmux 和 iTerm 后端有 1200 多行代码在仓库里,但 detectBackend 直接返回 in-process,显式指定其他模式只会拿到一条 warning(backends/detect.ts:38-45)。所以「多个终端窗格看多个 agent」目前不可用。

per-agent hook 没有作用域过滤。 frontmatter 里声明的 hook 注册进全局 HookRegistry 之后,在这个 agent 存活期间会对每一个同类型事件触发,不管当时活跃的是哪个 agent。注释把这标为 v1 限制,「proper per-agent scope filtering is deferred」(subagent-manager.ts:801-806)。

预算是软闸,会超支。 上界约为 (并发窗口-1) × 单次 dispatch token,注释建议运维按这个余量往下调阈值(workflow-budget.ts:18-27)。

mcpServers 的字符串引用形式不支持。 Claude Code 里可以用服务器名引用,Qwen 在解析层直接丢弃,理由是与其静默透传给 MCP loader 让它后面报个莫名其妙的错,不如在这里拒掉(agent-frontmatter-schema.ts:125-128)。

worktree 隔离拒绝父工作树有未提交改动。 因为 git worktree add 检出的是提交状态,父的未提交改动不会传过去,子代理会对着陈旧的 HEAD 干活(agent.ts:1903-1909)。

fork 只在交互式会话可用(isForkSubagentEnabled = config.isInteractive(),fork-subagent.ts:23)。非交互会话里显式请求 fork 会静默退回 general-purpose 子代理。

嵌套工作流只允许一层。 嵌套沙箱建的时候不带 workflow 实现,第二层调用直接抛(workflow-orchestrator.ts:334-344)。

队友的文本输出对其他 agent 不可见。 唯一的汇报通道是 send_message。这是设计,不是 bug,但它意味着一个不守规矩的队友可以干完活却什么都没交付。


11. 横向对比与本组其他章

  • 子代理跑的还是主循环那一套——AgentCore.runReasoningLoop。本章只讲外面那层壳,循环本身见 01 主循环
  • 子代理的模型选择器(inherit / fast / authType:model-id)由模型层解析,见 02 多协议模型层
  • EXCLUDED_TOOLS_FOR_SUBAGENTS 是在工具层的「按需披露」之上再叠的一层过滤,工具注册与披露机制见 03 工具层
  • approvalMode / bubble / AUTO 分类器和 stripDangerousRulesForAutoMode 属于安全护栏,见 04 安全护栏
  • 子代理的系统提示模板(${var} 替换)、竞技场的 system-reminder、fork 的 boilerplate 都是上下文工程的手法,见 05 上下文工程

12. 代码地图(导航索引)

主题文件路径符号名
子代理配置载体packages/core/src/subagents/types.tsSubagentConfig, SubagentLevel, BUBBLE_APPROVAL_MODE
frontmatter 解析(CC 兼容)packages/core/src/subagents/agent-frontmatter-schema.tsclaudePermissionModeToApprovalMode, parseAgentMcpServers, parseAgentHooks, parseMaxTurns
内置 agentpackages/core/src/subagents/builtin-agents.tsBuiltinAgentRegistry, DEFAULT_BUILTIN_SUBAGENT_TYPE
名字/提示/工具校验packages/core/src/subagents/validation.tsSubagentValidator.validateName
加载、序列化、造 agentpackages/core/src/subagents/subagent-manager.tsSubagentManager.listSubagents, createAgentHeadless, buildSubagentContextOverride, convertToRuntimeConfig, serializeSubagent
agent 工具入口packages/core/src/tools/agent/agent.tsAgentTool, AgentParams, resolveSubagentApprovalMode, createApprovalModeOverride, rebuildToolRegistryOnOverride, TOOL_REGISTRY_REBUILT
fork 伪类型packages/core/src/tools/agent/fork-subagent.tsFORK_AGENT, buildForkedMessages, runInForkContext, isInForkExecution, buildWorktreeNotice, buildChildMessage
推理循环 + 工具黑名单packages/core/src/agents/runtime/agent-core.tsAgentCore.prepareTools, runReasoningLoop, EXCLUDED_TOOLS_FOR_SUBAGENTS
一次性壳packages/core/src/agents/runtime/agent-headless.tsAgentHeadless.execute, ContextState, templateString
常驻壳packages/core/src/agents/runtime/agent-interactive.tsAgentInteractive.enqueueMessage, shutdown
嵌套深度 / 身份packages/core/src/agents/runtime/agent-context.tsrunWithAgentContext, getCurrentAgentDepth, getCurrentAgentId
事件 / 统计 / 终止模式packages/core/src/agents/runtime/agent-events.ts, agent-statistics.ts, agent-types.tsAgentEventType, AgentStatistics, AgentTerminateMode, AgentStatus
工作流编排packages/core/src/agents/runtime/workflow-orchestrator.tsWorkflowOrchestrator.run, resolveMaxAgentsPerRun, resolveConcurrencyLimit, DEFAULT_MAX_AGENTS_PER_RUN
工作流沙箱packages/core/src/agents/runtime/workflow-sandbox.tscreateWorkflowSandbox, stripExportMeta, extractAndStripMeta
重放 journalpackages/core/src/agents/runtime/workflow-journal.tsderiveAgentKey, deriveArgsSeed, canonicalizeAgentOpts, WorkflowJournal
token 预算 / 停滞看门狗packages/core/src/agents/runtime/workflow-budget.ts, workflow-stall.tsresolveMaxTokensPerWorkflow, resolveStallMs, runStallResilient, MAX_STALL_ATTEMPTS
工作流提示词 / 保存packages/core/src/agents/runtime/workflow-prompts.ts, workflow-saved.tsWORKFLOW_SUBAGENT_SYSTEM_PROMPT, WORKFLOW_NAME_PATTERN
工作流登记 / 快照packages/core/src/agents/workflow-run-registry.ts, workflow-snapshot.tsWorkflowRunRegistry, toSnapshot, MAX_RETAINED_SNAPSHOTS
workflow 工具packages/core/src/tools/workflow/workflow.tsWorkflowTool, WORKFLOW_PARAM_SCHEMA
团队编排packages/core/src/agents/team/TeamManager.tsTeamManager.spawnTeammate, setupEventBridge, tryAutoClaimTask, formatLeaderEnvelope, escapeEnvelopeTags
信箱 / 任务板packages/core/src/agents/team/mailbox.ts, tasks.tsMailboxMessage, withTaskFileLock, RECIPROCAL_CALLER
队友身份 / 提示 / 审批packages/core/src/agents/team/identity.ts, promptAddendum.ts, leaderPermissionBridge.tsrunWithTeammateIdentity, buildTeammatePromptAddendum, forwardApproval, wrapConfirmWithBadge
执行后端packages/core/src/agents/backends/detect.ts, InProcessBackend.tsdetectBackend, InProcessBackend, DISPLAY_MODE
竞技场packages/core/src/agents/arena/ArenaManager.ts, ArenaAgentClient.ts, diff-summary.tsArenaManager.start, setupWorktrees, ArenaAgentClient.create, summarizeUnifiedDiff, ARENA_MAX_AGENTS
竞技场 system-reminderpackages/core/src/core/prompts.tsgetArenaSystemReminder
worktree 底座packages/core/src/services/gitWorktreeService.ts, worktreeSessionService.tsGitWorktreeService, generateAgentWorktreeSlug, writeWorktreeSessionMarker, WorktreeSession
后台任务 / 恢复packages/core/src/agents/background-tasks.ts, background-agent-resume.tsBackgroundTaskRegistry.assertCanStartBackgroundAgent, BackgroundAgentResumeService
定时任务packages/core/src/services/cronScheduler.ts, packages/core/src/tools/cron-create.tsCronScheduler, CronCreateTool, WAKEUP_MIN_SECONDS