---
title: 目标模式
description: 给 Agent 一个目标而不是一条消息——系统在同一 Session 上循环驱动 Task，直到目标完成、受阻或 token 预算耗尽。
---

## 是什么

普通 Task 在模型不再调用工具、给出回复时就结束了。目标模式反转了这个契约：你给出一个**目标（objective）**，系统在同一个 Session 上持续驱动 Task——每一轮重新注入目标并检查控制文件——直到目标进入终态。模型不能靠"不说话"来停下：它必须通过下述协议**声明**完成（或真正的僵局），否则循环继续。

三个入口都能发起目标：

| 入口 | 用法 |
| --- | --- |
| Web App | 输入框的 `+` 菜单 →「目标模式」（或输入 `/goal`）；chip 上可填 token 预算（`500k`、`2m`，留空不限）。输入框选中的技能以 `[use_skills]` 块前缀在第一轮消息上，与普通发送完全一致 |
| CLI chat | `/goal[:<预算>] <目标>`，例如 `/goal:500k 让所有测试通过` |
| CLI 单次运行 | `penguin run --goal [预算] -m "<目标>"`；仅目标完成时退出码为 0 |
| Server API | `POST /api/sessions/:id/tasks`，body 带 `{ input, goal: { budget } }`（budget 为 `-1` 或缺省 = 不限额） |

在 SDK 中，目标模式是唯一入口 `run` 的一个选项——`session.run(input, { goal: { budget } })`——而不是独立 API：输入文本即目标，轮次在这一次调用内部循环，流的最后一条消息是携带结局的 `goal_finished` 事件。

## 控制文件：GOAL.yaml

循环的状态通道是一个文件，位于 `<agent_dir>/scratchpad/<session_id>/GOAL.yaml`（与模型的 `PLAN.md` 约定同级），目标启动时由系统创建：

```yaml
objective: 让所有测试通过
status: active
```

系统对这个文件**只写一次**（创建时），此后只读 `status`：

| 字段 | 写入方 | 说明 |
| --- | --- | --- |
| `objective` | 系统，创建时 | 正典值在循环内存里、每轮协议块中重申——文件被改动也不影响任何行为 |
| `status` | 模型 | 只允许改为 `complete` 或 `blocked`——模型回传循环的信箱，每轮结束后被读取 |

预算数字随每轮的 `[goal]` 块给出，不在文件里；系统侧终局（`budget_limited`、`aborted`）只存在于 `goal_finished` 事件与服务端状态——文件永远保持模型自己最后写下的样子，这正是中断目标想要的断点。读取是容错的：文件缺失、YAML 解析失败、协议外的 status 一律归一化为 `blocked`——控制通道坏了就停下循环，而不是无限空转。

## 循环

每一轮的 user 消息是一个 `[goal]` 协议块加纯文本正文——第一轮原样携带你的原始消息（含技能调用块等前缀）；后续轮重新注入目标文本。Web App 把协议块折叠为普通用户气泡下方的「目标 · 第 N 轮」提示；Trace 中原样保留。协议块内嵌 `GOAL.yaml` 的内容（模型看到的就是它要编辑的那个文件，按创建时的同一份值组合）、自带一行当前预算数字，并附工作规则——声明完成前必须基于证据逐项核验、不许把目标缩水成更容易的子集、关键进展写入 `PLAN.md` 以跨越上下文压缩。Task 结束后系统读取 `status`：

- `complete` → 目标完成，循环停止。
- `blocked` → 循环停止；模型缺什么写在它最后一条回复里。注入规则要求**同一阻塞条件持续三个连续轮次**后才允许声明 `blocked`，临时性障碍不会终结目标。
- `active` → 预算允许则进入下一轮。

### 目标里的图片

目标可以附带图片——「把页面改成这张设计稿的样子」本身就是一个目标，而一张截图比一段描述说得清楚。图片一律写入会话 scratchpad，在目标文本里以 `[attached image: <路径>]` 行引用，**与模型是否支持视觉无关**：目标每轮都作为协议块的文本被重新注入，图片没法以图片的形态跟着走。只在第一轮发，后续每一轮的目标就指向了一个早已被压缩掉的东西，而目标文本读起来却毫无破绽。作为路径，它跨越每一轮、每一次压缩都稳定存在，而模型只在真的要看的时候才付出 token（有视觉用 `read_image`，没有则 `describe_image`）。图片不能替代文字——一张图说明不了目标，所以没有文字的目标输入会被拒绝。

聊天页在第一轮气泡下方完整展示附图，后续轮次收成一行 chip（点击展开）：它确实在每一轮的输入里，但二十轮的目标不该把同一张图重复二十次。

某一轮以中断结束（用户停止、LLM 故障）时整个目标随之结束、不再续推——磁盘上的状态保持 `active`，工作区与目标文件就是干净的断点。Web App 中常规停止按钮即中止整个循环；CLI 中是 Ctrl-C。被单 Task 轮次上限（`max_turns`）掐断的轮同理：模型没来得及写目标文件，循环以 `aborted` 结束，而不是永远重演同一次掐断。

## Token 预算

计数是增量制——**非缓存 input + output**（`request.total − cache_read`），对每一轮的每个请求累加，*包括 `run_subagent` 派生的子 Session*。`used` 从 0 开始。这个累加值是**花费的估算而非账单**：缓存读取并非免费，只是单价远低于非缓存 input，忽略它既不失真，也免去了依赖各模型价目表。

预算在轮与轮之间检查。耗尽时不会把模型拦腰斩断：系统注入最后一个收尾轮——总结进展、列出剩余工作、给出明确的下一步，并且不许因为钱花完了就标 `complete`——之后系统以 `budget_limited` 终局（`goal_finished` 事件；不写文件）。正因为只在轮间检查，进行中的一轮不会被截断：实际花费最多可超出预算一轮，外加收尾轮。未设预算时循环一直跑到 `complete` 或 `blocked`——边界是模型对两个终态的诚实，外加 100 轮的硬性兜底上限，防止一个从不写目标文件的模型无限循环。

## 服务端状态与事件

Web 服务端把每次目标运行记入 `goal_state` 表（objective、status、budget、used、rounds）——聊天页的目标 banner 加载时从最新一行恢复，实时进度通过会话 SSE 通道的 `goal_started` / `goal_round` / `goal_finished` 事件到达。系统侧终态（`aborted`、`budget_limited`）仅存在于表与流事件中；磁盘文件保持模型最后写下的内容以便续跑。删除 Session 会连同 scratchpad（包括 `GOAL.yaml`）一起清除其目标记录。
