HARNESS 108

Background Tasks

"慢操作丢后台,Agent 继续想下一步"

之前 Agent 执行命令时只能傻等——npm install 跑 3 分钟,Agent 干坐 3 分钟。
s08 让 Agent 会说:"你先跑着,我做点别的,你跑完了告诉我。"

1

问题:有些命令要跑很久,Agent 只能干等

s01-s07 的所有工具都是同步阻塞的——Agent 调 bash "npm install",然后什么也不做,等 npm 跑完(可能 3 分钟),拿到输出,继续下一步。这 3 分钟里 Agent 完全闲置。

三个浪费场景

场景一:用户说"装依赖,顺便创建配置文件"。Agent 先 npm install(3 分钟),等完了再创建文件(5 秒)。明明可以同时做——npm 在后台跑,Agent 同时创建文件。

场景二:Agent 想跑测试套件(2 分钟)。它只能在测试跑完后再阅读输出决定下一步。但测试跑的时候 Agent 的脑子是完全空的——它在等 subprocess 返回。

场景三:Agent 同时需要装前端依赖和后端依赖。s07 的 Agent 只能一个一个来——先 cd frontend && npm install,等完再 cd backend && pip install。明明两个互不依赖,完全可以同时跑。

🍳 生活类比

s01-s07 的 Agent 就像一个厨师,每次只能做一件事:烧水的时候站在灶台前等着,水开了才能去切菜。

s08 的 Agent 像一个真正的厨师:把水烧上(后台任务),同时去切菜(继续工作)。水开了,水壶叫一声(通知),厨师回来看一眼,继续做菜。

2

解决方案:Fire and Forget(发射后不管)

s08 让 Agent 能把慢命令丢到后台线程去跑,自己立刻继续做别的事。命令跑完后,结果通过一条通知消息"塞回"对话。

Agent 用户说"装依赖,顺便创建 readme"
Agent 调 background_run("npm install") → 得到 task_id="a1b2c3d4"
后台                               npm install 开始跑...
Agent 不等待!立刻调 write_file("README.md", ...) → 创建完成
Agent 此时 npm install 可能还在跑。Agent 可以做别的事...
后台                               npm install 完成!结果推入通知队列
通知 下一轮 LLM 调用前:drain_notifications() → 注入 npm 结果
Agent 看到通知:"npm install 完成了,安装了 142 个包"
核心机制就三步
发射:background_run 启动线程,立即返回一个任务编号
不管:Agent 继续工作,不等待,不轮询
通知:后台线程完成后,把结果放入通知队列。Agent 下一次调 LLM 前,"排空"通知队列——所有完成的后台任务结果被注入对话
3

案例对比:有后台 vs 没后台,同一个任务差多少?

任务:"安装 npm 依赖,同时创建 src/index.js 和 README.md,然后跑一下测试"

❌ 没有后台任务(s07 及之前)

bash "npm install" → 等 3 分钟 → 完成 → write_file "src/index.js" → write_file "README.md" → bash "npm test" → 等 1 分钟 → 完成。

总耗时:约 4 分钟。Agent 闲置了约 4 分钟。

✅ 有后台任务(s08)

background_run "npm install" → 立即返回 → write_file "src/index.js"(同时 npm 在后台跑)→ write_file "README.md"(npm 可能还在跑)→ npm 完成通知到达 → background_run "npm test" → Agent 继续分析...→ 测试完成通知到达。

总耗时:约 3 分钟(两个安装命令的时间重叠了 Agent 的文件创建操作)。Agent 没有闲置——它在后台任务运行期间做了有价值的文件创建和分析工作。

💡 价值不只是"快了"
更重要的是 Agent 的"注意力利用效率"。阻塞模式下 Agent 大量的时间花在等 I/O 上——这不是 Agent 的错,是 harness 没给它并行执行的能力。后台任务让 Agent 的"思考时间"和"等待时间"解耦。
4

BackgroundManager:线程 + 队列 + 锁

BackgroundManager 是 s08 的核心类,只有约 50 行代码,但它引入了 harness 工程中三个新概念:线程通知队列线程锁。一个一个看。

初始化:三样东西

