子代理与长任务——DeepSeek Harness的Session与Activation,Plan、Goal、Jobs为什么都只是插件
回头看我们线上最难查的两个问题,都和"长流程"有关。
一个是首聊导览。新用户第一次聊天,有一套引导流程要走完。我们通过 A/B 回放发现,模型经常嘴上答应了"好的,我来帮你……",实际上却没有去执行对应的步骤。
另一个更诡异:某次工具调用失败了,这次失败被后台的记忆提取当成了一条"事实"写进了记忆。从那以后,只要用户提到这件事,Agent 就拒绝回答,而且一直拒绝下去。
带着这两个问题去读 DeepSeek Harness(dsh)的子代理和长任务设计,发现答案其实就藏在它反复强调的两条原则里。这是 dsh 系列的第六篇。
一、子代理:一个会话,至多一个驻留
先看子代理。dsh 里的一个本地子代理,由两部分组成:
持久的 Session
-> 可选的、活的 Activation
-> 一个保留着的 AgentHandle
-> Agent 的 inbox 作为唯一的 turn 队列
-> 零个或多个它拥有的子 Activation
Session 是身份和历史,持久保存;Activation 只是"它当前在内存里"的一段时间,可以执行好几个 turn,也可以在等待后代时一直驻留着。
会话持久,驻留短暂。一个子代理干完活以后,Activation 就释放了,但 Session 还在。父代理之后想追问一句,就从日志里冷恢复出一个新的 Activation,历史、深度、配置全部还原,接着聊。
这里有一个很重要的约束:只有一个队列,就是 agent 自己的 inbox。dsh 早期的设计里,子代理被建模成一个后台任务,任务有自己的队列,agent 又有自己的 inbox,两个队列谁说了算?排序权威之争就是这么来的。后来干脆把任务这一层去掉了,没有第二个队列,也没有"排队中"这种状态。
子代理的状态也不是单独维护的,而是从已有的事实里派生出来的:
| 状态 | 条件 |
|---|---|
| running | agent 正在执行 turn |
| waiting | agent 静止了,但 inbox 不空,或者它拥有的子代理还没销毁 |
| settled | 静止、inbox 为空、所有子代理都已销毁,释放 Activation |
注意 waiting 的第二个条件:父代理在它的子代理全部销毁之前,不能结算。这解决了一个很经典的问题:子代理自己的 turn 结束了,但它派出去的孙代理还在跑,这时候如果把子代理销毁了,孙代理就成了孤儿,没人负责回收。
还有一个细节,"谁是谁的父"这件事,dsh 记了两份:持久的父子血缘,用来做授权判断;进程本地的拥有关系,用来决定销毁的顺序。持久的血缘不等于活的所有权,两者不混用。
二、发布之前,组装完毕
子代理的配置有三个控制项:
| 控制 | 回答的问题 |
|---|---|
| persona | 用什么角色指令替换默认的人格 |
| toolFilter | 子代理能看到哪些工具 |
| maxDepth | 委派树最多能有多深 |
这些配置都在一个"未发布的 setup 窗口"里完成。setup 成功了,才发布 session 和 agent、启动驱动器;失败了,整体回滚。这样就不存在"第一个请求用了默认人格、之后才换成正确人格"的子代理。
深度的计算也很严谨:子代理的深度等于父代理的有效深度加一,而父代理的有效深度取持久记录和运行时记录中更大的那个。这样进程重启也没法把递归的计数"清零"。
还有两点我觉得很值得学:
- 工具过滤不是安全边界。所有插件都跑在同一个可信的进程里,过滤只是控制"子代理能看到什么",真正的隔离要靠沙箱和审批;
- 能力声明要诚实。如果某个子代理的后端不支持工具过滤,请求了就直接返回
UNSUPPORTED_CAPABILITY,而不是假装接受、然后静默地忽略。
父子之间的通信也有规矩:只允许相邻的 agent 互发消息,并且消息带着不同的来源标记。子代理自己说的话,是一条 relay;运行时对"子代理怎么结束的"做的陈述,是一条 notice。对话记录里不会把运行时的话算到子代理的头上。
三、长任务的能力,全都是插件
让 agent 做"大事"的那些能力,plan、goal、todo、后台作业、workflow,在 dsh 里全部是可选的插件,而不是 loop 的模式开关。loop 根本不知道它们的存在。
| 能力 | 是什么 | 关键设计 |
|---|---|---|
| Plan | 计划模式 | 只是软引导,改变提示词,不做任何强制 |
| Goal | 同一会话内的完成目标 | 阶段持久保存;"自动继续"的许可不持久,resume 后要人重新授权 |
| Todo | 待办列表 | 每次整表替换,不做增量修改 |
| Jobs | 后台作业 | 靠所有者校验控制访问,而不是靠 id 保密 |
| Workflow | 模型写的编排脚本 | 元数据先校验;资源上限由调用方固定,脚本看不到也改不了 |
挑两个展开说。
Goal 有一个阶段的概念:进行中、暂停、阻塞、完成,这些是持久的事实,可以回放、可以 fork。但"要不要自动继续干下去"这个许可,是刻意不持久化的。进程重启、会话被 fork 之后,必须由人重新授权,agent 才会自动工作。因为"自动继续"本质上是一种权限,权限需要现场授予,不能从日志里继承。
Workflow 的设计也很克制。模型可以写一段编排脚本,但脚本的元数据和参数必须是纯 JSON,在执行之前就校验完毕,绝不为了读取元数据而去执行脚本。最多能派几个子代理、用哪个后端,这些策略由调用方固定,脚本既看不到,也改不了。
还有一个叫 Ralph loop 的模式:朝着一个固定的目标反复迭代,每一轮都是一个全新的子会话,不带上一轮的对话。跨轮的状态只靠共享的工作区,加上一份有界的结构化交接:状态、摘要、证据、下一步、阻塞项。每轮都带着完整的历史,上下文很快就会膨胀。
四、两条贯穿始终的原则
把这一章读完,可以提炼出两条原则。
第一条,"许可"不持久,"事实"持久。
goal 的自动继续许可、plan 模式下还没落盘的选择,都是进程本地的;阶段、待办、作业的结局,这些才是持久的事实。许可要在当前进程里被重新授予,事实才能从日志里继承。
第二条,软引导和硬强制分层。
plan 模式只改变提示词,告诉模型"现在只调研、不改文件";真正不让它改文件的,是沙箱和审批。dsh 明确说,plan 和沙箱、审批之间互不读写对方的状态。提示词管引导,代码管强制。
五、回头看我们的两个线上问题
1. 首聊导览:模型嘴上答应了,却没执行
首聊导览是一个多步骤的引导流程。最开始,我们把整个流程写在提示词里,让模型按步骤走,该调工具的时候调工具。
A/B 回放暴露的问题是:模型经常"口头完成"了某一步,回复里说"好的,已经帮你设置好了",但对应的工具根本没被调用。用户以为设置好了,其实什么都没发生。
这恰恰就是第二条原则说的事:提示词只能软引导,不能硬强制。我们后来的做法,是把导览里的关键决策从 LLM 手里收了回来,放到服务端确定性地执行:哪一步、该做什么、做完了没有,由服务端的状态机说了算;模型只负责在每一步里把话说好。
这和 dsh 的 workflow、goal 的思路是一致的:流程的骨架由代码固定,模型在骨架里发挥。
2. 一次失败,变成了永久拒答
第二个问题更隐蔽。某次用户问起一项健康数据,查询工具恰好失败了,模型回复"暂时无法获取"。后台的记忆提取把这一轮对话也处理了,提取出了一条类似"这项数据无法获取"的记忆。
从那以后,每次用户再问,这条记忆都会被召回,模型看到它,就直接回答"无法获取",连工具都不去调了。一次临时的失败,被固化成了永久的"事实"。
用 dsh 的视角看,问题出在两个地方:
- 来源没有区分。工具的错误结果、运行时的通知,和用户亲口说的话,在记忆提取看来是一样的东西。dsh 用不同的
source区分"agent 说的"和"运行时说的",就是为了防止这种混淆; - 一次性的状态被当成了持久的事实。工具调用失败是一个瞬时的状态,就像 goal 的"许可"一样,它不应该被持久化,更不应该被下一次会话继承。
我们最后在记忆提取这一层设了防:
def extractable_messages(turn: Turn) -> list[Message]:
"""只把用户和助手的正常对话交给记忆提取"""
return [
m
for m in turn.messages
# 工具结果、运行时通知不参与记忆提取
if m.source in ("user", "assistant")
# 这一轮里有工具失败时,助手的回复也跳过,避免把"失败"固化成"事实"
and not (m.source == "assistant" and turn.has_tool_error)
]
同时在提取的提示词里补了一条规则:系统能力、工具可用性这类信息,一律不作为用户记忆保存。
总结
- 子代理等于一个持久的 Session 加上至多一个活的 Activation,agent 的 inbox 是唯一的队列,状态从已有的事实派生;
- 父代理在子代理全部销毁之前不结算,持久的血缘用于授权,活的所有权用于销毁顺序,两者分开记录;
- 子代理的所有配置在发布前组装完毕,能力不支持就明确拒绝,工具过滤不是安全边界;
- plan、goal、todo、jobs、workflow 全都是插件,loop 不知道它们的存在;
- "许可"不持久,"事实"持久;提示词只能软引导,关键的决策要交给代码确定性地执行。