Skip to content
Go back

走读 DeepSeek Harness:Agent Loop 如何驱动一次完整执行

Edit page

理解 Agent,不能只看一次 LLM API 调用。真正的执行链路还要回答:输入如何进入运行时、上下文如何组装、模型如何调用工具、工具结果如何返回模型,以及循环在什么时候结束。

这条链路的核心就是 Agent Loop。本文以 DeepSeek Harness(下文简称 dsh)为例,沿着关键源码走读一次完整执行。

DeepSeek Harness Agent Loop 架构图

一、Agent Loop 与 ReAct

Agent Loop 是智能体为完成目标而反复执行的控制流程:

Input
  -> Build Context
  -> Call LLM
  -> Parse Response
     -> Final Answer: Stop
     -> Tool Call: Execute Tool
  -> Append Tool Result
  -> Call LLM Again

普通聊天通常止于一次“输入—输出”。Agent 则会根据工具返回的真实结果持续调整行动,直到模型给出最终回答,或系统触发错误、取消、预算等终止条件。

ReAct(Reason + Act)描述的是“推理—行动—观察”的交替策略;Agent Loop 则是承载这种策略的运行时控制框架。因此可以把 ReAct 看作 Agent Loop 的一种实现思路,但两者并不完全等价:完整的 Agent Loop 还要负责会话状态、工具调度、权限、并发、取消、恢复和终止。

dsh 的具体驱动器名为 ReactLoopAgent。它实现了典型的“模型—工具—观察—再调用模型”循环,但不要求模型显式输出 ThoughtActionObservation,结构化的 tool-call block 就足以驱动下一步。

二、dsh 的执行模型:Driver、Turn 与 Step

阅读主循环前,需要先区分三个层次:

层次含义
DriverAgent 从被唤醒到重新空闲的一次活动,可连续处理多个排队任务
Turn一轮用户任务,可能包含多次模型请求
Step一次模型请求,以及该响应触发的工具调用

它们的关系是:

Driver
  -> Turn 1
     -> Step 1: LLM requests a tool
     -> Step 2: LLM reads the result
     -> Step 3: LLM returns the answer
  -> Turn 2

所以,一个 Turn 不等于一次 LLM 调用。模型先读取文件、再修改文件、最后回答,通常是一个 Turn 中的多个 Step。

dsh 的关键实现集中在:

packages/core/agent-loop/src/agent.ts
packages/core/agent-loop/src/tool-calls.ts
packages/core/session/src/index.ts

把细节压缩后,主调用链如下:

createAgent
  -> followup
  -> wakeDriver
  -> kick
  -> turn
  -> preStep
  -> step
  -> buildRequest
  -> LLM Stream
     -> Final Answer: End Turn
     -> Tool Call: Execute Tools -> Next Step

三、从输入到 Driver 启动

AgentLoop.createAgent() 会准备独立的 Session,并创建具体驱动器:

const agent = new ReactLoopAgent(loopCtx, id, options, session);

随后,Session 和 Agent 分别注册到运行时,系统发布生命周期事件,并返回带 dispose()AgentHandle。调用方只依赖统一的 Agent 接口,不需要了解循环的具体实现。

消息不会直接调用 LLM,而是先进入 Inbox:

方法目标队列唤醒 Driver用途
followupnext-turn开启普通后续 Turn
steernext-step在最近的 Step 边界加入指令
injectnext-step静默加入模型可见上下文

Inbox 的变化会被记录为事件,因此 Session 恢复后仍能重建尚未处理的输入。

当一条需要处理的消息到达时,wakeDriver() 把 Agent 从 idle 切换到 running,并创建贯穿模型与工具调用的 AbortController

this.setPhase({
  kind: "running",
  abort: new AbortController(),
  turn: this.phase.lastTurn,
  step: 0,
  wakeRequested: false,
});

this.loopCtx.agents.withInitiator(this, () => this.kick());

最外层的 kick() 负责连续消费排队的 Turn:

private async kick(): Promise<void> {
  try {
    while (await this.turn()) {}
  } finally {
    this.setPhase({ kind: "idle", lastTurn: turn });
  }
}

这里的 while 处理多个 Turn,而不是单个 Turn 内的工具循环。

四、Turn 与 Step 如何推进

turn() 才是 Agent Loop 的核心骨架。它先记录 turn/start,再不断创建 Step:

while (true) {
  const decision = await this.preStep(...);

  this.session.append("step/start", { turn, step });
  const stepEnd = await this.step(decision.assembly);
  this.session.append("step/end", { turn, step });

  if (stepEnd && this.inbox.nextStep.length === 0) {
    break;
  }
}

