为什么你的 Claude Code 还在转圈等,别人的已经干完三件事了?
同样是让 Claude Code 跑全量测试,有人盯着终端转圈等 8 分钟,什么也干不了;有人的 Claude Code 拿到一个任务 ID 就转头去改别的模块、查日志、写单测,等测试跑完,一条通知插进对话,它再回来接着修失败用例。
差别不在模型,在于用没用对它的异步工具。
我一开始也以为这只是“后台跑个命令”。直到把 Claude Code 2.1.303 和 2.1.304 的请求体抓下来对比,才发现不是:tools 从 20 个变成 12 个,system prompt 5,768 个字符,正文一个字没改。
Claude Code 正在把一部分工具调用从“函数调用”改成“提交任务”。用前端的话说,就像把同步阻塞调用改成了 Promise:tool_use 先返回一个任务 ID,等跑完了再由运行时把结果通知推回来。比 Promise 更进一步的是:这些任务还能持续推送多条事件(Monitor),也能随时按 ID 取消(TaskStop)。
这篇不聊虚的概念,直接把这 5 种后台玩法拆开,顺便聊聊什么时候千万别用——因为用错了,省下的等待时间会从 token 账单里双倍还回去。
本文基于 Claude Code 2.1.303–2.1.305 的行为;版本差异会在文中标出。抓包还带出另一层异步(
ToolSearch的默认值变化),放在文末附录;不关心版本细节可以跳过。
省流版
- 异步工具不是“后台跑命令”,是“工具返回任务 ID,完成时再叫你”。
- 5 种形态:后台 Bash、后台 Agent、Monitor、Cron / ScheduleWakeup、Workflow。
- 大多数场景只需要前两种;Workflow 是最后一步,不是第一步。
- 两个最容易被低估的成本:prompt cache(模型侧上下文缓存)和权限边界。
先分清:两种异步,解决的不是一回事
Claude Code 里有两个东西都被叫“异步”,但它们解决的不是同一个问题。
| 维度 | 定义异步(ToolSearch,懒加载) | 执行异步(后台任务) |
|---|---|---|
| 异步的对象 | 工具的 JSONSchema | 工具的执行与结果 |
| 触发方式 | 模型调用 ToolSearch | run_in_background 或任务型工具 |
| 返回什么 | 完整 schema | 任务 ID / 句柄 |
| 完成信号 | 同一次调用内返回 | 新的一轮事件 |
| 省什么 | 上下文 token | 墙钟时间(实际耗时) |
| 主要代价 | 多一次搜索往返 | 状态、权限、成本、生命周期 |
一句话:前者是懒加载,后者是任务调度。
混着聊,就会问出“ToolSearch 能不能让工具并行”这种问题。不能。它自己就是同步调用,只是把 schema 的获取从“启动时全量注入”改成了“用到时一次往返”。
为什么非要拆成两步?
同步工具调用是一次阻塞 RPC:模型发起 Bash,harness(模型外面的运行时/调度层)执行,结果回到上下文,模型才能继续。这期间模型不能做别的事,用户也只能等。
Claude Code 给前台 Bash 的默认超时是 120 秒,最大 600 秒。但更有意思的是超时之后的行为:命令不会直接失败,而是被移到后台——除非命令以 sleep 开头(sleep 本身就是在等,转后台没有意义),或者后台任务被禁用。
这个设计说明了一件事:长任务的正确归宿不是更长的 timeout,而是任务队列。
于是就有了下面 5 种形态。它们不是 5 个功能,是同一套任务模型的 5 个入口。
5 种形态,一张表先看完
| 形态 | 代表工具 | 立即返回 | 唤醒信号 | 取消 |
|---|---|---|---|---|
| 后台命令(Bash) | Bash + run_in_background | 任务 ID | 进程退出,重新唤起模型 | TaskStop |
| 后台子代理(Agent) | Agent | 代理 ID | 完成通知 | TaskStop / 名称 / agent ID |
| 事件监控(Monitor) | Monitor | 监控句柄 | 每行输出 / 每条消息 | TaskStop |
| 定时任务(Cron / ScheduleWakeup) | CronCreate / ScheduleWakeup | 任务 ID | 到点触发一轮 | CronDelete / stop: true |
| 脚本编排(Workflow) | Workflow | 任务 ID | task-notification | TaskStop |
表里没有 Agent Teams:它是多个会话的协作,不是单任务的异步形态;选型表里会作为对照项出现。
下面按“从轻到重”排,每个给一个最小调用和适用边界。
1. 后台 Bash:把进程交给任务表
模型侧的一次调用长这样:
{
"name": "Bash",
"input": {
"command": "npm test -- --runInBand",
"description": "Run the full test suite",
"run_in_background": true
}
}
从调用到结果,整条链路是这样的:
图 1:后台 Bash 的完整链路。调用立即返回任务 ID + 输出路径;进程退出后由 harness 注入通知,模型再用 Read 取输出。
三个细节:
- 不要加
&。 工具描述写得很明确:命令会 detached 运行,跨 turn(轮次)存活,进程退出时重新唤起模型。&是 shell 层的后台,和 harness 的任务表不是一回事。 timeout的含义变了。 前台是“最多等多久”,后台是“最多活多久”:默认 600 秒,最大 7200 秒。- 输出落盘。 任务返回里带输出路径,模型用
Read去取,不要把几万行日志灌回上下文。2.1.277 已经移除了TaskOutput,别再找它。
后台任务还有一整套生命周期约束:
| 约束 | 值 | 说明 |
|---|---|---|
| 输出上限 | 5GB | 单个任务超过会被终止 |
| 会话退出 | 自动清理 | Claude Code 退出时任务结束 |
| 内存压力 | 空闲 ≥ 30 分钟且没有 turn 在跑 | macOS / Linux 会停止这类任务 |
| 无人值守时限 | 默认 30 分钟,最大 2 小时 | -p(非交互模式)、CI、云会话 |
-p + 文本 prompt | 默认 10 分钟 | 一次性任务,没有后续交互 |
| 本地交互式 | 会话存续期间无时限 | 但退出 Claude Code 仍会清理 |
2. 后台 Agent:默认就不等你
Agent 工具负责创建子代理(subagent)。它的 run_in_background 描述很能说明设计取向:
Subagents run in the background by default; you'll be notified when one completes.
只有在“下一步动作完全依赖这个结果,且期间没有别的事可做”时,才建议传 false。换句话说,前台子代理是例外,后台才是默认。
它和后台命令的区别在返回物:命令的产物是输出文件,子代理的产物是一份最终报告。工具描述特意提醒:子代理的最终报告不会展示给用户,主对话需要转述关键结论;已完成的子代理可以用 SendMessage 按名称继续,上下文还在,新开一个 Agent 则是从零开始。
隔离也做进了参数:
isolation: "worktree":给子代理一个临时 git worktree(同一仓库的独立工作目录),没改动就自动清理;isolation: "remote":扔到远程云环境,始终后台,是否可用由网关决定。
后台子代理最大的坑不是并发数,是结果的所有权。工具描述里有一句硬约束:
Never fabricate or predict a pending agent's results — the notification is never something you write yourself.
模型不能“猜”一个还没回来的结果,也不能自己编一条完成通知。通知必须由 harness 注入。这条约束把异步系统里最容易出现的“幻觉完成”堵在了协议层。
3. Monitor:等的不是结束,是变化
后台命令等的是进程退出,Monitor(事件监控)等的是每一行输出。工具描述把它定义为“在后台跑一个命令,把每一行输出喂回 Claude”,也可以直接连 WebSocket,把每条消息当成一个事件。
这解决的是另一类问题:不是“这个任务要跑多久”,而是“这件事什么时候发生变化”。tail 日志、轮询 CI、盯目录、跟踪长脚本,都属于这一类。
三个容易被忽略的限制:
- 有截止时间。 默认 5 分钟,最长 30 分钟;
-p单 prompt 场景最长 10 分钟。到点结束,只给模型一条通知,让它决定要不要重新开。 - 不是所有环境都有。 Amazon Bedrock、Google Cloud Vertex(Agent Platform)、Microsoft Foundry 不可用;设置了
DISABLE_TELEMETRY或CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC也会消失。 - 走 Bash 同一套权限。 auto 模式下由分类器审查命令。
我同时设了 DISABLE_TELEMETRY=1 和 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1,2.1.303 和 2.1.304 的工具列表里都没有 Monitor;文档和实测一致。企业环境关了遥测,Monitor 就用不了。
4. Cron / ScheduleWakeup:把“未来”也变成任务
CronCreate(定时任务)接受标准 5 段 cron,按本地时区解释:
{
"name": "CronCreate",
"input": {
"cron": "7 * * * *",
"prompt": "check the deploy status and report failures",
"recurring": true,
"durable": false
}
}
几个很“工程”的约束:
- 只在 REPL(交互式会话)空闲时触发,不会打断正在进行的 turn;
-p/ CI 没有 REPL,cron 不会触发,别把它当 CI 调度器; - 每个会话最多 50 个 cron 任务;
- 循环任务 7 天后自动过期,最后触发一次再删除;
- 默认只存在内存里,
durable: true才写入.claude/scheduled_tasks.json;durable 只负责跨重启存活,7 天过期是循环任务本身的规则,不会因为 durable 而延长; - 有 jitter:循环任务最多晚半个周期(上限 30 分钟),整点/半点的单次任务最多早 90 秒。
jitter 和“别把任务都排在 :00”是同一个考虑:全世界的 Claude 同时打 API,对谁都没好处;工具描述甚至建议模型主动挑一个非 0/30 的分钟。
另一条路径是 ScheduleWakeup,服务于 /loop 的动态节奏:模型每次醒来后自己决定下一次什么时候再检查,延迟被限制在 60–3600 秒。CronCreate 用于用户给定的时间点;ScheduleWakeup 用于“盯着这个 PR”这类由模型决定节奏的场景。
5. Workflow:把编排本身写成脚本
Workflow 是这 5 种里最重的一种。它不在对话里逐轮决定下一步,而是执行一段 JavaScript 脚本,脚本里用 agent()、parallel()、pipeline()、phase() 编排子代理。工具描述里的官方示例(节选,省略了 DIMENSIONS 和几个 schema 的定义):
const results = await pipeline(
DIMENSIONS,
d => agent(d.prompt, { phase: 'Review', schema: FINDINGS_SCHEMA }),
review => parallel(review.findings.map(f => () =>
agent(`Verify: ${f.title}`, { phase: 'Verify', schema: VERDICT_SCHEMA })
))
)
这段脚本的关键不是语法,是中间结果放在脚本变量里,不放在模型上下文里。对话只拿到最终报告。工具描述写得很直白:
Workflows run in the background — this tool returns immediately with a task ID, and a
<task-notification>arrives when the workflow completes.
它还有三个门槛:
- 必须由用户显式 opt-in。 关键词
ultracode、会话级 ultracode、明确说“用 workflow”等;模型不能自己觉得“这任务适合并行”就开。 - 默认规模指南是 medium。 少于 10 个 agent,用户可以在
/config里改。 - 支持
resumeFromRunId断点续跑。 未改动的agent()调用直接命中缓存,只有新增或修改的才重跑。
Agent Teams 默认关闭,需要 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1;它解决的是多个会话互相讨论,不是单任务异步。
调度器视角:异步工具到底难在哪
把 5 种形态放在一起,从外部行为看,Claude Code 的主循环就不再是“模型调工具、工具返回结果”,而是一个带任务表的事件循环:
图 2:把 5 种形态抽象成任务表。同步调用走
tool_result;异步调用进任务注册表,完成后经事件队列以 system-reminder / task-notification 的形式回来。图里的 Esc 用于打断当前 turn,不取消后台任务;取消后台任务用 TaskStop。
从这个视角看,异步工具的设计可以收成四件事:工具结果返回句柄而不是 payload;完成事件由 harness 注入,模型不能自造;取消和 deadline 是一等公民;输出落盘、权限留在前台。每条的细节在前面的形态里都出现过;如果你自己写 Harness,文末有一份落地清单。
最值得抄的一段:把缓存写进调度
ScheduleWakeup 的工具描述里有一大段在讲“该等多久”,核心不是 cron 语义,是成本:
This session's requests use the default 5-minute Anthropic prompt-cache TTL. Sleeping past 300 seconds means the next wake-up reads your full conversation context uncached — slower and more expensive.
然后它给了三段建议,外加一个要避开的点:
| 建议等待 | 缓存状态 | 典型场景 |
|---|---|---|
| 60–270 秒 | 命中 | 主动轮询外部状态:CI、部署、远程队列 |
| 300 秒 | 最差 | 不要选:既付了缓存 miss,又没有摊薄 |
| 300–3600 秒 | 未命中 | 没有更早检查的必要,或作为长时间兜底 |
| 1200–1800 秒 | 一次未命中 | 300–3600 区间里的推荐心跳,20–30 分钟检查一次 |
1200–1800 秒是 300–3600 秒区间内心跳的推荐值;300 秒单独避开。
这段描述值得单独拿出来,因为它暴露了一个大多数 Agent 框架没有处理的维度:调度器不只要知道任务什么时候该跑,还要知道模型侧的一次唤醒值多少钱。
5 分钟缓存 TTL 把等待切成两个成本区间;心跳用 1200–1800 秒,更长的等待(最多 3600 秒)留给兜底。
这也是为什么工具描述里反复强调“不要轮询”:
Do NOT schedule a short-interval wakeup to poll for background work you started — when harness-tracked work finishes, you are re-invoked automatically, so polling is wasted.
轮询在普通后端里只是浪费请求;在 Agent 里,每次轮询都是一次完整的模型调用,会把上下文重新读一遍。异步工具的第一使命,是把“等待”从模型调用里拿出去。
最容易踩的坑:通知不一定来
异步系统最脆弱的就是通知链路。2.1.299 修了 ScheduleWakeup 在 SDK、IDE、remote-control 中,以及后台任务运行时不触发的问题;2.1.290 修了 Mac 睡眠唤醒后后台 agent 报 "Agent stalled"、Workflow 子代理从头重启;2.1.300 修了云容器替换后已恢复的 agent 被判 lost。
结论:长任务要有兜底。ScheduleWakeup 自己的描述就写着,对 harness 能跟踪的工作不要轮询,但要留一个 1200 秒以上的 fallback,防止通知丢了。
另外两个容易被忽略的边界,前面已经提过,这里只留一句:后台任务不会一直在(无人值守默认 30 分钟、最大 2 小时,本地会话退出也会清理);后台也不能替用户同意(权限提示回到主会话,跨会话权限洗白被明确禁止)。
怎么选:默认同步,按“等待的性质”分流
| 需求 | 选择 | 判断依据 |
|---|---|---|
| 几秒内返回 | 同步工具 | 异步的任务管理成本高于等待成本 |
| 一条命令要跑几分钟 | Bash + run_in_background | 不需要推理,只需要时间 |
| 一个需要读文件、做判断的旁支任务 | Agent(默认后台) | 结果重要,过程不该污染主上下文 |
| 持续发生的事件(日志、CI、WebSocket) | Monitor | 等的不是结束,是变化 |
| 固定时间/周期触发 | CronCreate;自定节奏用 ScheduleWakeup | 触发条件是时间,不是任务完成 |
| 几十个 agent、需要交叉验证 | Workflow | 编排本身需要被固化、复跑、断点续跑 |
| 多个会话要互相讨论 | Agent Teams(实验性) | 需要 peer 协作,不是单纯并发 |
表里的第 2–6 行是 5 种异步形态;同步工具和 Agent Teams 是对照项。Agent Teams 是会话协作,不是单任务异步,所以不计入 5 种。
三个容易选错的场景:
- 短任务用异步:一条 10 秒的命令开后台,额外付出任务 ID、通知、输出文件、清理的成本,不如直接等。
- 同文件并行:子代理可以用 worktree 隔离,但 Agent Teams 的 teammate 不做 worktree 隔离,必须按文件边界分活,否则同文件冲突很难避免。
- 把 Workflow 当“更多子代理” :Workflow 的价值是脚本可读、可复跑、可 resume,不是单纯把并发数从 5 提到 50。没有验证环节、没有中间结果复用的 fan-out,用普通
Agent更便宜。
这三类错误的共同点,是把“并发”当成了免费资源。实际上,异步的每一层都要付钱:任务管理、缓存 miss、子代理的独立上下文。选错形态,省下来的等待时间,最终会从 token 账单里还回去。
常用命令,直接抄:
| 想看什么 | 用什么 | 在哪用 |
|---|---|---|
| 当前会话的后台任务 | /tasks | 会话内 slash command |
| 动态 workflow 的进度 | /workflows | 会话内 slash command |
| 所有后台会话 | claude agents | 终端 CLI |
| 把当前 Bash 转后台 | Ctrl+B | 会话内快捷键 |
| 停掉所有后台子代理 | Ctrl+X Ctrl+K(连按两次确认) | 会话内快捷键 |
Ctrl+X Ctrl+K 停的是后台子代理;要停某条后台 Bash,用 TaskStop 按任务 ID 取消。
如果你自己写 Harness:把设计原则落成协议
如果你不自己写 Harness,这节可以跳过;前面的选型表已经够用。
这些原则落到代码里,主要是四件事:
- 协议要支持句柄。 同步返回
result,异步返回{task_id, output_path, deadline};不要让长任务伪装成同步调用。 - 每个任务都要有 ID、输出落点、取消入口、截止时间。 四者缺一,任务就会在某个边界变成孤儿。
- 把模型侧的缓存窗口写进调度策略。 等待不是免费的;如果一次唤醒要重读整个上下文,调度粒度就应该按缓存 TTL 设计。
- 权限不能外包给后台。 后台任务不能替用户同意,也不能把被拒绝的操作转交给另一个会话执行。
至于“完成事件必须由运行时注入”“输出落盘、上下文只拿摘要”,它们是协议层的硬约束,不是实现细节——做不到,前面四件事也守不住。
最后说点得罪人的
Claude Code 的异步工具,真正变化的不是“能后台跑命令”这件事——shell 早就能 &。变化的是工具调用的语义:从“函数返回结果”变成“提交任务、等待事件、按 ID 取消”。
这个语义一旦成立,Agent 的瓶颈就从“模型有多快”变成了“调度器有多可靠”。后台 Bash 能不能在会话退出时收干净、子代理的结果会不会丢、定时任务在机器休眠后还记不记得、一次唤醒要不要重读上下文——这些才是异步系统的分水岭。
所以我的判断是:大多数团队不需要先上 Workflow。先把后台 Bash、TaskStop、输出落盘和截止时间做对,就已经拿到了异步工具最实用的一层收益。 多代理编排是下一步,不是第一步。
好的 Harness 不消灭等待,它把等待变成可调度的事件。
附:2.1.304 的默认值变化
这一节是开头抓包的后续,不影响主线的 5 种形态;不关心版本细节可以跳过。
回到开头那份请求体。2.1.304 默认的 tools 数组里有 12 个工具,其中两个在 2.1.303 默认里没有:ToolSearch 和 DeferredToolPlaceholder。但这两个不是 2.1.304 新增的工具——2.1.303 设了 ENABLE_TOOL_SEARCH=true 之后,工具列表与 2.1.304 默认完全一致。变的只是默认值。
算一下数就清楚了:2.1.303 默认的 20 个工具里,10 个常驻、10 个被延迟;2.1.304 把这 10 个延迟掉,再加上两个新工具,正好 12 个。被延迟的名单里,CronCreate、SendMessage、TaskStop 本身就是异步任务的控制面——在这组自定义 base URL 的抓包里,它们也要先经过 ToolSearch 才能调用。四组对照的完整数据放在下面的“数据说明”里。
结论:2.1.304 没有改机制,只把自定义 ANTHROPIC_BASE_URL 下的 tool search 默认打开了。此前走网关的用户必须手动设 ENABLE_TOOL_SEARCH,现在不用了;两组交叉对照的结果完全一致(见“数据说明”)。
这次默认值切换还带出一个回归:如果 Vertex(Agent Platform)、Bedrock 或网关拒绝 tool search,2.1.304 会让该配置下的每个请求都失败;2.1.305 才改成“自动去掉 tool search 重试”。走网关的人,2.1.304 不是可以停留的版本。
但无论默认值怎么变,ToolSearch 都不是“后台执行”:它仍然发生在当前 turn 内,模型发起搜索、拿到 schema、继续调用。它省的是上下文,不是墙钟时间。
数据说明
文中 2.1.303 / 2.1.304 的请求体数据来自本地抓包:下载对应版本的 darwin-arm64 原生包,用 -p、假 API key、空配置目录,本地起一个假的 /v1/messages 服务记录请求,模型统一为 claude-sonnet-5-5。工具数量与字符数为四次运行的实测值,未做 token 估算。
| 运行 | tools 数量 | schema 字符数 | 整个请求体 |
|---|---|---|---|
| 2.1.303 默认(自定义 base URL) | 20 | 48,860 | 64,980 |
| 2.1.304 默认(自定义 base URL) | 12 | 28,567 | 45,065 |
2.1.303 + ENABLE_TOOL_SEARCH=true | 12 | 28,567 | 45,068 |
2.1.304 + ENABLE_TOOL_SEARCH=false | 20 | 48,860 | 64,979 |
整个请求体的 1–3 字节差异来自版本号/配置路径;tools JSON 本身一致。
被延迟的 10 个工具是:CronCreate、CronDelete、CronList、EnterWorktree、ExitWorktree、NotebookEdit、SendMessage、TaskStop、WebFetch、WebSearch。
参考资料
- Tools reference(Bash / Monitor / TaskStop / Workflow 行为)
- Background Bash commands
- Run prompts on a schedule(
/loop、CronCreate、jitter、7 天过期) - Orchestrate subagents at scale with dynamic workflows
- Orchestrate teams of Claude Code sessions
- Create custom subagents
- Scale to many tools with tool search
- Claude Code CHANGELOG