📝 中文注释版
class BackgroundManager: def __init__(self): self.tasks = {} # 所有任务的状态记录 self._notification_queue = [] # 已完成任务的通知队列 self._lock = threading.Lock() # 线程锁,保护队列的读写安全
💻 英文代码原版
class BackgroundManager: def __init__(self): self.tasks = {} self._notification_queue = [] self._lock = threading.Lock()

为什么需要线程锁? 因为两个线程会同时访问通知队列——后台线程往里面加数据(完成通知),主线程从里面取数据(排空)。如果同时读写,数据可能损坏。锁保证同一时刻只有一个线程在操作队列。

run():启动线程,立即返回

📝 中文注释版
def run(self, command): task_id = str(uuid.uuid4())[:8] # 生成 8 位随机 ID self.tasks[task_id] = { # 记录任务状态 "status": "running", "command": command } thread = threading.Thread( # 创建线程 target=self._execute, # 线程要执行的函数 args=(task_id, command), # 传给函数的参数 daemon=True # 守护线程:主程序退出时自动结束 ) thread.start() # 启动线程!立即返回,不等待 return f"后台任务 {task_id} 已启动"
💻 英文代码原版
def run(self, command): 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"

守护线程(daemon=True):如果主程序退出(用户按了 Ctrl+C),后台线程自动被杀死。不会出现"主程序退出了但后台线程还在跑"的诡异情况。

_execute():在线程里跑命令,完成后推送通知

