Background Tasks - 后台任务与异步执行
L13 要回答一个问题:如何在保持 Agent 主循环单线程的前提下,让慢命令不阻塞工作流?
L11 给了我们骨架:三种恢复路径 + 有限重试 + 优雅失败。但真实 Agent 执行慢命令(npm install、pytest、docker build)时,模型在等待期间什么都做不了——用户还想继续别的工作,却被整轮流程堵住。怎么做?
本章讲三个机制:
| 机制 | 解决什么问题 | 核心设计 |
|---|---|---|
| 后台执行线 | 慢命令不阻塞主循环 | background_run 立刻返回 task_id |
| 通知队列 | 后台完成后告知主循环 | drain_notifications + 摘要注入 |
| 任务表分离 | 执行状态 vs 结果投递 | RuntimeTaskRecord + Notification |
这三个机制叠加在 L11 的 Error Recovery 上,循环本身不变。理解 L13,就理解了 Agent 如何”并行等待不阻塞主循环”。
一、核心命题:主循环只有一条
“主循环仍然只有一条,并行的是等待,不是主循环本身。”
这句话概括了 Background Tasks 的本质。不是让主循环变多线程——而是让”慢执行”移到后台,主循环继续推进别的事情。
问题背景:慢命令阻塞
同步执行慢命令会导致:
| 问题 | 说明 |
|---|---|
| 模型等待期间无事可做 | 调用 API 后等待 pytest 结果,整轮流程卡住 |
| 用户被整轮流程堵住 | 想继续别的工作,却被一个慢命令阻塞 |
错误理解:后台就是另一条主循环。
正确理解:主循环保持单线程,后台是另一条执行线。
主循环流程变更
流程说明:
- 主循环:background_run → 登记任务 → 立刻返回 task_id → 继别工作
- 后台执行线:真正执行命令 → 写入通知 → 写磁盘文件
- 下一轮:drain_notifications → 摘要注入 → 模型收到结果
验证你的理解
Q1:为什么”后台是另一条主循环”是错误理解?
:::details 答案 主循环必须保持单线程,否则状态管理混乱。
如果多条主循环:
- 多个 messages[] → 状态不一致
- 多个 tool_use → 权限检查混乱
- 多个 stop_reason → 循环控制失控
正确设计:主循环单线程,后台是另一条执行线。并行的是等待时间,不是主循环本身。 :::
二、数据结构:任务表 vs 通知队列
后台任务至少需要”任务表 + 通知队列”两块状态。职责分离。
RuntimeTaskRecord:执行状态管理
task = { "id": "a1b2c3d4", "command": "pytest", "status": "running", # running/completed/failed/timeout "started_at": 1710000000.0, "result_preview": "", "output_file": "",}职责:管执行状态(谁在跑、跑到哪)。
| 字段 | 用途 |
|---|---|
| id | task_id,唯一标识 |
| command | 执行的命令 |
| status | 当前状态(running/completed/failed/timeout) |
| started_at | 启动时间 |
| result_preview | 摘要(不超过500字) |
| output_file | 完整输出文件路径 |
Notification:结果投递通道
notification = { "type": "background_completed", "task_id": "a1b2c3d4", "status": "completed", "preview": "tests passed", # 只放摘要,不放全文}职责:管结果投递(完成了、通知主循环)。
| 字段 | 用途 |
|---|---|
| type | 通知类型(background_completed) |
| task_id | 对应任务ID |
| status | 最终状态 |
| preview | 摘要(不超过500字) |
职责分离原则
| 状态块 | 职责 | 存储位置 |
|---|---|---|
| tasks(任务表) | 执行状态管理 | 内存 + 磁盘 |
| notifications(通知队列) | 结果投递通道 | 内存队列 |
为什么分离?
| 不分离的问题 | 分离的好处 |
|---|---|
| 任务状态和通知混在一起 → 状态混乱 | 执行状态独立管理 → 清晰 |
| drain 需要遍历所有任务 → 效率低 | drain 只遍历通知 → 高效 |
| 任务持久化包含通知 → 冗余 | 任务持久化不含通知 → 简洁 |
验证你的理解
Q2:为什么 notifications 只放 preview,不放完整输出?
:::details 答案 防止上下文撑爆 + 防止注意力稀释。
如果通知放完整输出:
- pytest 可能输出数千行 → messages[] 撑爆
- 模型注意力被稀释 → 核心任务权重下降
- 多个后台任务完成 → 上下文爆炸
正确设计:通知只放 preview(500字摘要),完整输出写磁盘文件。模型需要全文时用 read_file 读取。 :::
三、通知机制:drain + 摘要注入
后台任务完成后,通知队列有消息——下一轮主循环需要”排空”通知。
drain_notifications 流程
流程:
- drain_notifications 排空通知队列
- 把通知摘要 append 到 messages (user role)
- 再调用模型
- 模型收到后台结果
摘要注入位置
# 主循环入口def agent_loop(): # 1. 先 drain_notifications 排空通知队列 notifications = drain_notifications() for notif in notifications: # 2. 把通知摘要 append 到 messages (user role) messages.append({ "role": "user", "content": f"Background task {notif['task_id']} completed: {notif['preview']}" })
# 3. 再调用模型 response = call_llm(messages)关键点:通知注入在调用模型前——模型本轮能看到后台结果。
验证你的理解
Q3:为什么通知注入在调用模型前,而不是调用后?
:::details 答案 模型本轮能看到后台结果,不需要等到下一轮。
如果注入在调用模型后:
- 本轮模型看不到后台结果
- 用户问”后台任务跑完了吗” → 模型不知道
- 需要下一轮才能看到 → 响应延迟
正确设计:drain 在调用模型前 → 模型本轮能看到结果 → 实时响应。 :::
四、最小工具:background_run + background_check
后台任务需要两个工具:发起后台任务 + 查询任务状态。
background_run:发起后台任务
def background_run(command: str) -> str: # 1. 登记 RuntimeTaskRecord task_id = generate_task_id() task = { "id": task_id, "command": command, "status": "running", "started_at": time.time(), } tasks[task_id] = task
# 2. 写磁盘持久化 save_task_to_disk(task)
# 3. 启动后台线程执行 start_background_thread(command, task_id)
# 4. 立刻返回 task_id return task_id关键设计:立刻返回 task_id,不是同步卡住。
background_check:主动查询状态
def background_check(task_id: str) -> dict: task = tasks.get(task_id) if not task: return {"error": "Task not found"}
return { "status": task["status"], "preview": task["result_preview"], "output_file": task["output_file"], }用途:模型主动查询任务状态(notification 已 drain 或未 drain)。
| 场景 | 用什么 |
|---|---|
| notification 已 drain | 模型已看到结果 → 不需要 background_check |
| notification 未 drain | 模型调用 background_check 查询 |
验证你的理解
Q4:background_run 为什么立刻返回 task_id,而不是等待执行完成?
:::details 答案 主循环不阻塞,继续推进别的工作。
如果等待执行完成:
- pytest 可能跑10分钟 → 主循环阻塞10分钟
- 模型在等待期间什么都做不了
- 用户被整轮流程堵住
正确设计:background_run 立刻返回 task_id → 主循环继续 → 后台独立执行 → 下一轮看到结果。 :::
五、回顾:L13 在循环上叠加了什么
回到开头的问题:如何在保持 Agent 主循环单线程的前提下,让慢命令不阻塞工作流?
答案:
| 叠加机制 | 代码位置 | 循环的变化 |
|---|---|---|
| 后台执行线 | background_run 工具 | 不变——只是返回 task_id |
| 通知队列 | drain_notifications | 不变——只是 messages 增加 |
| 任务表分离 | RuntimeTaskRecord | 不变——只是状态管理 |
循环本身仍是 L01 的 while True + stop_reason + messages[]。L13 只是在”执行层”叠加了后台机制。
与后续课程的关系
| 课程 | 关系 |
|---|---|
| L11 Error Recovery | 后台任务失败 → 通知队列写入失败状态 → drain 注入失败消息 |
| L12 Task System | task(工作板)vs background task(运行作业)——职责分离 |
| L14 Cron Scheduler | 定时任务完成后同样写入通知队列 |
核心洞察:Background Tasks 是”等待不阻塞”的机制。理解 L13,就理解了 Agent 如何”主循环单线程 + 后台并行等待”。后续课程都在扩展后台机制——定时任务、任务系统。
六、心智模型升级:从 L0 到 L2
读完这篇文章,你的理解经历了怎样的升级?
L13 核心概念的理解升级路径
| 概念 | L0(表面) | L1(关联) | L2(深层) |
|---|---|---|---|
| 核心命题 | ”后台任务" | "主循环单线程 + 后台执行线" | "主循环只有一条,并行的是等待,不是主循环本身。类比 TCP 连接:发送方继续发数据,不等 ACK 回来——滑动窗口机制” |
| 任务表分离 | ”两个数据结构" | "RuntimeTaskRecord + Notification" | "职责分离——任务表管执行状态(谁在跑),通知队列管结果投递(完成了)。类比 Git:index(暂存区)vs HEAD(提交历史)——职责分离” |
| 通知机制 | ”后台完成后告诉主循环" | "drain_notifications + 摘要注入" | "通知只放 preview——防止上下文撑爆 + 防止注意力稀释。类比消息队列:只放事件摘要,不放完整数据。模型需要全文时 read_file” |
| 后台工具 | ”background_run 和 background_check" | "发起任务 + 查询状态" | "background_run 立刻返回 task_id——不是同步卡住。类比异步 API:Promise 立刻返回,await 等结果。主循环继续推进,不阻塞” |
如何检验你达到了 L2?
用这个问题自测:
如果通知队列放完整输出(数千行 pytest 日志),会发生什么?完整链条是什么?
:::details L2 层级回答 上下文撑爆 + 注意力稀释 + 多任务累积爆炸。
完整链条:
- pytest 输出数千行日志
- notification 包含完整输出
- drain 注入 messages → messages[] 撑爆(数万 tokens)
- Transformer 注意力权重归一化 → pytest 日志权重累加
- 核心任务权重被稀释 → 输出可能跑偏
- 多个后台任务完成 → 通知队列累积 → messages[] 更大
- 触发 Context Compact → 摘要生成 → 丢失推理细节
正确设计:通知只放 preview(500字摘要),完整输出写磁盘文件。模型需要全文时 read_file 读取。 :::
本章目标:让每个核心概念都从 L0 升级到 L2。理解 L13,不只是记住任务表和通知队列,而是掌握”如何在单线程主循环上并行等待”的设计原则。
七、复习可视化
主循环与后台执行线的关系
时间线与并行关系
数据流向
前台 vs 后台对比
Task vs Background Task 边界
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!