理解 Agent,不能只看一次 LLM API 调用。真正的执行链路还要回答:输入如何进入运行时、上下文如何组装、模型如何调用工具、工具结果如何返回模型,以及循环在什么时候结束。
这条链路的核心就是 Agent Loop。本文以 DeepSeek Harness(下文简称 dsh)为例,沿着关键源码走读一次完整执行。

一、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
阅读主循环前,需要先区分三个层次:
| 层次 | 含义 |
|---|---|
| Driver | Agent 从被唤醒到重新空闲的一次活动,可连续处理多个排队任务 |
| 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 | 用途 |
|---|---|---|---|
followup | next-turn | 是 | 开启普通后续 Turn |
steer | next-step | 是 | 在最近的 Step 边界加入指令 |
inject | next-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() 会:
- 从 Inbox 领取消息。
- 组装 System Prompt。
- 组装当前可见的工具 Schema。
- 触发
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/message | turn/start、turn/end |
assistant/message | step/start、step/end |
tool/result | assistant/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,而是一套围绕循环建立的执行协议:
- Driver、Turn、Step 分别表示一次唤醒、一轮任务和一次模型调用。
- Inbox 统一承接 Follow-up、Steering 和 Context Injection。
preStep()负责在稳定边界组装上下文并开放扩展点。step()根据有无 Tool Call 决定结束 Turn 或继续循环。- 工具可以并发执行,但结果按模型调用顺序提交。
- Session 事件日志是唯一事实来源,模型历史由它派生。
一句话概括:
Agent Loop 负责推进模型与工具之间的循环,Session 负责保存并重建执行事实,扩展点负责把策略附着到稳定的执行边界上。
参考资料:
- DeepSeek Harness
packages/core/agent-loop/src/agent.tspackages/core/agent-loop/src/tool-calls.tspackages/core/session/src/index.ts