agent-loop:一个轮次的完整旅程

专栏:DeepSeek Harness · 第 7 / 14 篇
DeepSeek Harnessagent-loop源码

现在读整个产品的心脏:packages/core/agent-loop。前几篇的积累在这里汇聚——事件日志(第 6 篇)是它写的,Cordis 事件(第 4 篇)是它的神经,提示词(第 8 篇)是它组装的,工具(第 9 篇)是它调度的。

两个名词,先立规矩

  • step(步骤)= 一次模型请求 + 它调用的全部工具执行。
  • turn(轮次)= 零或多个 step:在领取首条输入之前打开,在不再欠任何工作时关闭。

一个被拒绝的轮次可以没有任何 step(日志仍记录了这次尝试)。轮次结束原因有六种:completedabortedblocked(pre-step 拒绝)、errormax-tokensinterrupted(崩溃恢复时合成)。

主循环骨架

循环的相位是一个简单的三态机:

// packages/core/agent-loop/src/agent.ts:39-47
type Phase =
  | { kind: 'idle'; lastTurn: number }
  | {
    kind: 'maintenance'
    abort: AbortController
    lastTurn: number
    wakeRequested: boolean
  }
  | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }

三种输入入口是理解循环的第一把钥匙(agent.ts:125-152):

followup(input)   // → next-turn 队列 + 唤醒:开新轮次
steer(input)      // → next-step 队列 + 唤醒:注入当前轮次的下一步
inject(input)     // → next-step 队列、不唤醒:静静躺着,等下次有活时捎上
cancel(cause)     // // 清队列(可保队列只中止活动)+ abort 当前轮次

next-step 里的东西(steering 消息、注入上下文、工具结果的附加上下文)会在当前轮次的下一步被领走;next-turn 则开启新一轮。输入先持久化(agent/inbox/spliced 事件)再改内存——和 Session 一致的「先记录后行动」。

turn():轮次的骨架

turn() 是主循环,值得分段全读。前半——打开轮次、pre-step 决策、进入步骤:

// packages/core/agent-loop/src/agent.ts:266-305(节选)
this.session.append('turn/start', { turn })
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
try {
  while (true) {
    signal.throwIfAborted()
    const step = phase.step + 1
    const decision = await this.preStep(target, { turn, step })
    if (decision.kind === 'reject') {
      turnEnds = { kind: 'blocked' }
      return false
    }
    // 首步批次被改写为空:仍关闭一个无 step 的轮次——
    // 「花掉轮次边界,但不花模型调用」
    if (phase.step === 0 && decision.messages.length === 0) {
      turnEnds = { kind: 'completed' }
      return false
    }
    signal.throwIfAborted()
    this.session.append('step/start', { turn, step })
    phase.step = step
    try {
      for (const message of decision.messages) {
        this.session.append('user/message', message, { surfaceOp: 'append' })
      }
      const stepEnd = await this.step(decision.assembly, decision.startsRequestSeries === true)
      // max-tokens 是粘性的:一旦某步触顶,后续正常完成的
      // 步骤不能把轮次结局降级回 completed
      if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
    } finally {
      this.session.append('step/end', { turn, step })
    }

后半——收尾检查点与轮次关闭:

// packages/core/agent-loop/src/agent.ts:306-342(节选)
    signal.throwIfAborted()
    // 轮次将关且没有新的 next-step 输入时,串行征询监听器
    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'        // 还有活:下一步从 next-step 领取
  }
} catch (error: unknown) {
  if (signal.aborted) {
    turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
    throw error
  }
  // 每个失败都是结构化的
  turnEnds = {
    kind: 'error',
    error: error instanceof LlmError
      ? error.failure
      : { message: errorChain(error), code: 'UNKNOWN' },
  }
  this.throwError(error)
} finally {
  // 无论哪条路径退出,turn/end 必写
  this.session.append('turn/end', { turn, reason: turnEnds! })
}
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()   // 换新控制器:旧控制器上的唤醒闩锁全部失效
phase.wakeRequested = false
phase.step = 0
return true                            // 队列还有活:驱动器开下一轮

三个设计点值得停下来品:

  1. turn-stopping 是 serial 事件且没有 next()——监听器无法「否决」关闭轮次,只能往 inbox 里 steer() 一条新输入,然后循环重读 inbox 依数据决策:有新输入就再跑一步,没有就关。把控制权反转成数据,监听器注册顺序就无法影响结局。
  2. finally 里写 turn/end——错误、取消、正常结束,轮次边界在日志里永远闭合。
  3. max-tokens 粘性——某个步骤触顶后,轮次结局不可能被后续步骤洗白。

