DeepSeek Harness 从 0 开始:07 Loop 模块(Agent 执行循环)

4 阅读9分钟

DeepSeek Harness 从 0 开始:07 Loop 模块(Agent 执行循环)

本系列从 0 开始,基于 Cordis 框架一步步实现一个简略版本的 DeepSeek Harness(loop、session、tool、system prompt 等)。前几篇我们有了 Session(记忆)、Tools(手)、Inbox(输入),这一篇实现真正的发动机——按 dsh 源码结构实现 ReactLoopAgent:turn/step 状态机驱动的 Agent 执行循环。

Loop 是什么?

dsh 的 agent-loop 包(packages/core/agent-loop)实现的是 turn/step 状态机ReactLoopAgent 通过 kick()while(turn()) 驱动一轮对话,turn 内跑多个 step,每个 step 是「LLM 请求 → 若返回工具调用则执行 → 带着结果再请求 → 直到模型输出最终回答」。它是 Agent 的「思考循环」——驱动模型与工具反复交互、直到完成任务的发动机。

项目目录结构

blog-07-loop/
├── package.json          # 项目配置:依赖、启动脚本
├── pnpm-lock.yaml        # 依赖锁定文件
└── src/
    └── main.ts           # 代码入口,pnpm dev 运行它

核心概念

概念一句话理解
ReactLoopAgentAgent 驱动:kick → while(turn()) → turn 内多个 step(dsh: agent-loop/src/agent.ts)
Turn(轮)一轮对话:turn/start → 若干 step → turn/end(带 reason)
Step(步)一次「LLM 请求 + 它请求的工具执行」;一个 step 内可能多次请求 LLM
Inbox claimpreStep 从 next-turn / next-step 队列取出本轮输入(dsh: core/agent/src/inbox.ts)
Phase 状态机idle / running 两态,agent/status 事件广播

Part 1:Session 与 Inbox——两个前置组件

Session:简化版事件日志

沿用第四篇的 Session 概念(事件日志 + 消息投影),这次按 dsh 的事件类型(turn/startturn/end 带 reason、step/starttool/calltool/result……)来定义:

// 事件日志:Agent 循环的每一步都是事件(每个事件带 seq 单调序列号)
type SessionEvent =
  | { type: 'turn/start'; seq: number; turn: number; timestamp: number }                    // 一轮对话开始
  | { type: 'turn/end'; seq: number; turn: number; reason: TurnEndReason; timestamp: number }  // 一轮对话结束(带原因)
  | { type: 'step/start'; seq: number; turn: number; step: number; timestamp: number }      // 一个步骤开始
  | { type: 'step/end'; seq: number; turn: number; step: number; timestamp: number }        // 一个步骤结束
  | { type: 'user/message'; seq: number; content: string; timestamp: number }               // 用户消息
  | { type: 'assistant/message'; seq: number; content: string; toolCalls?: ToolCall[]; timestamp: number }  // 助手消息(可带工具调用)
  | { type: 'tool/call'; seq: number; turn: number; step: number; callId: string; name: string; arguments: string; timestamp: number }  // 工具调用
  | { type: 'tool/result'; seq: number; turn: number; step: number; callId: string; content: string; isError: boolean; timestamp: number }  // 工具结果

// turn 为什么结束:completed 正常完成 / blocked 无输入被拒 / error 出错 / aborted 取消 / max-tokens 超上限
type TurnEndReason =
  | { kind: 'completed' }
  | { kind: 'blocked' }
  | { kind: 'error'; error: string }
  | { kind: 'aborted' }
  | { kind: 'max-tokens' }

// 简化版 Session:事件日志 + 消息投影
class SimpleSession {
  readonly events: SessionEvent[] = []  // 事件数组(append-only,只追加)

  // 追加事件:自动分配 seq(等于当前事件数,从 0 连续递增)
  append(input: SessionEventInput): SessionEvent {
    const event = { seq: this.events.length, timestamp: Date.now(), ...input } as SessionEvent
    this.events.push(event)
    return event
  }

  // 从事件流投影模型消息(Surface,第四篇讲过)
  deriveMessages(): Message[] {
    // user/message → user,assistant/message → assistant(含 tool_calls),
    // tool/result → tool(用 tool_call_id 关联)
  }
}

