第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;一个 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 打开 turn 的 step —— 一次模型调用加上它请求的工具执行。
【来源: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 在以下三种情况结束——
- pre-step 被拒 → 立即以 blocked 结束,且没有产生任何 step
- 第 0 步就没有任何消息 → completed,无 step(空轮次)
- 某个 step 得出终结结论(completed/max-tokens)且 next-step 队列为空 → 先广播 agent/turn-stopping(让插件有机会塞新东西),若没人塞就 break
只要模型还在调工具、或者有人 steer 塞了 next-step 消息,turn 就继续。
1.3 turn/end 的 reason 全集
| kind | 含义 |
|---|
| completed | 正常完成 |
| aborted | 取消请求打断了活跃轮次(原因细分 user / parent / hook / disposed) |
| blocked | pre-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。领取过的消息已被删除,不会保留。
- enter → messages 是拟进入 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/created 和 agent/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】
默认值 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 针对并行安全调用使用的滚动池,默认值为 10;1 是串行。
还有一个热更新特性:
它同时也是 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 的心脏,但它被刻意做成了最薄的包:
- 三级生命周期:session(一条日志)→ turn(轮次,无 step 上限)→ step(一次模型调用 + 工具执行)
- inbox 双队列:next-turn / next-step,配 followup / steer / inject 三种投递语义
- pre-step waterfall:进 step 前的裁决点,系统提示词在这里装配,插件可以拒绝或改写批次
- 事务性发布:创建 agent 是受回滚保护的事务,enter() 做 ID 裁决,失败全回滚
- 并行池:maxParallelToolCalls 默认 10,1 即串行
- 发起方作用域:AsyncLocalStorage 实现的进程内便利,不是跨进程身份
下一章看模型的入口:模型调用 —— provider 中立的 LLM seam。
- README-教程总览
- 第4章-Seam架构-抽象服务与具体实现的分离
- 第6章-模型调用-provider中立的LLM-seam
- 第8章-会话-事件溯源与surface投影 —— 三级生命周期的日志视角