pre-step:模型看到什么,在这里决定

// packages/core/agent-loop/src/agent.ts:237-255
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
  if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
  const signal = this.phase.abort.signal
  const claimed = this.inbox.claim(target, position.turn)
  const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
  signal.throwIfAborted()
  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<PreStepDecision> => Promise.resolve<PreStepDecision>({
      kind: 'enter',
      messages: context === undefined ? claimed : [...claimed, context],
    }),
  )
  signal.throwIfAborted()
  return decision.kind === 'reject' ? decision : { ...decision, assembly }
}

默认行为:领走全部 next-step 批次(轮次边界上再搭一条 next-turn),把变化的运行时上下文快照并进批次。然后 agent/pre-step waterfall——监听器可以改写批次、附加内容,或直接 reject(该轮以 blocked 关闭,已领取的消息保持已删除)。

step():发请求、流式、settlement、工具回流

// packages/core/agent-loop/src/agent.ts:344-382(前半,节选)
private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise<StepEndReason | null> {
  const { turn, step, abort: { signal } } = this.phase
  const system = renderPrompt(assembly)
  while (true) {                          // 这个 while 只为 request-error 重试服务
    const surfaceGeneration = this.session.surface.replaceGeneration
    const { request, preparedCall } = await this.buildRequest(
      turn, step, assembly.tools, system,
      this.session.deriveMessages(),      // ← 消息历史:日志的纯函数
      startsRequestSeries, surfaceGeneration, signal,
    )
    const live = new AssistantStreamAttempt(...)
    const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
    live.start()
    for await (const chunk of stream) {
      signal.throwIfAborted()
      live.push(chunk)
    }

buildRequest 里值得注意:agent/request waterfall 允许插件替换调用配置(换模型、调参数),但消息不可触碰——它们是日志的投影;请求信封以四种 reason 落成 request/header(initial / resume / change / series)。成功 settle 之后(agent.ts:444-476):

// packages/core/agent-loop/src/agent.ts:450-476(节选)
const message = createAssistantMessage({ content: live.blocks(), source: {...} })
live.settle('assistant/message', () => this.session.append('assistant/message', {
  turn, step, message,
  ...live.usage === undefined ? {} : { usage: live.usage },
  stream: live.stream,
}, { surfaceOp: 'append' }).seq)
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }

const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const { concluded } = await executeToolCalls(
  this.loopCtx, turn, step, toolCalls, signal,
  context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)
return concluded ? { kind: 'completed' } : null   // null = 还有欠的活,继续下一步

工具结果的 additionalContexts 被 splice 进 next-step 队列——工具回流驱动下一步的闭环就在这一行。失败路径则把流提交为 assistant/attempt,再走 agent/request-error waterfall:监听器(如 llm-retry 插件)返回 {kind:'retry'}continue 重开一步,否则抛出结构化的 LlmError

全景时序图

先看循环的相位状态机(注意 running 期间 turn/step 两个计数器单调递增):

图表(agent-loop-internals.md)

再把整条链画出来(这是本专栏最重要的一张图,建议对照源码走一遍):

图表(agent-loop-internals.md)

与其他模块的联系

  • session(第 6 篇):循环是日志唯一的写入者;deriveMessages() 是每次请求的消息来源;turn/end 在 finally 里必写;
  • system-prompt(第 8 篇):pre-step 调 assemble() 拿 system 文本与工具 schema;
  • tools(第 9 篇):executeToolCalls 把工具结果经 additionalContexts splice 回 inbox next-step——工具回流驱动下一步;
  • llm(第 11 篇):preparedCall.stream() 发起采样;失败经 agent/request-error 征询重试。

两个最容易混淆的事件对:agent/assistant-stream 是进程本地的实时流(start/chunk/end 三帧,chunk 是瞬态,给 UI 用的);assistant/message 是持久 settlement(嵌完整流,模型历史的权威)。同理 tools/result(实时观测)vs tool/result(持久事件)。一对「实时广播 + 持久结算」贯穿 dsh 的所有数据面。

下一篇拆开循环里的两大部件之一:提示词组装。

← 返回文章列表