夜雨聆风学习资料网

ARTICLE · 1235518

DeepSeek-Harness 源码专题(2):长任务注意力涣散,它的解法是把记忆搬出上下文

DeepSeek-Harness 源码专题(2):长任务注意力涣散,它的解法是把记忆搬出上下文

摘要:长任务不追上下文压缩,ralph 每轮换全新子代理:跨轮只传不可变目标 + 16K 封顶的结构化 handoff,长期记忆只有共享工作区。报告是替换而非追加、状态机三态互斥、worker 自述不算验收,每轮上下文开销钉死在 O(1)。

上一篇拆压缩时留了个话头:压缩是上下文涨上来之后的补救,日志只增不减,模型视野里该忘的还是得忘。这一篇看另一个思路:干脆不让上下文涨。

场景接着上篇的会话。迁移做完,遗留的活儿来了:全库还有 47 个 deprecated 调用要清,散在三十几个文件里。模型判断这一个 turn 跑不完,用户回了五个字:用 Ralph 循环。

这五个字值得停一下。它是用户点名,不是模型自选,这个区别后面 §六 专门管。先回答主线问题:一个要跑几十轮的迭代任务,凭什么不越跑越糊涂?

先把结论放出来:ralph 的做法是每轮换一个全新子代理,它收不到父级对话,也收不到上一个兄弟的会话;跨轮只传两样东西:不可变的 objective,和一份 16K 字符封顶的结构化交接报告;长期记忆只有磁盘上的共享工作区。《每回合都是失忆的新代理,256 轮循环凭什么干得完》把 packages/workflow/tool-ralph 这一个文件的实现走读拆过了,这篇换个问法:每轮清零之后,工作凭什么不断档?

一、先算账:两种方案的成本曲线

同一个长任务,两个解法。一个是压缩:上下文照涨,涨过阈值砍一刀,《砍掉一半对话,日志反而多了四条事件》拆的那条路,上篇的 surface replace 是它的视图层。压缩的成本曲线是 O(n),第 50 轮的推理仍然拖着前半段的摘要。

另一个是 ralph 的清零:每轮的输入固定为目标加一份有界报告,第 1 轮和第 50 轮的上下文一样大。成本曲线是 O(1)。代价写在明处:每轮都是冷启动,上一轮脑子里的中间假设全丢,只压得出 16K 的东西能带过去。

这条路能不能走通,取决于一个问题:记忆搬出上下文之后,放在哪、怎么核对。这就是承重的部分。

二、工作区是唯一的事实来源

每轮子代理的 prompt 是六段固定文案拼出来的,我们看其中第四段,记忆契约(index.ts:159):

'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.'

这段是每轮工人进场前的行为准则(index.ts:159)。四句话四个动作:共享工作区和它当前的工作树是长期记忆和事实来源;动手前先检查;保住已有成果;改了要验证。最后一句最克制:上一份报告只是有界交接,用它之前先对着工作区核实。

为什么强调「核实」?因为交接是上一个工人写的自述,工作区才是干出来的事实。回到我们的案例:round 1 的工人清了 12 个 deprecated 调用,报告 continue 加 nextSteps;round 2 起的是另一个全新工人,它看不到 round 1 的对话,能做的是打开工作区看 git diff:12 个文件确实改过了,报告里剩下 35 个没清的清单再对照代码核实一遍,然后接着干。记忆搬去磁盘之后,上下文里流转的就只剩「该干什么」这一件事。

三、交接报告是替换,不是追加

跨轮状态只有一个变量:previous,上一份报告。看循环尾就明白这个设计有多省(index.ts:168-176):

  if (rawReport === null) {    return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }  }  const report = validateReport(rawReport)  if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }  if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }  previous = report}return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }

这段是每轮收尾的状态推进(index.ts:168-176)。注意看 previous = report 这一行:新报告通过校验后整体替换旧报告,旧的那份随即被丢弃。循环跑 50 轮,内存里永远只有一份 handoff;它要是追加,几十轮后交接本身就退化成第二个不断膨胀的上下文,这条路就走回压缩的老问题了。

终态也只有三种干净形状:complete 和 blocked 立即返回;跑完 maxRounds 还没终态,budget-limited 带着最后一份 continue 报告收场。注意第一行:某轮子代理失败(返回 null),整个运行以 round-failed 终止,不重试。偶发抖动也会杀死整次循环,换来的是失败语义可预测,现场还留着:最后一份成功交接会带进错误信息,人接手时知道断在哪、之前干到哪。

四、报告的承重墙:schema、状态机、尺寸,三合一

交接报告为什么能扛住几十轮传递?因为它从三个方向同时被卡死。形状上,五字段 JSON Schema 全部 required,多一个键都不行;语义上,状态机三态互斥:continue 必须带 nextSteps 且 blocker 为空,complete 必须有 evidence、零 nextSteps、空 blocker,blocked 必须给出具体卡在哪。这两重 dd23 拆得细,不重复。本篇只看第三重,尺寸:序列化后超过 16K 直接抛错,让整个 workflow 失败,既不截断也不降级,截断的风险是悄悄丢掉关键状态,宁可这轮失败让人看见。那四行校验 dd23 引过(index.ts:144-147),不复读。这个上限的默认值躺在配置里(index.ts:35-40):

export const Config: z<Config> = z.object({  subagentProvider: z.string().default('spawn'),  maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),  maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),  maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),})

这段是部署侧的配置底座(index.ts:35-40)。256 回合既是默认值也是部署天花板,模型请求的 maxRounds 会被 resolveMaxRounds(index.ts:208-217)压在这个天花板之内。两处 16K 各管一件事:handoff 卡跨轮交接,result 卡父级看到的终局文本,把「有界」钉成配置而不是约定。

