本文是「从零理解 Claude Code:20 个 Agent Harness 机制」系列的第 13 篇。
对应源码:s13_background_tasks
设想 Agent 正在初始化一个前端项目。
它需要执行依赖安装,还要检查 package.json、阅读构建脚本、确认环境变量。依赖安装可能需要几分钟,读取配置文件只需要几毫秒,若所有工具调用都同步执行,Agent 发起安装命令后,就只能等终端返回结果,后面的检查工作也得暂停。
s13 将慢操作放进后台执行。Agent 拿到任务已启动的反馈后,可以继续调用其他工具,后台命令完成时,结果会作为一条通知重新放回对话。
一、同步工具调用为什么会堵住主循环
上一章中的工具调用是同步的。模型请求执行命令,程序执行命令,拿到结果后再交给模型决定下一步。
这个流程适合读取文件、搜索文本、查看 Git 状态等短操作。遇到依赖安装、全量测试、镜像构建或部署命令时,主循环会一直停在工具执行阶段。
以 npm install 为例,Agent 已经知道接下来还要阅读项目配置,却必须等安装结束后才能继续。等待期间没有新工具结果,也不会发起下一轮模型调用。
本章的代码改变了工具的执行策略,短操作仍然同步执行,慢操作被交给后台线程,主循环继续处理当前任务。
| 操作类型 | 示例 | 处理方式 |
|---|---|---|
| 短操作 | 读取文件、搜索代码、查看状态 | 同步执行并立即返回结果 |
| 慢操作 | 安装依赖、运行测试、构建项目、部署 | 后台执行,稍后注入通知 |
二、程序怎样判断命令是否需要转入后台
bash 工具增加了一个 run_in_background 参数,模型可以在调用工具时明确要求后台执行。
如果模型没有指定该参数,教学代码会根据命令中的关键词做兜底判断:
python
def should_run_background(tool_name: str, tool_input: dict) -> bool:
if tool_input.get("run_in_background"):
return True
return is_slow_operation(tool_name, tool_input)
关键词列表包括 install、build、test、deploy、compile 等。它们覆盖了依赖安装、构建、测试和部署这类通常耗时较长的命令。
这种启发式判断适合教学演示,实际使用时要更谨慎。
例如,echo test 很快完成,却因为包含 test 被判定为慢操作。某个运行很久的自定义脚本如果没有包含这些关键词,也可能继续走同步执行。代码里的 run_in_background=False 也不会强制命令留在前台,因为它仍会继续进入关键词判断。
生产系统更适合让模型或调用方明确指定执行策略,再用运行时间、命令类型和资源占用作为辅助判断。
三、后台任务启动后,为什么要先返回占位结果
后台任务启动时,程序会生成一个任务 ID,并在内存中记录命令、原始工具调用 ID 和任务状态。
后台工作线程负责执行真正的工具调用,主循环则立刻向模型返回一条占位工具结果:
css
if should_run_background(block.name, block.input):
bg_id = start_background_task(block)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"[Background task {bg_id} started] "
f"Result will be available when complete.",
})
这条占位结果有两个作用。
第一,模型知道命令已经启动,可以继续安排其他工作。
第二,原始工具调用需要尽快完成消息配对。模型发起 npm install 时,会附带一个工具调用 ID。程序启动后台线程后,立刻用这个 ID 返回一条占位结果,告诉模型命令已经开始执行。此时,这次工具调用已经完成了自己的协议流程。
后台任务结束后,完整输出不能再使用原来的工具调用 ID 返回。原 ID 已经对应过占位结果,再追加一份工具结果会让同一次调用出现两份结果。教学代码因此为后台任务单独分配 bg_0001 这类任务编号,并通过任务完成通知把结果带回对话。
把后台任务的状态和消息关系放在一起看,会更容易理解为什么完成通知需要单独注入。

