任务 Agent 循环:观察→决策→门控→执行,以及长任务的三道护栏
对话页那套工具调用循环是「一问一答」:模型输出 tool_calls → 执行 → 把结果喂回模型,直到它不再调工具。查知识库、抓网页这种短链路很合适,但「打开记事本 → 输入 → 保存」是多步、有状态的任务,它缺四样东西:
- 没有跨轮的任务状态:每轮 model call 之间没有「任务目标」的持久概念,模型可能中途忘了要干什么;
- 没有步数上限:模型可能死循环调用工具(比如反复抓同一个网页);
- 没有失败计数:同一个工具连续失败 3 次,模型还会继续试;
- 没有急停机制:用户无法中途终止任务。
我们的桌面 AI 应用为此单独写了一个顶层状态机(packages/core/src/agent/task-loop.ts)。它不是对话循环的扩展,而是和对话并列的新骨架。
循环骨架
┌────────────────────────────────────────────────────────────
│ TaskRunnerService.start(run)
│ (run 已在 POST /api/tasks 时创建,status=queued)
└──────────────────────┬─────────────────────────────────────
▼
┌──────────────────────────────────────────────────────────────────────────────
│ run_task_loop (AsyncGenerator<TaskLoopEvent, TaskRunView>)
│
│ 1. updateRunStatus(running)
│ 2. yield run_started
│
│ loop:
│ ┌─ 检查急停(signal.aborted / control.state==='stopped')
│ │ → finalize('stopped', 'user_stop')
│ ├─ 检查暂停(control.state==='paused')
│ │ → yield run_paused → waitIfPaused()
│ │
│ ├─ 观察:screen_snapshot(截图 + UIA 清单)
│ │ → repo.addStep(observe) + finishStep
│ │ → 失败则 consecutiveFailures++,检查上限
│ │
│ ├─ 决策:planner.decide({ goal, steps, observation })
│ │ → { action: 'tool'|'done'|'fail', tool, args }
│ │
│ ├─ action==='done' → finalize('completed')
│ ├─ action==='fail' → finalize('failed', 'error')
│ │
│ ├─ 工具动作:
│ │ ├─ resolveTool(toolName) + allowedTools 白名单
│ │ ├─ 熔断器检查(isTripped → 任务终止)
│ │ ├─ HITL 权限门控(gateToolPermission)
│ │ ├─ executeToolCall → result
│ │ └─ breakers.recordResult(toolName, result.ok)
│ │
│ └─ 检查上限:
│ ├─ run.stepCount >= run.maxSteps → 'stopped','max_steps'
│ └─ consecutiveFailures >= TASK_MAX_FAILURES → 'failed','max_failures'
└──────────────────────────────────────────────────────────────────────────────
循环是一个 AsyncGenerator,每一步 yield 一个 TaskLoopEvent(run_started / step_started / step_finished / run_paused / run_resumed / run_finished),web server 的 SSE /events 路由直接消费这些事件推给前端。
三道安全护栏
护栏一:步数上限 + 连续失败上限
// packages/shared/src/constants.ts
export const TASK_MAX_STEPS = 20; // 单任务最多 20 步
export const TASK_MAX_FAILURES = 3; // 连续失败 3 次终止
- 步数上限:防失控。20 步对「打开记事本写一句话保存」绰绰有余(约 5-8 步);对复杂任务可能不够,但 20 步是「安全优先」的取舍——用户随时可以启动新任务继续。
- 连续失败上限:防死循环。同一个工具连续失败 3 次(观察失败 / 工具执行失败 / 决策失败),任务自动终止。注意是连续失败,不是总失败数——中间有一步成功就清零。
护栏二:熔断器联动
既有的工具熔断器(tool-breaker.ts)状态机:closed → open(连续失败 3 次 trip)→ half-open(5 分钟冷却后放行一次)。
任务循环与熔断器联动:目标工具已熔断时,任务直接终止,而不是跳过该工具继续。因为核心工具(比如 screen_snapshot)一旦熔断,任务就无法继续观察——硬撑只会浪费步数。
if (deps.breakers?.isTripped(toolName)) {
repo.finishStep(actionStep.id, {
status: 'failed',
error: `工具 ${toolName} 已熔断,任务终止`,
});
const fin = finalize('failed', 'breaker');
yield fin.event;
return fin.run;
}
护栏三:急停热键 + AbortSignal
全局急停热键 Ctrl+Alt+Esc(desktop 主进程用 globalShortcut 注册)触发后:
- 主进程调 web server 的
POST /api/tasks/stop-all; TaskRunnerService.stopAll()遍历所有活跃 run,调control.stop();- 循环的
control.state === 'stopped'分支触发,finalize('stopped', 'user_stop'); - 同时清理所有
taskGrants(任务级授权)。
另外,SSE 客户端断线时 request.signal 自动 abort,循环检测到 signal.aborted 也以 user_stop 终态退出——不会出现「客户端关了但任务还在跑」的孤儿循环。
循环的生命周期归 SSE 连接所有
一个关键架构决策:循环的生命周期由 SSE /events 拥有,不是 POST 创建时启动。
POST /api/tasks
→ createRun({ status: 'queued' }) // 只创建,不启动循环
→ 201 返回 run
GET /api/tasks/:id/events (SSE)
→ run.status === 'queued'
→ resolveTaskLaunchArtifacts(planner, allowedTools, visionCapable)
→ taskRunner.start(run) → 消费 AsyncGenerator → sseResponse
→ run.status === 'running' (active)
→ 409 Conflict(同 runId 重复启动防护)
→ run.status === 终态
→ replayTerminalRun(回放已落库 steps + run_finished)
这样设计有三个好处:
- 客户端断线即终止:SSE 连接断 → request.signal abort → 循环 user_stop 退出,无需额外的心跳或租约机制;
- 重复启动防护:
taskRunner.start入口加active.has(runId)守卫,抛 409,防止 active map 被覆盖或产生孤儿生成器; - 终态可回放:刷新页面时,/events 对终态 run 不启动新循环,而是把已落库 steps 包成 step_finished 事件、追加 run_finished 后关闭——前端能看到完整时间线。
planner:循环的大脑
planner(packages/core/src/agent/llm-planner.ts)负责每一轮的决策:
- 输入:
{ goal, steps: TaskStepView[], observation: TaskObservation, allowedTools } - 输出:
{ action: 'tool'|'done'|'fail', tool?, args?, reason, message? } - temperature=0:决策需要确定性,不采样
- maxTokens=1024:控制决策输出长度
planner 的 system prompt 包含:任务目标、已执行步骤历史(含每步结果摘要,避免重复观察)、当前观察(截图摘要 + UIA 清单)、可用工具白名单、输出格式约束(JSON schema)。
visionCapable 校验放在 /events 启动前:resolveTaskLaunchArtifacts 检查模型 capabilities.includes('vision'),否则抛 422 引导用户切到 qwen2.5vl——没有视觉能力的模型不能跑桌面任务。
测试与验证
任务循环的测试(task-loop.test.ts 等 60+ 测)覆盖:
- 状态机:queued → running → completed/failed/stopped 的转换;
- 步数上限:maxSteps=1 时第一步后终止,stop_reason='max_steps';
- 失败上限:连续 3 次观察失败 → failed,stop_reason='max_failures';
- 熔断联动:工具已熔断 → 任务终止,stop_reason='breaker';
- 急停:control.stop() 后循环以 user_stop 退出;
- 暂停/继续:control.pause() → run_paused → control.resume() → run_resumed;
- 重复启动防护:同 runId 二次 start 抛 409。
收尾
任务 Agent 循环是「观察→决策→门控→执行→再观察」的状态机,用三道护栏(步数/失败双上限 + 熔断器联动 + 急停热键/AbortSignal)回答了「长任务怎么不失控」。核心架构决策是循环生命周期由 SSE /events 拥有——客户端断线即终止,无需额外心跳,且天然支持终态回放。
下一篇讲急停的完整链路:Ctrl+Alt+Esc 从主进程到 web server 再到循环终止的端到端流程,以及为什么「停止」比「暂停」难做得多。