从一个问题开始
你给 Agent 发了一句话:"帮我找出项目里所有没有单测的函数,然后补全。"
接下来的三分钟里,Agent 先用 search_files 找了所有 .ts 文件,再用 read_file 逐个读了二十几个文件,最后用 write_file 写了一批测试。全程没有问你任何问题。
这些工具调用是谁在调度?循环什么时候结束?如果模型中途说"我需要先确认一下目录结构",这句话是怎么触发新一轮工具调用的?
这就是 Agent Loop 要回答的问题。
两个核心概念:Turn 和 Step
理解 Agent Loop 只需要记住两个概念:
Step(步骤):一次完整的模型调用加上它触发的所有工具调用。一个 Step = LLM 输出 + 零到多个工具执行。
Turn(轮次):从用户发送输入开始,到 Agent 不再欠任何响应为止的完整过程。一个 Turn 包含一个或多个 Step。
Turn 开始
↓
Step 1:模型回复 + 调用 search_files
↓
Step 2:模型分析搜索结果 + 调用 read_file × 5
↓
Step 3:模型写测试 + 调用 write_file × 3
↓
模型回复:"已完成,共补全 12 个函数的测试。"
↓
Turn 结束为什么需要区分 Turn 和 Step?因为一个 Turn 可以包含多个连续的模型请求——工具执行完毕后,结果会附在历史里,模型继续决策。这个循环直到模型不再调用工具、且没有新的待处理输入时才停止。
驱动器的工作方式
dsh 的 Agent Loop 核心是 ReactLoopAgent 类(源码:packages/core/agent-loop/src/agent.ts)。
它的驱动循环极其简单:
// packages/core/agent-loop/src/agent.ts
/** 主循环:持续执行 Turn,直到没有更多待处理工作 */
private async kick(): Promise<void> {
try {
// turn() 返回 true 表示还有待处理工作,继续下一个 Turn
while (await this.turn()) {}
} catch (_error) {
// 错误在这里被隔离,不会崩溃外层
} finally {
this.setPhase({ kind: 'idle', lastTurn: turn })
// 如果有被 latch 的唤醒请求,继续处理
if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
}
}驱动器有三种状态:
| 状态 | 含义 |
|---|---|
idle | 无活跃工作,等待唤醒 |
running | 正在执行一个 Turn |
maintenance | 执行非 Turn 任务(如压缩对话历史) |
当新消息到达时,wakeDriver() 被调用,驱动器从 idle 切到 running,开始处理。
消息怎么进入 Loop
Agent 有一个 Inbox(收件箱)。不同路径的输入进入不同的 inbox 分区:
// 三种输入方式,对应三种调度语义
// 1. followup:普通用户消息,进入下一个 Turn
agent.followup(message) // → next-turn 队列,唤醒驱动器
// 2. steer:在最近的 Step 边界注入,进入下一个 Step
agent.steer(message) // → next-step 队列,唤醒驱动器
// 3. inject:注入上下文,不唤醒驱动器
agent.inject(message) // → next-step 队列,不唤醒三者的区别很关键:
followup开启一个全新的 Turn,用于正常的用户输入steer在当前 Turn 的下一个 Step 被领取,用于在 Agent 执行中途引导它(比如 "不对,先读 README 再说")inject把上下文放进队列但不唤醒驱动器,等待自然的步骤边界才被领取,适合后台插入提示
完整执行流程
一个 Turn 从打开到关闭的完整路径:
turn/start(记入 Session 日志)
│
├── 领取消息(从 inbox 取出)
├── 组装 System Prompt(调用 ctx.systemPrompt.assemble)
│
▼
[agent/pre-step waterfall] ← 扩展点 ①
│ 返回 reject 或 enter(messages)
│ reject → Turn 空结束(不留 Step 记录)
│
├── step/start(记入 Session 日志)
│
▼
[agent/request waterfall] ← 扩展点 ②
│ 替换 LLM 调用配置(模型、maxTokens 等)
│
├── 提交 system/message 和 user/message
├── 从 Session 日志派生并冻结请求
│
▼
[llm/stream waterfall] → 模型流式输出
│ → agent/assistant-stream(实时推送 UI)
│ → 记录 assistant/message 或 assistant/attempt
│
▼
工具调用(并行池 + 独占屏障)
│ → tool/call 记入 Session
│ → tools/pre-execute → tools/execute → tools/post-execute
│ → tool/result 记入 Session
│
├── step/end(记入 Session 日志)
│
├── 还有工具结果要处理,或 next-step 有新消息?
│ 是 → 继续下一个 Step
│ 否 → 检查是否结束 Turn
│
▼
[agent/turn-stopping serial] ← 扩展点 ③
│ 监听器可以调用 agent.steer() 让 Turn 继续
│ 没有新工作 → 关闭 Turn
│
turn/end(记入 Session 日志)三个关键扩展点
扩展点 ①:agent/pre-step(waterfall)
在每个 Step 开始前触发。你可以检查、改写或拒绝即将进入模型的消息。
type PreStepDecision =
| { kind: 'reject' } // 拒绝这个 Step,Turn 空结束
| {
kind: 'enter'
messages: UserMessage[] // 实际进入模型的消息(可以改写)
startsRequestSeries?: true // 是否开始新的请求序列
}实际用途:
// 示例:拦截包含敏感路径的指令
ctx.on('agent/pre-step', async (payload, next) => {
const { messages } = payload
for (const msg of messages) {
if (messageContainsSensitivePath(msg)) {
// 拒绝这个 Step
return { kind: 'reject' }
}
}
// 没问题,继续
return next()
})注意:这是 waterfall,必须要么调用 next() 要么返回决策。agent/pre-step 是 dsh 里最强的"闸门"——连模型都还没看到消息,你就能把它拦住。
扩展点 ②:agent/request(waterfall)
在每个模型请求发出前触发,用于替换调用配置:
ctx.on('agent/request', async (payload, next) => {
const config = await next() // 拿到默认配置
// 根据当前任务调整参数
return {
...config,
maxTokens: 8192, // 给这个会话更大的输出空间
reasoningEffort: 'medium', // 降低推理强度节省 token
}
})这个事件运行在 step/start 之后、system/message 和 user/message 被提交之前。取消操作发生在这里不会提交任何 Session 记录。
扩展点 ③:agent/turn-stopping(serial)
Turn 在自然终止前触发。监听器可以向 Agent 注入新的工作来让 Turn 继续:
ctx.on('agent/turn-stopping', async (payload) => {
const { agent, turn, signal } = payload
// 检查是否还有待确认的任务
const pendingTask = await db.getPendingTask(agent.session.id)
if (pendingTask) {
// 注入新工作,Turn 会继续
agent.steer({
type: 'user',
content: [{ type: 'text', text: `还有一项任务:${pendingTask.description}` }],
})
}
// 如果什么都不做,Turn 自然关闭
})agent/turn-stopping 是 serial 事件(见第二篇),没有 next(),监听器只是有机会"推"数据进去,但不能"投票"是否关闭。数据决定一切——有新工作就继续,没有就结束。
assistant/message vs assistant/attempt
这是 Session 日志里两个容易混淆的事件:
assistant/message | assistant/attempt | |
|---|---|---|
| 何时出现 | 模型成功完成输出 | 失败/重试/取消/流错误 |
| 写入模型历史 | ✅ 会(被 deriveMessages() 采纳) | ❌ 不会(被忽略) |
| Session 日志 | ✅ 记录 | ✅ 记录(用于审计) |
| 场景 | 正常响应,含内容为空的成功 | 网络错误、context overflow、取消 |
这个设计保证了对话历史的干净性:失败的尝试不会污染模型下次看到的历史,但所有尝试都被审计保留了。
循环为什么会结束
很多人困惑:Agent 调用了一堆工具,为什么某个时刻它就停了?
循环结束的条件是:
- 模型不再调用工具(响应里没有
tool_call) next-stepinbox 没有新工作
满足这两个条件时,循环进入 agent/turn-stopping。监听器若不注入新工作,Turn 就此关闭。
如果工具本身想让 Turn 提前结束,可以在工具定义的 execute 里调用 exec.concludeTurn():
// 某个工具认为任务已完成,主动结束 Turn
async execute(args, exec) {
const result = await doSomething()
exec.concludeTurn() // 告诉 Loop:这个 Turn 到此结束
return result
}实战:打印 Agent 的执行轨迹
把上面的扩展点组合起来,可以写一个监控插件,打印每个 Turn/Step 的事件:
export const name = 'agent-tracer'
export const inject = ['tools']
export function apply(ctx: Context): void {
// 监听 Turn 状态变化
ctx.on('agent/status', (payload) => {
console.log(`[Agent] status → ${payload.status}`)
})
// 监听每个 Step 开始
ctx.on('agent/pre-step', async (payload, next) => {
const { turn, step, messages } = payload
console.log(`[Step] turn=${turn} step=${step} messages=${messages.length}`)
return next()
})
// 监听工具调用结果
ctx.on('tools/result', (exec, result) => {
const icon = result.isError ? '✗' : '✓'
console.log(`[Tool] ${icon} ${exec.name}`)
})
// 监听 Turn 结束
ctx.on('agent/turn-stopping', (payload) => {
console.log(`[Turn] ${payload.turn} stopping`)
})
}注册这个插件后,每次 Agent 运行你都能看到完整的执行轨迹,不需要修改任何 dsh 核心代码。
设计总结:为什么这样设计
Agent Loop 的设计有两条明确的分界线:
持久 vs 实时:
turn/*、step/*、assistant/message等是会话事件——持久化、可回放、可迁移agent/pre-step、agent/request、agent/assistant-stream等是实时扩展点——只在活跃驱动器中存在,不持久化
数据驱动:Loop 的每个决策都由数据决定,而不是外部"投票"机制:
- 工具调用不调用了 → 有自然停止点
- 工具结果里有
concludesTurn→ 提前结束 agent/turn-stopping里有监听器注入新工作 → 继续
这意味着你永远不需要 "force stop" 的 API——数据里没有新工作,Loop 自然就停了。
系列下一篇
下一篇 Session 与记忆 会讲 dsh 的持久化设计:Session 日志为什么是 append-only、deriveMessages() 怎么从日志重建模型历史、以及如何在不同会话之间传递记忆。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页