Inbox:next-turn / next-step 双队列

dsh 的 Inbox 是「基于 Session 事件的投影」,维护两个待处理队列next-turn(下一轮对话的输入)和 next-step(下一步边界的输入,如工具结果的附加上下文):

// 队列目标:next-turn 下一轮输入 / next-step 下一步输入
type InboxTarget = 'next-turn' | 'next-step'

// Inbox:两个待处理队列(dsh 的 Inbox 投影)
class Inbox {
  // 状态:两个队列,各存待处理消息
  private state: Record<InboxTarget, InboxMessage[]> = { 'next-turn': [], 'next-step': [] }

  // 是否有待处理消息(任一队列非空)
  get hasPending(): boolean {
    return this.nextTurn.length > 0 || this.nextStep.length > 0
  }

  // 追加消息到目标队列(followup → next-turn,steer → next-step)
  append(target: InboxTarget, message: InboxMessage): void {
    this.state[target].push(message)
  }

  // claim:移除并返回本轮 step 的消息批次
  // 先清空 next-step,再按需取 next-turn 一条(dsh 的 claim 语义)
  claim(target: InboxTarget): InboxMessage[] {
    const claimed = this.state['next-step'].splice(0)
    if (target === 'next-turn') {
      claimed.push(...this.state['next-turn'].splice(0, 1))
    }
    return claimed
  }
}

为什么两个队列? 用户新消息走 next-turn(开启一轮新对话);工具执行中的附加上下文走 next-step(在当前 step 结束后、下一 step 开始时消费)——dsh 用它们区分「新的一轮」和「正在进行的对话中的补充输入」。

Part 2:Mock LLM——模拟模型决策

没有真实 API key 时,用一个 Mock 适配器模拟「模型返回文本或工具调用」。它按消息历史决策:先调 calculator,再调 weather,最后输出总结:

class MockLlm {
  constructor(readonly options: MockLlmOptions) {}

  // 模拟 llm.stream():返回流式块(文本或 tool-call)
  stream(messages: Message[]): AsyncIterable<LlmChunk> {
    return (async function* () {
      const toolResults = messages.filter(m => m.role === 'tool')

      if (toolResults.length === 2) {
        // 两个工具结果都回来了:输出最终总结(无工具调用 → step 完成)
        const calc = toolResults[0]?.content
        const weather = toolResults[1]?.content
        yield { type: 'text', text: `最终回答: ${calc}${weather}` }
        return
      }

      if (toolResults.length === 1) {
        // calculator 结果已回:再调 weather
        yield { type: 'tool-call', call: { id: 'weather-1', name: 'weather', arguments: JSON.stringify({ city: 'Shanghai' }) } }
        return
      }

      // 初始:先调 calculator
      yield { type: 'tool-call', call: { id: 'calc-1', name: 'calculator', arguments: JSON.stringify({ expression: '2 + 2' }) } }
    })()
  }
}

Mock 的关键:根据 role: 'tool' 消息的数量决定下一步——这正是真实模型的行为模式(看到工具结果才知道下一步),只是决策逻辑被硬编码了。

Part 3:ReactLoopAgent——turn/step 状态机

这是 dsh 的核心。完整的执行循环:

