隐秘的角落,Harness 的工具边界在哪里?

0 阅读12分钟

image.png

这次要学习的,不是 Agent 怎样发出一次模型请求,而是请求返回以后发生什么:为什么有时还要继续一个 step,为什么有时结束 turn,模型重复调用工具时谁来干预,以及像 Docker build 这样的长任务为什么不适合一直占住前台。

把这些问题连起来,就能看见 Agent 的另一半:模型负责提出下一步,Harness 负责维护生命周期、执行边界和失控防护。


小炎: 模型已经回答了,Agent 不就应该结束了吗?为什么还需要 Harness 判断?

大老师: 因为模型不会返回一个可信的“整个任务已完成”布尔值。它返回的是文字、tool call、达到 token 上限或者错误等协议结果。Harness 必须结合工具执行结果和 Inbox 状态,判断接下来该继续还是停止。

模型决定:这一拍说什么、调用什么
工具决定:真实世界发生了什么
插件决定:是否拦截、重试或追加输入
Agent loop 决定:继续 step、结束 turn,还是进入 idle

所以 Agent 不是“让模型一直想,直到它自己满意”,而是由宿主推进的一台状态机。


小炎: stepturnidle 听起来都像结束,它们到底差在哪里?

大老师: 它们是三个不同层级。

层级什么时候结束
step一次模型流及其触发的这一批工具调用已经处理完
turn当前 step 得到终止性结果,并且没有新的 next-step 工作
Agent 进入 idleturn 已结束,而且整个 Inbox 都没有待处理工作

因此:

step 结束 ≠ turn 结束
turn 结束 ≠ Agent 已经空闲

例如模型提出 shell tool call 后,这一次模型响应已经结束,但 turn 不能结束。Harness 还要执行 shell,再发起下一个 step,让模型看到真实结果。

一个 turn 最终会记录明确原因,例如:

  • completed:控制流正常收敛;
  • max-tokens:至少一个 step 达到输出 token 上限;
  • blockedagent/pre-step 拒绝进入;
  • aborted:活动被取消;
  • error:错误结束;
  • interrupted:持久层恢复时发现进程崩溃留下的未闭合 turn。

小炎: 那 Inbox 为什么还要分成 next-stepnext-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-executedenyctx.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 生命周期的控制,可以归纳为五件事:

  1. Agent loop 根据模型结果和 Inbox 状态推进 step → turn → idle
  2. completed 只表示控制流收敛,不代表结果正确。
  3. Repeat reminder 负责软纠偏,调用前 Guard 负责强制拒绝,两者不能混用。
  4. 工具 timeout 限制一次执行,Agent cancel 停止整个活动,OS sandbox 限制最终系统能力。
  5. 长任务应尽量变成后台 job,用句柄、增量读取和完成通知重新连接模型决策。

这套设计背后有一条可以迁移到其它 Agent 系统的原则:

越接近模型的机制,越理解任务语义,但越依赖模型配合;越接近执行环境的机制,强制力越高,却越不知道用户真正想完成什么。

好的 Harness 不会只放一个“停止按钮”,而是把提醒、拒绝、超时、取消、后台化和系统权限放在各自最合适的位置,再用 Session 与 Inbox 把它们连成一条可恢复、可解释的控制链。