DeepSeek Harness 源码解读(四):一次 Turn 为什么会跑多个 Step

0 阅读7分钟

从 Inbox 到 turn/end,沿 agent.ts 拆解 Turn、Step、流式输出、同 Step 重试和工具续行,看懂一次用户输入为什么可能触发多次模型请求。第一次看 Agent Loop,我下意识把“一条用户消息”理解成“一次模型请求”。顺着 packages/core/agent-loop/src/agent.ts 走完以后,才发现这两个边界并不重合。

用户消息先进入 Inbox。驱动器打开 Turn,再根据当时欠下的工作创建 Step。模型如果要求调用工具,工具结果写回 Session 后,同一个 Turn 会继续开下一个 Step;请求失败后选择重试,甚至不会增加 Step 编号。

这篇只做一件事:沿当前源码把一条输入从 Inbox 追到 turn/end

项目地址:deepseek-ai/deepseek-harness

源码基线:0.1.0-rc.5

输入先进入 Inbox,不会直接调用模型

ReactLoopAgent 暴露了三种投递方式:

followup(input: UserMessage): void {
  this.send(input, 'next-turn', true)
}

steer(input: UserMessage): void {
  this.send(input, 'next-step', true)
}

inject(input: UserMessage): void {
  this.send(input, 'next-step', false)
}

followup() 把消息放进 next-turn 队列,并唤醒驱动器;steer() 把消息放进 next-step 队列,驱动器正在运行时会并入当前 Turn;inject() 同样准备下一 Step 的输入,但不会单独把空闲 Agent 唤醒。

这三个方法的差异不是命名上的细节。它们决定一条消息应该开启新 Turn,还是在当前 Turn 的下一个模型边界生效。

Agent 从 idle 进入 running 后,只启动一条驱动链:

private async kick(): Promise<void> {
  try {
    while (await this.turn()) {}
  } catch (_error) {
    // 已上报的失败和取消在驱动器边界收口
  } finally {
    // 恢复 idle;如果还有待处理输入,再次唤醒
  }
}

所以同一个 Agent 不会因为连续收到几条消息就并发跑几套主循环。Inbox 可以继续收消息,kick() 负责按 Turn 串行消化。

Turn 在认领输入之前就已经开始

turn() 先追加 turn/start,然后才尝试生成第一个 Step:

const turn = phase.turn + 1
this.session.append('turn/start', { turn })
phase.turn = turn

let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'

while (true) {
  const step = phase.step + 1
  const decision = await this.preStep(target, { turn, step })
  // ...
}

这个顺序意味着 Turn 记录的是一次被驱动器正式接手的尝试,而不只是“成功请求了模型”。后面的策略即使拒绝输入,turn/start 也已经是 Session 事实。

preStep() 做了三件事:从指定队列认领消息、重新组装 System Prompt 和工具 schema、进入 agent/pre-step waterfall。

const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(
  assembleContextFor(this, signal),
)

const decision = await this.dispatch.waterfall(
  'agent/pre-step',
  { messages: claimed, ...position, signal },
  () => Promise.resolve({
    kind: 'enter',
    messages: context === undefined
      ? claimed
      : [...claimed, context],
  }),
)

这里的返回值是权威决定。监听器可以保留消息、替换消息,也可以直接返回 reject。压缩、Hook 注入和运行时上下文都能在这个位置影响下一步,但最终进入模型的用户消息还要先写入 Session。

有 Turn,不一定有 Step

源码专门处理了两种“零 Step”情况:

if (decision.kind === 'reject') {
  turnEnds = { kind: 'blocked' }
  return false
}

if (phase.step === 0 && decision.messages.length === 0) {
  turnEnds = { kind: 'completed' }
  return false
}

第一种是 agent/pre-step 明确拒绝。第二种是第一批输入在认领后被移除,或者监听器把 enter 改写成了空消息。

两种情况都不会追加 step/start,也不会调用模型,但 finally 仍会追加 turn/end。Session 中留下的是一个完整、可解释的 Turn,而不是让这次尝试看起来从未发生。

这也是 Turn 不能简单等同于“用户问了一次,模型答了一次”的原因。Turn 是驱动器的工作边界,Step 才是模型交互边界。

Step 先记输入,再从 Session 派生请求

排除前面的零 Step 分支后,代码才正式打开 Step。第一个 Step 需要非空 enter;工具欠下的后续 Step 即使没有新用户消息,也可以继续,这一点后面会看到。

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)
  // ...
} finally {
  this.session.append('step/end', { turn, step })
}

用户消息先成为 user/message 事件,随后 step() 才从 Session 推导模型历史:

const { request, preparedCall } = await this.buildRequest(
  turn,
  step,
  assembly.tools,
  system,
  this.session.deriveMessages(),
  signal,
)

Agent Loop 没有维护另一份私有聊天数组。恢复会话、分叉会话和重新构造请求都以 Session 日志为准。

