learn-claude-code s11 解析:后台任务机制如何让 Agent Loop 摆脱慢命令阻塞
本篇基于 s11_background_tasks/README.zh.md 与配套实现 s11_background_tasks/code.py,讲清 learn-claude-code 课程第 11 章(s11)的后台任务机制:如何让耗时 Bash 命令在后台线程执行、以占位 tool_result 立即返回 bg_id,并在后续轮次以 <task_notification> 通知形式收集结果。读完后你将掌握「显式请求后台执行 + 完成队列 + 通知注入」这一完整模式,能自行在 Agent Harness 中实现不阻塞主循环的异步命令执行。
问题:同步执行会让 Agent Loop 停摆在慢命令上
learn-claude-code 是一个从零搭建的 nano Claude Code 风格 agent harness 课程("Bash is all you need"),s11 对应其中的 Harness 层:后台 — 异步执行,不阻塞主循环。
在前序章节中,工具调用是同步的:读取文件或运行 git status 通常很快,等待并不明显。但安装依赖、跑完整测试、构建项目可能持续几分钟——在命令返回之前,Harness 无法处理当前响应中的下一个工具调用,也不能进入下一轮模型调用。如果后续工作并不依赖这个命令,继续等待就没有必要:例如 Agent 启动完整测试后,本来还可以检查文档或整理其他文件,但同步执行会让整个 Agent Loop 停在这次 Bash 调用上。
s11 要解决的问题因此很明确:让耗时的 Bash 命令在后台执行,使 Agent Loop 可以继续处理其他工作,并在后续轮次收集完成结果。
解决方案:占位结果 + 后续轮次收集
s11 的设计把慢操作放入后台线程:当前工具调用先返回一个占位 tool_result,Agent Loop 可以继续运行;后续轮次开始时再收集已经完成的结果,以通知形式加入对话。
同步与后台执行的对比如下(继承自原文档):
| 同步 (s04) | 后台 (s11) | |
|---|---|---|
| 慢操作 | 当前工具调用被阻塞 | 后台线程执行 |
| Agent Loop | 等待命令返回 | 收到占位结果后继续运行 |
| 结果 | 命令结束后返回 | 先返回 bg_id,后续轮次收集结果 |
| 判断标准 | — | bash 的 run_in_background 参数 |
关键取舍:后台任务不会主动唤醒 Agent。完成的任务只是被排队,下一次 Agent Loop 进入时才会被收集注入。这是一个「拉(pull)」而非「推(push)」模型,实现简单且无需跨线程唤醒 LLM 调用。
工作原理
should_run_background:显式请求,而非关键词猜测
模型通过 bash 工具的 run_in_background 参数请求后台执行。只有参数明确为 true 且工具是 bash 时,才会进入后台路径;其他调用仍然同步执行:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
return (
tool_name == "bash"
and tool_input.get("run_in_background") is True
)
见 s11_background_tasks/code.py#L390-L394。注意这里刻意使用 is True 做严格判断:"true" 字符串、1 等值都不会命中。设计意图是不再根据 install、build 或 test 等关键词猜测——是否进入后台由工具调用明确决定,执行模式的选择权交给模型,而非 Harness 用启发式规则代替。
配套的 schema 变更只有极小幅度:bash 工具在原有 command 参数上新增了一个可选的布尔参数 run_in_background,见 s11_background_tasks/code.py#L169-L175:
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {
"command": {"type": "string"},
"run_in_background": {"type": "boolean"}},
"required": ["command"]}},
此外,系统提示词中有一句引导语:"Set run_in_background to true only for independent Bash commands."(见 s11_background_tasks/code.py#L44-L47),即只建议对与其他后续工作相互独立的命令使用后台执行——如果后续操作依赖该命令的输出,同步执行才是正确选择。
BackgroundManager:后台执行与生命周期管理
BackgroundManager 保存任务状态和完成队列,是全章唯一的新类型。start() 先登记任务,再启动 daemon 线程,并立即返回 bg_id:
class BackgroundManager:
def __init__(self):
self.tasks = {}
self.results = {}
self._ready = []
self._lock = threading.Lock()
def start(self, block) -> str:
# Register task, then run _run() in a daemon thread.
...
def _run(self, task_id: str, command: str):
output, exit_code = _run_bash_process(command)
status = "completed" if exit_code == 0 else "failed"
with self._lock:
self.tasks[task_id]["status"] = status
self.results[task_id] = _format_bash_result(output, exit_code)
self._ready.append(task_id)
结合 s11_background_tasks/code.py#L306-L360 的完整实现,可以补充以下细节:
- 任务 ID 生成:内部计数器自增后格式化为
bg_0001、bg_0002……(f"bg_{self._counter:04d}"),单例会话内单调递增。 - 参数校验:
start()拒绝非 bash 工具(ValueError("Only Bash commands can run in the background"))和空命令字符串,校验失败时不会留下任务记录。 - 失败回滚:若
thread.start()抛出异常,会在锁内self.tasks.pop(task_id, None)移除已登记的任务再向上抛出,避免产生「已登记但从未执行」的僵尸任务。 - 线程属性:worker 线程是
daemon=True,即主进程正常退出时后台任务不阻塞进程终止。 - 状态流转:
running→completed(退出码为 0)或failed(退出码非零,或 worker 抛出异常时结果记为Error: <异常类型>: <信息>)。完成后任务 ID 被追加进_ready完成队列。 - 并发安全:
tasks、results、_ready的所有读写都在threading.Lock保护下进行;模块级单例BACKGROUND = BackgroundManager()供整个 Agent Loop 使用。
进程执行与生命周期清理:是清理,不是沙箱
后台命令通过 _run_bash_process() 执行,见 s11_background_tasks/code.py#L82-L111。几个值得注意的实现事实:
- 使用
subprocess.Popen(command, shell=True, cwd=WORKDIR, start_new_session=True, ...),start_new_session让 shell 运行在独立的进程组中; process.communicate(timeout=120):命令超过 120 秒未完成时返回Error: Timeout (120s);- 输出为
stdout + stderr合并后截断至 50000 字符,无输出时返回(no output); - 无论正常结束、超时还是异常,
finally块都会调用_stop_process_group()停止原进程组(先发SIGTERM,50ms 后再发SIGKILL),见 s11_background_tasks/code.py#L56-L63。
除此之外还有两条全局清理路径:atexit.register(_stop_all_shell_processes) 在进程正常退出时清理所有存活的 shell 进程组;signal.SIGTERM 处理器 _handle_termination_signal 在收到终止信号时同样先清理再退出(见 s11_background_tasks/code.py#L73-L79)。
原文档明确指出边界:这只是生命周期清理,并不是沙箱——一个自行 setsid/另建 session 的进程仍可能离开该进程组,清理无法覆盖它。输出格式化由 _format_bash_result() 完成:退出码为 0 或 None 时直接返回输出;非零退出码则前缀 Error: command exited with status N(见 s11_background_tasks/code.py#L114-L117)。
collect_background_results:通知收集
后续轮次开始时,collect() 从完成队列中取出结果,格式化为 <task_notification> 通知:
def collect_background_results() -> list[str]:
return BACKGROUND.collect()
完整实现在 s11_background_tasks/code.py#L361-L382。collect() 在锁内一次性清空 _ready,把任务记录与结果从字典中 pop 出来(保证每条结果只被收集一次),然后为每个任务生成如下格式的通知文本:
<task_notification>
<task_id>bg_0001</task_id>
<status>completed</status>
<command>npm install</command>
<summary>...</summary>
</task_notification>
其中 <summary> 是结果的前 500 字符(result[:500])。一个重要的协议细节是:通知不复用原始 tool_use_id。原始 tool call 已经用占位 tool_result 回复过了;后续收集完成结果时,task_notification 是作为独立事件加入对话的——这样就维持了「一个 tool_use 仍然只对应一个 tool_result」的 Anthropic 消息协议约束,避免为同一个 tool_use_id 生成两个 tool_result 导致 API 报错。
inject_background_results()(见 s11_background_tasks/code.py#L405-L422)负责把通知以 user 消息的形式并入对话历史:若最后一条消息已是 user 角色,就把文本块合并进去,否则追加一条新的 user 消息。
execute_tool 与 Agent Loop 的集成
每次调用 LLM 前,Agent Loop 先收集已经完成的后台结果;execute_tool() 仍然在主线程执行 PreToolUse hook(权限检查),然后再选择同步或后台执行:
while True:
inject_background_results(messages)
response = client.messages.create(...)
def execute_tool(block) -> str:
blocked = trigger_hooks("PreToolUse", block)
if blocked is not None:
return str(blocked)
if should_run_background(block.name, block.input):
task_id = start_background_task(block)
output = f"[Background task {task_id} started]"
else:
output = call_tool(block)
trigger_hooks("PostToolUse", block, output)
return output
对应源码见 s11_background_tasks/code.py#L425-L443(execute_tool)与 s11_background_tasks/code.py#L448-L477(agent_loop,其中 inject_background_results(messages) 正是 while True 循环体的第一行)。这里有几个值得注意的执行语义:
- 权限检查先于后台派发。
PreToolUse的权限 hook(permission_hook,含DENY_LIST与DESTRUCTIVE列表,见 s11_background_tasks/code.py#L224-L250)在主线程运行,被拒命令根本不会启动后台线程。 - hook 对后台任务同样完整生效。后台路径的占位输出(
[Background task bg_0001 started] The result will be collected on a later turn.)也会经过PostToolUsehook,例如大输出检测 hook。 - 后台派发失败降级为错误字符串:
start_background_task抛异常时,占位结果变为Error: <异常>,对话协议不受影响。 - 注入点固定:通知只在「下一次进入 Agent Loop、调用 LLM 之前」被注入。任务完成不会中断正在进行的 LLM 流式响应。
合起来跑:三轮流转示例
原文档给出的端到端时序(继承如下):
Turn 1:
LLM → bash "npm install" (run_in_background=true)
→ start_background_task → bg_0001
→ tool_result: "[Background task bg_0001 started]..."
→ LLM: "OK, I'll check later. Let me also read the config."
Turn 2:
LLM → read_file "package.json" (fast, sync)
→ tool_result: file content
Turn 3:
→ collect bg_0001 as <task_notification>
→ LLM sees: config file + install notification in one message
npm install 在后台运行的同时,Agent Loop 继续执行了 read_file 并完成了第二轮交互;第三轮开始时,安装完成通知与配置文件内容在同一条消息中呈现给模型。
本章相对 s04 内核新增了什么
继承原文档的增量对照表:
| 组件 | S04 Kernel | S11 |
|---|---|---|
| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |
| bash schema | command |
command + run_in_background |
| 新函数 | — | should_run_background、start_background_task、collect_background_results、inject_background_results |
| 新类型 | — | BackgroundManager |
| 通知格式 | — | <task_notification>(不复用 tool_use_id) |
| 循环行为 | 工具同步执行 | 显式后台执行,后续轮次收集完成结果 |
| 工具 | 5 | 5(bash schema 增加一个参数) |
也就是说,s11 并没有引入第 6 个工具,也没有改变 5 工具内核(bash、read_file、write_file、edit_file、glob)与 4 类 hook 事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop)——增量被刻意压缩到「一个 schema 参数 + 一个管理器类 + 四个函数」,这正是该课程「逐层叠加、每章最小增量」的写法。
实践:运行 s11 并观察
运行方式
s11 是独立可运行的单文件脚本,依赖见 requirements.txt(anthropic>=0.25.0、python-dotenv>=1.0.0、pyyaml>=6.0)。脚本启动时通过 load_dotenv(override=True) 加载 .env,并读取以下环境变量(见 s11_background_tasks/code.py#L33-L42):
MODEL_ID:必需,脚本以os.environ["MODEL_ID"]直接取值,未设置会直接抛KeyError;ANTHROPIC_API_KEY:由anthropic.Anthropic()客户端默认读取;ANTHROPIC_BASE_URL:可选,用于指向自建网关;设置后脚本会主动pop掉ANTHROPIC_AUTH_TOKEN,避免两类凭据冲突。
进入后台会话:
cd learn-claude-code
python s11_background_tasks/code.py
交互提示符为 s11 >>,输入 q/exit 或空行退出。
推荐 prompt 与观察要点
原文档给出的三个验证 prompt(继承):
Run pip list in the background and find all Python files in this directoryRun npm install (use run_in_background) and while waiting, read package.jsonRun a short sleep in the background, then list all Markdown files
观察重点:显式设置 run_in_background 后,命令有没有被送到后台(终端会打印 [background] started bg_0001: ...)?bg_id 是否返回?后续轮次有没有以 <task_notification> 格式收集完成结果(打印 [background] collected bg_0001: completed/failed)?
测试如何验证这些行为
仓库中 tests/test_background_tasks.py 用假 anthropic/dotenv 模块加载课程脚本(不发起真实 LLM 调用),验证了 s11 的四个关键行为,可作为「实现事实」的佐证:
- test_s11_keeps_the_s04_kernel_and_adds_one_bash_option:断言工具集仍恰好是 5 个、bash schema 包含
run_in_background、hook 事件集合为 4 个,且没有引入Task、MEMORY_DIR等新内核——印证「最小增量」声明。 - test_background_execution_requires_an_explicit_bash_flag:
bash + {"command": "npm install"}(无参数)不进入后台;run_in_background: True才进入;对write_file传True同样不进入——印证显式请求 + 仅限 bash 两条规则。 - test_background_bash_passes_permission_before_dispatch:构造一个含
rm -rf的后台 bash 调用,断言background_tasks为空、tool_result内容为Permission denied——印证权限检查先于后台派发。 - test_completed_result_is_collected_once_before_a_later_llm_call:启动后台任务并等待其
completed后,跑一轮agent_loop,断言发给 LLM 的首条消息包含<task_notification>、<task_id>、<status>completed</status>与命令输出ready,且第二次collect_background_results()返回空列表——印证通知在后续 LLM 调用前注入且只被收集一次。
设计取舍与适用边界
从源码结构与原文档可以归纳出 s11 的几条边界,理解它们有助于在自己的 Harness 中正确复用该模式:
- 拉模型通知:结果收集只发生在轮次边界(每次 LLM 调用前)。如果会话长时间停留在同一轮的工具调用中,完成通知会滞留在队列里;这是用实现简洁性换取的。
- 硬性参数:单条命令 120 秒超时、输出截断 50000 字符、通知 summary 截断 500 字符。长构建任务若超过 120 秒会以
Error: Timeout (120s)标记为failed进入通知——生产级实现需要按任务类型可调超时。 - daemon 线程语义:后台线程为 daemon,进程退出即终止,未完成任务不会等待收尾(进程组清理靠
atexit/SIGTERM兜底)。 - 清理 ≠ 沙箱:独立进程组只保证能回收 shell 的直接后代,不能阻止进程逃逸出进程组,也不提供任何资源隔离;需要更强隔离时应在外部沙箱层解决。
- 显式优于启发式:后台与否完全由模型的
run_in_background参数决定,Harness 不做关键词推断,也意味着质量取决于系统提示词引导("only for independent Bash commands")与模型本身的判断。
接下来:s12 Cron Scheduler
后台任务解决了「慢操作不阻塞」。但如果是「定时」做某件事呢?比如「每天早上 9 点跑测试」「每 5 分钟检查一次服务器状态」——这是下一章 s12 Cron Scheduler 的主题:给 Agent 装一个闹钟。更完整的章节脉络可参考各章 README(如 s11_background_tasks/README.md 英文原文、s11_background_tasks/README.ja.md 日文版)以及课程总纲 README.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00