ARTICLE · 990213
DeepSeek-Harness 源码剖析(六):主循环不拼消息、不带重试,ReAct 凭什么转起来
摘要:agent-loop 把「日志即真相」推进到请求层:主循环不拼消息历史,每次请求从事件日志现推导;重试不是内建策略,是 agent/request-error 上插件授予的特权;每条消息回链 chunk 序列。一个 turn 从 user/message 到 turn/end 的完整走读。
上一篇结尾留了一个承诺:这本账本上,一次完整的 turn 怎么跑起来。从 user/message 进日志,到 inbox 双队列在事件流上重建,到 turn/end 落盘。这篇来兑现。
拆的对象是 packages/core/agent-loop(@deepseek-ai/dsh-agent-loop),dsh 默认的 Agent 驱动,web-ui 和 CLI 的会话入口、subagent 的子代理、llm-retry 的重试、会话标题生成,全都消费它。先给两个反直觉的定性。第一,主循环不拼消息历史:发给模型的 messages 数组每次都从会话日志现推导,agent.ts 里没有第二个地方攒消息。第二,主循环不带重试:模型调用失败时默认动作是「不重试」,重试是插件在 agent/request-error 接缝上授予的特权。
这两个「不」听着别扭。循环框架的惯性想象里,主循环就该攥着消息历史和重试策略,dsh 把两样都交了出去,循环反而变得可测、可重放。别扭的感觉先放着,走完这个 turn 自然消解。
这个包比想象的小。agent.ts 承担全部驱动逻辑,我核稿时数了一遍,515 行(教程初稿记的 497 已过时,buildRequest 一带漂了 19 行);调度工具的 tool-calls.ts 289 行;守住「请求可重构」的 invariant.ts 只有 63 行。三个文件加一个 713 行的 index,撑起整个 ReAct 循环。
它在插件图里的位置一句话说清:提供 ctx.agentLoop 服务,inject 五个服务(agents、sessions、llm、tools、systemPrompt),监听 session/event 维护 inbox 和运行时上下文,对外发布 agent/pre-step、agent/request、agent/request-error、agent/turn-stopping 四条接缝。下游谁在用?web-ui 和 CLI 的会话入口、subagent 的子代理、llm-retry 的重试、会话标题生成。
ctx.agentLoopAgentFactory 经 ctx.agents.setFactory 上架 | |
agent/pre-stepagent/request、agent/request-error(waterfall)+ agent/turn-stopping(serial) | |
主线用老办法:我们跟一个完整的 turn 走。你发一句话进来,看它怎么变成事件、变成请求、变成工具调用,最后变成 turn/end。
先把两个定性钉在这里,走完全文回来对账:不拼消息,换来的是「请求等于日志推导」这条断言能成立;不带重试,换来的是策略全部外置、循环本体 515 行写完。两个「不」都是让渡,让渡换保证。
一、三个相位:没有轮询的状态机
agent.ts 开头的文件注释就是纲领:「Every request is derived from the session log」(agent.ts:2-3),每一次模型请求都是会话事件的推导结果。整个驱动的可变调度状态只有一个 Phase(agent.ts:38-46):
type Phase = | { kind: 'idle'; lastTurn: number } | { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean } | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }这段是驱动的全部调度状态。三选一:idle 无事可做;maintenance 在跑独占式维护作业(比如历史压缩);running 是一个活跃驱动,持有当前的 turn/step 计数和专属 AbortController。没有轮询,没有常驻任务,机器只在真正有工作时占一个 Promise。相位每次切换都经 setPhase 发布 agent/status 事件,外部看到的状态变化本身就是事件流的一部分。
maintenance 值得单独说两句。历史压缩要改写日志里的旧消息,绝不能和正常驱动并排跑,所以它独占整个 agent:进入前先等 running 收尾,持有一个自己的 AbortController,期间到达的输入全部记在 wakeRequested 上,作业结束重放。UI 拿到的 status getter 也把 maintenance 归为 idle,维护作业不算「运行中」,界面上不会闪一下假的思考状态。
维护相位的常客就是历史压缩:把一长段旧对话打包成摘要,通过上一篇讲的 surface replace 通道改写表面。改写历史是个危险动作,独占执行保证改写期间没有并发驱动同时在读表面、派请求。上一篇说 replace 是 compaction 改写历史的唯一合法通道,而这条通道只在 maintenance 相位里走。
唤醒的处理是个三分法(wakeDriver,agent.ts:172-193)。已经在 running 时,新唤醒什么都不用做,活跃驱动自己会在循环末尾检查 inbox.hasPending 接着跑下一轮。maintenance 期间到达的唤醒记在 wakeRequested 上,作业结束后重放。已中止但尚未收尾的驱动同样记下唤醒,唯独 disposed 不记:销毁原因的取消不该被重放,销毁流程不必等任何模型轮次。
驱动主体 kick(agent.ts:210-223)短得让人想再看一遍:
private async kick(): Promise<void> { try { while (await this.turn()) {} } catch (_error) { // Reported failures and cancellation are contained at the driver boundary. } finally { if (this.phase.kind === 'running') { const { turn, wakeRequested } = this.phase this.setPhase({ kind: 'idle', lastTurn: turn }) if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } }}我第一次读到这几行时来回看了两遍:有效代码五行,一个 while 加一个 finally。失败和取消都在驱动边界被容纳,finally 保证相位一定回落到 idle。turn() 返回 boolean,true 表示队列里还有待处理输入、接着开下一个 turn,false 表示可以收工。kick 里的 while 就是拿这个返回值当循环条件,整个驱动的生命周期,读起来像一段伪代码。
驱动起来了。往里一层:一个 turn 的外圈长什么样。
二、turn 的外圈:边界、粘性与最后注入
外层 turn()(agent.ts:246-330)做的事,掰开是五条。
第一,进门先追加 turn/start 事件,turn 号是 phase 里的计数加一。第二,内层 while (true) 每轮走一次 preStep,第一个 step 认领 next-turn 队列,后续 step 认领 next-step 队列;pre-step 插件要是返回 reject,turn 直接以 blocked 结束。第三,首步认领为空时有个专门处理(agent.ts:272-275):唤醒消息被移除、或 pre-step 插件把消息改写成空,turn 仍然拥有完整边界,但一个模型调用都不花,以 completed 结束。源码注释原话是「still owns the initial turn boundary, but it spends no model call」。空 turn 也是 turn,事件成对出现。
第四,max-tokens 是粘性的。一旦某步撞上输出上限,turn 的结束原因就定格在 max-tokens,后面正常完成的 step 不能把它「降级」回 completed(agent.ts:281-290 连续两条注释解释这一点)。粘性保证了报告给用户的结束原因反映的是那次截断,而不是被后续顺利的步骤冲淡。
第五,turn-stopping 检查。turn 已经有了结束原因,但 next-step 队列又冒出新消息,这时先 serial 派发 agent/turn-stopping(agent.ts:296),给插件最后一次注入机会,然后复查队列才决定收不收尾。什么场景用得上?比如「用户追加了一条紧急更正,这个 turn 不能就这么算了」的需求:插件在 turn-stopping 上把更正注入 next-step,复查时队列非空,循环继续。
收尾在 finally 里,无条件追加 turn/end。异常路径也成对,这是重放有效性的地基。要续跑时换一个全新的 AbortController、清掉 wakeRequested、step 归零,那行注释写着「A fresh controller makes a latch set on the old one stale」:旧信号上的唤醒记录随控制器一起作废,陈旧唤醒不会被重复投递。
外圈清楚了。往里一层:一步的边界上发生什么。

日志驱动的 ReAct 主循环,四条插件接缝与 63 行 invariant 守卫全程在场
三、preStep:一步边界的严格顺序
preStep(agent.ts:225-243)是步边界的协议,顺序焊死,四步:
const claimed = this.inbox.claim(target, position.turn)const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))signal.throwIfAborted()const sections = renderContextSections(assembly)const context = this.runtimeContext.project(joinContextSections(sections), sections)const decision = await this.dispatch.waterfall('agent/pre-step', { messages: claimed, ...position, signal }, () => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context] }))这段是 preStep 的主干(有删节)。第一步认领,从 inbox 拿走本步要处理的消息,claim 的第二个参数是当前 turn 号,只认领属于这个 turn 的输入。第二步组装提示词:systemPrompt 服务在这里展开模板,agent-loop 构造时注册了 provider、model、cwd 三个提示词变量,模型每一步都知道自己是谁、在哪个目录里干活。第三步投影运行时上下文。第四步 waterfall 拦截,插件可以改写 messages,也可以返回 reject 直接结束这个 turn。每个 await 前后都有 signal.throwIfAborted(),取消在步边界立即生效。
上一篇埋的 inbox 双队列,在这里兑现。Inbox(packages/core/agent/src/inbox.ts)是 next-turn 和 next-step 两个队列,三个输入入口:
followup | |||
steer | |||
inject |
三个入口各有用处。你聊天打字走 followup;跑到一半想插一句「顺便也看看测试文件」走 steer;文件监听器发现磁盘变了、把变更说明塞进上下文,走 inject,模型下一眼自然看到,循环节奏不被打断。
构造时它从 session.header.seedLength 起重放 agent/inbox/spliced 事件重建两个队列,之后每次变更先把规范化 splice 写成事件、再改内存投影。先落账再改内存,顺序带来的性质很微妙:同步的 session/event 观察者看到的是 splice 之前的列表,手里拿着事件里的坐标就能恢复被删掉的消息。inbox 本身没有独立状态,重启后照着重放就能复原。你发的那句话此刻正躺在 next-turn 队列里,等第一个 step 认领。
第三步的运行时上下文投影(runtime-context.ts,76 行)解决一个具体矛盾:当前时间、环境状态这类动态内容每步都在变,但 system prompt 已经进了 request/header 事件,不宜步步改写。解法是把动态部分投影成一条用户消息压在队尾,语义是三值的:undefined 表示从未有过快照,null 表示有过但被清除,对象是当前值。内容不变不产生新事件;内容清空时写入一段显式的失效标记文本,模型需要明确知道旧快照不再适用。所以认领列表的最后一项永远是最新快照,紧贴你的输入。
边界备好了。该组装请求了,消息从哪来?
四、消息从哪来:deriveMessages 的消费现场
答案上一篇给过:session.deriveMessages()。agent-loop 每次现调用,用完即弃。看 step 的主体(agent.ts:332-350):
while (true) { const { request, preparedCall } = await this.buildRequest( turn, step, assembly.tools, system, this.session.deriveMessages(), signal, ) const assembler = new BlockAssembler() const chunkSeqs: number[] = [] const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) for await (const chunk of stream) { signal.throwIfAborted() chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) assembler.push(chunk) }这段是 step 的核心循环(有删节)。注意 buildRequest 的参数行:消息来源就是 deriveMessages(),从日志表面现投影。内存里没有第二份消息历史可以漂移。每步都现推导一遍,听起来费,其实上一篇的增量投影把成本压到了只算新节点;换来的是「任何时刻重放都等于在线状态」,这笔账划算。
还有一处细节回收 llm 篇的伏笔:preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)。buildRequest 正常返回时带着 prepareCall 钉好的注册,走前面那条路,解析与派发绑死在同一次注册上;极少数拿不到 preparedCall 的场合才退回运行时解析。llm 篇第九节说过「高频调用方要主动钉住注册,agent-loop 正是这么做的」,就是这一行。
再看 for await 循环。每个流式 chunk 先 session.append('assistant/chunk', ...) 入账,拿到 seq 号存进 chunkSeqs,然后才喂给 BlockAssembler。chunk 是日志型事件,模型看不见,但它保住了 token 级的回放保真:UI 想逐字还原一次输出的过程,日志里全有。BlockAssembler 是 dsh-llm 那边 chunk 到消息块的唯一规范装配算法,上一篇的七种 StreamChunk 在这里落成完整的消息。
流结束,装配完成的 assistant 消息以 assistant/message 入账,sourceEventSeqs: chunkSeqs(agent.ts:367)把这串序号挂在消息上,消息的 source 里记着 provider、model 和可选的 replayState。一条消息由哪些 chunk 组成、出自哪个模型,日志里查得到出生证明。上一篇说的证据链,具体就长这样。
撞上 max-tokens 的步还有个专门处理:usage 照样入账,但装配出来的是一条只有 usage 的空壳消息。上一篇 deriveMessages 里那个 if (msg) 判空,过滤的就是它。截断的痕迹留在日志里,对话历史里不留残废消息,两头各得其所。
取消的粒度也值得看一眼。signal.throwIfAborted() 在 for await 循环体内,不在循环外:模型输出到一半你按下停止,已入账的 chunk 全部保留,取消点之后的 chunk 一个都不进日志。配合上一篇的 turn/end{kind:'interrupted'},半截输出的边界也是精确的。
请求发得出去,还要发得干净。谁能保证这一路没有谁偷偷改了请求?
五、buildRequest:把请求钉在日志上
buildRequest(agent.ts:426-514)是插件改写模型路由的唯一正规通道,五段走完才产出请求。
第一段推导种子配置:从日志折叠出当前 requestHeader() 作基线,并且只恢复「由该确切模型拥有」的显式 effort。这条规则很细:你在 deepseek-chat 上显式调过 reasoning effort,中途切到别的模型,恢复时这个 effort 不跟着走。理由写在源码逻辑里:显式 effort 是用户意志,只对当时那个模型有效;adapter 自己的默认值属于补全,不算意志,不恢复。给插件提案前还先剥掉 adapter 派生值,让插件面对的是用户意图,不是适配器补全。
第二段 waterfall:agent/request 拿到 payload 和 next,可就地改 config 再 next,也可短路返回全新 config。缺 provider 或 model 直接硬错误。运行期切模型、降级路由全走这里,连会话标题生成都用它:标题插件在这个接缝上把请求改写到一个小而便宜的辅助模型,跑完再把结果交回去,主对话的配置一个字段不用动。
第三段 prepareCall:把路由解析成 PreparedLlmCall,解析与派发钉死在同一次注册上,llm 篇讲过的 HMR 竞态在这里被消费。
第四段 header 持久化:首次写 request/header {reason:'initial'}(resume 路径是 'resume'),之后只在与基线不等时写 {reason:'change'}。system 和 tools 的变化也体现在 header 变更事件里,日志因此也是配置演化的完整历史:上周三那次对话用的什么模型、带没带自定义工具,翻日志就有答案,不用考古配置文件。
第五段冻结(agent.ts:505):
const request = markAgentLoopRequest(deepFreeze({ ...header.config, messages, ...system, ...tools, sessionId, signal }))deepFreeze 让任何下游改写直接抛错;markAgentLoopRequest 打上「循环构建」的进程内身份标记(WeakSet)。有个细节:AbortSignal 被刻意豁免冻结,它是取消通道,冻结了就废了。
五段里没有一段碰 messages 的内容。这就轮到守卫登场了。
六、63 行断言:请求等于日志推导
invariant.ts 是一个伴生插件,全文 63 行,我核稿时从头到尾数过。它在 llm/stream 上前置注册守卫({global: true, prepend: true},invariant.ts:54),注释写明 prepend 是防止短路型重放监听器吞掉检查。对每个带循环标记的请求,它做四件事:确认冻结、确认携带可解析到活会话的 sessionId、确认日志里有 step/start 且能折叠出 header,然后是核心断言:
const expected = session.deriveMessages()if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { fail(`llm request ... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)}这段是断言的核心(invariant.ts:38-41)。请求的 messages 必须逐字节等于派发时刻从日志推导的结果,报错名就叫 log-reconstruction desync。后面还跟着 model、system、temperature、maxTokens、stop、tools 与折叠 header 的一致性比对。
这个断言能成立,靠的是 agent-loop 自己守纪律:从不缓存消息、从不清洗请求。多数项目的事件日志只管审计和恢复,运行时状态仍在内存里养着,两套状态迟早发散。dsh 只留一套,内存里的是投影,请求是投影的纯函数。断言本身挂在 invariants 服务上,install 时声明 inject: ['invariants'](invariant.ts:16),先拿包所有权再注册检查,插件体系里连「守卫」都是正规军。断言失败走 invariants 服务的统一上报,开发期立刻炸出来,而不是等重放对不上时再猜。代价也明码标价:JSON.stringify 全量比对是每次请求的开销,这个伴生插件更像开发期护栏,生产可以不装。
模型答完了。答完的下一步,往往是伸手要工具。
七、工具调度:并行执行、串行提交
模型一次可能要多个工具,executeToolCalls(tool-calls.ts:59)按模型给出的顺序扫描调用列表,切成组。切组的依据是一次现场查询:ctx.tools.executionMode(first.exec)(tool-calls.ts:88)问注册表下一个调用该独占还是并行。互斥调用(exclusive)单独成组当屏障,屏障前后的组必须完全落定才能继续,比如会写文件的 bash 和只读查询就不该交叠,具体怎么声明是下一篇 tools 插件的内容。连续的并行调用吞进一个组,进池子并发跑。
提交有讲究。内层 runGroup(tool-calls.ts:121)维护着四组状态:按模型序排好的位置槽、每个调用对应的 tool/call 事件序号、进行中的 dispatch、还有一个 committed 指针。推进这个指针的函数全文就一个(tool-calls.ts:146):
// `committed` advances only across contiguous model-order slots.const commitReady = async (): Promise<void> => { while (committed < group.length) { const slot = slots[committed] if (slot === undefined) break // …finalize/finish 算出 result… appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) committed++ }}这段是提交指针的全部推进逻辑。注意 while 循环的跳出条件:slots[committed] 还空着就 break,slot 3 的结果先回来,也得等 slot 2 提交之后才能落日志,代码头一行的注释就是规则原文(committed only across contiguous model-order slots)。派发可以重叠,tool/result 的顺序永远和模型看到的一致。并行执行、串行提交,八个字概括这个调度器,重放时工具结果不会乱序。
池子的补给(fillPool,tool-calls.ts:198-213)每个 start 前重读 executionMode,注册表变更可以在运行中制造新的屏障;tool/call 事件在分类后、启动前提交,让注册表的变化能影响还没启动的调用。畸形参数也不丢信息:parseArguments(tool-calls.ts:104-110)对非法 JSON 原样保留字符串,空串映射为 {},怎么裁决交给工具层。
工具执行完,结果怎么回到循环?executeToolCalls 的第四个参数是个上下文接纳器:工具结果的附加上下文被插进 next-step 队列,本步的 tool/result 之外,下一 step 边界会一并认领。返回值约定只有两种:工具调用全部终结返回 {kind:'completed'},还有没终结的调用返回 null,外层循环看到 null 就开下一个 step。模型看一眼结果再决定下一步,ReAct 的循环就这样滚起来。
中止的善后最见功力(tool-calls.ts:249-259)。abort 到达时,已启动的调用排空并落真实结果;没启动的每个调用经 appendSkippedToolCall 补写 tool/call 加合成结果,错误码 TOOL_ABORTED_BEFORE_DISPATCH。为什么必须合成?日志重放要求每个 tool/call 都有配对的 tool/result,悬空的工具消息会让下一轮 deriveMessages 产出残缺请求,多数模型 API 直接拒绝。上一篇 repair 在崩溃后补配对边界,这里在运行中取消时补,同一原则的两次落地。
工具也有条不紊了。还剩一个分支:模型调用失败怎么办。
八、重试是特权:finish 三分支
流结束时三种走向(step 内部)。正常结束:装配 assistant 消息入账,有工具调用就进下一轮 step,这就是 ReAct 的思考-行动交替在事件层面的形态。撞上 max-tokens:直接结束本 step,结束原因粘住。第三种是失败:走 agent/request-error waterfall(agent.ts:375),payload 带 provider、failure、retryPolicy。
关键在默认动作。waterfall 没有人接,返回 undefined,循环抛 LlmError 结束。默认不重试。
重试是插件授予的特权,不是内建策略。这就是前几篇现象的结构性答案:llm-retry 挂在 agent/request-error 上,返回 {kind:'retry'} 循环才 continue 重发请求,重试计数作为 llm/retry 事件落进会话日志,崩溃重启后接着数。循环本体对重试策略一无所知。你可以在 llm-retry 前面挂自己的降级路由,循环不改一行。
为什么把重试放在这一层而不是 llm/stream 那层?因为这里有 turn/step 上下文。llm 篇讲过:只有站在 agent 的肩膀上,重试计数才能写进会话日志,才能知道「这个 turn 已经试了几次」。策略定义在 llm(provider 注册时提供),执行挂在 agent-loop 的接缝上,各取所长。
失败被结构化得也很干净(agent.ts:307-311):LlmError 保留原始事实,其余异常包一层再抛。abort 则走 assembler.interruptedBlocks(),取消前收到的半截输出也装配成块如实入账。模型的半个回答,日志里照样有记录。
九、工厂与取消:三种 abort 源,一个控制器
循环之上还有一层所有权问题:agent 实例本身的生命周期。AgentLoop 服务(index.ts:296)inject 五个服务,经 ctx.agents.setFactory 上架(index.ts:350)。
prepare(index.ts:459)构建一个 agent 的完整预备体,取消源有三个:调用方传入的 signal、宿主 fiber 的卸载、工厂自身的销毁。前两个的融合代码就这么几行(index.ts:478-487):
const abort = new AbortController()const onCallerAbort = (): void => { abort.abort(callerSignal?.reason instanceof Error ? callerSignal.reason : new Error(`agent 「${id}」 creation aborted`, { cause: callerSignal?.reason }))}const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }callerSignal?.addEventListener('abort', onCallerAbort, { once: true })this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })这段是取消源的汇流处。注意两个监听都带 { once: true },触发一次就自我拆除,不留悬挂监听器;谁先到,理由就汇进同一个 AbortController,循环内部从头到尾只认这一根 signal。第三个取消源(宿主 fiber 卸载)不在这段里,它走下面那个记忆化的 dispose:先 cancel,再等空闲,最后拆 scope。而且注册发生在任何资源存在之前(index.ts:456 的注释:「BEFORE publication, so a mid-setup unload rolls everything back」),卸载落在构建中途也能整体回滚。cordis 篇讲过的 effect 清理链条,在这里被一个工厂完整走了一遍。
放到用户视角:你在 web-ui 里关掉一个会话标签页,宿主 fiber 卸载,融合控制器立刻 abort,正在跑的工具排空、没启动的补合成结果、turn/end 落账、scope 逆序拆干净。关一个标签页的成本是固定的,不管当时循环跑到哪一步。
声明式配置还支持 sessionId 和 resumeSessionId 互斥的精确身份,配置驱动的启动走 restoreOrCreateConfigured(index.ts:407):先试恢复,恢复不了再建。resume 失败时的降级条件卡得很死:持久层确实没有这个 id 才降级为首建,会话损坏或后端故障照样报错,报错还要经 agent-loop/config-start-failed 事件送到身份绑定的消费者手里,不会被静默吞掉。这个分寸和上一篇「版本拒绝先于结构校验」是同一种品味:能降级的降级,该报错的报错,不糊弄。
输入侧的竞态也守着(agent.ts:116-119):wakingAfterAbort 在插入消息之前捕获,若目标驱动已 abort,唤醒输入改投 next-turn 队列留给下一个驱动。cancel 默认清空收件箱再 abort;keepInbox 时排队消息保留,下一个驱动接着用。
失败怎么离开循环也有一致通道。throwError(agent.ts:203)把异常连同当时的 turn/step 打包成 agent/error 事件 emit 出去再抛,UI 的红色提示、日志里的错误记录、遥测的上报,消费的都是同一份结构化事实。
十、设计取舍
为什么循环做成插件?因为「ReAct 循环」只是一种驱动策略。上层 dsh-agent 只定义 Agent 抽象接口和 Inbox 协议,agent-loop 作为默认工厂上架。何时重试、路由到哪个模型、哪些消息进上下文,这些策略决策全部是接缝上的插件行为,循环本体不认识任何具体插件。
替换它会失去什么?turn/step 事件的规范产出(下游重放、UI、持久化修复全依赖这对边界)、并行执行串行提交的调度语义、请求可重构的 invariant 保证。接口留着的:写一个单轮问答 agent、一个 workflow 驱动的 agent,只要产出同样的事件骨架,持久化和 UI 照常工作。subagent 插件就是现成例子,它复用同一个循环,只换掉工具集和 preset,再过两篇就拆到。
二次扩展有四条接缝,按介入时机选:
agent/requestwaterfall:动态切模型、注入临时参数; agent/pre-step:改写本步消息,或直接 reject 结束 turn; agent/request-error:自定义恢复策略,llm-retry 排在这里,你可以在它前面做降级路由; agent/turn-stopping:turn 决定结束但 next-step 还有新消息时,插件最后一次注入机会。
已知代价两条。每 chunk 一个事件,长输出下写入量放大,靠持久层的 write-behind 攒批缓冲;状态机的正确性依赖大量注释锚定微妙顺序(「captured before the insertion」、「fresh controller makes a latch stale」),改 send、wakeDriver、turn 之前务必先读注释,这点源码作者自己都强调了。
十一、结语
回到开场的那个 turn,整条链路走一遍:followup 进队列(inbox)→ 唤醒驱动(kick,turn/start 落账)→ 认领与组装(preStep,环境快照压轴)→ 从日志现推导消息(deriveMessages)→ 五段钉死请求(buildRequest)→ 63 行断言比对(invariant)→ chunk 逐个入账(攒进 chunkSeqs)→ 消息出生(assistant/message 回链)→ 工具进池、结果按模型序提交(executeToolCalls)→ max-tokens 粘住结束原因,turn/end 无条件落账。
这一整条链上,主循环自己攒下的状态只有一个 Phase 对象。
下一篇往下钻一层:循环里模型伸出去的那只手。executeToolCalls 只管调度,工具从哪注册、参数谁校验、执行前谁放行,是 tools 插件的事。本篇池子里每个调用头上那道 executionMode 分类,答案就在那儿。
你的 Agent 框架里,消息历史是内存里拼的数组,还是能从日志重放出来的推导?重试写在循环里,还是挂在接缝上?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。