Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/DeepSeek Harness/第5章

第5章:Agent Loop —— 唯一含循环逻辑的包

8 分钟 · 更新于 2026-09-01

第5章:Agent Loop —— 唯一含循环逻辑的包

第 1 章说过一个反直觉的事实:185 个包里,只有 dsh-agent-loop(1295 行)包含具体的循环逻辑。这一章拆开它:session / turn / step 三级生命周期怎么转,inbox 怎么投递消息,agent 创建怎么做到事务性发布,以及"发起方作用域"这个容易被误解的机制。

本章大部分细节来自该包的官方 README 与打包源码,事件序列来自本机真实会话日志。所有引文都可复核。


一、三级生命周期:session、turn、step

session 到 turn 到 step 的生命周期|900

配图说明:session 是一条追加日志,内含多个 turn;一个 turn 内含多个 step(无上限,工具调用或 steering 会延续轮次);一个 step = 一次模型调用 + 它请求的工具执行。放大看 step:pre-step 裁决(waterfall,可 reject/enter)→ 模型调用(assistant/chunk × N)→ 工具执行(五段流水线)→ 继续或结束。底部是 inbox 双队列(next-turn / next-step)与 followup / steer / inject 三种投递。

1.1 三个词的定义

先看官方事件词汇表里的权威注释:

  • session(会话)Session 是「agent 全部交互历史的仅追加真源」,一个 agent 与其会话共享同一个 SessionId。 【来源:dsh-session/README.zh.md;dsh-agent/lib/index.js】

  • turn(轮次)turn/start 事件在循环领取排队输入或运行 pre-step 之前打开轮次。拒绝、空输入、取消或失败可能让它无 step 直接关闭。 【来源:dsh-session/lib/types/types.d.ts】

  • step(步骤)step/start 打开 turnstep —— 一次模型调用加上它请求的工具执行。 【来源:dsh-session/lib/types/types.d.ts】

层级关系:

text
session(会话,一条追加日志,多个 turn)
 └── turn 1(轮次)
      ├── step 1(一次模型调用 + 它触发的工具执行)
      ├── step 2(如果模型又调了工具,turn 继续)
      └── step N
 └── turn 2
     └── ...

1.2 一个 turn 能有多少个 step?—— 没有上限

这是和"轮次预算"直觉相反的地方。README 原话:

没有内置轮次预算:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期扩展点(如 agent/turn-stopping)执行取消。 —— dsh-agent-loop/README.zh.md

源码里的 turn 循环是个 while (true),退出条件只有几处:

javascript
if (decision.kind === "reject") { turnEnds = { kind: "blocked" }; return false; }
if (turnEnds && decision.messages.length === 0) break;
if (phase.step === 0 && decision.messages.length === 0) { turnEnds = { kind: "completed" }; return false; }
...
if (turnEnds && this.inbox.nextStep.length === 0) {
  await this.dispatch.serial("agent/turn-stopping", { turn, signal });
}
if (turnEnds && this.inbox.nextStep.length === 0) break;
target = "next-step";

【来源:dsh-agent-loop/lib/index.js:538-573】

翻译成人话:turn 在以下三种情况结束——

  1. pre-step 被拒 → 立即以 blocked 结束,且没有产生任何 step
  2. 第 0 步就没有任何消息completed,无 step(空轮次)
  3. 某个 step 得出终结结论completed/max-tokensnext-step 队列为空 → 先广播 agent/turn-stopping(让插件有机会塞新东西),若没人塞就 break

只要模型还在调工具、或者有人 steer 塞了 next-step 消息,turn 就继续。

1.3 turn/end 的 reason 全集

