learn-claude-code s11 解析:后台任务机制如何让 Agent Loop 摆脱慢命令阻塞
【免费下载链接】learn-claude-codeBash is all you need - A nano claude code–like 「agent harness」, built from 0 to 1项目地址: https://gitcode.com/GitHub_Trending/an/learn-claude-code
本篇基于 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()仍然在主线程执行PreToolUsehook(权限检查),然后再选择同步或后台执行:
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 messagenpm 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。
【免费下载链接】learn-claude-codeBash is all you need - A nano claude code–like 「agent harness」, built from 0 to 1项目地址: https://gitcode.com/GitHub_Trending/an/learn-claude-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考