flowchart TB
    KICK[&#34;kick() 主驱动<br/>while turn() 直到无 pending 输入&#34;]
    TURN[&#34;turn() 一轮对话&#34;]
    TS[&#34;turn/start&#34;]
    PRE[&#34;preStep<br/>claim Inbox 消息&#34;]
    SS[&#34;step/start&#34;]
    STEP[&#34;step() 一个步骤<br/>LLM 请求 + 工具循环&#34;]
    STREAM[&#34;llm.stream()<br/>assistant/message&#34;]
    CHECK{&#34;有工具调用?&#34;}
    EXEC[&#34;executeToolCalls<br/>tool/call → 执行 → tool/result&#34;]
    SE[&#34;step/end&#34;]
    TE[&#34;turn/end 带 reason&#34;]
    NEXT{&#34;还有 pending 输入?&#34;}

    KICK --> TURN
    TURN --> TS
    TS --> PRE
    PRE --> SS
    SS --> STEP
    STEP --> STREAM
    STREAM --> CHECK
    CHECK -->|否| SE
    CHECK -->|是| EXEC
    EXEC --> STREAM
    SE --> TE
    TE --> NEXT
    NEXT -->|是| TURN
    NEXT -->|否| IDLE[&#34;回到 idle 空闲&#34;]

循环的关键路径(箭头语义在正文):step()llm.stream() 后判断——有工具调用就执行,执行完带着 tool/result 回到 llm.stream() 再请求EXEC → STREAM 的回边),直到模型输出无工具调用的最终文本才走 step/end;一轮 turn 跑完若还有 pending 输入(NEXT),开启下一轮。

class ReactLoopAgent {
  readonly inbox: Inbox
  readonly session: SimpleSession
  private phase: Phase  // { kind: 'idle' } | { kind: 'running', turn, step }
  private tools = new Map<string, (args) => Promise<string>>()

  // followup:排入下一轮并唤醒驱动(dsh: send + wakeDriver)
  followup(content: string): void {
    this.inbox.append('next-turn', { id: `m-${Date.now()}`, content })
    if (this.phase.kind === 'idle') this.wakeDriver()
  }

  // 唤醒驱动:进入 running 并启动 kick 循环
  private wakeDriver(): void {
    this.setPhase({ kind: 'running', turn: this.phase.lastTurn, step: 0, aborted: false })
    void this.kick().finally(() => {
      if (this.phase.kind === 'running') {
        this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
      }
    })
  }

  // 主驱动循环:不断跑 turn,直到没有 pending 输入(dsh: kick)
  private async kick(): Promise<void> {
    try {
      while (this.phase.kind === 'running' && await this.turn()) {}
    } catch (error) { /* 错误已在 turn 内结构化记录 */ }
  }

  // 一轮对话(dsh: turn)
  private async turn(): Promise<boolean> {
    if (this.phase.kind !== 'running') return false
    const turn = this.phase.turn + 1
    this.phase.turn = turn
    this.session.append({ type: 'turn/start', turn })

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

    try {
      while (this.phase.kind === 'running') {
        if (this.phase.aborted) { turnEnds = { kind: 'aborted' }; break }

        // preStep:从 Inbox claim 消息
        const step = this.phase.step + 1
        const claimed = this.inbox.claim(target)
        if (claimed.length === 0) {
          if (turnEnds !== null) break
          turnEnds = { kind: 'blocked' }
          return false
        }

        this.phase.step = step
        this.session.append({ type: 'step/start', turn, step })
        try {
          for (const message of claimed) {
            this.session.append({ type: 'user/message', content: message.content })
          }
          const stepEnd = await this.step(turn, step)
          if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
        } finally {
          this.session.append({ type: 'step/end', turn, step })
        }

        // turn-stopping:没有新输入则结束本轮
        if (turnEnds !== null && this.inbox.nextStep.length === 0) break
        target = 'next-step'
      }
    } catch (error: any) {
      turnEnds = this.phase.aborted
        ? { kind: 'aborted' }
        : { kind: 'error', error: String(error?.message ?? error) }
    } finally {
      this.session.append({ type: 'turn/end', turn, reason: turnEnds ?? { kind: 'error', error: 'unknown' } })
    }

    if (!this.inbox.hasPending) return false
    this.phase.step = 0
    return true
  }

  // 一个步骤:LLM 请求 → 工具调用循环(dsh: step)
  private async step(turn: number, step: number) {
    while (this.phase.kind === 'running') {
      // 从 Session 投影消息,请求 LLM
      const messages = this.session.deriveMessages()
      const stream = this.llm.stream(messages)

      const toolCalls: ToolCall[] = []
      let text = ''
      for await (const chunk of stream) {
        if (chunk.type === 'text') text += chunk.text
        else if (chunk.type === 'tool-call' && chunk.call) toolCalls.push(chunk.call)
      }

      this.session.append({
        type: 'assistant/message',
        content: text,
        ...(toolCalls.length > 0 ? { toolCalls } : {}),
      })

      // 没有工具调用 → step 完成
      if (toolCalls.length === 0) return { kind: 'completed' }

      // 有工具调用 → 执行;执行完不 return,带着 tool/result 再请求 LLM
      await this.executeToolCalls(turn, step, toolCalls)
    }
    return null
  }

  // 执行工具调用(dsh: tool-calls.ts)
  private async executeToolCalls(turn: number, step: number, calls: ToolCall[]): Promise<void> {
    for (const call of calls) {
      this.session.append({ type: 'tool/call', turn, step, callId: call.id, name: call.name, arguments: call.arguments })

      let content: string
      let isError = false
      try {
        const args = JSON.parse(call.arguments || '{}')
        const tool = this.tools.get(call.name)
        if (!tool) throw new Error(`Tool ${call.name} not found`)
        content = await tool(args)
      } catch (error: any) {
        isError = true
        content = String(error?.message ?? error)
      }

      this.session.append({ type: 'tool/result', turn, step, callId: call.id, content, isError })
    }
  }
}

这是整个框架的心脏,几个关键设计(都来自 dsh 源码):

  • kick()while(turn()):主驱动循环。turn() 返回 false 表示没有 pending 输入了,循环停止,agent 回到 idle——空闲时零开销,有输入才唤醒(dsh 的 wakeDriver 机制);
  • step 内的 while(true) 工具循环:这是最容易忽略的点——一个 step 可以多次请求 LLM。模型要工具 → 执行 → 结果写进 Session → 带着 tool/result 再请求 → 模型看到结果决定下一步 → 直到输出最终文本(无工具调用);
  • turn/end 带 reason:completed / blocked / error / aborted / max-tokens——记录「为什么结束」,可审计(第四篇的 Session 事件现在有了真实的生产者);
  • finally 保证边界闭合:即使 step 抛错,step/endturn/end 也会写入——事件流永远完整。

Part 4:AgentLoop Service 与运行

Service 封装

// AgentLoop 服务:管理 agent 生命周期(dsh: agent-loop/index.ts)
class AgentLoopService extends Service {
  private agents = new Map<string, ReactLoopAgent>()  // 注册表:sessionId → agent
  private counter = 0  // 自增计数器,生成唯一 session ID

  constructor(ctx: Context) {
    super(ctx, 'agentLoop')  // Cordis v4 两参数构造
  }

  // 生成唯一 session ID:自增 session-<n>
  private mintId(): string {
    return `session-${++this.counter}`
  }

  // 创建 agent(简化版:dsh 里通过 createAgent + publish 发布)
  create(options: AgentOptions & { llm?: MockLlm }): ReactLoopAgent {
    const agent = new ReactLoopAgent(
      this.mintId(),
      { provider: options.provider ?? 'mock', model: options.model ?? 'mock-model', maxTokens: options.maxTokens },
      options.llm ?? new MockLlm({ provider: 'mock', model: 'mock-model' }),  // 缺省用 Mock LLM
    )
    this.agents.set(agent.session.id, agent)  // 登记到注册表
    return agent
  }
}

// 扩展 Cordis 类型:ctx.agentLoop 可用
declare module '@cordisjs/core' {
  interface Context {
    agentLoop: AgentLoopService
  }
}

跑一个真实循环

let ctx = new Context()
// ctx.plugin() 是异步的,需要 await 等待服务激活
await ctx.plugin(AgentLoopService)

// 创建 agent(provider/model 指定路由,真实项目接真实适配器)
const agent = ctx.agentLoop.create({ provider: 'mock', model: 'react-loop', maxTokens: 512 })

// 注册 Agent 可用的工具(第五篇讲过:工具是插件注入的)
agent.registerTool('calculator', async (args) => {
  const match = (args.expression as string).match(/(\d+)\s*\+\s*(\d+)/)
  const sum = match ? Number(match[1]) + Number(match[2]) : NaN
  return `计算 ${args.expression} = ${sum}`
})

agent.registerTool('weather', async (args) => {
  return `${args.city} 天气: 晴天 25°C`
})

// 用户发消息:排入 next-turn 并唤醒驱动(agent 进入 running 开始循环)
agent.followup('帮我计算 2 + 2,然后查一下上海天气')

// 等待 agent 收敛(dsh: whenIdle,等所有 turn/step 跑完回到 idle)
while (agent.status === 'running') {
  await new Promise(resolve => setTimeout(resolve, 10))
}

运行输出——Session 事件流完整记录了整个循环

👤 用户: 帮我计算 2 + 2,然后查一下上海天气

📜 Session 事件流:
  #0 turn/start   (turn 1)
  #1 step/start   (turn 1 step 1)
  #2 user/message "帮我计算 2 + 2,然后查一下上海天气"
  #3 assistant   "" 工具调用: calculator
  #4 tool/call    calculator({"expression":"2 + 2"})
  #5 tool/result  "计算 2 + 2 = 4"
  #6 assistant   "" 工具调用: weather
  #7 tool/call    weather({"city":"Shanghai"})
  #8 tool/result  "Shanghai 天气: 晴天 25°C"
  #9 assistant   "最终回答: 计算 2 + 2 = 4;Shanghai 天气: 晴天 25°C"
  #10 step/end     (turn 1 step 1)
  #11 turn/end     (reason: completed)

读这条事件流,你能看到 Agent 的完整思考过程

  1. #1 step 开始,#2 用户消息进 Session;
  2. #3 模型第一次响应:要调 calculator(assistant 消息带 tool_calls);
  3. #4 tool/call 记录调用,#5 tool/result 记录结果「计算 2 + 2 = 4」;
  4. #6 模型带着结果再次响应:还要调 weather——这就是 step 内的循环!
  5. #7 #8 weather 调用与结果;
  6. #9 模型第三次响应:输出最终文本(无工具调用)→ step 完成;
  7. #10 step/end#11 turn/end(reason: completed)——一轮对话结束。

一个 turn,一个 step,三次 LLM 请求——每次工具执行的结果都回到模型,模型据此决定下一步。这正是 dsh step()while(true) 循环的真实运行效果。

常见问题 FAQ

Q: turn 和 step 到底什么区别?

A: turn(轮)是「一轮对话」:从用户发消息到最终回答,turn/start → 若干 step → turn/end(带 reason)。step(步)是「一次 LLM 请求 + 它请求的工具执行」;一个 step 内模型可能多次请求 LLM(要工具 → 看结果 → 再要工具 → 看结果 → 输出回答)。一个 turn 通常 1~3 个 step。

Q: step 内的工具循环怎么终止?

A: 靠「模型不再要工具」——每次工具执行后,tool/result 写进 Session,模型再次请求时看到结果。Mock LLM 里:tool 消息满 2 条就输出最终文本(无 tool-call)。真实模型同理:看到足够信息后直接回答。循环的终止条件是模型自己决定的,不是代码硬编码的轮数

Q: turn/end 的 reason 有什么用?

A: 记录「为什么这轮结束」:completed(正常完成)/ blocked(无输入被拒绝)/ error(出错)/ aborted(取消)/ max-tokens(超 token 上限)。这些是审计的关键——出 bug 时能回看「这轮是正常结束还是被中断」。dsh 的 TurnEndReason 就是合并可扩展的 sum type。

Q: followup 和 steer 有什么区别?

A: followup(普通追问)排入 next-turn——开启新一轮对话;steer(引导)排入 next-step——在当前对话的下一步边界消费(比如工具结果附带上下文)。claim 时先清 next-step,再按需取 next-turn 一条。

Q: 为什么 agent 空闲时零开销很重要?

A: 真实部署里 agent 大部分时间在等输入。如果空闲时也跑着定时器,纯属浪费。dsh 的 idle 态:没有驱动循环、没有定时器,followup() 来了才 wakeDriver() 进入 running——按需启动,用完即停

小结

  1. Loop = ReactLoopAgent 状态机kick()while(turn()),turn 内多个 step,step 内 LLM 请求 + 工具循环;
  2. step 循环是核心:模型要工具 → 执行 → 结果回 Session → 再请求 → 直到输出最终回答——一个 step 可以多次请求 LLM;
  3. Inbox 双队列:next-turn(新一轮)/ next-step(补充输入),preStep 时 claim;
  4. 事件流完整:turn/start → step/start → user/message → assistant(tool_calls)→ tool/call → tool/result → ... → step/end → turn/end(带 reason)——Session 记录 Agent 的每一步思考。