ARTICLE · 1177110
DeepSeek-Harness 源码深读(23):每回合都是失忆的新代理,256 轮循环凭什么干得完
摘要:ralph 工具面只有 objective 和 maxRounds 两个字段,循环本体是插件内嵌的固定脚本,经 workflow 引擎逐回合执行。每回合起一个不继承上下文的 structured-output 新子代理,长期记忆放在共享工作区的磁盘上,回合之间只传一份 16K 封顶的结构化交接;schema、语义、尺寸三重校验在脚本内写一遍,宿主侧隔着 provider 边界再防御性解码一遍。
教程线《子代理后端只要两个方法,其余全归框架》认识过 subagent 的接缝,上一篇拆了 workflow 引擎的 worker 线程和 vm 沙箱。这一篇看跑在引擎上的一个成品应用:packages/workflow/tool-ralph,全部实现一个文件 479 行。
先摆场景。fs-local 的测试挂了三个,用户对 agent 说:用 Ralph 循环把这个修到全绿。注意「用 Ralph 循环」这五个字,它是用户点名,不是模型自选,这个区别后面有专门的段落管着。现在的问题是:一个迭代 256 回合的任务循环,上下文怎么扛得住?
先把结论摆出来。ralph 的答案是不扛。每回合起一个全新的、不带任何历史的子代理,长期记忆放在共享工作区的磁盘上,回合之间只传一份 16K 字符封顶的结构化交接。循环代码本身是插件里的固定脚本,模型一个字都改不了。
一、模型只准填两个空
我们来看工具面对模型摊开什么。ralph 的参数定义(index.ts:415-425):
parameters: { objective: { type: 'string', required: true, description: 'The immutable completion objective for every fresh Ralph round.', }, maxRounds: { type: 'number', description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.', }, },这段是工具的输入面(index.ts:415-425)。注意看只有两个字段:一个必填的 objective,一个可选的回合上限,而且这个上限还会被 resolveMaxRounds(index.ts:208-217)压在部署天花板之内,模型请求 10000 回合,天花板 256,直接报错拒绝执行。
循环结构呢?在插件常量 RALPH_SCRIPT 里(index.ts:90 起,String.raw 模板字符串),一段约 90 行的脚本,经 workflowEngine 执行。脚本常量头顶的注释写得更直白(index.ts:86-89):Fixed, deployment-owned orchestration. The model supplies data only; it cannot alter the loop, provider route, schema, or handoff validation。模型供数,部署供码:目标不可变,循环不可改,schema 不可换。
为什么要把循环从模型手里拿走?让模型自己写一个 while 循环调子代理,它有很强的动机在半路改目标、加例外、忘了终止条件。我们这个系列反复见到这个手法:tools 的权限关卡、sandbox 的围栏,都是把纪律从提示词层下沉到代码层。ralph 是同一个思路用在循环上。
但是真正的难题还不是循环谁写,是长迭代和上下文的矛盾。
二、每回合换个新代理,记忆搬去磁盘
想象另一个方案:一个子代理带着全部历史跑 256 回合。每回合的推理、工具结果、报告全都留在上下文里,第 100 回合时它要拖着前 99 回合的全部细节做推理,越到后面质量越差。会话内的办法是压缩,《砍掉一半对话,日志反而多了四条事件》拆过那条路;ralph 的问题是跨 256 个回合,压缩也追不上累积速度。
它的解法在主循环里(index.ts:151-167,摘核心):
let previousphase('Fresh-agent rounds')for (let round = 1; round <= args.maxRounds; round += 1) { const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous) const prompt = [ 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.', 'Immutable objective:\n' + args.objective, 'Ralph round: ' + round + ' of ' + args.maxRounds + '.', // …(第四、五、六段见下文) ].join('\n\n') const rawReport = await agent(prompt, { label: 'Ralph round ' + round, phase: 'Fresh-agent rounds', schema: reportSchema, })这段是整个工具的心脏。注意看循环体里唯一跨回合存活的变量:previous,上一份报告。每回合的 agent() 都是一次全新的子代理调用,prompt 第一行就告诉它 You receive no parent conversation and no prior child session,父会话没有,前一个兄弟的会话也没有。顺手还堵了递归:Do not call the ralph tool,你自己就是这个回合的工人,别再套一层 ralph。
那记忆在哪?prompt 第四段(index.ts:159)说得直白:The shared workspace and its current working tree are the long-term memory and source of truth,共享工作区和它当前的工作树才是长期记忆和事实来源,动手前先检查,改完要验证。对我们的案例就是:round 1 的工人读测试文件、修了第一个,报告 continue 加 nextSteps「跑剩下两个」;round 2 起的是另一个新工人,它看到的不是上一轮的对话,是磁盘上已经改过的测试文件,加上一份 16K 的交接。handoff 在这里的定位写得极克制(index.ts:159):Treat the previous report only as a bounded handoff; confirm it against the workspace,交接只是提示,用之前先对着工作区核实。
代价也明摆着:每个工人都得从零读一遍工作区,重复探索的成本换掉了上下文累积的灾难。回合数再多,每个工人的上下文都是 O(handoff) 而不是 O(历史)。
整条回合循环,连同报告的四条出路,一张图:

每回合一个失忆的新工人
注意看回合之间唯一的通道:16K 的交接报告。工作区在磁盘上一回合一回合长出来,每个工人的上下文成本恒为 O(交接)。
可是交接是模型生成的 JSON。模型说自己的报告长什么样就长什么样,凭什么信?
三、三重关卡:形状、语义、尺寸
第一重,形状。脚本里内嵌了一份 JSON Schema(index.ts:91-102):
const reportSchema = { type: 'object', properties: { status: { type: 'string', enum: ['continue', 'complete', 'blocked'] }, summary: { type: 'string' }, evidence: { type: 'array', items: { type: 'string' } }, nextSteps: { type: 'array', items: { type: 'string' } }, blocker: { type: 'string' }, }, required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'], additionalProperties: false,}这段是报告的形状约束。注意看五个字段全部 required,多一个键都不行。agent() 调用带着 schema: reportSchema,走的是 provider 的 structured output 能力(spawn provider 的能力位里 outputSchema: true,subagent-spawn-in-process/src/index.ts:42),模型侧就按这份 schema 生成,回来的是已验证的对象。
第二重,语义。schema 管不了「continue 却不带下一步」这种值合法但语义矛盾的东西,所以脚本里还有一个 validateReport(index.ts:125-148,摘后半):
switch (report.status) { case 'continue': if (report.nextSteps.length === 0 || report.blocker !== '') { throw new Error('a continuing Ralph report needs nextSteps and an empty blocker') } break case 'complete': if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') { throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker') } break case 'blocked': if (!normalizedText(report.blocker)) { throw new Error('a blocked Ralph report needs a concrete blocker') } break default: throw new Error('Ralph round report status is invalid') } const serialized = JSON.stringify(report) if (serialized.length > args.maxHandoffChars) { throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')') } return report这段是三个状态的互斥约束。注意看每个分支掐的组合:continue 必须带 nextSteps 且 blocker 为空;complete 必须有 evidence、零 nextSteps、空 blocker,「干完了」必须拿出证据还不许留尾巴;blocked 必须给出具体的 blocker,不许空着手说干不下去。
第三重,尺寸,就是上面最后四行。JSON.stringify(report).length > args.maxHandoffChars 直接 throw,默认 16384。这是整个机制的地基:回合之间传递的状态量被硬性封顶,第 200 回合的交接和第 2 回合一样大,「有界记忆」不是纪律约定是运行时强制。
奇怪的地方来了。这些校验都在脚本内,脚本又是部署方写死的,理论上不可能产出坏值。宿主侧为什么还要把同一套契约再写一遍?
四、同一份契约写两遍
因为脚本执行的地方不在宿主进程里。上一篇拆过:workflow 脚本跑在 worker 线程的 vm 里,agent() 的返回值要跨过 provider 边界才能回到宿主。引擎可换(worker-thread 之外还可以有别的实现),provider 可换(spawn 之外还有 acp、fork 一族),固定脚本理论上不变,但工具出口能依赖的只有自己。源码注释就一句(index.ts:246):Defensively decode the fixed script's report across a provider boundary。
宿主侧的 readReport(index.ts:247-257):
function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary' || value['status'] !== expectedStatus || !normalizedText(value['summary']) || !normalizedList(value['evidence']) || !normalizedList(value['nextSteps']) || typeof value['blocker'] !== 'string' || value['blocker'] !== value['blocker'].trim()) { throw new Error('Ralph workflow returned a malformed round report') }这段是宿主侧的报告解码。注意看第二行那个精确键集检查:Object.keys(value).sort().join(',') 必须逐字符等于 'blocker,evidence,nextSteps,status,summary',多一个键、少一个键、改一个键名,全部当垃圾拒收。后面逐字段重放归一化校验,再重放三分支互斥(:265-274),再查尺寸(:275-278),跟脚本里的 validateReport 一条不差。
终局值的解码还埋了两个只有写死脚本才敢做的假设。readRunResult(index.ts:303-320,摘两段):
case 'budget-limited': if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { throw new Error('Ralph workflow returned a malformed terminal result') } if (roundsStarted !== maxRounds) { throw new Error('Ralph workflow returned budget-limited before the round limit') } return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) } case 'round-failed': { if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') { throw new Error('Ralph workflow returned a malformed terminal result') } if (roundsStarted === 1) { if (value['lastReport'] !== null) { throw new Error('Ralph workflow returned an invalid first-round failure') } return { status: 'round-failed', roundsStarted } }这段是终局值的形状判决。注意看两个刁钻的断言:报 budget-limited(预算耗尽)却没跑满 maxRounds,拒收,因为固定脚本只可能在循环走完时才返回这个状态,提前出现说明有 bug;round-failed 发生在第 1 回合却带着 lastReport,也拒收,第 1 回合失败时根本没有「上一份交接」。解码器对脚本行为的了解细到这个程度,坏值逃不出去。
账也要算:同一份契约写两遍,脚本改一个字段名,宿主的字符串比较就要跟着改,这是实打实的维护成本。换来的东西是工具出口成为最后一道不变量防线,换引擎、换 provider 都不用重新审这段代码的可信度。
契约防的是坏数据。还有一个前提没落实:每回合「新代理」这件事本身,靠什么保证?
五、fresh 的硬检查,预算的合并
配置里 subagentProvider 默认 'spawn',但谁都能改配置。所以 execute 里有一步硬检查 requireFreshProvider(index.ts:220-232):
function requireFreshProvider(ctx: Context, name: string): SubagentProvider { const provider = ctx.subagents.getProvider(name) if (provider === undefined) { throw new Error(`Ralph subagent provider 「${name}」 is not registered`) } if (!provider.capabilities.outputSchema) { throw new Error(`Ralph subagent provider 「${name}」 does not support structured output`) } if (provider.inheritsParentContext) { throw new Error(`Ralph subagent provider 「${name}」 inherits parent context; Ralph requires a fresh provider`) } return provider}这段是 fresh 语义的准入检查。注意看它查的是两个能力位而不是名字:outputSchema 必须支持(第三节的 schema 要靠它落地),inheritsParentContext 必须为否。我核过仓库里的两个实现:spawn 是 false(subagent-spawn-in-process/src/index.ts:44),fork 是 true(subagent-fork-in-process/src/index.ts:64)。配置错写成 fork,execute 当场报错,fail loud。Ralph 的全部前提是每回合干净上下文,配一个继承父会话的 provider,跑起来就是个名不副实的 Ralph,与其悄悄退化不如直接拒绝。
循环的兜底也做在启动参数里(index.ts:447-455):
const run: WorkflowRun = ctx.workflowEngine.start({ script: RALPH_SCRIPT, meta: RALPH_META, args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars }, subagentProvider: resolved.subagentProvider, maxTotalAgents: maxRounds, parent, signal: exec.signal, })这段是引擎启动调用。注意看 maxTotalAgents: maxRounds 这一行:Ralph 每回合恰好一个 agent,所以回合上限和引擎的 agent 总数兜底是同一个数字,模型请求的回合数就是引擎允许的总数。如果这两个数字分开设,模型要 256 回合、引擎只许 100 个 agent,循环会在第 100 回合被引擎强停,两个数字的缝隙就是 bug 的住处。取消也桥好了:外层 signal 一旦 abort,run.cancel('parent step aborted')(index.ts:456-458),listener 加 aborted 双保险。
回合失败的处理有个值得咂摸的取舍:不重试。agent() 解析出 null(子代理没产出结构化报告),立即以 round-failed 终止整次循环。偶发的网络抖动也会杀死整个循环,换来的是行为可预测,失败就是失败,不会被重试搅浑。但现场留着:renderRoundFailure(index.ts:386-391)把最后一份成功的交接原文放进错误信息带给父代理,下一轮调用可以从断点语义上接着干。
最后一道坑:工人说 complete,就真完成了?
六、自报告不是验收
渲染层的措辞是刻意挑的(index.ts:365-366):Ralph worker reported completion after ${rounds}.,工人报告了完成,仅此而已。配套的使用政策钉在系统提示里(index.ts:407-411):
ctx.systemPrompt.section({ name: 'tool:ralph', order: 116, text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', })这段是工具的使用政策。注意看两句纪律:开头 ONLY when the direct human explicitly asks,无限迭代是烧钱模式,必须用户点名才准启用,普通长任务用 goal 工具、有边界的分发用普通 subagent 或 workflow,回到我们开头那五个字「用 Ralph 循环」;中间 Completion and blockers are worker reports, not independent evaluation,完成和阻塞都是工人的自述,不是独立评估。validateReport 能逼出 evidence 数组,但证据本身的真伪,工具层没有能力也没有试图判断。父代理拿到的是一份措辞诚实的报告,验收是它自己的事。
文本出口也封了顶:所有父向文本经 boundResult(index.ts:354-357)裁到 maxResultChars(默认 16384),截断标记 … [truncated] 计入上限内,不会出现「封顶后加上标记反而超限」的边界 bug。顺带一提,引擎转发的 workflow/agent-start 事件带着 RALPH_META 的名字 ralph-loop(index.ts:80-84),观察端看事件流就能认出哪些 agent 是 Ralph 的回合。
我们的案例到这就闭环了:round 5 的工人对着工作区跑完全部测试,报告 complete,evidence 里是三条测试名。父代理读到的第一行是「工人报告完成」,要确认,它自己去跑一遍测试。
七、结语
回到 round 1 那个失忆的工人。它不知道有 round 0,看不到父对话,手里只有一段目标、一个轮次号、一份 16K 的交接。它能接上前面所有的活,靠的是工作树里那些已经改过的文件,以及 validateReport 逼出来的 nextSteps。每回合的上下文成本恒定,撑住 256 轮的是磁盘,不是上下文窗口。
这个包没有自有状态,一次调用就是一次 workflow run;invariant.ts 全文 30 行,注册了一个空的安装器,校验职责全在 workflow 和 subagent 的属主那边(invariant.ts:18-21 的注释写明 owns no independent event stream)。它是系列里少见的「薄」插件,薄在状态,厚在防御:一份契约写两遍,两个预算并成一个数字,连「完成」这个词都用 worker reported 消毒过。
循环内的工人已拆完。循环外还有一件事:模型写给用户的每一段提示,怎么流过四条通道、各自的折叠和截断规则是什么。下一篇拆 web-prompt-flow,深读第 24 篇见。
你的 Agent 跑长任务,记忆放在上下文里还是磁盘上?评论区聊聊踩过的坑。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































