这次要学习的,不是 Agent 怎样发出一次模型请求,而是请求返回以后发生什么:为什么有时还要继续一个 step,为什么有时结束 turn,模型重复调用工具时谁来干预,以及像 Docker build 这样的长任务为什么不适合一直占住前台。
把这些问题连起来,就能看见 Agent 的另一半:模型负责提出下一步,Harness 负责维护生命周期、执行边界和失控防护。
小炎: 模型已经回答了,Agent 不就应该结束了吗?为什么还需要 Harness 判断?
大老师: 因为模型不会返回一个可信的“整个任务已完成”布尔值。它返回的是文字、tool call、达到 token 上限或者错误等协议结果。Harness 必须结合工具执行结果和 Inbox 状态,判断接下来该继续还是停止。
模型决定:这一拍说什么、调用什么
工具决定:真实世界发生了什么
插件决定:是否拦截、重试或追加输入
Agent loop 决定:继续 step、结束 turn,还是进入 idle
所以 Agent 不是“让模型一直想,直到它自己满意”,而是由宿主推进的一台状态机。
小炎: step、turn 和 idle 听起来都像结束,它们到底差在哪里?
大老师: 它们是三个不同层级。
| 层级 | 什么时候结束 |
|---|---|
step | 一次模型流及其触发的这一批工具调用已经处理完 |
turn | 当前 step 得到终止性结果,并且没有新的 next-step 工作 |
Agent 进入 idle | turn 已结束,而且整个 Inbox 都没有待处理工作 |
因此:
step 结束 ≠ turn 结束
turn 结束 ≠ Agent 已经空闲
例如模型提出 shell tool call 后,这一次模型响应已经结束,但 turn 不能结束。Harness 还要执行 shell,再发起下一个 step,让模型看到真实结果。
一个 turn 最终会记录明确原因,例如:
completed:控制流正常收敛;max-tokens:至少一个 step 达到输出 token 上限;blocked:agent/pre-step拒绝进入;aborted:活动被取消;error:错误结束;interrupted:持久层恢复时发现进程崩溃留下的未闭合 turn。
小炎: 那 Inbox 为什么还要分成 next-step 和 next-turn?一个消息队列不行吗?
大老师: 因为“补充当前工作”和“另开一个用户回合”不是同一种调度语义。
next-step
→ 尽量进入当前 turn 的下一个模型步骤
next-turn
→ 等当前 turn 结束,再单独开始新 turn
三个入口的区别很具体:
| 方法 | 写入位置 | 会不会唤醒 idle Agent |
|---|---|---|
followup() | next-turn | 会 |
steer() | next-step | 会 |
inject() | next-step | 不会 |
新的用户问题通常应该用 followup();需要尽快影响当前工作的补充信息适合 steer();只想把上下文放进下一步、但不值得单独启动模型的通知可以用 inject()。
这也是后台 Subagent 或 job 结果能够自然回到父 Agent 的原因:它们不是偷偷修改模型记忆,而是进入 Inbox,等待一个明确的 step 边界。
小炎: 用“列出文件”这个例子,Agent 到底是怎么从继续变成结束的?
大老师: 可以沿着状态变化看:
followup("列出文件")
↓
turn 1 / step 1
↓ 模型返回 bash tool call
Harness 执行 bash,记录 tool/call 与 tool/result
↓ 普通 tool call 表示模型还要观察结果
turn 1 / step 2
↓ 模型只返回最终文字,不再调用工具
step 得到 completed
↓ next-step 为空,turn-stopping 也没有追加工作
turn/end(completed)
↓ Inbox 为空
Agent idle
源码中的核心分支可以压缩成:
没有 tool call → completed
普通 tool call → 执行工具,然后继续下一 step
工具声明 concludesTurn → 没有新输入时 completed
请求策略要求 retry → 仍在当前 step 重新尝试
重试不算新 turn,因为它仍然是在取得这一次模型步骤的有效结果。只有不再重试的错误,才会让 turn 以 error 结束。
小炎: completed 是不是说明用户交代的任务已经正确完成了?
大老师: 不是。它只说明控制流已经收敛。
模型没有普通 tool call 需要继续
+ next-step 没有新输入
+ 插件没有延长当前 turn
= completed
它不能证明文件改对了、测试通过了,也不能证明模型没有漏掉步骤。业务正确性要由测试、类型检查、验证工具、Workflow 验收条件或 reviewer Agent 提供。
这是一个很重要的职责分离:
Agent loop 负责“流程有没有闭合”
验证机制负责“结果是否符合目标”
把二者混在一起,就会误以为模型停止说话等于工程任务已经可靠完成。
小炎: 如果模型一直调用工具,Agent loop 岂不是可以永远跑下去?
大老师: 核心 loop 确实会继续处理普通 tool call,所以 Harness 在外围放了多层防护,而不是指望模型天然有自制力。
| 层次 | 处理的问题 | 强制程度 |
|---|---|---|
| Repeat reminder | 模型没意识到自己正在重复 | 只提醒 |
tools/pre-execute / tool guard | 某次调用是否允许 | 可以拒绝 |
| Timeout policy | 单次工具是否运行过久 | 协作式截止时间 |
| Agent cancel | 整个活动是否停止 | 中止当前活动 |
| OS sandbox | 进程实际上能访问什么 | 操作系统强制 |
这些层不能互相替代。重复提醒不是权限控制;工具超时不等于取消整个 Agent;应用层 Guard 也没有操作系统沙箱那样的最终强制力。
其中 cancel() 面向的是当前整个 Agent 活动:它会中止模型流、工具执行和 step/turn 边界,默认还会清空 Inbox;调用方只有显式选择 keepInbox,才会把待处理消息留给后续运行。它比单工具超时范围更大,但下游若完全忽略取消信号,也不意味着所有工作都能在同一瞬间消失。
小炎: 重复调用到底是按一个 turn 统计,还是按整个 Session 统计?它会保存所有历史吗?
大老师: 都不完全准确。当前实现按存活的 Agent 对象维护,并且每个 Agent 只保存最后一条受跟踪调用及其连续次数:
const chains = new WeakMap<Agent, Chain>()
interface Chain {
key: string
count: number
}
key 由工具名和规范化参数组成:
key = JSON.stringify([toolName, canonicalArguments])
它不是一份历史 list,也不是 Map<每种调用, 总次数>。空间只随存活 Agent 数量增长,不随调用历史增长。
grep X → { key: grep-X, count: 1 }
grep X → { key: grep-X, count: 2 }
grep Y → { key: grep-Y, count: 1 }
grep X → { key: grep-X, count: 1 }
链可以跨 step,也可以跨 turn;turn/end 本身不会清零。但是新的真实用户消息会重置对应 Agent,因为上下文已经发生实质变化。父 Agent 与 Subagent 各自计数;compaction 不重置当前内存链;Session 关闭后重新恢复会创建新 Agent,因此计数不会持久化回来。
默认在连续第 3、5、8 次时注入逐级增强的提醒。被排除的记录类工具可以对链保持透明,所以:
grep X → todo_write(excluded)→ grep X
最后一次仍算连续第 2 次 grep X。
小炎: 既然检测到了重复,为什么不在第三次直接禁止?
大老师: 因为重复不必然等于失控。轮询异步任务、确认部署状态和有限次数幂等重试,都可能合理地使用相同参数。
所以 repeat reminder 位于 tools/post-execute:工具结果照常记录,它再追加一条来源清楚的 plugin context,提醒模型阅读上次结果、修改参数、换方案或者结束任务。它不改写结果,也不否决调用。
这带来明确边界:
- 它只检测规范化后的精确重复,不理解语义相似;
- 模型可以无视提醒;
- 超过最高阈值后不会每次都继续提醒;
- 被权限拒绝的重复调用也会计数,因为反复撞同一条规则同样值得纠正。
真正需要强制拒绝时,应使用 tools/pre-execute 的 deny 或 ctx.tools.guard()。后者是单调拒绝:任何一个 Guard 拒绝后,后面的 Guard 不能把它改回允许。
小炎: 工具超时怎么配置?读文件和构建镜像显然不能共用同一个时间。
大老师: 正因为合理时长不同,通用 timeout-policy 不拥有一个全局数字。预算由每个工具的 ToolDefinition.timeoutMs 声明,policy 只负责执行:
defineTool({
name: 'web_fetch',
timeoutMs: 10_000,
})
不同工具可以声明不同预算;长任务可以配置更长时间;没有合适统一上限的工具也可以不声明。若同一个工具内部存在快慢不同的操作,可以由工具自己按操作管理,拆成不同工具,或者把长工作设计成后台 job。
这里的超时是协作式的:deadline 到达时,Harness 通过 AbortSignal 通知工具停止。只有工具及其 provider 真正响应这个 signal,调用才能收束。忽略 signal 的实现不会被 JavaScript 凭空硬杀死。
Harness 也不会简单使用 Promise.race(tool, timer),因为那可能让模型看到“已超时”,而后台工具稍后仍完成写入,形成两套互相冲突的现实。正确做法是通知取消、等待工具真正静止,再返回结构化超时结果。
小炎: 那我运行 docker build,是不是五分钟后一定超时?
大老师: 不一定。bash 走自己的 shell executor 超时,而不是刚才的通用 tool timeout-policy。具体时长由当前组合决定。
| 方式 | 当前典型行为 |
|---|---|
bash-local 前台默认 | 2 分钟,但 profile 可以覆盖;有的组合配置为 1 分钟 |
调用显式传 timeoutMs | 使用请求值,但会受 executor 上限约束;local 默认上限为 10 分钟 |
tool-bash-persistent | 默认单条命令 5 分钟 |
run_in_background: true | 不应用前台 executor timeout,由 job 生命周期管理 |
因此五分钟可能来自 persistent bash 的默认值,也可能是调用显式传入 300000ms,但它不是所有 Docker build 的全局规则。
后台模式“不应用前台 executor timeout”也不代表任务永生:job_kill、owner dispose、服务 teardown、上层取消或进程自身退出仍然可以结束它。
前台执行的时间线是:
LLM 返回 bash tool call
↓
docker build 运行,当前 step 等待
↓
命令完成,或者到期后进程组被收束
↓
stdout/stderr 与退出、超时事实形成 tool result
↓
下一次 LLM 请求才开始
Docker 每输出一行进度,并不会自动触发一次新的模型请求。模型要等这个前台工具调用真正返回,才能重新获得控制权。
小炎: 如果构建时间无法预测,Harness 会不会自动读取进度,再让模型决定是否继续等?
大老师: 不会自动轮询。更合适的做法是把构建放进后台:
bash({
command: "docker build -t my-app .",
run_in_background: true
})
↓
started background job bash-1
启动调用很快返回 job id,模型随即可以继续处理其他工作。需要查看时,它再调用:
job_output(bash-1) # 非阻塞读取下一段增量
job_output(bash-1, wait: true, 30000) # 在一次工具调用中有界等待
job_kill(bash-1) # 请求停止
job_output(wait: true) 等待期间也不会反复调用模型。它仍然是一个工具调用,只有返回后才进入下一次 LLM 请求。
任务最终完成时,tool-jobs 会发送 completion notification:所有者正忙时放入 next-step,所有者空闲时默认用 follow-up 唤醒。通知只说明任务已结束,并指导模型调用 job_output 收集最终结果,不会把整个日志强塞进通知。
空闲唤醒还有连续次数上限,防止“后台任务完成 → 唤醒模型 → 模型又启动任务 → 再次唤醒”形成自激循环。系统的目标不是自动忙轮询,而是让真正的状态变化重新获得一次决策机会。
小炎: 所以长任务的本质,不只是把 timeout 调大?
大老师: 对。真正要分开的,是两种完全不同的时间:
后台工作实际需要运行多久
≠
模型的一次工具调用应该阻塞多久
前台适合有界、很快能得到完整结果的操作;后台 job 适合构建、部署、测试矩阵等长任务:
启动后台工作
→ 返回 job id
→ 模型继续独立任务,或者进入 idle
→ 状态真正变化时收到完成通知
→ job_output 收集最终结果
→ 测试或其它验证判断任务是否正确
这样既不会用频繁模型请求轮询进度,也不会让一个无法预测时长的进程长期占住 Agent step。
总结:控制循环,也要保留恢复行动的空间
DeepSeek Harness 对 Agent 生命周期的控制,可以归纳为五件事:
- Agent loop 根据模型结果和 Inbox 状态推进
step → turn → idle。 completed只表示控制流收敛,不代表结果正确。- Repeat reminder 负责软纠偏,调用前 Guard 负责强制拒绝,两者不能混用。
- 工具 timeout 限制一次执行,Agent cancel 停止整个活动,OS sandbox 限制最终系统能力。
- 长任务应尽量变成后台 job,用句柄、增量读取和完成通知重新连接模型决策。
这套设计背后有一条可以迁移到其它 Agent 系统的原则:
越接近模型的机制,越理解任务语义,但越依赖模型配合;越接近执行环境的机制,强制力越高,却越不知道用户真正想完成什么。
好的 Harness 不会只放一个“停止按钮”,而是把提醒、拒绝、超时、取消、后台化和系统权限放在各自最合适的位置,再用 Session 与 Inbox 把它们连成一条可恢复、可解释的控制链。