Skip to content
Go back

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

Updated:
Edit page

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

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

本文已按本地仓库的 0.1.0-rc.5 更新,对应提交 47f943859b。这一版本的循环仍由 ReactLoopAgent 驱动,但输入生命周期、取消后的重新唤醒、请求头记录、工具调度和 Session 消息投影都比早期实现更严格。

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。它实现了典型的“模型—工具—观察—再调用模型”循环,但不要求模型显式输出 Thought、Action 和 Observation,结构化的 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
  -> setupAndPublish
  -> 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());

如果新输入落在 maintenance 或旧 Driver 已取消但尚未完全退出的窗口,当前版本不会丢掉唤醒,而是设置 wakeRequested。旧活动收敛到 idle 后,只要 Inbox 仍有待处理输入,就会启动新的 Driver。这个 wake latch 解决了“消息已经入队,但唤醒恰好撞上取消窗口”的竞态。

最外层的 kick() 负责连续消费排队的 Turn,并在退出时回放被锁存的唤醒:

private async kick(): Promise<void> {
  try {
    while (await this.turn()) {}
  } finally {
    const { turn, wakeRequested } = this.phase;
    this.setPhase({ kind: "idle", lastTurn: turn });
    if (wakeRequested && this.inbox.hasPending) this.wakeDriver();
  }
}

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

四、Turn 与 Step 如何推进

turn() 才是 Agent Loop 的核心骨架。当前版本会先记录 turn/start,再领取第一批输入并不断创建 Step。这样即使唤醒消息在同步观察器中被移除,日志里也仍有一组配对的 Turn 边界,而不会出现已经领取输入却没有所属 Turn 的状态:

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. 按当前 Agent Scope 组装 System Prompt 和工具 Schema。
  3. 将运行时上下文投影成一条可选消息。
  4. 触发 agent/pre-step waterfall,让扩展决定进入或拒绝 Step。
const claimed = this.inbox.claim(target, turn);
const assembly = await this.loopCtx.systemPrompt.assemble(...);
const context = this.runtimeContext.project(...);

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

扩展点可以补充上下文、改写消息或返回 reject;被接受的批次随后以带 surfaceOp: "append" 的 user/message 写入 Session。reject 会以 blocked 结束 Turn,已经领取的输入不会重新放回 Inbox。

step() 返回 Promise<StepEndReason | null>:

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

其中 null 最关键:它区分了“当前模型调用结束”和“当前用户任务结束”。如果某一步达到 max-tokens,之后又因 Steering 继续执行,最终 Turn 仍保留 max-tokens,不会被后续正常完成的 Step 降级成 completed。

五、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、tool/result 的配对提交:

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

工具支持两种调度方式:parallel 进入由 maxParallelToolCalls 限制的滚动并发池,exclusive 形成屏障并独占执行。调度器会在尚未启动的调用真正入池前重新读取执行模式,所以前面工具对注册表的修改仍能让后续调用变成屏障。即使多个工具主体乱序完成,后处理、附加上下文和结果仍按模型给出的调用顺序提交,避免对话历史因为完成时序不同而错位。

DeepSeek Harness 工具调用调度器

取消也必须保持历史可回放:已经启动的调用会先 drain 并按序提交;尚未分发的调用会得到 ABORTED_BEFORE_DISPATCH 合成错误结果。这样下一次恢复时不会留下只有 Tool Call、没有 Tool Result 的非法消息对。

工具结果写为 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,
});

消息类事件还必须显式声明 surfaceOp,例如 append 或 replace。deriveMessages() 不是扫描所有事件拼接聊天记录,而是从这条有序 surface 投影并缓存 LLM 真正需要的历史;压缩可以用 replace 遮蔽旧节点,而不需要改写追加式日志:

进入模型历史仅用于控制、回放或诊断
user/messageturn/start、turn/end
assistant/messagestep/start、step/end
tool/resultassistant/chunk、tool/call 等

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

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

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

  • 模型不再产生工具调用:completed。
  • 工具明确要求结束 Turn。
  • 模型达到输出上限:max-tokens。
  • 预处理被拒绝:blocked。
  • 模型或循环发生不可恢复错误:error,并保存结构化失败信息。
  • 用户取消,或 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. wake latch 保证取消或 maintenance 收敛窗口中的新输入不会失去唤醒。
  4. preStep() 负责在稳定边界组装上下文并开放扩展点。
  5. step() 根据有无 Tool Call 决定结束 Turn 或继续循环。
  6. 工具可以有界并发执行,exclusive 调用形成屏障,但结果始终按模型顺序提交。
  7. Session 事件日志是唯一事实来源,模型历史由 surface 投影派生。

一句话概括:

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

参考资料:


Edit page