理解 Agent,不能只看一次 LLM API 调用。真正的执行链路还要回答:输入如何进入运行时、上下文如何组装、模型如何调用工具、工具结果如何返回模型,以及循环在什么时候结束。
这条链路的核心就是 Agent Loop。本文以 DeepSeek Harness(下文简称 dsh)为例,沿着关键源码走读一次完整执行。
本文已按本地仓库的 0.1.0-rc.5 更新,对应提交 47f943859b。这一版本的循环仍由 ReactLoopAgent 驱动,但输入生命周期、取消后的重新唤醒、请求头记录、工具调度和 Session 消息投影都比早期实现更严格。

一、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
-> 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 | 用途 |
|---|---|---|---|
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());
如果新输入落在 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() 会:
- 从 Inbox 领取消息。
- 按当前 Agent Scope 组装 System Prompt 和工具 Schema。
- 将运行时上下文投影成一条可选消息。
- 触发
agent/pre-stepwaterfall,让扩展决定进入或拒绝 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 形成屏障并独占执行。调度器会在尚未启动的调用真正入池前重新读取执行模式,所以前面工具对注册表的修改仍能让后续调用变成屏障。即使多个工具主体乱序完成,后处理、附加上下文和结果仍按模型给出的调用顺序提交,避免对话历史因为完成时序不同而错位。

取消也必须保持历史可回放:已经启动的调用会先 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/message | turn/start、turn/end |
assistant/message | step/start、step/end |
tool/result | assistant/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,而是一套围绕循环建立的执行协议:
- Driver、Turn、Step 分别表示一次唤醒活动、一轮任务和一次模型调用。
- Inbox 统一承接 Follow-up、Steering 和 Context Injection。
- wake latch 保证取消或 maintenance 收敛窗口中的新输入不会失去唤醒。
preStep()负责在稳定边界组装上下文并开放扩展点。step()根据有无 Tool Call 决定结束 Turn 或继续循环。- 工具可以有界并发执行,exclusive 调用形成屏障,但结果始终按模型顺序提交。
- Session 事件日志是唯一事实来源,模型历史由 surface 投影派生。
一句话概括:
Agent Loop 负责推进模型与工具之间的循环,Session 负责保存并重建执行事实,扩展点负责把策略附着到稳定的执行边界上。
参考资料:
- DeepSeek Harness
- 本文对应源码提交
47f943859b packages/core/agent-loop/src/agent.tspackages/core/agent-loop/src/tool-calls.tspackages/core/session/src/index.ts