四、后台结果怎样重新进入模型上下文
后台线程执行结束后,会把结果写入内存中的结果字典,并把任务状态标记为已完成。
主循环在处理工具调用后,会检查是否有已经完成的后台任务。发现结果后,程序构造一条独立通知:
xml
<task_notification>
<task_id>bg_0001</task_id>
<status>completed</status>
<command>npm install</command>
<summary>...</summary>
</task_notification>
这条通知和当前工具结果一起组成一条新的用户消息,随后交给模型。
设想第一轮中,Agent 把依赖安装放进后台,同时读取了项目配置。第二轮里,安装完成通知和配置文件内容会一起进入上下文。模型可以根据两部分信息继续判断,例如确认依赖安装成功后再运行构建命令。
通知不复用原始工具调用 ID,避免与此前的占位结果冲突。后台任务完成是一次独立事件,使用任务 ID 更容易追踪。
五、教学实现还缺少哪些后台任务能力
设想 Agent 把 npm test 放到后台执行,然后趁测试运行的时间读取 package.json、检查测试脚本。
测试启动后,Agent 已经收到一条提示:后台任务 bg_0001 正在运行。它继续分析配置,随后认为当前任务已经可以结束,于是返回最终回答。
这时测试可能还在跑。
如果用户直接退出程序,后台线程会跟着主进程结束,测试也会中断,任务状态和命令输出都只保存在内存里,下一次启动 Agent 时,程序不知道刚才是否跑过测试,也没有地方可以查看测试运行到哪一步。
即使用户没有退出程序,后台结果也不一定能立刻回到模型上下文。教学代码只会在处理一轮工具调用后检查后台任务是否完成。模型如果在下一轮直接返回普通文本,没有再调用工具,主循环就结束了,bg_0001 的完成通知会继续留在内存中,等待之后某次工具调用才有机会被收集。
任务完成后,程序还会把完整输出缩短为前 200 个字符的摘要:
text
<task_notification>
<task_id>bg_0001</task_id>
<status>completed</status>
<summary>前 200 个字符的命令输出</summary>
</task_notification>
假设 npm test 输出了一千多行,摘要里只显示最后的报错标题。Agent 想继续确认哪个测试失败、错误栈指向哪里时,当前代码已经没有读取完整后台输出的入口。通知注入后,任务记录和完整输出都会从内存字典中删除。
还有一个容易误解的地方:任务状态写成 completed,只表示后台线程已经执行结束。
如果 npm test 返回了错误,run_bash() 会把标准输出和错误输出拼成一段文本返回。后台线程依然会把任务标记为完成,因为它没有记录命令退出码。也就是说,completed 在这份教学代码里表示命令已经结束,不表示命令执行成功。
| 场景 | 教学代码中的结果 | 实际系统需要补充的能力 |
|---|---|---|
| 用户关闭 Agent | 守护线程和内存状态一起消失 | 独立进程、任务持久化、恢复机制 |
| 后台命令完成 | 只能在后续工具调用后尝试注入通知 | 独立通知队列和主动唤醒 |
| 命令输出很长 | 只把前 200 个字符交给模型 | 保存完整日志,并提供读取接口 |
| 命令执行失败 | 线程结束后仍标记为 completed | 保存退出码、成功或失败状态、错误类型 |
| 命令是否后台执行 | 显式参数加关键词判断 | 更明确的执行策略和资源控制 |
README 中提到,真实 Claude Code 对后台 Shell 任务提供了更完整的生命周期管理:输出可以重定向保存,后续能够读取日志,也可以停止任务,完成事件通过通知队列送回对话。
s13 保留的是最小流程:慢命令不再堵住主循环,完成后尝试将结果通知模型。要让后台任务真正适合长期运行,还需要解决任务退出、状态保存、结果读取和失败判断这些问题。
六、小结
同步工具调用适合短操作。命令需要运行几分钟时,主循环若一直等待,Agent 就无法同时读取文件、检查配置或推进其他任务。
s13 把慢命令交给后台线程,并先返回一条任务已启动的占位结果。命令完成后,结果以独立通知重新进入对话。这样,模型知道任务正在执行,也能在等待期间继续处理其他工作。
当前教学实现还没有持久化后台任务、保存完整可读取日志、区分命令成功与线程结束,也没有主动把通知送回已经结束的循环。后续设计后台任务时,除了考虑怎样启动命令,也要确认进程退出后任务怎么办、命令失败怎样表示、完整输出由谁保存。
下一章会处理定时执行的问题。后台任务解决的是一项工作已经开始后怎样不阻塞,定时调度关注的是任务应该在什么时候开始。