大模型应用实战

DeepSeek Harness 系列(04):Agent Loop——一次对话是怎么跑起来的

用户发了一条消息,Agent 回复了一段话并调用了三个工具——这背后到底发生了什么?这篇文章完整拆解 dsh Agent Loop 的运行机制:Turn 与 Step 的结构、驱动器怎么工作、每个扩展点的用途,以及你能在哪里插入控制逻辑。

·约 9 分钟阅读·AI Engineering

从一个问题开始

你给 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/messageassistant/attempt
何时出现模型成功完成输出失败/重试/取消/流错误
写入模型历史✅ 会(被 deriveMessages() 采纳)❌ 不会(被忽略)
Session 日志✅ 记录✅ 记录(用于审计)
场景正常响应,含内容为空的成功网络错误、context overflow、取消

这个设计保证了对话历史的干净性:失败的尝试不会污染模型下次看到的历史,但所有尝试都被审计保留了。


循环为什么会结束

很多人困惑:Agent 调用了一堆工具,为什么某个时刻它就停了?

循环结束的条件是:

  1. 模型不再调用工具(响应里没有 tool_call)
  2. next-step inbox 没有新工作

满足这两个条件时,循环进入 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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页