在上篇文章 Learn-Claude-Code | 笔记 | Planning & Coordination | s07 Tasks 中,我们介绍了开源项目 learn-claude-code 第七个章节 s07: Tasks 的内容,这篇文章我们继续跟着教程文档来学习并发部分的内容,记录下个人学习笔记,和大家一起分享交流
Note:本篇文章主要学习记录教程第四部分 Concurrency 中 s08: Background Tasks 章节的内容。
github:https://github.com/shareAI-lab/learn-claude-code
reference:https://chatgpt.com/
如果说 s07 的 Task System 解决的是 “任务如何被表示成一个可持久化、可表达依赖关系的外部结构”,那么到了 s08,这一节开始进入一个非常自然、也非常关键的新主题:当任务已经被定义出来之后,它们到底应该怎么执行?
前面几节里,我们其实一直默认了一个很重要但也很隐蔽的前提,那就是:工具调用是同步的。无论是 s01 里最基础的 bash,还是 s02 之后的 read_file / write_file / edit_file,再到后面 s07 的 task_create / task_update / task_list,整个 agent loop 的节奏始终是一样的:模型发出一个工具调用,程序立即执行这个工具,等工具返回结果之后,再把结果追加到 messages 里,继续下一轮 LLM 调用。
也就是说,在之前的世界观里,工具调用天然意味着 “当前这一轮必须等它做完”。这一点可以从前面几节始终保持不变的 loop 结构里看得很清楚:agent loop 本身没有任何并发调度的概念,它只是非常朴素地 “调模型 → 执行工具 → 回填结果 → 继续”。
但问题在于,一旦进入真实工程场景,这种完全同步的模式就会很快暴露出问题。因为很多工具调用并不是 “一两秒内就能立刻返回” 的短操作,而可能是:
- 跑一次完整测试
- 编译整个项目
- 执行 lint / format
- 构建镜像
- 启动某个外部进程再等待它输出
这些事情的共同特点是:慢,而且很多时候并不需要模型在这段时间里傻等着它完成。
比如用户说一句:
Run tests, and while they run, create a config file.
如果还是沿用前面那种同步 agent loop,那么模型哪怕 “知道” 自己完全可以边等测试边去写配置文件,系统也做不到。因为工具层的现实是:一旦 pytest 这种命令被发出去,主循环就会被阻塞,直到命令结束为止。在这段时间里,模型既不能继续推理,也不能继续调用别的工具,更谈不上并发做别的事情。
所以 s08 想解决的问题,已经不再是 “任务怎么表示” 了,而变成了:
当某些工具调用很慢时,Agent 该如何把它们放到后台执行,同时让主循环继续推进?
这也是为什么这一节属于 Concurrency 这个新大主题。到了这里,Agent 的能力升级已经不只是规划、记忆、持久化这些 “状态层” 的问题,而开始进入 执行调度层:同样是做事,但能不能不阻塞地做,能不能一边等、一边继续推进别的工作,这两者在工程上差别是非常大的。文档对这一节的概括也说得很直接:慢操作丢后台,agent 继续想下一步;后台线程负责跑命令,完成后再把结果注入回来。
我们先把这个问题说透:为什么前面一直都好好的同步工具调用,到了 s08 就突然不够用了?樂
从前几节的视角来看,同步模式其实很自然。模型需要某个外部信息,就调用工具;工具执行之后,把结果回填给模型;模型基于新信息再决定下一步。这样的控制流简单、清楚,而且和 s01 的最小 agent loop 完全一致。
前面之所以能一路这么走下来,是因为我们接触到的大多数工具都属于 “短平快” 的操作:读一个文件、写一点文本、更新一个 JSON、列一下任务板,这些动作的耗时都非常短,所以哪怕主循环同步等待,也几乎感觉不到问题。
但这并不意味着同步模式本身没有缺陷,只是前面的工具还没有把这个缺陷暴露出来而已。
一旦工具调用变成下面这种情况:
pytest npm install docker build sleep 60 && echo done
问题就立刻出现了。因为在同步模型里,agent loop 的行为会变成这样:
LLM 发起工具调用 ↓ 程序执行命令 ↓ 主线程阻塞等待 ↓ 命令结束 ↓ 把结果喂回模型
也就是说,只要命令没有执行完,整个 Agent 就相当于 “挂住” 了。这种挂住不是说程序崩了,而是说 主推理循环没有办法继续工作。模型不能趁这段时间去做别的事,系统也不能并行推进其他任务。
文档里举得很直白:像 npm install、pytest、docker build 这类命令可能跑好几分钟,在阻塞式循环下模型只能干等;用户明明说的是 “装依赖,顺便建个配置文件”,智能体却只能一个一个来。
所以这里真正暴露出来的,不只是 “命令慢” 这么简单,而是同步 loop 的一个根本限制:
在同步 agent loop 里,工具执行时间会直接变成模型思考时间。
这显然是很浪费的。因为测试在跑的时候,模型完全可以去读别的文件、生成 README、写一个配置模板、查看任务板状态;它不需要把整段等待时间都浪费掉。换句话说,慢任务的等待,本质上不应该占用主线程的推理机会。
而且再往下想一步,这个问题还不只是 “慢” 带来的浪费,还涉及到 并发性。比如下面这个场景:
Run tests Lint code Check status
如果三件事都同步做,就只能串行推进;但从任务性质上看,它们完全可以并行。测试和 lint 各自跑各自的,主线程甚至还可以在这段时间里继续处理别的逻辑。所以 s08 真正要解决的问题,其实可以概括成一句话:
Agent 不能因为某个长任务还没返回,就失去继续行动的能力。
这也是 Concurrency 这一部分之所以重要的根本原因。前面几节更多是在解决 “系统有没有外部状态”、“状态能不能跨上下文保存”、“任务能不能表达依赖图”,而从 s08 开始,问题进一步变成:这些状态和任务,能不能被非阻塞地执行和协调起来。
明确问题之后,s08 给出的解法依然非常符合这个项目一贯的风格:不是推翻原来的 agent loop,而是在 loop 外围增加一个后台执行层。文档给出的核心图景很清楚:主线程继续跑 agent loop,后台线程去执行慢命令,等后台任务完成后,把结果先放进一个通知队列;然后在下次 LLM 调用之前,把这些排队的结果统一注入回上下文。
它的整体结构大致可以写成这样:
Main thread Background thread +-----------------+ +-----------------+ | agent loop | | subprocess runs | | ... | | ... | | [LLM call] <---+------- | enqueue(result) | | ^drain queue | +-----------------+ +-----------------+ Timeline: Agent --[spawn A]--[spawn B]--[other work]---- | | v v [A runs] [B runs] (parallel) | | +-- results injected before next LLM call --+
这里最关键的变化,有三层。
第一层变化是:引入了后台任务管理器。
代码里新增的核心组件是 BackgroundManager,它维护两类状态:一个是所有后台任务本身的状态表 tasks,另一个是完成后等待被注入的 _notification_queue。初始化也非常简单:一个字典保存任务元信息,一个列表暂存通知,再加一把锁来保证多线程写队列时的安全。
第二层变化是:工具调用不再等命令做完,而是 “先启动,立刻返回”。
在 run() 里,系统会为每个后台任务生成一个 task_id,先把它登记成 running,然后创建一个 daemon=True 的线程,让这个线程去执行真正的 _execute(),而主线程这边马上返回一个 “任务已启动” 的消息。也就是说,对模型而言,background_run 这个工具的返回值不是命令输出本身,而是 “我已经替你把这件事丢到后台了”。
第三层变化是:后台任务的结果不会立即打断当前推理,而是通过通知队列延迟注入。
线程真正跑完命令之后,会把输出截断、记录状态,然后把一个精简后的通知对象 append 到 _notification_queue。而主循环并不会被异步地强行打断,它只是在每次准备进行新的 LLM 调用之前,先调用一次 drain_notifications(),把当前所有待注入的后台结果取出来,再统一包装成一个
片段塞进 messages。
这套机制非常漂亮,因为它同时满足了三件事:
- 主线程不阻塞:长任务后台跑,主循环继续前进。
- 结果不丢失:后台完成后先入队,等下一次 LLM 调用时再送给模型。
- 控制流仍然克制:没有引入复杂的事件总线,没有改写 agent loop 的根骨架,只是在调用前多了一步 “排空通知队列”。
文档里专门强调了一点:循环本身仍然保持单线程,只有子进程 I/O 被并行化。也就是说,并发被严格限制在工具执行层,而不是让整个 agent loop 变成一个到处并发的复杂系统。
这其实很符合这个项目一贯的设计哲学。前面 s02 就告诉我们 “加工具不需要改 loop”,到了 s08 这里,同样还是这个思路:想增加并发能力,不是去改写整个 loop,而是把阻塞执行替换成后台执行,再把结果按原有消息协议送回来。
这一节教程配了 7 张 Background Task Lanes 的图。如果说 s07 的那组 Task Dependency Graph 图是在说明 “任务图如何随着依赖关系逐步解锁”,那么 s08 的这一组图,重点就在于说明:后台任务并不是和主循环混成一团,而是被放到不同执行 lane 中,以通知队列作为桥梁与主线程重新汇合。
先看第一张图。这个时候图里还什么都没有发生,只有四条空的 lane:
- Main Thread
- Background 1
- Background 2
- Notification Queue
底部说明写的是:
Three Lanes The agent has a main thread and can spawn daemon background threads for parallel work.
这张图的作用,其实就是先把这一节的执行世界观建立起来:从 s08 开始,Agent 不再只有一条单独的执行主线,而是有了 主线程 + 若干后台线程 + 一个通知队列 这样一个分层结构。
这里最重要的不是已经执行了什么,而是系统的 “空间结构” 先被画出来了。尤其是 Notification Queue 的单独存在,其实已经提前暗示了这一节的关键设计:后台线程的结果不会直接回到模型,而是先排队等候。
第二张图里,Main Thread 这一条 lane 开始被点亮,出现了一小段紫色的起始块。底部说明写的是:
Main Thread Working The main agent loop runs as usual, processing user requests.
这一步特别重要,因为它先强调了一件事:s08 并不是推翻原来的 agent loop,而是让主线程继续 “像以前一样工作”。也就是说,前面几节里我们已经非常熟悉的那条主循环,在这里并没有消失。主线程仍然是整个系统的核心控制骨架,仍然负责接收用户输入、调用模型、处理工具协议。
这和很多人第一次听到 “后台任务” 时的直觉不太一样。很多人会以为后台任务意味着整个系统会彻底变成多线程主导的模式,但这张图先明确告诉你:不是的,主循环照旧,后台只是附加能力层。
第三张图里,主线程上出现了一个紫色块 Main agent loop,并且有一根箭头从主线程指向 Background 1,后者刚刚被点亮。底部说明写的是:
Spawn Background Background tasks run as daemon threads. The main loop doesn't wait for them.
这一张图就是 s08 的第一个真正关键时刻。因为它第一次把 “后台执行” 从概念变成了动作:主线程把一个任务发出去,后台线程开始工作,但主线程自己并没有停下来等它。
这里最核心的不是 “线程被创建了” 这件事本身,而是那句 The main loop doesn’t wait for them。因为这恰恰就是同步工具调用和后台任务之间的本质区别。在同步模式里,箭头一旦出去,主线程就得跟着停下来;但在这里,箭头出去之后,主线程仍然可以继续往右推进。
这一步对应代码中的 BG.run():生成 task id,创建 daemon thread,调用 thread.start(),然后立刻返回。
第四张图进一步把这个并发关系说清楚了。此时主线程上的 Main agent loop 还在继续往右延伸,Background 1 上已经开始跑 Run tests,同时又有一根箭头从主线程指向 Background 2,后者也开始点亮。底部说明写的是:
Multiple Backgrounds Multiple background tasks can run concurrently.
这张图的意义非常大,因为它说明 s08 新引入的并不只是 “一个后台槽位”,而是真正的 并发执行能力。主线程不仅可以把一个任务丢出去,而且可以连续地丢出多个任务;这些任务并不会互相阻塞,而是各自在不同后台 lane 中独立推进。
和 s07 的任务图相比,这里我们看到的是另一种 “并行性”:s07 强调的是任务依赖图层面的 “哪些事情理论上可以并行推进”,而 s08 则进一步解决了执行层面的 “这些可并行的慢操作,系统到底能不能真的同时跑起来”。所以可以说,s07 是计划层面的并行,s08 是执行层面的并行。
第五张图里,Background 2 上的 Lint code 已经完成,旁边标了一个小小的 done,同时 Notification Queue 中出现了一块绿色卡片:
tool_result Lint: 0 errors
底部说明写的是:
Task Completes Background task finishes. Its result goes to the notification queue.
这一步特别关键,因为它展示了后台任务结果是如何回流的。注意,这里结果并没有直接冲回 Main Thread,也没有直接打断主循环;它只是先进入了 Notification Queue。
这说明 s08 的设计非常克制。系统并没有采用那种 “后台线程一完成就立即抢占主线程” 的中断式模式,而是选择了 先把结果暂存在一个缓冲区里。这样做的好处很明显:主线程仍然保持自己的节奏,不会因为任何一个后台任务突然完成,就被强制打断当前控制流。
这一步对应代码里的 _execute():线程执行完子进程后,在持锁状态下把一个 {task_id, status, command, result} 的通知对象 append 到 _notification_queue。
第六张图里,Run tests 和 Lint code 两个后台任务都已经完成,通知队列里积累了两块绿色的结果卡片。与此同时,主线程上方还标出了一个橙色虚线框:
LLM API call
底部说明写的是:
Queue Fills Results accumulate in the queue, invisible to the model during this turn.
这张图把 s08 的一个非常细微但又非常重要的设计讲透了:后台结果不是一完成就立刻对模型可见的。
它们会先在 Notification Queue 里累积,而在当前这一轮还没进入下一个 LLM 调用之前,模型其实 “看不到” 这些结果。这里的 invisible 非常重要,因为它说明模型的上下文更新是有边界的,不是随时随地都能被异步结果打进去。换句话说,模型看到后台结果的时刻,是被系统严格控制的。
这种设计其实特别有工程感。因为它避免了上下文在一轮执行中途被异步篡改,也避免了主循环的状态变得难以推理。后台线程可以并发执行,但 上下文注入仍然只发生在明确的 LLM 边界处。
最后第七张图展示了整套机制的汇合点。Notification Queue 中原先积累的结果不见了,取而代之的是一句淡灰色提示:
queue drained -- injected into next LLM call
并且有一根橙色箭头从 Notification Queue 指向主线程上的下一个 LLM API call 边界。底部说明写的是:
Drain Queue Just before the next LLM call, all queued notifications are injected as tool_results. Non-blocking, async.
这就是 s08 整节课最核心的一步:在下一次 LLM 调用之前,统一排空通知队列并注入上下文。
这一步把整条闭环补完整了:
- 后台线程负责慢慢跑
- 跑完先入队
- 不打断主线程当前节奏
- 等到下一次需要调用模型时,再统一把所有后台结果送回去
这样一来,系统同时达成了两件事:
1. 执行层真正实现了异步、非阻塞;
2. 上下文层仍然保持了清晰、可控的同步边界。
这也是为什么文档最后那句总结非常精炼:Non-blocking, async. 因为从主线程视角看,它确实没有被后台任务阻塞;而从执行机制看,后台线程又确实在异步地完成事情,并通过队列与主循环重新汇合。
完整动画演示如下图所示:
看完流程图之后,接下来我们正式进入 agents/s08_background_tasks.py。和前面几节一样,文件顶部先给出了这一节的设计意图说明:
#!/usr/bin/env python3 # Harness: background execution -- the model thinks while the harness waits. """ s08_background_tasks.py - Background Tasks Run commands in background threads. A notification queue is drained before each LLM call to deliver results. ... Key insight: "Fire and forget -- the agent doesn't block while the command runs." """
这段注释其实已经非常准确地把 s08 的主题概括出来了。这里最重要的不是 “background thread” 这个技术细节本身,而是那句:the model thinks while the harness waits.
也就是说,从 s08 开始,等待这件事不再属于模型,而属于 harness。模型不应该为了某个慢命令卡住自己的推理机会,真正去 “等” 的应该是后台执行层。前面几节里,模型和工具执行几乎是绑在一起的;但到了这一节,这两者开始被显式拆开了:模型继续思考,harness 负责等后台任务慢慢跑完。 这正是并发执行在 Agent 里的真正意义。
初始化部分和前面几节仍然很像:.env、Anthropic 客户端、WORKDIR、MODEL 这些都没有变,说明底层运行框架依然是同一套。真正新增的核心组件,是 BackgroundManager。
先看它的初始化:
class BackgroundManager: def __init__(self): self.tasks = {} # task_id -> {status, result, command} self._notification_queue = [] # completed task results self._lock = threading.Lock()
这个类的作用可以一句话概括:
负责后台任务的启动、状态追踪,以及完成结果的排队通知。
这里的三个成员非常关键。
第一个是 self.tasks。它保存的是所有后台任务的状态表,也就是说,哪怕某个任务已经发到后台去跑了,系统仍然能通过 task id 找到它当前是 running、completed、timeout 还是 error。这意味着后台任务并不是 “发出去就彻底不管了”,而是仍然有一份明确的系统侧状态。
第二个是 self._notification_queue。这个队列是 s08 真正的新东西。前面几节的工具执行结果都是 “立即返回、立即塞回上下文”,而从这里开始,后台任务的结果第一次拥有了一个中间缓冲层:它先不直接回到模型,而是先排队。
第三个是 self._lock。这一点虽然代码上看很简单,但非常必要。因为 _notification_queue 会被后台线程写入,也会被主线程读取和清空;如果没有锁,这种跨线程共享结构就会有并发安全问题。
接下来先看 run():
def run(self, command: str) -> str: """Start a background thread, return task_id immediately.""" task_id = str(uuid.uuid4())[:8] self.tasks[task_id] = {"status": "running", "result": None, "command": command} thread = threading.Thread( target=self._execute, args=(task_id, command), daemon=True ) thread.start() return f"Background task {task_id} started: {command[:80]}"
这段代码其实就是 “后台执行” 能力的入口。它最关键的特征有两个。
第一个特征是:任务先登记,再启动线程。
一旦收到一个命令,系统会立即生成一个 task_id,然后在 self.tasks 中把它记录为 running。这一步很重要,因为它意味着任务状态从创建那一刻起就已经被系统掌握了,而不是等命令真的跑完之后才知道 “原来有这么个任务”。
第二个特征是:线程启动之后立刻返回。
这里的 thread.start() 只是把执行交给后台,真正的返回值并不是命令输出,而是:
Background task {task_id} started: ...
这就是 s08 和前面同步工具调用之间最本质的不同。前面 bash 的返回值是命令结果本身,而这里 background_run 的返回值只是一个 “已启动” 确认。也就是说,工具调用的语义已经从 “执行并返回结果” 变成了 “提交任务并返回句柄”。
这一点在工程上特别重要。因为从这里开始,模型和系统之间新增了一层 “引用关系” — 模型不必立刻拿到完整输出,它先拿到的是一个 task id,这个 id 后面可以被用来追踪状态、检查完成情况,或者等待通知注入。
再看 _execute():
def _execute(self, task_id: str, command: str): """Thread target: run subprocess, capture output, push to queue.""" try: r = subprocess.run( command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=300 ) output = (r.stdout + r.stderr).strip()[:50000] status = "completed" except subprocess.TimeoutExpired: output = "Error: Timeout (300s)" status = "timeout" except Exception as e: output = f"Error: {e}" status = "error" self.tasks[task_id]["status"] = status self.tasks[task_id]["result"] = output or "(no output)" with self._lock: self._notification_queue.append({ "task_id": task_id, "status": status, "command": command[:80], "result": (output or "(no output)")[:500], })
这一段可以说是 s08 的核心执行逻辑。
先看前半段,它其实和前面几节里的 run_bash() 很像:同样是 subprocess.run(),同样有 shell=True、cwd=WORKDIR、capture_output=True、text=True、timeout=300。这说明后台执行并不是发明了一种全新的命令执行器,本质上仍然还是 shell 子进程,只不过这一次它被放到了独立线程里去跑。
这里特别值得注意的是 timeout=300。前面的同步 bash 工具用的是 120 秒,而后台任务这里放宽到了 300 秒。这其实很合理,因为它的定位本来就是 “用来跑慢任务” 的。文档里也明确说了,后台任务主要就是为 npm install、pytest 这类慢命令准备的。
再看后半段,就能看出后台任务和普通同步命令的根本区别了。线程跑完之后,它做了两件事:
第一件事,是更新 self.tasks[task_id] 里的最终状态和完整结果:
self.tasks[task_id]["status"] = status self.tasks[task_id]["result"] = output or "(no output)"
这意味着后台任务的完整执行结果会被留在任务状态表里,后面 check() 就可以基于这个表来查询具体任务的现状。
第二件事,是把一个 “通知对象” 压入 _notification_queue:
with self._lock: self._notification_queue.append({ "task_id": task_id, "status": status, "command": command[:80], "result": (output or "(no output)")[:500], })
这里特别有意思的是:压入队列的不是完整结果,而是一个 截断后的摘要版通知。完整输出最多保留到 50000 字符放进 tasks 表里,但排队进入通知队列的 result 只保留了前 500 个字符。这其实非常有工程味道,因为它说明通知队列的职责不是存档,而是提醒主线程 “有个后台任务完成了,以及大概结果是什么”。真正详细的结果如果需要,仍然可以通过 check_background 去查。
也就是说,s08 这里其实已经把后台任务的数据分成了两个层次:
- 一个是完整状态存储层
tasks - 一个是轻量通知层
_notification_queue
这种设计非常清楚,也很符合后续要把结果注入模型上下文的需求。毕竟对于模型来说,先知道 “测试过了 / lint 没报错 / 某个命令超时了” 往往就已经足够,不必一股脑把几十 KB 的完整日志都塞进上下文。
再看 check():
def check(self, task_id: str = None) -> str: """Check status of one task or list all.""" if task_id: t = self.tasks.get(task_id) if not t: return f"Error: Unknown task {task_id}" return f"[{t['status']}] {t['command'][:60]} " lines = [] for tid, t in self.tasks.items(): lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}") return " ".join(lines) if lines else "No background tasks."
这一段体现了 s08 的另一个重要点:后台任务不是不可见的。
很多时候我们会把 “后台执行” 理解成一种完全脱离主系统的 fire-and-forget 行为,好像任务发出去之后就只能等命运。但这里并不是这样。系统仍然提供了一个显式的查询接口:
- 如果传入
task_id,就返回某个具体任务的状态和结果 - 如果不传,就列出所有后台任务的状态概览
这意味着后台任务并不是 “黑盒线程”,而是继续被纳入系统自己的可查询状态空间里。也就是说,虽然执行被异步化了,但 状态管理仍然是同步可见的。
这和 s07 的设计其实有很强的一致性:前面 s07 强调 “任务必须成为系统自己的外部事实”,而到了 s08,这种思想继续延伸成:后台执行中的任务,也必须成为系统侧可检查、可追踪的事实,而不是纯粹的隐式线程行为。
然后再看 drain_notifications():
def drain_notifications(self) -> list: """Return and clear all pending completion notifications.""" with self._lock: notifs = list(self._notification_queue) self._notification_queue.clear() return notifs
这段代码虽然很短,但可以说是整节课机制闭环的关键。因为它定义了 Notification Queue 和主线程之间的唯一正式接口:主线程不会一个个去盯后台线程,它只是在合适的时候把整个队列排空。
这里最重要的不是 “读取队列” 本身,而是 “读取之后立刻清空”。也就是说,通知队列本质上是一个一次性缓冲区:某批后台结果被取走并注入模型后,就不应该重复注入第二次。这个设计非常简洁,也让队列的语义非常清楚 — 它存的是 “尚未被送达给模型的后台完成通知”。
理解了 BackgroundManager 之后,再往下看工具层就很顺了。和前面所有章节一样,这一节也没有动之前的工具 dispatch 模式,只是在原有工具之外新增了两个与后台执行相关的工具:background_run 和 check_background。
对应的 handler 映射是:
TOOL_HANDLERS =
这一点其实特别值得注意,因为它再次说明了这个项目最稳定的设计原则:能力增强发生在工具层,而不是通过重写 loop 来完成。
从 s01 到 s08,dispatch map 这个模式始终没有变。现在新增并发能力,也并不是单独造一个 “并发子系统接口”,而只是继续沿用原有的工具注册方式:
background_run负责把命令发到后台check_background负责回头查询后台状态
这说明并发执行并没有被设计成一种 “绕过工具协议的特殊能力”,而是仍然被老老实实地纳入原有的工具体系。对模型来说,它只是多了两个新工具;对系统来说,则是通过这两个工具把异步执行能力挂进了原有 Agent 框架。
最后再来看 agent_loop(),你会发现这一节最漂亮的地方,仍然是 “底层 loop 几乎没变”:
def agent_loop(messages: list): while True: # Drain background notifications and inject as system message before LLM call notifs = BG.drain_notifications() if notifs and messages: notif_text = " ".join( f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs ) messages.append( "}) messages.append({"role": "assistant", "content": "Noted background results."}) response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000, ) ...
整体结构还是我们已经非常熟悉的那一套:最外层 while True 没变,每轮还是调用模型,还是检查 stop_reason,还是执行工具,还是把 tool_result 塞回上下文。真正新增的,就只有开头那一小段:
notifs = BG.drain_notifications() if notifs and messages: ... messages.append( "}) messages.append({"role": "assistant", "content": "Noted background results."})
这一段代码几乎把 s08 的全部精髓都浓缩在里面了。
第一,它说明后台结果的注入时机是非常明确的:就在每次新的 LLM 调用之前。这和前面流程图的第七张图完全对应。
第二,它说明后台结果仍然是通过 messages 注入上下文,而不是通过某种额外隐藏状态让模型 “神奇地知道”。也就是说,这个项目从头到尾都坚持一个非常一致的原则:模型能知道的东西,最终都必须体现在消息上下文中。
第三,它用一个非常显式的包装标签:
<background-results> ...
background-results>
把这类消息和普通用户输入区分开了。这一点也很有意思,因为它说明系统希望模型把这些内容理解成一种特殊的 “异步返回结果”,而不是把它们误当成普通自然语言对话。
第四,它甚至还会追加一条 assistant 消息:
{"role": "assistant", "content": "Noted background results."}
这一步和前面很多章节里的设计其实是相呼应的:系统会显式在消息历史里留下 “模型已经接收并确认了这些后台结果” 的痕迹。这种做法虽然表面上看有点啰嗦,但在教学项目里非常清楚 — 它让整段历史在 messages 中保持结构完整,也方便后面继续推理时模型知道 “这些结果不是未读状态,而是已经被接收了”。
看完这一节的 agent_loop(),就会发现 s08 真正厉害的地方在于:它看起来引入了线程、异步执行、通知队列、后台查询,好像系统复杂了不少;但真正落到 agent 主控制骨架上,变化其实非常克制。
底层 loop 没变,tool_use / tool_result 协议没变,dispatch map 没变,messages 依然是上下文主载体。变化只是:原来同步立即返回的工具结果,现在多了一层后台执行 + 队列缓冲 + 边界注入的机制。
所以从代码角度看,s08 的真正价值并不只是 “多了两个工具”,也不只是 “能用线程跑命令了”,而是第一次让这个项目在执行调度层面长出了非常重要的一种工程能力:
慢任务不再等于主循环停摆;主线程继续前进,后台任务异步完成,再通过通知队列在下一个 LLM 边界与模型重新汇合。
博主在给定下面的提示词情况下:
Run "sleep 5 && echo done" in the background, then create a file while it runs
想通过调试看看整个过程发生了什么,我们来具体分析下:
第一次 loop(请求开始)

loop1: 模型响应结果

loop1: BackgroundManager 工具创建任务

loop1: 工具执行结果
这一轮非常关键,因为它第一次把 s08 的 “后台执行” 能力真正触发出来了。
先看 loop1: 模型响应结果。从调试图里可以看到,这一轮的 response.content 并不是一个单独的文本块,而是包含了两个 block:
- 第一个是
TextBlock,内容大意是:模型先说明自己要去后台运行sleep命令,同时在命令运行期间继续创建文件; - 第二个是
ToolUseBlock,工具名是background_run,输入参数里给出的命令正是:sleep 5 && echo done。
这一步说明模型已经正确理解了用户要求里的两个动作其实是有先后节奏要求的:不是先同步执行 sleep 5 && echo done 再做别的事,而是先把它放进后台,再继续后续操作。也就是说,从模型决策层面,它已经没有把这件事情当成普通阻塞命令,而是明确识别成了 background_run 的适用场景。
然后看 loop1: BackgroundManager 工具创建任务 这张图。此时程序进入了 BackgroundManager.run(),创建了一个新的 task id,比如图里显示的 ,同时在 self.tasks 中新增了一条记录:
{ '': { 'status': 'running', 'result': None, 'command': 'sleep 5 && echo done' } }
这里其实能非常直观地看到 run() 的职责:它并不是去同步执行命令,而是先在系统状态里登记一条后台任务,然后启动守护线程。此时 status='running',result=None,恰好说明这个任务已经被系统接管,但真正的 shell 命令还在后台慢慢跑,没有结果返回。
最后再看 loop1: 工具执行结果。这一轮工具调用返回给模型的内容不是命令输出本身,而只是一个启动确认:
Background task started: sleep 5 && echo done
这一步正是 s08 和前面同步工具调用最大的不同。前面的 bash 工具一旦被调用,返回的就是命令执行结果;而这里 background_run 返回的只是 “任务已经启动” 的确认信息。也就是说,第一次 loop 的真正产物,不是 done,而是 task id。
从这一刻开始,模型和系统之间多了一层显式的后台任务引用关系:模型知道有一个后台任务已经在跑,而且后面可以通过这个 task id 去继续追踪它。
第二次 loop

loop2: 模型响应结果

loop2: 工具执行结果
第二次 loop 最能体现 s08 的核心价值:主线程并没有因为后台任务还没结束而停下来。
先看 loop2: 模型响应结果。从图里可以看到,这一轮 response.content[0] 是一个 TextBlock,模型明确表示:
Now let's create a file while the background task is running:
紧接着 response.content[1] 是一个 ToolUseBlock,这次调用的工具已经不是 background_run,而是 write_file。也就是说,模型在拿到第一轮 “后台任务已启动” 的 tool_result 之后,立刻继续推进用户要求中的第二部分 — 趁后台任务还在运行时创建文件。
这一步的意义非常大,因为它不只是 “又调用了一个新工具” 这么简单,而是直接证明了:
后台任务的存在,并不会阻塞主线程继续发起新的工具调用。
如果这里还是 s01~s07 那种同步工具模式,那么系统在第一轮其实会被 sleep 5 && echo done 卡住整整 5 秒,根本不可能马上进入写文件这一步。但现在它做到了,这正说明后台线程已经把 “等待” 从主循环里剥离出去了。
再看 loop2: 工具执行结果。这一轮 write_file 返回的结果是:
Wrote 138 bytes
同时从调试图也能看到,新文件 background_example.txt 已经被真正写出来了,内容大致包括:
- 文件是在后台任务运行时创建的
- 背景任务 ID
- 对应命令
- 时间戳
第三次 loop

loop3: 请求上下文

loop3: 模型响应结果

loop3: 工具执行结果
第三次 loop 是整个调试过程中最值得仔细看的地方,因为它第一次把 notification queue 的注入时机 清清楚楚地展示出来了。
先看 loop3: 请求上下文。从图里能看到,这一次在调用模型之前,messages 里新增了两条消息:
{ 'role': 'user', 'content': '
[bg:] completed: done
' }, { 'role': 'assistant', 'content': 'Noted background results.' }
这两条消息正是 agent_loop() 开头这段逻辑产生的:
notifs = BG.drain_notifications() if notifs and messages: messages.append( "}) messages.append({"role": "assistant", "content": "Noted background results."})
也就是说,在第二次 loop 结束、准备进入第三次 loop 时,后台线程那边已经把 sleep 5 && echo done 跑完了,并且 _execute() 已经把结果:
{ "task_id": "", "status": "completed", "result": "done" }
压进了 _notification_queue。于是主线程在下一次新的 LLM 调用之前,先执行了一次 drain_notifications(),把这条通知排空,然后包装成
结构追加进 messages。
这里最值得强调的一点是:后台结果不是一完成就立即打断当前 loop 注入模型,而是延迟到下一次 LLM 调用边界才统一注入。
这一点从图里看得非常清楚,因为
这两条消息是在第三次 loop 之前才突然出现的,而不是在第一轮或第二轮中途**去的。这也正好对应了前面流程图里第六张和第七张图强调的内容:结果先在队列里积累,直到下一次 LLM call 前才统一 drain。
再看 loop3: 模型响应结果。模型此时已经看到了
里那条:
[bg:] completed: done
所以它在文本 block 里明确表示:后台任务已经完成并且输出了 done,接下来它准备检查刚刚创建的文件,并继续验证后台任务状态。然后它发起了一个新的 ToolUseBlock,调用的是 read_file,去读取 background_example.txt。
最后看 loop3: 工具执行结果。这一轮 read_file 返回的就是刚刚写入文件的内容,也就是:
This file was created while the background task was running. Background task ID: Command: sleep 5 && echo done Timestamp: $(date)
这说明模型已经成功完成了第一部分验证:文件确实存在,而且内容和之前写入的预期一致。
第四次 loop

loop4: 模型响应结果

loop4: BackgroundManager 工具检查任务

loop4: 工具执行结果
第四次 loop 的重点,在于模型开始主动使用 check_background 去查询后台任务状态,而不是只依赖队列通知。
先看 loop4: 模型响应结果。图里显示这一轮的 response.content 只有一个 ToolUseBlock,工具名是 check_background,并且输入参数里明确传入了:
{"task_id": ""}
这说明模型在上一轮已经通过
知道后台任务完成了,但它并没有就此结束,而是进一步主动去调用显式查询接口,验证指定 task id 当前的详细状态。
再看 loop4: BackgroundManager 工具检查任务。此时调试停在了 BackgroundManager.check() 里,代码走的是:
return f"[{t['status']}] {t['command'][:60]} "
从图里可以看出,此时 t 的内容已经是:
{ 'status': 'completed', 'result': 'done', 'command': 'sleep 5 && echo done' }
所以 check_background(task_id="") 的返回值正是:
[completed] sleep 5 && echo done done
最后看 loop4: 工具执行结果,这一轮返回给模型的 tool_result 内容也确实正是上面这段结果。这里就能非常清楚地看到,check_background 和第三次 loop 注入的
是两种不同粒度的反馈:
里只有一句摘要:[bg:] completed: donecheck_background则返回了任务状态 + 原始命令 + 完整结果
也就是说,前者更像系统自动送达的异步通知,后者更像模型手动发起的状态查询。
第五次 loop

loop5: 模型响应结果

loop5: BackgroundManager 工具检查任务

loop5: 工具执行结果
第五次 loop 更有意思,因为它展示了 check_background() 在 不传 task_id 时的另一种行为,也就是 “列出所有后台任务”。
先看 loop5: 模型响应结果。这一轮 response.content[0] 是一个文本 block,内容大意是:
Let me also list all background tasks to confirm:
接着 response.content[1] 是一个新的 ToolUseBlock,仍然是 check_background,但这一次 input={},也就是 没有传 task_id。
再看 loop5: BackgroundManager 工具检查任务。这一次代码不再走单任务分支,而是走:
lines = [] for tid, t in self.tasks.items(): lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}") return " ".join(lines) if lines else "No background tasks."
从图里可以看到,此时 lines 的值是:
[': [completed] sleep 5 && echo done']
所以这一轮 check_background() 返回的就是整个后台任务列表:
: [completed] sleep 5 && echo done
最后看 loop5: 工具执行结果,返回给模型的也正是这份 “全局任务板” 式的状态概览。
第六次 loop(回答完成)

loop6: 模型响应结果
到了第六次 loop,模型终于不再请求任何工具调用,而是直接输出最终回答。
从调试图里可以看到,这一次 response.content[0] 是一个纯 TextBlock,也就是模型整理完所有上下文之后给出的最终总结,整个循环在这里结束。
整个终端输出结果如下:

从最终终端输出可以把这六次 loop 串成一条非常完整的执行主线:
第一轮,模型调用 background_run,后台任务被创建,系统立即返回 task id;
第二轮,模型没有等待后台命令结束,而是立刻继续调用 write_file,完成文件创建;
第三轮,在新的 LLM 调用边界处,通知队列被排空,done 被包装进
注入上下文,模型据此读取文件内容继续验证;
第四轮,模型通过 check_background(task_id) 查询单个后台任务的详细状态;
第五轮,模型又通过 check_background() 查询整个后台任务列表,从全局角度确认状态一致;
第六轮,模型基于前面所有工具结果,输出最终总结,整个 agent loop 正常结束。
所以,如果只是抽象地看代码,我们当然可以说 s08 实现了 “后台任务 + 通知队列 + 非阻塞执行”;但通过这次真实调试可以更直观地看到,这套机制真正成立依赖的是三层配合:
第一层,是 background_run() 把原本同步阻塞的命令执行变成了 “立即返回 task id + 后台线程慢慢跑” 的异步提交模式;
第二层,是 _notification_queue 把后台线程完成后的结果先暂存在主线程之外,而不是立即强行打断当前推理;
第三层,是 agent_loop() 在下一次新的 LLM 调用之前执行 drain_notifications(),把这些异步完成结果以
的形式重新并回消息上下文。
这也是为什么 s08 这一节虽然表面上只是增加了两个工具,但实际上它给整个项目带来的变化非常大。因为从这一节开始,Agent 已经不再只是一个 “调一个工具等一个结果” 的同步系统,而开始真正具备了一种更接近工程现实的执行调度能力:慢任务可以在后台异步推进,而主线程仍然可以继续做别的事情,等到合适的边界再重新汇合。
OK,以上就是 Background Tasks 工作原理的完整分析了。
大家也可以试试文档里给出的这些 prompt,感受一下后台执行引入之后,这个 Agent 在执行节奏上和前面同步模式会有什么不同:
1. Run "sleep 5 && echo done" in the background, then create a file while it runs
2. Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.
3. Run pytest in the background and keep working on other things
如果说 s07 最大的贡献,是让任务第一次从 “会话里的临时计划” 变成了 “磁盘上的持久化任务图”;那么 s08 的贡献就是让我们进一步意识到:任务被定义出来之后,还必须有一种不阻塞主循环的执行方式。
前面几节里,Agent 虽然已经会调用很多工具,也已经能把任务结构化、持久化、压缩上下文、加载技能,但从执行节奏上看,它依然是一个完全同步的系统:每次 tool call 都要等结果回来,主线程始终被当前工具调用牵着走。而 s08 通过 BackgroundManager、后台线程、通知队列和 background_run / check_background 两个工具,把这件事第一次真正改掉了。
代码和文档都非常清楚地体现了这一点:run() 只负责提交后台任务并立即返回,_execute() 在独立线程中执行命令并把结果送入 _notification_queue,drain_notifications() 则在每次新的 LLM 调用之前统一排空通知,再把这些结果包装成
注入 messages。整个过程中,主循环并没有因为慢命令而停下来,真正去 “等待” 的是 harness,而不是模型。
所以,s08 真正的价值并不只是 “让命令在线程里跑起来”,也不只是 “多了个后台工具”,而是第一次把这个项目从一个 只会同步执行的 Agent,推进成了一个开始具备 异步调度能力的 Agent。从这一节开始,Agent 不再需要把所有等待时间都浪费在当前工具调用上,而是开始拥有一种更接近真实工程系统的执行观:慢任务丢到后台,主线程继续推进,等到合适的边界,再把后台结果有序地重新送回模型。
这一步其实非常关键,因为它为后面的多 Agent 协作、团队协议、自治执行等机制都铺好了路。前面 s07 解决的是 “任务图怎么存在”,而到了 s08,我们终于开始回答另一个同样重要的问题:
这些任务,不只是要存在,还要能被不阻塞地跑起来。
OK,以上就是本期想要分享的全部内容了。
本篇文章我们围绕 s08 Background Tasks 这一节,从运行现象出发,结合代码实现与调试分析,完整梳理了 Agent 是如何通过后台任务机制,引入异步执行能力的。
相比前面的章节,这一节带来的变化不再是能力扩展或结构抽象,而是第一次在执行层面引入了 “时间维度”:任务不再必须同步完成,Agent 可以在主循环持续推进的同时,将耗时操作交由后台独立执行,并在合适的时机再与主流程汇合。
从工程视角来看,这一机制的关键在于:系统开始具备基本的调度能力。主线程负责决策与编排,后台任务负责具体执行,两者通过通知队列进行解耦与协作。这种设计使得 Agent 不再受限于单线程阻塞模型,在面对 IO 或长耗时操作时,依然能够保持整体运行的流畅性。
进一步来看,Background Tasks 实际上完成了一次重要的能力补齐:在前面的章节中,我们已经解决了 “如何组织能力、任务与上下文”,而到了 s08,系统开始回答另一个关键问题 — 这些任务应该如何在时间上被调度与执行。
也正因为如此,这一节的意义并不只是 “让任务跑得更快”,而是让 Agent 从 “顺序执行系统”,迈向 “具备并发与调度能力的系统”。当任务规模继续增长时,这种能力将直接决定系统是否能够在真实场景中稳定运行。
下篇文章我们将来学习第五部分 Collaboration 中 s09 Agent Teams 章节的内容,敬请期待珞
- https://github.com/shareAI-lab/learn-claude-code
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/279249.html