DeepSeek Harness Agent Loop 全解析:逻辑 · 难点 · 亮点
0. 一句话总览
Agent Loop 是 DSH 中驱动一个会话「思考 → 调用工具 → 产出回复」的日志驱动 React 循环:
bash
系统提示词 + 上下文 + 用户消息
↓ 组装成一次冻结、可回放的 LLM 请求
LLM 流式输出 → assistant/chunk 事件 → 组装成 assistant/message
↓ 若含工具调用
按 串行屏障 / 有界并行池 调度执行 → tool/call + tool/result 事件 → 结果放回下一步输入
↓
进入下一步(step)或下一轮(turn),直到 completed / blocked / max-tokens / aborted / error
核心原则:一切输入派生自会话日志(session log),一切输出写回会话日志。 没有内存态的「当前对话」,只有可回放的事件流------这是它能在 SW 挂起、重启、暂停、被目标轮次/subagent 反复唤醒续跑的根基。
第一部分 · 全部逻辑
1.1 三个时间尺度:Session / Turn / Step
| 概念 | 含义 | 对应日志事件 |
|---|---|---|
| Session(会话) | 一个 agent 的全部历史:事件日志 + 请求头 + 上下文 | request/header、request/context |
| Turn(轮) | 「一次输入 → 完整回应」的边界,对应一次任务/提问 | turn/start、turn/end(带 reason) |
| Step(步) | 一轮内「一次 LLM 请求 + 它产生的全部工具调用」的边界 | step/start、step/end、assistant/message、tool/call、tool/result、assistant/chunk |
- 一轮 turn 常含多个 step:模型先发带 3 个工具调用的消息 → 工具执行 → 结果作为下一步输入 → 模型再发纯文本 → 本轮结束。
turn/end.reason枚举:completed(完成)/blocked(pre-step 拒绝)/max-tokens(截断)/aborted(取消)/error(异常)。
1.2 Agent 状态机(phase)
ReactLoopAgent.phase 三种状态,各自持有独立 AbortController:
scss
idle ──wakeDriver()──▶ running(driver:kick() 循环跑 turn)
▲ │
└─────kick 退出────────┘
idle ──runMaintenance()──▶ maintenance(非 turn 后台任务,如拆卸/维护)
- idle :等待唤醒;
followup()/steer()(带 wakeup)与inject()(不带)投递消息。 - running :
wakeDriver()新建 phase(turn=lastTurn, step=0、新 AbortController),在withInitiator下执行kick();kick()反复await turn()直到返回 false。 - maintenance :仅能从 idle 进入,跑完回 idle;期间到达的 wake 记为
wakeRequested,结束后若 inbox 仍有消息则立即重新拉起 driver。 cancel(cause, {keepInbox}):默认清空 inbox 并 abort 当前 phase;abort 后的唤醒消息会被重分类投到next-turn。whenIdle():等待当前 activityDone 完成(用于拆卸前同步)。
1.3 Inbox:下一轮 / 下一步的输入队列(dsh-agent)
两个持久化队列,所有变更以 agent/inbox/spliced 事件写日志:
| 队列 | 用途 | 典型来源 |
|---|---|---|
next-turn |
等待单独开一轮的输入 | 普通用户消息、goal round 提示、followup() |
next-step |
等待下一步边界的输入 | 工具结果上下文、steer()、运行时上下文快照 |
claim(target, turn):先取走next-step全部;若 target 为next-turn再取队首 1 条(一轮只消费一条轮级消息,可消费任意条步级消息)。- 每条 splice 含
removedCount;取消类删除标记outcome: 'canceled'------这是「输入被消费并运行」与「工作被丢弃未运行」的区分依据(见 2.9)。 - 会话恢复时 inbox 从日志重建(跳过
seedLength之前的种子区),validate()防越界/重复 id。
1.4 Turn 循环(turn())
preStep的agent/pre-stepwaterfall 返回{kind:'enter', messages}或{kind:'reject'}:插件可拒绝 本轮(blocked)或增补消息(如注入运行时上下文)。- turn 结束判定注意顺序:
turnEnds 已存在 && 本次无消息→ 直接结束;step==0 && 无消息→ 空轮 completed。 - 异常路径:abort → reason
aborted;否则 reasonerror(LlmError取 failure,其他取errorChain根因),先发agent/error事件再抛出。 - 若 inbox 还有 pending → 重置 AbortController 自动开下一轮(这就是 goal round / 排队消息能连续驱动同一会话的原因)。
1.5 Step 循环(step())
- 流式 chunk 逐块落日志(可回放),
BlockAssembler把 chunk 组装成 block(文本/工具调用/推理等);assistant/message用sourceEventSeqs关联 chunk 序列。 - 工具执行产生的额外上下文(快照、指导消息)经
acceptContext塞回inbox.next-step。
1.6 请求构建(buildRequest)
- 读持久化
request/header(config + adapterDefaults);requestProposal()先剔除 adapter 派生的reasoningEffort/maxTokens,再交给插件。 agent/requestwaterfall:插件可改写 provider/model 等配置(模型路由扩展点)。llm.prepareCall(config, signal)绑定适配器;NO_ADAPTER时降级使用原 config。- 生成
canonicalHeader:首次写request/header(reason:initial/resume),配置变化时再写(reason:change);provider/model/contextWindow 变化写request/context。 - 返回冻结请求 (
markAgentLoopRequest+deepFreeze),携带sessionId与signal。
1.7 工具调用调度器(executeToolCalls)
- 按每个工具的
executionMode(exec).kind分组:exclusive(串行屏障)/parallel(可并行)。 - exclusive 是屏障:先等当前并行池排空再单独执行,后续调用重新分类。
- parallel 是有界滚动池 :
maxParallelToolCalls(默认 10,设置键agent-loop.maxParallelToolCalls),完成一个补一个。 - 结果按模型顺序 提交(
commitReady游标),与完成顺序无关。 - 每个调用先写
tool/call(拿 seq),结果tool/result用sourceEventSeqs:[callSeq]关联。
取消/失败语义:
- Abort :已启动调用 drain 完并提交真实结果;未启动调用写合成错误(
TOOL_ABORTED_BEFORE_DISPATCH)保证回放一致。 - 调度器内部失败 :停止新派发、drain 已启动调用、抛第一个错误,不伪造结果。
- 任一工具结果
concludesTurn === true→ 本轮 step 结束。
1.8 运行时上下文投影(RuntimeContextProjection)
- 记住日志里最后一个
source.plugin === dsh-system-prompt的快照。 project(current, sections)只在渲染结果与保留值不同 时生成新 user/message(source.form:"snapshot"携带 sections);清空时写CLEARED哨兵文本。- 快照是未提交候选,由 preStep 的
agent/pre-step瀑布决定是否纳入本轮。
1.9 工厂与生命周期(AgentLoop Service)
- 声明式配置 :
maxParallelToolCalls+agents[](id/sessionId/resumeSessionId/provider/model/maxTokens/cwd);launcher 可通过CONFIGURED_AGENT_IDENTITIES_KEY覆盖身份。 - 创建/恢复 :
create()同步风格;createAgent()异步 + setup 回调(归调用方 fiber 所有);resume()从sessionPersistence加载;restoreOrCreateConfigured先恢复、无记录才新建。 - 所有权(FactoryOwnership) :跟踪所有 live agent teardown 与启动任务;
dispose()abort 全部(reasonagent loop is not active),先拆 agent 注册再拆 session 注册,等全部 settle。 - 发布原子性 :
prepare()先把 teardown 注册进 owner fiber 与 factory(中途卸载可回滚),再publish():sessions.enter → agents.enter → announce → agent/session-start。 - 系统提示词变量:
provider/model/cwd。 - 运行时设置:
agent-loop.maxParallelToolCalls通过source()间接引用,不重建服务即可热更新。
1.10 不变式(invariant.js)
agent-loop-invariant 在 llm/stream 钩子上强制校验每条循环请求:
- 请求必须冻结;
- 必须带
sessionId且会话存活; messages冻结且逐字节等于session.deriveMessages();- 与
foldRequestHeader折叠的 header 一致(model/system/temperature/maxTokens/stop/tools)。
任一条不满足即 fail------这就是「请求可从日志重建」的硬保证。
1.11 与 Goal Round 联动(自动续跑)
dsh-goal-round-driver:goal armed + agent idle + 无竞争输入时,渲染 <goal_round> 提示(Objective + Round N/M),以 source.kind:"goal" 消息 followup 进 inbox → 唤醒开新一轮。轮间做 sessions.flush checkpoint;达 maxGoalRounds → goal 置 blocked(round-limit)。
第二部分 · 难点(这些地方为什么容易错,代码如何兜底)
2.1 并发工具调用 vs 严格顺序的日志
难 :并行池里工具完成顺序不可控,但 tool/result 必须按模型声明顺序落盘,否则回放/前端展示错乱;且 exclusive 调用插在并行组中间时,不能把后面的 parallel 一起并发跑掉。
兜底 :commitReady 用 committed 游标按模型顺序提交(没到序的结果先等);fillPool 在遇到非 parallel 调用处 break,只消费已启动的部分(consumed = started),exclusive 留给下一轮屏障;TOOL_RUNTIME_SCHEDULER 统一 prepare/dispatch/finalize 管道,保证每个调用的生命周期一致。
2.2 取消(abort)与日志一致性
难:abort 瞬间有的调用已启动、有的还没启动。若给已启动调用伪造结果会污染真实数据;若不给未启动调用记结果,日志就缺了「这条调用被取消了」的事实,回放时模型会看到「发了调用但没有结果」。
兜底 :abort 时已启动调用 drain 完并提交真实结果 (绝不伪造),未启动调用写合成错误结果 (TOOL_ABORTED_BEFORE_DISPATCH,isError:true),两者语义分明、日志完整。
2.3 调度器内部失败:不伪造、不死锁
难:调度器(工具注册/准备)自己挂了时,既不能像 abort 那样写合成结果(那是用户取消,不是系统故障),也不能无限等待在途调用。
兜底 :schedulerFailure 记下第一个错误 → 停止新派发 → Promise.allSettled drain 在途调用 → 抛错。已记录的 tool/call 事件保留,但不伪造 任何 tool/result。
2.4 可回放性:请求必须与日志严格一致
难:LLM 请求的 messages 是从「内存里攒的对话」推导的,而会话日志是唯一真相。一旦某插件偷偷改请求、或日志重建路径与派发路径脱节,回放出的就是另一个请求------会话恢复后行为漂移。
兜底 :请求冻结 (deepFreeze)+ 携带 sessionId + invariant.js 在 llm/stream 上逐字节比对 deriveMessages() 与折叠 header,任何脱节立即 fail。这是「日志即真相」的强制执法。
2.5 运行时上下文注入的噪声控制
难:把系统提示词里动态渲染的「当前运行时上下文」(文件策略、技能清单等)每轮每步都整体塞进请求,既费 token 又污染历史,还会让模型注意力涣散。
兜底 :RuntimeContextProjection 记录「最后保留的快照」,只在内容变化时 生成新 user/message;清空时写 CLEARED 哨兵。不变就不注入。
2.6 SW 挂起 / 重启 / 会话恢复
难:MV3 SW 随时可能被挂起,agent loop 的内存态(phase、inbox 投影)会丢;恢复后若 inbox 重建错位,消息会重复消费或丢失。
兜底 :inbox 的所有变更都以 agent/inbox/spliced 事件持久化,恢复时从日志重建(跳过 seedLength 种子区);validate() 拒绝越界 splice 与重复消息 id;resume() 走 sessionPersistence.prepare,取消用 raceAbortCall 释放被弃的 preparation([Symbol.dispose])。
2.7 生命周期所有权与「中途卸载」
难:agent 的 setup 还没跑完,owner fiber 或 factory 就被拆了------此时 teardown 逻辑还没注册完,容易泄漏;发布到一半(session 已 enter、agent 还没 announce)崩溃,注册表就脏了。
兜底 :prepare() 里 teardown 先于发布注册 (dispose 用 disposing ??= 记忆化,幂等);machineReady promise 让 dispose 能等待未完成的构造;assertLive() 在 publish 每一步之间校验,任一失败走统一 dispose 回滚;owner effect 在 setup 期 abort 时主动 dispose(true)。
2.8 模型路由与 adapter 默认值的拉扯
难 :持久化的 request/header 里可能有 adapter 写入的默认 reasoningEffort/maxTokens;插件又要在 agent/request 瀑布里改 provider/model。若把 adapter 默认值当用户配置交给插件,路由会乱。
兜底 :requestProposal() 先剥离 adapter 派生值 再给插件提案;prepareCall 绑定后生成 canonicalHeader,按 initial/resume/change 三档记录 header 变更;provider/model/contextWindow 变化单独记 request/context。NO_ADAPTER 优雅降级。
2.9 空轮 / 边界 / max-tokens 语义
难 :一轮 turn 在第一步之前就停下时,turn/end 的形状和「被拒绝/空 claim」完全一样------只看 turn 记录,分不清「输入被消费后没跑完」和「根本没干活」。
兜底 :dsh-agent 的 consumed-work 记账(foldConsumedWork / accountsForClaim)把 inbox 的 removedCount 与 outcome:'canceled' 纳入判定:completed 不算记账、blocked/aborted/interrupted/error 都算------取消时任何人都能只读日志得出「丢了多少未运行的工作」。
max-tokens :turnEnds 一旦为 max-tokens 就粘住(后续 step 不覆盖),即使后续步骤跑完,本轮仍按截断记账------语义明确。
2.10 多输入源竞争与身份冲突
难:同一会话可能同时有用户消息、goal round、steer 注入;多个配置 agent 可能撞同一个 sessionId。
兜底 :goal-round-driver 用 competingQueued / needsCheckpoint 栅栏避免与用户输入撞车;丢弃自己轮次时 restoreOtherClaimed 把其他已 claim 消息放回 inbox;validateConfiguredAgents 拒绝 sessionId 与 resumeSessionId 并存、拒绝重复身份;waitForDrainingConfiguredIdentity 等同一 id 的旧生命周期拆完再建。
第三部分 · 亮点(值得借鉴的设计)
3.1 日志即真相(Log as Source of Truth)
一切状态(消息、inbox、请求头、上下文、turn/step 记账)都从会话日志派生或写回,没有旁路内存态。这带来三个免费收益:崩溃可恢复、行为可回放、任何人(包括外部调用方)都能只读日志推导出准确状态------包括「取消后丢了哪些未运行的工作」(见 2.9)。
3.2 冻结请求 + 不变式执法
deepFreeze 的请求对象 + markAgentLoopRequest 标记 + invariant.js 在 LLM 流入口逐字节比对。不是「尽量保持一致」,而是不变量本身成为运行时护栏:任何破坏可回放性的改动当场 fail。这是把架构原则变成可测试约束的范例。
3.3 模型顺序提交 × 有界并行池
并发执行(吞吐)与严格顺序落盘(正确性)解耦 :工具随便并行跑,日志严格按模型声明顺序提交;exclusive 屏障与并行池复用同一 runGroup 管道。默认 10 并发且可运行时热调,兼顾安全与效率。
3.4 取消语义完备且分层
abort(用户取消)与 scheduler failure(系统故障)走两套不同语义:前者补合成错误结果保日志完整,后者坚决不伪造。取消瞬间「已启动/未启动」两类调用各有明确归宿,不会出现悬空调用。
3.5 上下文按 diff 注入
运行时上下文投影只在变化时生成消息(含 CLEARED 哨兵),省 token、少噪声、历史干净。这是长会话下系统提示词动态部分的标准解法。
3.6 事件驱动插件扩展点(waterfall 矩阵)
| 事件 | 作用 |
|--------------------------------------------------------|-----------------------------|-----------|--------|
| agent/pre-step | 拒绝本轮(blocked)或增补消息 |
| agent/request | 改写 provider/model,实现模型路由 |
| agent/request-error | 按 retryPolicy 决定是否重试 LLM 请求 |
| agent/turn-stopping | 轮末追加工作(可阻止 turn 结束) |
| `agent/inbox/inserted | discarded | claimed` | 观察输入流转 |
| agent/status / agent/error / agent/session-start | 状态广播与错误上报 |
循环本身不写死任何业务,全部通过瀑布开放,这是「通用驱动 + 可插拔策略」的教科书结构。
3.7 生命周期原子发布与有序拆除
teardown 先注册后发布 、dispose 记忆化幂等、publish 每步 assertLive、工厂 teardown 有统一 reason------把「中途卸载」这个最容易泄漏的角落做成了确定性的回滚路径。
3.8 Inbox 持久化 + 严格校验
队列变更即事件、恢复即重放、splice 带 removedCount/outcome:'canceled'、validate() 防越界与重复 id。连「待办队列」都是可回放日志的一部分,这比内存队列高一个量级的健壮性。
3.9 与 Goal Round / Subagent 的天然配合
目标轮次、任务面板、subagent 都只是往 inbox 投消息 + 唤醒,agent loop 自动开新一轮------驱动方不需要理解循环内部 ,只依赖公开的 followup() 与持久化语义。接口边界清晰,扩展成本极低。
3.10 运行时设置热更新
maxParallelToolCalls 通过 source() 间接引用 + 设置 section 的 setSource 回调,改设置不需要重建 AgentLoop 服务。把「配置」与「运行时值」解耦的轻量做法。
附录 A · 术语表
| 术语 | 含义 |
|---|---|
| ReactLoopAgent | 驱动一个会话的循环主体(turn/step 状态机) |
| AgentLoop | 工厂服务:create/resume、所有权、声明式配置 |
| Inbox | next-turn / next-step 持久化输入队列 |
| phase | idle / running / maintenance 三态 + AbortController |
| waterfall / serial | Cordis 事件两种模式:瀑布(可改结果、可短路)/ 串行(顺序执行) |
| BlockAssembler | 把流式 chunk 组装成 assistant 内容块 |
| deriveMessages | 从会话日志推导当前应发给模型的消息序列 |
| request/header | 持久化的请求配置头(provider/model/温度等) |
| RuntimeContextProjection | 运行时上下文快照的 diff 投影器 |
| consumed-work | 从日志折叠出「已消费未运行的工作」的记账 |
附录 B · 源码速查
| 位置 | 内容 |
|---|---|
dsh-agent-loop/lib/index.js |
ReactLoopAgent、AgentLoop、工具调度、上下文投影、常量 |
dsh-agent-loop/lib/invariant.js |
请求重建不变式插件 |
dsh-agent/lib/index.js |
Inbox、agentEvents、assembleContextFor、consumed-work 记账 |
dsh-llm |
BlockAssembler、消息工厂、markAgentLoopRequest、LlmError |
dsh-session |
事件日志、deriveMessages、requestHeader、foldRequestHeader |
dsh-system-prompt |
系统提示词 section 组装/渲染、运行时上下文 |
dsh-goal-round-driver |
目标轮次自动续跑、轮间 checkpoint |