生命不息,折腾不止。上一课解决了「上下文涨满」,这一课解决「活干到一半 Agent 失忆」:给你的 Agent 立一个跨会话、跨重启都不丢的目标,它就能自己一鼓作气续跑最多 256 轮。

一、目标不是「待办清单」,是「方向盘」

先纠正一个最常见的第一反应:很多人以为 dsh 里的 goal(目标) 就是让 Agent 记一张 todo 清单,跑到一半断了重来就行。不是。goal 是一个会话里的长期完成目标:目标文本、当前阶段(phase)、已经跑了多少轮、每一次改动的修订历史,全部写进 append-only 的会话日志里。所以会话恢复(resume)、会话分叉(fork)、甚至进程重启,目标都原封不动还在。

但这里有个最容易踩、也最关键的分工,务必先记住:

dsh-goal 只存状态,不调度工作。 它是「方向盘」,不是「发动机」。

真正让 Agent 一轮接一轮自动往下跑的,是另一个包 goal-round-driver(续跑驱动)。理解了「状态」和「调度」是两拨人各管一摊,整个 goal 体系就通了。整个体系一共四个包:

包 角色 一句话
dsh-goal 状态核心 存目标、管阶段、记历史,只存不跑
dsh-tool-goal 模型的手 让模型能读/建/改目标(get_goal 等工具)
dsh-command-goal 人的手 给你一个 /goal 命令直接操作
dsh-goal-round-driver 发动机 Agent 空闲时自动排下一轮,直到跑满/完成/被拦

一个会话最多同时有一个「当前目标」:没跑完的目标得先 edit / pause / resume / block / clear 掉,才能换下一个;只有已经 complete 的目标可以直接被新目标顶替。这保证了「长期目标」唯一的语义不会乱。

二、四个包怎么搭(composition)

目标这块是「一切皆插件」,所以装配方式就是往 composition 里加条目。以官方 README 的写法为准,四件套长这样:

1
2
3
4
5
6
7
8
9
10
11
12
- id: goal
name: '@deepseek-ai/dsh-goal'
config:
defaultMaxGoalRounds: 256
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
config:
blockedAfterConsecutiveRounds: 3
- id: command-goal
name: '@deepseek-ai/dsh-command-goal'
- id: goal-round-driver
name: '@deepseek-ai/dsh-goal-round-driver'

几个要点:

  • defaultMaxGoalRounds: 256 是默认轮次上限,只有「创建时没显式指定上限」的目标才用它;创建时自己带了值的,以自带为准。它必须是个正整数。
  • blockedAfterConsecutiveRounds: 3 是模型的阻塞阈值:同一个阻塞条件要连续 3 轮都出现,模型才允许报 blocked。
  • dsh-command-goal 注册 /goal 命令依赖命令系统 dsh-commands;dsh-goal 本身要依赖会话投影 dsh-session-projection。用官方 npx @deepseek-ai/dsh web 的默认组合时这些通常都在列,自己精装配时别忘了带上,否则 ctx.goals 可能挂不上。
  • 注意一个易错点:轮次上限写在 goal 定义里,阻塞阈值写在 tool-goal 里,别在 goal-round-driver 里再配一份——那一层本身没有任何配置项,重复值只会造成两头策略不一致。

三、人怎么用:/goal 命令(你的手)

挂上 dsh-command-goal 后,你就能在交互界面直接敲命令控制目标,不用绕模型。命令和它的直接输出只留在界面里,不会塞进模型请求(省钱、也不干扰模型)。

输入 结果
/goal 显示当前目标、阶段、轮次进度(x/上限)、是否 armed,以及下一步可用命令
/goal <目标内容> 创建并 arm 一个新目标;或顶替一个已完成的目标
/goal edit <目标内容> 改目标文本,不改阶段、不改激活状态
/goal pause 暂停并解除激活(disarm)
/goal resume 恢复 / 在会话恢复或分叉后重新 arm(受剩余轮次上限约束)
/goal clear 清除当前目标(历史仍保留在会话日志里)

这里有一个特别阴的语法坑,实测很容易中招:控制词 clear / pause / resume / edit 只有在「整段输入就这一个词」时才被认成命令。除此之外的任何非空后缀都会被当成目标文本。也就是说:

  • /goal pause → 暂停 ✅
  • /goal pause after verification → 会创建一个字面内容为「pause after verification」的目标 ❌(不是「暂停」)

所以控制命令后面千万别手贱加注释。edit 单独敲(不带参数)会直接返回一个脚本检查错误,等于提醒你「正文呢」。

另外,/goal 支持附件:创建或编辑时选中的图片/文件,会被合成一条普通用户消息,附上固定文案 Reference attachments for the goal objective.,后续目标轮次能当普通历史读到它——附件的状态由目标域保存,不是单独存。

四、模型怎么用:三个工具 + 乐观锁

模型那一侧由 dsh-tool-goal 提供三个工具,返回的都是一段紧凑 JSON:

