数据截至 (上游 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:172 的 AgentParams):
{
"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 定义,转成运行时配置并造 AgentHeadless | packages/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 这种服务器级通配 | 无 |
model | inherit / fast / model-id / authType:model-id | inherit |
approvalMode | default / 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 permissionMode | Qwen approvalMode | 为什么 |
|---|---|---|
acceptEdits / auto | auto-edit | 语义对齐 |
bypassPermissions | yolo | 这才是「全放行」 |
dontAsk | default | dontAsk 在 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)的三条规则:
- 父是宽松模式(
yolo/auto-edit/auto)时,父赢——子代理必须能自己跑,不能退化成「每个工具调用都弹窗」,那在无头场景下等于挂死。 - 否则用 agent 定义里写的模式;但特权模式需要可信目录——不可信目录里的 agent 定义想给自己开
yolo/auto-edit/auto,一律驳回并退回父模式(:278-285)。注释点名auto也算特权:它的 LLM 分类器能自动批准 shell / 网络调用,让一个不可信仓库自己授权等于 白送。 - 都没写: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_worktree | worktree 状态属于父会话,子代理不许自己进出 |
team_create/delete、task_*、send_message | 团队控制面 |
队友有一张单独的表 EXCLUDED_TOOLS_FOR_TEAMMATES(agent-core.ts:150):队友需要 send_message 和 task_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)并直接返回错误。
注释还说明了为什么不能靠扫历史检测:嵌套 AgentTool 的 this.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: true | isBackgrounded: 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() 总数 | 1000 | QWEN_CODE_MAX_WORKFLOW_AGENTS | 10000 |
| 同时在飞的 agent 数 | max(1, min(16, cpus-2)) | QWEN_CODE_MAX_WORKFLOW_CONCURRENCY | 64 |
| 每次运行的输出 token | 无上限(null) | QWEN_CODE_MAX_TOKENS_PER_WORKFLOW | 1 亿 |
对应 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 :44、MAX_STALL_ATTEMPTS :47)。
关键在「进展」的定义(:16-22):任何 reasoning-loop 事件(轮次开始、流式文本、token 用量、工具调用/结果)都算进展;而且工具执行期间计时器是挂起的——一个跑 90 秒的 shell build 不能被判成停滞。计时器只累计「既不产出、也没有工具在跑」的墙钟时间。
区分「停滞中止」和「父级取消」靠 watchdog.stalled() 加父 signal 的 aborted 标志:前者重试,后者直接往上传。
schema 模式还白捡一个救援:如果停滞发生在子代理已经交出合法 structured_output 之后,单次 dispatch 会在检查终止模式之前先把 payload 返回,外层看到的是成功,不会重试。