
一些注意点:
- S13 没有新增独立工具,只是在
bash上增加run_in_background。 - 是否后台执行,优先交给模型判断。
- 后台启动后,HarnessX 先回填一条
role: "tool"占位结果,把bg_0001交给模型。 - 后台完成后,HarnessX 在下一轮请求模型前,把完成结果注入成新的
role: "user"消息。 - 注入内容用
<task_notification>包住,里面放同一个task_id、状态、命令和输出摘要。 - 模型能感知
bg_0001,但只能通过messages感知,不能直接读取后台任务表。 bg_0001只保证当前 Node.js 进程内唯一,不做跨进程恢复。- 输出太长时写入
.task_outputs/background/,通知里只放预览和文件路径。
重点
第 13 课只改变 bash 的执行方式:
- 快命令继续前台执行。
- 慢命令可以设置
run_in_background=true,放到后台执行。 - Agent 立即拿到任务 ID,继续处理其他工作。
- 后台完成后,通过
<task_notification>把结果送回 Agent Loop。
用洗衣机理解等待,用号码牌理解通知
后台任务很像洗衣机。
把衣服放进去,按下启动后,你可以先去做饭、回消息、看论文。洗完以后,洗衣机再“滴滴滴”通知你。你不会站在洗衣机前干等 30 分钟。
Agent 的 bash 工具也一样:
read_file、git status通常很快,适合在前台等待结果。pip install torch、npm run build可能需要几分钟,适合放到后台。- 慢命令运行期间,Agent 可以继续读取文件、分析代码或执行其他工具。
- 命令完成后,再把结果通知 Agent。
这不是让 LLM 在后台继续生成 token,而是避免 Agent Loop 卡在一次慢工具调用上。
再从运行机制看,慢命令也像去窗口办理一项耗时业务。
同步执行时,你把材料交给窗口后一直站着等。
后台执行时,你先拿到号码牌。窗口继续处理,你可以先去办别的事;处理完成后,系统再叫号。
在 Node.js 里,“后台”不等于开一个新的 JavaScript 线程。
当前实现用 exec 启动子进程,但 Agent Loop 不 await 它。子进程继续跑,主循环马上拿到 bg_0001 往下走。
这层放在 Agent 架构的哪里
后台任务位于“权限门之后、真正执行 bash 的位置”。
各层职责很清楚:
- DeepSeek:
- 通过工具参数表达“这条命令可以后台跑”。
- 权限门:
- 仍然先判断命令能不能执行。
- 后台任务表:
- 记录进程内的任务生命周期和结果。
- Agent Loop:
- 每轮请求模型前收集已完成任务。
- messages:
- 同时承载启动占位结果和后续完成通知。
后台任务表里保存的是一张运行时记录,不是一个可被模型直接调用的新工具。
哪些 bash 会进入后台
主路径是模型显式设置 run_in_background=true。
模型没传这个字段时,教学版再用慢命令关键词兜底。
false 必须强制前台。显式选择的优先级高于关键词猜测。
子代理暂时不开放后台执行。否则父 Agent 的通知队列和子代理的生命周期会混在一起,这不是当前课程要解决的问题。
bg_0001 怎么保证多个任务不会串
bg_0001 是当前 Node.js 进程里的后台任务主键。
每次启动后台命令时,计数器先加一,再生成 bg_0001、bg_0002、bg_0003。这个编号生成动作是同步完成的,所以同一个进程里不会有两个任务拿到相同 ID。
编号生成后,HarnessX 先把任务记录写进 backgroundTasks Map,再启动子进程。Map 的 key 是任务 ID,value 里保存这条任务自己的命令、状态、时间和输出。
即使命令 B 比命令 A 更早完成,结果也不会串:
- 命令 A 的回调通过闭包记着
bg_0001,只更新 Map 里的bg_0001。 - 命令 B 的回调通过闭包记着
bg_0002,只更新 Map 里的bg_0002。 - 通知里的
task_id、command、status、summary都来自同一条 Map 记录。 - 通知注入后删除对应 ID,避免同一结果被重复通知。
这个 ID 只保证单个 HarnessX 进程内唯一。重新运行 hx agent 后,计数器会从零开始,又可能出现 bg_0001。当前后台任务表也不会跨进程恢复,所以它不是数据库主键,也不承担全局唯一性。
模型也能感知 bg_0001,但它不能直接读取后台任务表。HarnessX 必须先把 ID 写进 messages,再通过下一次 HTTP 请求发给模型。
所以模型的感知分成两个时间点:
- 启动后:
- 模型从
role: "tool"的占位结果里看到bg_0001。
- 模型从
- 完成后:
- 模型从
role: "user"的<task_notification>里看到同一个 ID、最终状态和输出。
- 模型从
模型没有后台进程的实时视图。HarnessX 不发起下一轮模型请求,模型就不会知道状态发生了变化。
一次完整的一来一回
下面这张图同时画出模型请求、前台工作和后台完成。
这张图最重要的一段是:后台 sleep 还没完成时,模型已经可以发起 read_file。
发给 DeepSeek 的请求和返回
第一次请求里,HarnessX 仍然发送原来的工具列表,只是 bash schema 多了一个布尔参数。
{
"model": "deepseek-chat",
"messages": [
{
"role": "user",
"content": "后台执行 sleep 1 && echo background done,等待期间读取 README.md 前 20 行"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "bash",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string" },
"run_in_background": { "type": "boolean" }
},
"required": ["command"]
}
}
}
],
"tool_choice": "auto"
}DeepSeek 返回的是结构化工具调用,不会自己启动本机进程。
记住,是模型本身,来判断是否
run_in_background
{
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_bg_001",
"type": "function",
"function": {
"name": "bash",
"arguments": "{\"command\":\"sleep 1 && echo background done\",\"run_in_background\":true}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}HarnessX 启动子进程后,马上用原来的 tool_call_id 回填一次占位结果。
{
"role": "tool",
"tool_call_id": "call_bg_001",
"content": "[Background task bg_0001 started]\nCommand: sleep 1 && echo background done\nResult will be injected as <task_notification> when complete."
}后台完成后,HarnessX 追加一条新的 role: "user" 消息。
{
"role": "user",
"content": "<task_notification>\n <task_id>bg_0001</task_id>\n <status>completed</status>\n <command>sleep 1 && echo background done</command>\n <output_chars>15</output_chars>\n <summary>background done</summary>\n</task_notification>"
}这里的事实边界必须分清:
一个 tool_call 只对应一个 role: "tool" 结果。
后台完成发生在未来,它是一个独立事件,所以不能再冒充原始工具结果。当前实现把它放进新的用户侧消息,让模型在下一轮看到。
后台任务的状态怎么变化
后台任务表是进程内状态,不是第 12 课的持久化任务板。
边界也很明确:
- Agent 进程退出后,内存任务表不会恢复。
- 当前没有停止任务、查询任务、跨进程恢复。
- 最终回答出现但后台仍在跑时,主循环最多再等待 5 秒收通知。
- 这些限制是教学版边界,不在本课继续扩展。
输出太长时怎么处理
后台输出可能比通知本身大很多。全部塞进 messages 会迅速占满上下文。
这和第 8 课的原则一致:大结果留在文件里,上下文只保留继续工作需要的信息。
怎么跑
沿用第 0 课的 npm link 和 DeepSeek 配置。
# 慢命令必须进入后台
# 等待期间还要完成一次前台 read_file
# 最后必须根据后台通知总结,而不是只说“已经启动”
hx agent "启动后台 bash:sleep 2 && echo background done,并设置 run_in_background=true;等待期间调用 read_file 读取 README.md 前 20 行;收到 task_notification 后,总结后台任务的 status、command 和 summary。"观察这条时间线:
关键日志应当按这个顺序出现:
[BACKGROUND] started bg_0001: sleep 2 && echo background done
> read_file path=README.md limit=20
[BACKGROUND] completed bg_0001: sleep 2 && echo background done
[BACKGROUND] injected notifications=1判断跑通只看三件事:
- 先出现后台任务 ID。
- 后台运行期间出现前台文件读取。
- 最终回答读到了通知里的真实结果。
这一课真正要记住
- 工具数量:
- 参考教程保持 8 个,当前 HarnessX 保持 14 个;S13 都没有新增独立工具。
bg_0001:- 当前进程内唯一的号码牌,也是后台任务 Map 的 key。
- 占位工具结果:
- 立即完成原始工具调用的协议配对。
<task_notification>:- 后台完成后的独立事件。
- 后台任务表:
- 只在当前 Node.js 进程内保存生命周期。
# 第 13 课一句话
慢 bash 先拿号码牌,Agent 继续办别的事;叫号后,再把真实结果放回下一轮对话。源码
源码只保留第 13 课新增链路。先看图,再用 JS 伪码确认它落在什么位置。
源码大流程图
代码概览
工具层只多一个可选参数。
// 事实:还是 bash 工具,只增加执行策略参数
bash.parameters = {
command: "string",
run_in_background: "boolean?",
};主循环每轮先收通知,再请求模型。
while (还有轮次) {
messages += 收集已完成的后台通知();
回复 = await 请求模型(messages, tools);
messages += 回复;
messages += await 执行工具调用(回复);
}代码细分
工具分发先过权限门,再决定是否等待。
async function 执行工具调用(调用) {
if (权限拒绝(调用)) return "Permission denied.";
if (是后台bash(调用)) {
return 启动子进程并立即返回任务ID(调用);
}
return await 前台执行并等待结果(调用);
}完成结果作为新事件进入下一轮。
function 收集已完成的后台通知() {
return 后台任务表
.筛选("completed 或 failed")
.转成("<task_notification>")
.从任务表移除();
}