1
{ "goal": null }

或者有目标时:

1
2
3
4
5
6
7
8
9
10
11
12
{
"goal": {
"id": "...",
"revision": 1,
"objective": "把仓库里所有失败的门禁修到全绿",
"phase": "active",
"roundsStarted": 0,
"maxGoalRounds": 256,
"blockedReason": null
},
"activation": "armed"
}

三个工具分别是:

  • get_goal() —— 读当前目标,没有就返回 null。
  • create_goal(objective, max_goal_rounds?) —— 从一条「顶层人类直接请求」里创建目标(可推断意图,任何语言都行)。
  • update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?) —— 对「精确的某个修订」执行 edit / pause / resume / complete / blocked。

这里有两个必须懂的机制:

第一,乐观锁(compare-and-set)。 每次 update_goal 都要带上 get_goal 拿到的精确 goal_id 和 revision。谁手里的状态旧了,谁就会收到一个清晰的「stale revision」错误,而不是悄无声息把别人的新进度覆盖掉。这就是为什么官方强调「先 get 再 update,抄下精确的 id 和 revision」。

第二,权限边界。 create / edit / pause / resume 这四类写操作,必须来自一个「顶层人类消息」(runtime-root agent 的当前轮)——子 agent 或非人类生产者建不了、也改不了目标。complete 和 blocked 才允许出现在自主目标轮里。而 resume 会拒绝一个持久化 paused 的目标(返回 GOAL_TOOL_RESUME_PAUSED),那种状态归用户侧的 /goal resume 管,模型插不了手。

顺带说一声:自主轮里成功报出 complete 或 blocked,那一轮会跟着结束物理 turn,模型会收到一条收尾指令,把最终话写给用户——所以模型不会「报完完成还继续瞎跑」。

五、发动机怎么转:goal-round-driver

这是整套里最「Agent」的部分。它没有配置项,触发续跑要同时满足三个条件:

  1. Agent 整机空闲;
  2. 目标处于 active 且 armed;
  3. 剩余轮次 > 0。

满足后它就自动排队一条 goal-round 提示,格式是一段文字:开头是 JSON 引号包围的目标内容 + round/maxGoalRounds,后面跟工作指令,让模型「以当前工作区、工具结果、持久化状态为权威」继续推进。每轮作为一个 goal 来源的用户消息进入历史。只有真正进入模型历史的那条 goal 消息才消耗轮次配额——人类消息、被判定为失效的占位都不会扣。

续跑会停在这几种情况:目标被 complete / pause / block;一轮在 max tokens 上截断;持久化写失败;agent 被 cancel;插件被卸载;轮次上限耗尽(这时会记录一个稳定码为 round-limit 的阻塞)。

全篇最重要、也最容易被忽略的一个安全设计在这:

会话恢复、分叉、或重新挂载驱动之后,一个 active 目标会自动 disarm,不会自己接着跑。

英文原文叫「an active goal is disarmed after any session-start edge」。也就是说,你半夜关掉进程,第二天早上用 resume 把它接回来,它不会一睁眼就自作主张继续 hammer 你的工具——必须你来一次显式 resume(或 /goal resume),它才重新 arm 接着干。这个「自觉停火」是刻意的:自动续跑的授权是进程内局部的,从不持久化,谁也别想靠「重放历史」把一个会话恢复成无人值守的续跑机。

还有个贴心点:cancel 操作也不会自动重来一轮——目标轮如果正在跑被 cancel,会在下一个空闲点停下来变 paused;跟目标无关的 cancel 只是 disarm,不会偷偷重排。

六、踩坑清单(都是实测/文档里明说的)

  1. 控制词要整段:/goal pause 等验证完 不是暂停,是建了个奇葩目标。
  2. 子 agent 建不了目标:create/edit/pause/resume 必须顶层人类消息发起,别指望子 agent 帮你立大旗。
  3. block 要「连续 3 轮同一条件」:难度大、不确定、还有活没干完,统统不算 block,别让模型早退。
  4. resume 分两种人:模型 update_goal resume 拒绝持久化 paused(GOAL_TOOL_RESUME_PAUSED),那种得你亲手 /goal resume。
  5. 256 只是「轮次」预算,不是钱包:它不计量 token、不计量美元、也不计量 provider 配额,别拿它当成本上限。
  6. 别在 goal-round-driver 里重复配轮次/阈值:两个值分属 goal 和 tool-goal,驱动层零配置,重复配会两头打架。
  7. attachments 不单独持久化:附件是合成进普通历史让目标轮读的,目标域本身不存附件状态。

生命不息,折腾不止。目标体系里还剩一块没讲:那些「又慢又可以后台跑」的活怎么办?下一篇钻进 dsh 的 后台 job——run_in_background 怎么把重活甩到后台不堵主循环、job_output / job_list / job_kill 怎么随时查看和掐掉,配合 goal 一起用,长任务才真正「放得下、收得回」。