📝 中文注释版
def _execute(self, task_id, command): try: r = subprocess.run(command, timeout=300) # 后台命令给 5 分钟超时 output = (r.stdout + r.stderr)[:50000] # 和 bash 一样的截断 status = "completed" except TimeoutExpired: output = "超时 (300s)" status = "timeout" self.tasks[task_id]["status"] = status # 更新任务记录 with self._lock: # 🔒 加锁 self._notification_queue.append({ # 结果推入通知队列 "task_id": task_id, "status": status, "result": output[:500], # 通知里只保留前500字符 })
💻 英文代码原版
def _execute(self, task_id, command): try: r = subprocess.run(command, shell=True, 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" self.tasks[task_id]["status"] = status with self._lock: self._notification_queue.append({ "task_id": task_id, "status": status, "command": command[:80], "result": (output or "(no output)")[:500], })
5

通知管道:后台结果怎么"回到"对话?

这是 s08 最巧妙的设计。后台任务完成后,结果不是"弹窗"或"系统提醒"——而是作为一条 user 消息被悄悄塞进对话。和 s03 的 nag reminder、s05 的 skill 内容一样的注入方式。

drain_notifications():取走所有通知,清空队列

📝 中文注释版
def drain_notifications(self): with self._lock: # 🔒 加锁读 通知列表 = list(self._notification_queue) # 复制一份 self._notification_queue.clear() # 清空原队列 return 通知列表 # 返回副本
💻 英文代码原版
def drain_notifications(self): with self._lock: notifs = list(self._notification_queue) self._notification_queue.clear() return notifs

为什么叫 "drain"(排空)? 因为这个方法把队列里所有积压的通知一次性取走。如果 Agent 连续工作了 3 轮没有检查通知,这期间可能有 2 个后台任务完成了——drain 会把两个通知都拿走,让 LLM 在下一轮调用前看到所有新信息。

注入到对话:每次 LLM 调用前

通知不是弹窗,不是系统警告。它是作为一条 user 消息,以 <background-results> 标签包裹,悄悄地出现在 messages 末尾:

# Agent Loop 中,LLM 调用前的代码: notifs = BG.drain_notifications() # 取走所有通知 if notifs: notif_text = "\n".join( f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs ) messages.append({ # 作为 user 消息注入 "role": "user", "content": f"<background-results>\n{notif_text}\n</background-results>" }) # ↓ 然后 LLM 调用时,看到这条消息,就像有人告诉它"npm install 跑完了" response = client.messages.create(messages=messages, ...)

LLM 看到的是一条普通对话消息。它自然地响应:"好的,npm install 完成了,安装了 142 个包。现在我来继续..."——Agent 不需要"轮询"后台状态。它被被动通知。

6

两个新工具:background_run + check_background

background_run:发射后台任务

Agent 调用 background_run(command="npm install") → 线程启动,立即返回任务编号。Agent 不需要等——它可以接着做别的事。

check_background:查看后台任务状态

Agent 可以随时问"我派出去的那个任务跑完了吗?"——调 check_background(task_id="a1b2c3d4") 看一个任务,或调 check_background()(不传参数)看所有任务。

大多数时候 Agent 不需要主动 check——通知会自动到达。但 check 给了 Agent 一种主动性:"我等不及了,让我看看跑到哪了"。

dispatch map:又是两行

TOOL_HANDLERS = { # ... 基础工具 ... "background_run": lambda **kw: BG.run(kw["command"]), "check_background": lambda **kw: BG.check(kw.get("task_id")), }

和 s02 学的模式一模一样——加工具 = 加 handler + 加 schema + 字典加一行。从 s02 到 s08,这个模式一次都没变过。

7

Agent Loop 集成:通知注入的位置很关键

agent_loop 中通知注入的时机

通知在每次 LLM 调用之前被 drain。这保证了 LLM 在思考下一步之前,能看到所有已完成的后台任务结果。

def agent_loop(messages): while True: # ① 排空通知队列 —— 如果有后台任务完成了,结果现在注入 notifs = BG.drain_notifications() if notifs: messages.append(user_msg_with_background_results) # ② LLM 调用 —— LLM 看到最新的后台结果 response = client.messages.create(messages=messages, ...) # ③ ... 工具分发,和以前一样 ...

为什么在 LLM 调用前而不是调用后? 如果在调用后 drain,LLM 在那一轮就看不到已完成的结果——它要多等一轮才能对结果做出反应。在调用前 drain = LLM 第一时间知道结果。

check_background 的特殊处理:它不在通知管道中

check_background 走的是普通工具 dispatch——LLM 调用它,handler 返回结果,作为 tool_result 注入。这和 background_run 的通知管道是两条不同的信息路径

通知管道(被动):后台完成 → 通知队列 → drain → user 消息 → LLM 看到

主动查询(主动):LLM → check_background → handler → tool_result → LLM 看到

两条路径互不干扰,服务于不同的使用场景。

8

从 s08 带走的三个核心认识

01

"不等"比"等得快"更好

不是让命令跑得更快,而是让 Agent 在命令跑的时候不闲着。后台执行把"等待时间"变成了"生产时间"。

02

通知优于轮询

Agent 不需要反复问"完了吗?"。后台完成后自动推送通知。注入方式和 tool_result 完全一样——不搞特殊通道。

03

线程是杠杆,锁是安全带

几行 threading 代码换来真正的并发执行。但共享数据必须加锁——多线程编程的第一条规则。s08 用最少的锁(只在队列操作时)换取最大的安全。

9

落地案例:后台任务在真实 AI 产品中的应用

📦 Claude Code 的后台执行

Claude Code 在执行 npm installpip installdocker build 等耗时命令时,内部就是用的后台线程模式。你会看到它"同时"做多件事——其实不是真正的并行 LLM 调用,而是命令在后台跑,LLM 继续思考其他任务。

🏗️ CI/CD Pipeline 的并行 Stage

Jenkins/GitHub Actions 中,不同 job 在不同 runner 上并行执行。s08 的后台线程就是这个模式的单机版——同一个 Agent 进程内,不同线程并行执行不同命令。

🤖 多 Agent 系统的"任务执行器"

s09 的多 Agent 团队中,每个 Agent 派发的"执行任务"其实就是 background_run。Agent A 说"帮我跑这个测试"→ 后台线程启动 → Agent A 继续做别的事 → 测试完成通知到达。

从 s09 开始,课程进入"多 Agent"阶段。
s08 给了单个 Agent 并行执行的能力。
s09 将引入多个 Agent 通过邮箱通信协作——
每个 Agent 可以把自己的重活丢到后台,继续和其他 Agent 协调。

s08 让 Agent 不再傻等慢命令。但一个 Agent 的力量终究有限。
s09:怎么让多个 Agent 组成团队,通过邮箱通信协作?

s09: Agent Teams →