每次调用模型前,preStep() 会:

  1. 从 Inbox 领取消息。
  2. 组装 System Prompt。
  3. 组装当前可见的工具 Schema。
  4. 触发 agent/pre-step 扩展点。
const claimed = this.inbox.claim(target, turn);
const assembly = await this.loopCtx.systemPrompt.assemble(...);

const decision = await this.dispatch.waterfall("agent/pre-step", {
  messages: claimed,
  turn,
  step,
  signal,
});

扩展点可以补充上下文、改写消息或拒绝当前 Step;被接受的输入随后以 user/message 写入 Session。

step() 返回 Promise<StepEndReason | null>

  • completed:模型已给出最终回答。
  • max-tokens:模型达到输出 Token 上限。
  • null:工具执行完成,但任务未结束,需要进入下一 Step。

其中 null 最关键:它区分了“当前模型调用结束”和“当前用户任务结束”。

五、LLM 与工具调用如何形成循环

step() 先从 Session 事件日志派生模型历史,再构造请求:

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

随后消费 LLM 的流式响应。每个 Chunk 都会先写入 Session,流结束后再组装完整的 assistant/message

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

响应完成后,主循环只需要判断是否存在工具调用:

const toolCalls = message.content.filter(
  block => block.type === "tool-call"
);

if (toolCalls.length === 0) {
  return { kind: "completed" };
}

const { concluded } = await executeToolCalls(...);
return concluded ? { kind: "completed" } : null;

工具并不是简单执行一次 await tool()executeToolCalls() 还要负责参数解析、执行策略、取消信号和结果提交:

Tool Call
  -> Parse Arguments
  -> Pre-execution Policy
  -> Dispatch
  -> Tool Body
  -> Post-execution Policy
  -> Append Tool Result

工具支持两种调度方式:parallel 进入有界并发池,exclusive 形成屏障并独占执行。即使多个工具主体并行运行,结果仍按模型给出的调用顺序写入 Session,避免对话历史因为完成时序不同而错位。

工具结果写为 tool/result。下一 Step 再调用 deriveMessages() 时,模型看到:

User Message
Assistant Tool Call
Tool Result

这就闭合了“模型决策—工具执行—结果观察—再次决策”的循环。

六、Session 事件日志与终止条件

dsh 不维护一份独立的可变聊天数组。session.append() 写入的追加式事件日志才是事实来源,每条事件都获得连续的 seq、时间戳和不可变数据快照:

const event = deepFreeze({
  type,
  seq: this.log.length,
  time: Date.now(),
  data: dataSnapshot,
});

deriveMessages() 再从事件中投影出 LLM 真正需要的历史:

进入模型历史仅用于控制、回放或诊断
user/messageturn/startturn/end
assistant/messagestep/startstep/end
tool/resultassistant/chunk

因此,事件日志同时连接了相邻 Step,并服务于流式 UI、故障诊断、Session 恢复和上下文压缩。它不是外围的调试附件,而是主循环的状态通道:

Derive History
  -> Call LLM
  -> Append Assistant Events
  -> Execute Tools
  -> Append Tool Results
  -> Derive History Again

一个 Turn 的主要结束条件包括:

  • 模型不再产生工具调用:completed
  • 工具明确要求结束 Turn。
  • 模型达到输出上限:max-tokens
  • 预处理被拒绝或发生不可恢复错误。
  • 用户取消,或 Agent 被释放。

准备结束时,dsh 还会触发 agent/turn-stopping;扩展逻辑仍可追加 Steering,让当前 Turn 再执行一个 Step。当前实现没有在主循环中硬编码最大 Step 数,循环预算需要通过取消机制或扩展策略约束。

七、总结

从源码看,dsh 的 Agent Loop 不只是一个 while,而是一套围绕循环建立的执行协议:

  1. Driver、Turn、Step 分别表示一次唤醒、一轮任务和一次模型调用。
  2. Inbox 统一承接 Follow-up、Steering 和 Context Injection。
  3. preStep() 负责在稳定边界组装上下文并开放扩展点。
  4. step() 根据有无 Tool Call 决定结束 Turn 或继续循环。
  5. 工具可以并发执行,但结果按模型调用顺序提交。
  6. Session 事件日志是唯一事实来源,模型历史由它派生。

一句话概括:

Agent Loop 负责推进模型与工具之间的循环,Session 负责保存并重建执行事实,扩展点负责把策略附着到稳定的执行边界上。

参考资料:

  • DeepSeek Harness
  • packages/core/agent-loop/src/agent.ts
  • packages/core/agent-loop/src/tool-calls.ts
  • packages/core/session/src/index.ts

Edit page