五、边界上的不信任:fail loud 与静止

固定脚本跑在 workflow 引擎的 worker 线程里,返回值要跨过 provider 边界才能回到工具进程。同一份契约在脚本内外各写一份、宿主侧防御性再解码一遍,这层 dd23 拆过了。我们补两个它没展开的点。

第一个是非正常结束的处理(index.ts:336-349):

function stopReasonError(result: WorkflowResult): string | undefined {  switch (result.stopReason) {    case 'completed':      return undefined    case 'cancelled':      return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}`    case 'error':      return `Ralph workflow failed: ${result.error ?? 'unknown error'}`    /* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */    default:      return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})`    /* v8 ignore stop */  }}

这段是引擎落点的判决(stopReasonError,index.ts:336-349)。只有 completed 算成功;cancelled 和 error 一律翻译成错误抛掉,注释还写明了扩展纪律:stopReason 是封闭联合,将来新增变型必须在这里 fail loud。局部输出绝不视为成功:跑了 40 轮被取消,就是失败,没有「40 轮的局部成果」这种半个成功。

第二个是取消之后的静止。execute 的收尾(index.ts:460-474):

      try {        const settled = await run.result        const error = stopReasonError(settled)        if (error !== undefined) throw new Error(error)        const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)        // …(round-failed 抛出与成功返回略)      } finally {        exec.signal.removeEventListener('abort', onAbort)        await run.dispose()      }

这段是工具执行的善后(index.ts:460-474)。父级步骤的 abort 信号双路接入引擎:监听 abort 事件调 run.cancel,注册时若已 abort 立即补一刀(index.ts:456-458)。关键是 finally 里的 await run.dispose():工具返回之前,必须等引擎在有限时间内终止、所有子代理完全停下来。父级被取消了,ralph 不会留下还在磁盘上瞎写的孤儿工人。

六、说法核对与路由收窄

现在回到开头的核对:「dsh 的解法是每一轮都换一个全新 agent」这个说法准不准?

基本准确,且比说法更严格。严格在三点。其一,fresh 不是提示词劝告,是 provider 能力位硬校验:inheritsParentContext 必须为 false,配置错写成会继承父会话的 provider,execute 当场报错,这条 dd23 引过 requireFreshProvider(index.ts:220-232),不展开。其二,模型对循环零控制权:循环是部署侧固定的脚本,模型只能填 objective 和 maxRounds 两个数据位,改不了循环逻辑、报告 schema 和交接校验(脚本常量头顶的注释 index.ts:86-89 原话:it cannot alter the loop, provider route, schema, or handoff validation)。其三,回合预算与引擎的子代理总数兜底是同一个数字(maxTotalAgents = maxRounds,index.ts:452),循环绕不过引擎预算。

路由上,这个工具被刻意收窄成专家路线。工具描述原文(index.ts:179-184):

const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '  + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '  + 'opens a new child with no parent conversation or prior child session; the shared workspace is '  + 'long-term memory, and only a bounded structured report crosses rounds. The call returns when '  + 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work '  + 'belongs to goal tools.'

这段是模型选工具时读到的路由说明(index.ts:179-184)。Use only when the direct human explicitly asks,只在直接人类点名时用;普通长任务归同会话的 goal 工具,有边界的分发归普通 subagent 或 workflow。配套的系统提示段(index.ts:407-411)还多声明一句:Completion and blockers are worker reports, not independent evaluation,完成和阻塞都是工人的自述,不是独立验收。validateReport 能逼出 evidence 数组,但证据的真伪工具层不判,验收是父代理的事。

这个模式的源头是 Geoffrey Huntley 的 「Ralph Wiggum」,名字借《辛普森一家》里那个总喊 「I'm helping!」 的小孩:一个 bash 循环把同一个 prompt 反复喂给全新实例,进度全部留在文件和 git 里,上下文每轮从零开始。dsh 保留了三个内核(每轮新上下文、文件系统记忆、小交接跨轮),其余全收紧:原始形态是无限循环、完成与否无人验收,这里加了状态机、双层预算和 blocked 终态(承认有些事必须人来)。收窄的理由写在代价里:每轮冷启动读工作区,轮内的中间假设压不进 16K;目标表述含糊时 worker 可能自报完成而实际没干完。

七、对账

问题答案落点
记忆在哪共享工作区,报告只是有界交接prompt 契约(index.ts:159)
跨轮传什么objective + 一份 ≤16K 结构化报告尺寸检查(index.ts:144-147)
上下文为何不膨胀报告替换而非追加previous = report(index.ts:174)
轮失败怎么办round-failed 不重试,带最后交接循环尾(index.ts:168-170)
取消会怎样fail loud + dispose 静止:336-349 / :471-474
谁可以用人类显式点名DESCRIPTION(index.ts:179-184)

结语

回到那 47 个 deprecated 调用。round 1 的全新工人读完工作区,清了 12 个,写下 continue 报告和下一步清单,然后被整个丢弃:不是会话被删,是它这个工人的存在就到这。round 2 的另一个全新工人打开工作区,git diff 里那 12 个文件改得明明白白,报告只是提示,事实在磁盘上。几十轮过后,每轮的上下文还是那么大:目标、工作区、16K。最后一名工人报告 complete,附上是证据清单;父代理看到的话术是 「Ralph worker reported completion」,完成是工人说的,验收得自己来。

压缩和清零不是对手,是一个光谱的两端:会话内的事交给压缩,跨轮的事交给磁盘。专题下一篇切 steer/interrupt——主循环跑在中途,人插进来一句话,线程上的工具调用怎么办,下一篇见。

你用过类似的「失忆工人」模式跑长任务吗,交接报告写到第几轮开始糊弄?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料