buildRequest() 还会经过 agent/request,解析最终 Provider 和模型,并在配置首次出现或发生变化时写入请求头:

const header = canonicalHeader({
  config,
  ...(system ? { system } : {}),
  ...(tools.length > 0 ? { tools } : {}),
})

if (!this.requestHeaderLogged) {
  this.session.append('request/header', {
    header,
    reason: baseline === undefined ? 'initial' : 'resume',
  })
}

Provider、模型和上下文窗口变化时还会追加 request/context。因此,模型看到的不只是对话消息可追溯,系统提示词、工具 schema 和模型路由也有对应的日志事实。

流式输出先写日志,失败重试仍属于同一 Step

模型流返回的每个 chunk 都会先写进 Session,再交给 BlockAssembler 拼成完整消息:

const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request)
  ?? this.loopCtx.llm.stream(request)

for await (const chunk of stream) {
  chunkSeqs.push(
    this.session.append('assistant/chunk', {
      turn,
      step,
      chunk,
    }).seq,
  )
  assembler.push(chunk)
}

成功结束后,Loop 追加 assistant/message,并用 sourceEventSeqs 指向组成它的 chunk。UI 可以实时消费 chunk,重放时又能拿到归一化后的完整消息。

请求失败时,代码不会立刻关闭 Step,而是进入 agent/request-error

if (finish.kind === 'error' || finish.kind === 'aborted') {
  const action = await this.dispatch.waterfall(
    'agent/request-error',
    {
      turn,
      step,
      provider: request.provider,
      failure: finish.failure,
      retryPolicy: preparedCall?.retryPolicy,
      signal,
    },
    () => Promise.resolve(undefined),
  )

  if (action?.kind !== 'retry') {
    throw new LlmError(
      finish.failure.message,
      finish.failure.code,
      finish.failure,
    )
  }

  continue
}

这个 continue 回到 step() 内部的请求循环,所以重试仍使用同一个 turnstep。只有重试成功、明确失败或被取消后,外层 finally 才追加 step/end

因此更准确的说法是:Step 是一次持久化的模型交互槽位,正常情况下对应一次模型响应;适配器失败后的重试属于同一个 Step,而不是悄悄制造新 Step。

工具结果会让同一个 Turn 继续开 Step

完整 Assistant 消息写入后,Loop 检查其中的 tool call:

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

没有工具调用,Step 返回 completed。存在普通工具调用时,executeToolCalls() 追加 tool/call,经过 Tools 管线执行,再按模型给出的顺序追加 tool/result;如果工具没有声明结束 Turn,step() 返回 null

null 表示“这一步完成了,但模型还欠一次请求”。外层 Turn 把目标改成 next-step,再次执行 preStep()step()

这里有个不太直观的细节:普通工具结果已经在 Session 中,所以下一个 Step 即使没有新的用户消息,也会照常调用模型。deriveMessages() 会把刚写入的 tool/result 带进历史。只有工具额外返回运行时上下文时,那些上下文才会被放入 next-step Inbox。

工具可以并发执行,但 tool/result 仍按模型原始调用顺序提交。第五篇会把这段调度单独展开。

Turn 只有在“不欠任何工作”时才结束

每个 Step 关闭后,Turn 会同时看两个条件:当前是否已经有结束原因,以及 next-step Inbox 是否为空。

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'

这里故意检查了两次。agent/turn-stopping 监听器执行期间仍可以调用 steer();一旦出现新输入,第二次检查就不会结束 Turn,而是继续创建下一 Step。

真正退出时,finally 无论成功、阻塞、错误还是取消,都会写入带原因的 turn/end。如果 Inbox 里还有 next-turn 消息,turn() 会换一个新的 AbortController、把 Step 计数归零,并让 kick() 打开下一个 Turn。

max-tokens 也是 Turn 级结果:某个 Step 命中上限后,即使后续因为 steering 又跑了 Step,最终结果也不会被普通 completed 覆盖。

把两条常见日志并排看,边界会更直观:

直接回答:
turn/start
  step/start
  user/message
  request/header + request/context
  assistant/chunk*
  assistant/message
  step/end
turn/end (completed)

调用工具后继续回答:
turn/start
  step/start            # Step 1
  user/message
  assistant/message     # 含 tool-call
  tool/call
  tool/result
  step/end
  step/start            # Step 2,同一个 Turn
  assistant/message     # 最终回答
  step/end
turn/end (completed)

DeepSeek Harness 一个 Turn 内的 Step 时序

以后排查“为什么模型多请求了一次”,我会先看 Session 中有几个 step/start,再看前一步是否产生了 tool/result、steering 或额外上下文;排查“为什么没有调用模型”,则看 turn/start 后是否直接出现了 blocked 或零消息的 turn/end

下一篇继续追 executeToolCalls():工具调用如何经过执行模式、审批、超时和结果后处理,又怎样在并发执行时保持日志顺序稳定。