kind含义
completed正常完成
aborted取消请求打断了活跃轮次(原因细分 user / parent / hook / disposed)
blockedpre-step 拒绝
error轮次失败;错误总是结构化失败(LlmError 事实原样,或 {message, code:'UNKNOWN'}
max-tokens至少一个 step 触达输出 token 上限,即使插件延续了轮次
interrupted持久化后端重载时关闭崩溃遗留的轮次;循环本身从不发出此标记

【来源:dsh-session/lib/types/types.d.ts:135-169】

1.4 实测:一个真实 turn 的事件序列

从本机真实会话日志提取 [实测](本教程第 1 轮对话,turn 1):

text
turn/start                       {turn: 1}
step/start                       {turn: 1, step: 1}
assistant/chunk   × 355          (模型流式输出,token 级)
tool/call                        {turn:1, step:1, callId:"toolu_...", name:"pwsh", arguments:"{...}"}
tool/result                      {turn:1, step:1, message:{...}}
assistant/chunk   × …            (模型继续输出)
step/end                         {turn: 1, step: 1}
…                                (更多 step 直到任务完成)
turn/end                         {turn: 1, reason:{kind:"completed"}}

这个序列揭示了第 8 章的核心设计:每一步都是事件流,会话日志按事件原样记录,消息历史由它派生。


二、inbox:三种投递语义

2.1 双队列

每个 agent 有一个 inbox,两个队列:

队列语义
next-turn等待独立轮次的提示词
next-step等待下一个 step 边界的输入

【来源:dsh-agent/lib/types/inbox.d.ts】

2.2 followup / steer / inject

统一 send() 原语按(目标队列 × 是否唤醒驱动器)路由,三个方法是预设别名:

方法目标队列唤醒语义
followup()next-turn追加一条普通轮次消息并唤醒
steer()next-step中途引导;idle 时同步启动轮次,运行中在下一个 step 边界消费
inject()next-step不唤醒的上下文;运行中的驱动器在最近 pre-step 边界领取,idle 则待处理直到被唤醒

【来源:dsh-agent-loop/README.zh.md;dsh-agent/README.zh.md】

源码实现:

javascript
followup(input) { this.send(input, "next-turn", true); }
steer(input)    { this.send(input, "next-step", true); }
inject(input)   { this.send(input, "next-step", false); }

【来源:dsh-agent-loop/lib/index.js:390-404】

三个方法分别什么时候用?

  • followup = 给它一个全新的任务("继续查下一个问题")
  • steer = 打断它正在做的事,引导方向("先别查了,看这个文件")
  • inject = 只给它补充上下文,不打断("AGENTS.md 变了,这是新内容")

第 12 章会看到 agent-instructions 插件正是用 inject 语义向模型注入嵌套 AGENTS.md 的。

2.3 领取(claim)与事件三件套

领取通过仅执行删除的 splice 移除整批消息,每移除一条发一次 agent/inbox/claimed { message, turn }。同时每次 inbox 变更都会在修改实时投影前先发布持久agent/inbox/spliced 事件。

实时通知(逐消息最小载荷):inserted / claimed / discarded;持久投影:agent/inbox/spliced(规范化的 splice 坐标)。同步观察方可以从 splice 前投影重建被移除的值。

【来源:dsh-agent-loop/README.zh.md;dsh-agent/README.zh.md】


三、agent/pre-step:进 step 前的裁决

3.1 payload

typescript
'agent/pre-step'(this: Scoped<Agent>, payload: {
  agent: Agent;
  messages: UserMessage[];   // 本 step 从 inbox 移除的消息
  turn: number; step: number;
  signal: AbortSignal;       // 当前 turn 的取消信号
}, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>;

【来源:dsh-agent/lib/types/runtime-types.d.ts:235-241】

这是一个 waterfall 事件(监听器按序处理,每个可以调用 next() 把决定权传给下一个),且是 scope-filtered(agent 作用域监听器只收到该 agent 的事件)。

3.2 裁决结果

typescript
type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] };

【来源:dsh-agent/lib/types/runtime-types.d.ts:47-52】

  • reject → turn 以 blocked 关闭,不产生 step。领取过的消息已被删除,不会保留。
  • entermessages 是拟进入 step 的完整、带标识、冻结的批次。下游监听器保留它,除非有意替换。

3.3 默认实现:系统提示词在这里装配

如果没有监听器改写,循环在 waterfall 之前先做 systemPrompt.assemble() 和 runtime-context 投影,把 runtime-context 作为一条额外消息附在已领取批次之后

javascript
const claimed = this.inbox.claim(target, position.turn);
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal));
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 === void 0 ? claimed : [...claimed, context] }));

【来源:dsh-agent-loop/lib/index.js:496-508】

这就是第 9 章"系统提示词由多个插件拼装"的挂载点 —— 模型看到的每一条消息,都经过这道 waterfall 才能进入 step。


四、创建 agent 的事务性发布

4.1 一个创建 = 一个受回滚保护的事务

创建与恢复属于同一个受回滚保护的事务:构造私有会话、具体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 session/createdagent/created;发出 agent/session-start;此后才启动驱动器。 —— dsh-agent-loop/README.zh.md

源码里的发布序列:

javascript
publish: (source) => {
  assertLive();
  detachSession = agent.ctx.sessions.enter(session);
  detachAgent   = loopCtx.agents.enter(agent, ownerCtx.agent);
  agent.ctx.sessions.announce(session);
  assertLive();
  loopCtx.agents.announce(agent);
  assertLive();
  emitAgentEvent(loopCtx, agent, "agent/session-start", { source });
  assertLive();
  return { agent, dispose };
}

【来源:dsh-agent-loop/lib/index.js:1157-1171】

注意每一步之间的 assertLive() —— 任何一步发现 abort,整个发布就回滚。

4.2 enter():发布的裁决点

javascript
enter(agent, owner) {
  const id = agent.id;
  if (id !== agent.session.id) throw new Error(`agent id "${id}" does not match session id "${agent.session.id}"`);
  ...
  if (this.store.has(id)) throw new Error(`agent "${id}" is already registered`);
  ...
  return detach;
}

【来源:dsh-agent/lib/index.js:601-626】

enter() 做三件事:① 强制 agent.id === agent.session.id;② 权威 ID 冲突检查;③ 不通知插入,返回绑定到确切条目的 detach。

4.3 并发创建与回滚

不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。 —— dsh-agent/README.zh.md

teardown 顺序有明确规定:

停止并排空 → 撤销作用域 → detach agent → detach 会话。私有作用域清理完成后,该 id 即可复用。 —— dsh-agent-loop/README.zh.md

一个精妙的细节:每次 detach 都绑定到确切进入的对象,因此"陈旧 disposer 无法移除之后出现的同 id 替代项"。

4.4 所有权模型

typescript
AgentHandle = { agent: Agent; dispose(): Promise<void> };

disposer 是消费方能力 —— 只持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 与工厂提供方是结构化共同拥有者,任意一方调 dispose() 都到达同一个记忆化的完全停稳边界。

【来源:dsh-agent/README.zh.md】


五、maxParallelToolCalls:并行与串行

默认值 10,来自两处源码确认:

javascript
const maxParallelToolCalls = value ?? 10;
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)
  throw new Error("maxParallelToolCalls must be a positive integer");

【来源:dsh-agent-loop/lib/index.js:896-898】

README 的说明:

限制每个 agent 针对并行安全调用使用的滚动池,默认值为 101 是串行

还有一个热更新特性:

它同时也是 agent-loop Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用。

并发模型的准确描述:

在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发和调用主体的执行会发生重叠。策略、持久结果和结果上下文仍保持模型顺序。

【来源:dsh-agent-loop/README.zh.md】


六、发起方作用域(initiator scope)

6.1 实现:AsyncLocalStorage

javascript
import { AsyncLocalStorage } from "node:async_hooks";
...
initiators = new AsyncLocalStorage();
initiatorRuns = new AsyncLocalStorage();

【来源:dsh-agent/lib/index.js:2, 418-419】

四个 API:currentInitiator() / requireInitiator() / withInitiator(agent, op) / withoutInitiator(op)

驱动器启动时就进入该边界:

javascript
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject);

【来源:dsh-agent-loop/lib/index.js:458】

6.2 为什么需要它

场景:父 agent 派了一个子 agent,子 agent 在跑。此刻发生了某个事件,事件处理器需要知道"谁发起的工作"——是父 agent 还是子 agent?

没有 initiator scope,你得把 agent 对象一路手工传参。有了它,ctx.agents.currentInitiator() 一行就能读出来,而且自动跟随异步链(AsyncLocalStorage 的语义)。

6.3 边界限制(官方原话,必须记住)

该作用域携带 Agent 本身,并且只在进程内有效。环境中的身份既不是存活证明,也不是授权;在服务、worker、进程、持久化和 wire 边界,显式 Agent 字段仍是权威来源。 —— dsh-agent/README.zh.md

发起方作用域只存在于进程内:worker、子进程、HTTP、持久队列和重启必须显式传递所需身份。 —— dsh-agent/README.zh.md

环境身份可能比存活状态更久:消费方在生命周期敏感工作前,仍要检查 agent.status、取消状态和所属能力约定。 —— dsh-agent/README.zh.md

一句话总结:initiator scope 是个"进程内的便利",不是跨进程的身份机制。 跨了进程边界,就得自己显式传 agent 字段。


七、动手复核

powershell
# 1. 唯一含循环逻辑的包
(Get-Content "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-agent-loop\lib\index.js").Count

# 2. 自己会话里真实的事件流
# 会话日志是 zstd 压缩的 JSONL,本教程的取证脚本用了 node 的 zstdDecompressSync 分帧解压。
# 你可以在任意会话里 grep 事件类型(参考正文 1.4 节的序列)。

# 3. maxParallelToolCalls 默认值
Select-String -Path "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-agent-loop\lib\index.js" `
  -Pattern 'maxParallelToolCalls'

# 4. 全部 turn/end reason
Select-String -Path "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-session\lib\types\types.d.ts" `
  -Pattern 'completed|aborted|blocked|max-tokens|interrupted'

八、总结

Agent Loop 是 dsh 的心脏,但它被刻意做成了最薄的包

  1. 三级生命周期:session(一条日志)→ turn(轮次,无 step 上限)→ step(一次模型调用 + 工具执行)
  2. inbox 双队列next-turn / next-step,配 followup / steer / inject 三种投递语义
  3. pre-step waterfall:进 step 前的裁决点,系统提示词在这里装配,插件可以拒绝或改写批次
  4. 事务性发布:创建 agent 是受回滚保护的事务,enter() 做 ID 裁决,失败全回滚
  5. 并行池maxParallelToolCalls 默认 10,1 即串行
  6. 发起方作用域:AsyncLocalStorage 实现的进程内便利,不是跨进程身份

下一章看模型的入口:模型调用 —— provider 中立的 LLM seam


  • README-教程总览
  • 第4章-Seam架构-抽象服务与具体实现的分离
  • 第6章-模型调用-provider中立的LLM-seam
  • 第8章-会话-事件溯源与surface投影 —— 三级生命周期的日志视角

本章目录
一、三级生命周期:session、turn、step二、inbox:三种投递语义三、agent/pre-step:进 step 前的裁决四、创建 agent 的事务性发布五、maxParallelToolCalls:并行与串行六、发起方作用域(initiator scope)七、动手复核八、总结Related Documents
苏ICP备2025204887号-2