DeepSeek Harness 源码解析(三):Agent Loop 底层运行机制——从 Turn、Step 到完整调用链

发布时间:2026/10/21 9:24:52
DeepSeek Harness 源码解析(三):Agent Loop 底层运行机制——从 Turn、Step 到完整调用链
在上一篇中我们了解了 Cordis 如何通过插件、服务和事件机制组织 DeepSeek Harness。但当用户真正发送一条消息时谁负责推动任务执行模型什么时候被调用工具结果怎样进入下一轮推理任务失败后又怎样确定执行到了哪一步本文将结合 DSH 官方源码沿着一次完整的 Agent 任务理解 Agent Loop 的运行机制。01为什么需要 Agent Loop假设我们希望让大模型完成一个任务“读取项目中的 README.md分析技术架构并给出总结。”最简单的执行过程是用户输入 ↓ 调用 LLM ↓ LLM 判断需要读取文件 ↓ 提出 read(README.md) ↓ 程序执行 read 工具 ↓ 将文件内容返回 LLM ↓ LLM 分析内容 ↓ 生成总结这个例子里大模型至少需要进行两次推理。第一次它决定读取文件。第二次它根据文件内容生成总结。但这里出现了一个问题第一次模型请求结束后谁负责判断模型是否需要调用工具工具执行完成后又是谁负责发起第二次模型请求显然我们不能只调用一次 LLM API 就期待整个任务自动完成。模型可以提出工具调用请求但模型本身并不负责操作本地文件系统。因此需要有一个执行程序负责向模型发送请求。检查模型输出。如果模型提出 Tool Call则调度工具执行。将工具结果纳入后续请求。再次调用模型直到满足结束条件。这就是 Agent Loop 的基本职责。在 DSH 中Agent Loop 是一个具体的执行驱动器而不是模型本身。接下来我们通过一个稍微复杂的任务理解它为什么需要 Turn 和 Step。02从一个复杂任务认识 Turn 和 Step假设我们把任务扩大请阅读项目中的 README.md、package.json、src/main.ts 和 docs/architecture.md分析项目架构生成 architecture-summary.md最后重新读取生成的文件检查内容是否完整。这是一个完整的用户任务。如果一切顺利模型可能产生下面的执行过程。执行步骤LLM 的判断工具调用调用数量Step 1先了解项目基本信息read README.md、package.json2Step 2继续分析入口和架构说明read main.ts、architecture.md2Step 3查找关键模块及其引用关系搜索 AgentLoop、Session2Step 4已获得足够信息生成报告文件write architecture-summary.md1Step 5重新读取报告进行检查read architecture-summary.md1Step 6根据检查结果给出最终总结无工具调用0我们假设模型按这个方案执行。那么用户只提交了一次任务但内部发生了 6 个 Step、8 次工具调用。这里就引出了两个核心概念。2.1 Turn 是什么在 DSH 中Turn 表示一个 Agent 执行轮次包含零个或多个 Step。对于上面的例子可以将整个任务理解为Turn 1分析项目并生成架构报告 │ ├── Step 1读取项目基础信息 ├── Step 2读取核心源码 ├── Step 3搜索模块关系 ├── Step 4生成架构报告 ├── Step 5验证报告内容 └── Step 6返回最终回答这里整个过程属于同一个 Turn。需要说明的是Turn 并不是任意业务任务的永久标识。在当前 DSH 默认执行方式下一条普通followup()消息通常开启一个新的 Turn。任务内部的工具处理和继续推理则可以产生多个 Step。2.2 Step 是什么DSH 官方给 Step 的定义是一次模型请求以及该次请求产生的工具调用处理。例如 Step 1Step 1 │ ├── 模型请求 │ ↓ │ LLM 返回 │ read(README.md) │ read(package.json) │ ├── 工具调用 1 │ ↓ │ README.md 内容 │ ├── 工具调用 2 │ ↓ │ package.json 内容 │ └── Step 结束所以一个 Step 不一定只有一次工具调用。它可以包含零个、一个或多个工具调用。另外一个 Step 内的模型请求也可能发生重试这不一定意味着要创建新的 Step。这就为后面分析错误与恢复提供了重要基础。03为什么需要 Turn 和 Step 两层结构现在让我们给刚才的任务增加一个意外情况。假设执行到 Step 4 时模型请求失败了。具体场景如下Turn 1 │ ├── Step 1 读取基础文件 成功 ├── Step 2 读取核心源码 成功 ├── Step 3 搜索模块关系 成功 │ ├── Step 4 生成报告 │ │ │ ├── Attempt 1 第一次模型请求 失败 │ │ │ ├── Attempt 2 重试模型请求 成功 │ │ │ └── write 工具 成功 │ ├── Step 5 验证报告 成功 └── Step 6 最终总结 成功现在我们来回答三个实际问题。3.1 怎样知道任务执行到了哪一步不能只依靠 Turn。因为 Turn 只能告诉我们当前处于第几个执行轮次。例如turn 1但它不能单独告诉我们已经完成多少次模型请求。我们还需要 Step。turn 1 step 4这就能确定当前执行位置属于第 1 个 Turn 中的第 4 个 Step。我们还需要结合模型请求尝试和 Session 事件Turn 1 / Step 4 │ ├── Attempt 1失败 ├── Attempt 2成功 ├── tool/callwrite └── tool/result成功这样才能进一步还原 Step 4 内部发生了什么。3.2 怎样知道哪个模型请求产生了哪些工具调用答案是通过 Step 边界和工具调用记录。继续使用 Step 4 的例子turn 1 step 4 Attempt 1模型请求失败 Attempt 2模型请求成功 ↓ 模型输出 write(report.md, 报告内容) ↓ tool/call callId call_001 name write ↓ tool/result callId call_001 result success这里可以得到三个结论turn 1, step 4说明write属于第 1 个 Turn 的第 4 个 Step。Attempt 2说明该工具调用来自重试成功后的模型响应。callId call_001将本次工具调用与对应结果关联起来。因此Turn 确定交互轮次Step 确定执行步骤Attempt 区分模型请求尝试callId 区分具体工具调用。3.3 Step 4 第一次模型请求失败为什么不直接进入 Step 5因为 Step 4 的工作还没有完成。在上面的假设中模型第一次请求失败后若 DSH 的错误处理机制允许重试就可以继续在当前 Step 内再次请求模型。因此Turn 1 ↓ Step 4 ├── Model Attempt 1失败 └── Model Attempt 2成功这里是两次 Model Attempt但仍然可以属于同一个 Step。所以我们必须区分概念记录的是什么TurnAgent 执行轮次Step模型请求及其工具处理的逻辑步骤Model Attempt某次实际模型请求尝试Tool Call某次具体的工具调用现在我们就能得到一个明确答案仅靠 Turn 无法定位具体模型请求仅靠 Step 又不足以区分同一步中的不同请求尝试或多个工具调用。需要将这些信息结合起来。接下来看看 DSH 源码怎样支持这种设计。04源码验证Turn 和 Step 是怎么实现的在 DSH 官方源码中默认 Agent Loop 的主要实现文件是packages/core/agent-loop/src/agent.ts其中有几个非常重要的函数kick() ↓ turn() ↓ preStep() ↓ step() ↓ prepareRequest() ↓ buildRequest() ↓ 模型请求与工具执行下面直接看实际代码。4.1kick()驱动 Agent 的 Turn 循环源码位置agent.ts 第 252265 行private async kick(): Promisevoid { try { while (await this.turn()) {} } catch (_error) { // 处理已报告的异常和取消 } finally { // 结束当前驱动必要时再次唤醒 } }其中最关键的是while (await this.turn()) {}这表示调用turn()执行一个 Turn。等待这个 Turn 执行结束。如果turn()返回true继续下一个 Turn。如果返回false退出当前循环。而turn()内部还有自己的 Step 循环kick()驱动 Turn │ ├── Turn 1 │ │ │ ├── Step 1模型请求 │ │ └── read 工具 │ │ │ ├── Step 2模型请求 │ │ └── glob 工具 │ │ │ └── Step 3模型请求 │ └── 最终回答 │ ├── Turn 1 结束 │ ├── 检查是否有下一轮待处理输入 │ └── 有则继续 Turn 2 无则退出当前驱动如果后续又有用户消息DSH 可以重新唤醒kick()。所以kick()负责驱动 Turnturn()负责组织 Stepstep()负责一次模型请求及其工具处理并可在符合重试条件时再次尝试模型请求。4.2turn()打开并执行一个 TurnDSH 真实源码关键语句摘录中间省略异常处理和其他逻辑this.session.append(turn/start, { turn }) // ... while (true) { const step phase.step 1 const decision await this.preStep(target, { turn, step }) // ... this.session.append(step/start, { turn, step }) // ... const stepEnd await this.step(decision) // ... }源码位置agent.ts 第 297367 行可以看到首先写入this.session.append(turn/start, { turn })表示当前 Turn 开始。接着进入while (true)这个循环用于推进当前 Turn 中的 Step。其中const step phase.step 1计算下一次 Step 编号。然后const decision await this.preStep(target, { turn, step })准备当前 Step 的输入。当输入被接纳后记录this.session.append(step/start, { turn, step })随后才会真正执行const stepEnd await this.step(decision)从这里可以明确区分turn()负责当前轮次的整体执行。preStep()负责准备并决定是否接纳 Step 输入。step()负责当前步骤的具体执行。05如果直接调用 LLM会发生什么现在回到用户最初的任务读取 README.md分析项目架构。假设我们不使用 DSH 的请求组装机制而是直接发送{ model: example-model, messages: [ { role: user, content: 请读取 README.md 并总结 } ] }这是一个简化的普通模型请求示例。但这个请求没有提供README.md 的实际文件内容可供模型调用的read工具定义已经执行过的历史工具结果。如果模型只能访问这些输入它就没有足够的信息读取本地文件。它可能回答请上传 README.md。也可能尝试根据有限信息进行猜测。但无论如何仅凭这个请求模型不会自动获得本地 README.md 的内容。所以真正的问题是一次完整的 Agent 模型请求究竟需要包含什么我们直接看 DSH 定义的请求类型。5.1 DSH 的模型请求包含哪些信息源码packages/llm/llm/src/types.tsDSH 真实类型定义摘录省略部分可选字段export interface GenerateOptions { provider: string model: string reasoningEffort?: ReasoningEffortId messages: RequestMessage[] system?: string tools?: ToolSchema[] toolHistory?: ToolHistory temperature?: number maxTokens?: number // ... }源码位置types.ts 第 528 行起现在我们逐个理解。字段作用provider使用哪个模型服务提供方model使用哪个具体模型messages发送给模型的对话历史system提供给独立单次请求的系统提示词字段tools当前提供给模型的工具定义toolHistory用于工具声明变化的历史投影reasoningEffort适配器支持的推理档位配置maxTokens输出 Token 上限这里还有一个 DSH 特有的细节。默认 Agent Loop 构建的请求System Prompt 不是通过单独的system字段传递而是由 Session 中的system/message派生到messages。因此不能简单认为 DSH 的 Agent Loop 每次都是{ system: ..., messages: [...] }它有自己的历史记录和请求构建机制。这点我们后面会结合buildRequest()验证。06System Prompt、历史消息和 Tool Schema 是怎样组装的根据5.1我们已经知道模型需要这些信息。接下来需要回答DSH 究竟在哪一步把它们组合起来6.1preStep()进入模型请求前的准备阶段DSH 真实源码摘录const claimed this.inbox.claim(target, position.turn) const assembly await this.loopCtx.systemPrompt.assemble( assembleContextFor(this, signal) ) const decision await this.dispatch.waterfall( agent/pre-step, { messages: claimed, ...position, signal }, // ... )源码位置agent.ts 第 267288 行这里首先执行this.inbox.claim(...)领取当前需要处理的输入。然后调用this.loopCtx.systemPrompt.assemble(...)组装模型请求所需的提示词及工具信息。但assemble()具体返回什么继续往下看。6.2PromptAssembly保存了哪些内容源码packages/core/system-prompt/src/index.tsDSH 真实源码export interface PromptAssembly { sections: AssembledSection[] contexts: AssembledContext[] tools: ToolSchema[] variables: Recordstring, string | undefined }源码位置system-prompt/index.ts 第 120129 行这个数据结构非常重要。它说明系统组装阶段需要处理四类内容sections系统提示词的不同组成部分。例如角色说明、行为规范等。contexts运行时上下文内容。例如工作目录信息、某些运行环境说明等。tools当前模型可见的工具 Schema。variables参与提示词渲染的变量。因此SystemPrompt.assemble()并不是简单地拼接一句固定的提示词。它需要汇总不同插件注册的提示词片段、上下文和工具定义。这也体现了 DSH 插件化架构的特点不同插件可以向最终模型输入贡献不同类型的信息。6.3 工具名称、用途和参数结构我们直接看ToolSchema。源码packages/llm/llm/src/types.tsDSH 真实源码摘录export interface ToolSchema { deferLoading?: true name: string description: string parameters: Recordstring, unknown }源码位置ToolSchema 定义第 485502 行现在我们可以明确回答为什么模型能够知道有一个read工具因为工具 Schema 会向模型描述name工具叫什么description工具用于什么parameters调用工具时需要什么参数。例如我们可以构造一个简化的工具 Schema{ name: read, description: 读取指定文件的内容, parameters: { type: object, properties: { path: { type: string } }, required: [path] } }6.4 DSH 为什么不把工具执行函数也发送给模型继续看工具注册表。源码packages/core/tools/src/index.tsDSH 真实源码摘录schemas(scope?: ScopeKey): ToolSchema[] { return [...this.view(scope).visible.values()] .map(definition this.schemaOf(definition, true)) }以及private schemaOf( definition: ToolDefinition, detachParameters: boolean ): ToolSchema { const { name, description, parameters, deferLoading } definition // ... return { name, description, parameters: detached, ...deferLoading true ? { deferLoading } : {}, } }源码位置tools/index.ts 第 12621302 行这里可以看出工具注册表向模型提供的是经过投影的 Schema而不是完整的ToolDefinition。例如工具真正的执行函数execute(args, exec)并不会作为可执行函数发送给模型。因此简化原理如下ToolDefinition完整工具 │ ┌──────┴──────┐ ↓ ↓ ToolSchema execute() │ │ ↓ │ LLM │ │ │ ↓ │ name arguments │ │ │ ↓ ↓ ToolRuntime │ ↓ 真正执行工具这就解释了模型为什么知道如何提出工具调用却不能直接执行工具内部的代码。原因在于DSH 通过schemaOf()将完整的ToolDefinition投影为模型可见的ToolSchema。模型只能获得工具的名称、描述和参数结构并不会获得完整的execute()执行函数及其实现代码。因此LLM根据 Schema 判断是否调用工具并生成工具名称及参数。ToolRuntime根据工具名称查找已注册的ToolDefinition调用真正的execute()函数。体现了 DSH 的一个核心设计思想模型负责决策Harness 负责执行。07agent/request插件怎样在请求发送前修改配置前面我们已经完成了输入和工具的准备。现在准备真正调用模型。但假设有一个新需求对于普通的文件查询任务我们希望限制模型单次输出的 Token 数量避免每次都产生过长的回答。如果没有扩展机制我们可能需要直接修改 Agent Loop 的请求构造代码。但 DSH 提供了agent/request扩展点。我们先看它在源码中的位置。7.1 Agent Loop 怎样触发agent/request源码packages/core/agent-loop/src/agent.tsDSH 真实源码摘录const proposedConfig await this.dispatch.waterfall( agent/request, { turn, step, signal }, () Promise.resolve(seedConfig), )源码位置agent.ts 第 593596 行这里最关键的是this.dispatch.waterfall(...)它说明agent/request使用的是 Cordis 的 Waterfall 事件机制。可以会看第二篇的 07 Waterfall 事件机制为什么不用普通通知事件因为我们的需求不是简单地记录“模型即将被调用”。而是希望参与决定最终发送给模型的请求配置。7.2 插件示例假设我们开发一个插件将模型请求的maxTokens限制为最多 1500。例如import type { Context } from deepseek-ai/cordis import type {} from deepseek-ai/dsh-agent export const name request-token-limit export function apply(ctx: Context) { ctx.on(agent/request, async (_payload, next) { // 获取下游处理链返回的请求配置 const config await next() // 参与决定最终的请求配置 return { ...config, maxTokens: Math.min(config.maxTokens ?? 1500, 1500), } }) }先看ctx.on(agent/request, ...)这是在插件激活时注册监听器。然后const config await next()表示继续执行下游 Waterfall 处理链并取得它返回的配置。最后return { ...config, maxTokens: ... }表示在下游结果基础上调整配置再返回给上游。调用过程可以理解为Agent Loop ↓ 触发 agent/request ↓ 进入请求限制插件 ↓ 调用 next() ↓ 下游监听器 / 默认配置 ↓ 获得 config ↓ 限制 maxTokens ↓ 返回修改后的 config ↓ Agent Loop 继续执行需要注意如果同一个 Waterfall 中还有其他插件它们可能进一步修改配置最终结果取决于处理链的组合顺序。7.3 Waterfall 修改后的配置交给谁继续看prepareRequest()。DSH 真实源码preparedCall await this.loopCtx.llm.prepareCall( proposedConfig, signal ) config preparedCall.config源码位置agent.ts 第 601612 行也就是说原始请求配置 ↓ agent/request Waterfall ↓ 插件参与修改配置 ↓ proposedConfig ↓ LLM Runtime.prepareCall() ↓ 解析对应模型适配器及请求能力 ↓ 准备实际模型请求这就是 Waterfall 在 DSH 中的实际价值插件可以参与请求处理而不需要把自己的逻辑直接写进 Agent Loop。不过还要明确一个边界agent/request在这里处理的是模型调用配置不是任意修改已经记录的模型消息历史。如果要向模型提供新的消息内容还需要使用相应的上下文和 Session 机制。08真正的模型请求是怎样构建并发送的现在我们已经完成输入领取Prompt AssemblyTool Schema 组装agent/pre-stepagent/requestprepareCall()。下一步才是构造请求并发送给模型。8.1buildRequest()从 Session 派生模型历史DSH 源码摘录const boundaryMessages session.deriveMessages() // ... 冻结消息对象 const request markAgentLoopRequest(Object.freeze({ ...header.config, messages: boundaryMessages, toolHistory: session.toolHistory(), ...header.tools ! undefined ? { tools: header.tools } : {}, sessionId: this.session.id, signal, })) return request源码位置agent.ts 第 684704 行这里值得关注的有三个字段。第一messagesmessages: boundaryMessages消息不是临时随意拼出来的。而是通过session.deriveMessages()从 Session 记录中派生出来。第二toolstools: header.tools当前请求需要携带对应的工具定义。第三header.config包含 Provider、Model 及相应模型调用配置。所以现在我们就有了一个完整的关系System Prompt │ ▼ system/message ──────┐ │ 用户消息 ─────────────┤ │ 历史模型消息 ─────────┤ ▼ Session Log │ ▼ deriveMessages() │ ├──────────────┐ ▼ │ messages │ │ Tool Registry → Tool Schema → tools │ │ agent/request → config ─────────────┤ ▼ GenerateOptions │ ▼ LLM Runtime │ ▼ Provider Adapter │ ▼ 模型 API这里也解释了一个重要区别System Prompt 是模型需要看到的指令内容而 Session Log 是 DSH 用来记录执行事实、派生模型历史的数据来源。二者并不是同一个东西。8.2 模型请求真正在哪里发送继续看step()。DSH 真实源码const stream preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)随后for await (const chunk of stream) { signal.throwIfAborted() live.push(chunk) }源码位置agent.ts 第 452460 行这里已经进入实际模型调用阶段。Agent Loop 通过准备好的调用或 LLM Runtime 发起流式请求。Provider Adapter 再将内部请求转换为对应模型提供方的请求格式。模型结果以流式数据返回Agent Loop 接收并处理这些数据。09模型返回 Tool Call接下来发生什么假设第一次模型请求返回{ name: read, arguments: { path: README.md } }这里仍然只是示意的工具调用数据。真实 DSH 内部会使用自己的 Tool Call 消息结构。那么 Agent Loop 怎样判断模型是不是要调用工具直接看源码。DSH 真实源码const toolCalls message.content.filter( block block.type tool-call ) if (toolCalls.length 0) { return { kind: completed } }源码位置agent.ts 第 549550 行这两行代码非常重要。它说明 Agent Loop 会检查模型返回的消息内容提取其中的 Tool Call。如果没有 Tool Call当前 Step 可以以completed结果结束。如果存在 Tool Call则进入工具调度。继续看const { concluded } await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, context this.inbox.splice( next-step, this.inbox.nextStep.length, 0, [context], ), )源码位置agent.ts 第 551554 行这里调用了executeToolCalls(...)其主要实现位于packages/core/agent-loop/src/tool-calls.ts9.1 工具怎样被执行在tool-calls.ts中调度器会准备工具执行对象。DSH 真实源码摘录const planned: PlannedCall[] toolCalls.map(block ({ block, exec: { callId: block.id, name: block.name, arguments: parseArguments(block.arguments), agent, signal, }, }))源码位置tool-calls.ts 第 7181 行可以看到工具执行对象包含callId 当前工具调用标识 name 工具名称 arguments 工具参数 agent 发起调用的 Agent signal 取消信号随后调度器通过 Tool Runtime 的相应接口执行准备、派发和结果处理。当前实现不仅支持按顺序调度还会根据工具是否允许并发决定哪些工具调用可以同时执行。9.2 工具调用与结果怎样关联继续看 Session 记录。DSH 真实源码function appendToolCall( session: Session, turn: number, step: number, block: ToolCallBlock ): SessionSeq { const event session.append(tool/call, { turn, step, callId: block.id, name: block.name, arguments: block.arguments, }) return event.seq }源码位置tool-calls.ts 第 262266 行现在就可以回答前面的问题系统怎么知道哪个工具属于哪个 Step因为tool/call记录里明确包含turn step callId name arguments而工具执行完成后系统会记录对应的tool/result。其中结果消息同样包含用于关联的callId。因此不需要依赖模型在自然语言里说“这是第 3 步”才能确定执行位置。DSH 已经通过结构化事件记录了这些信息。10Session、Session Log、System Prompt 究竟有什么区别现在需要解决一个特别容易混淆的问题。前面我们反复提到SessionSession LogSystem Prompt。它们似乎都与历史信息有关。但其实它们承担的是不同职责。10.1 Session 是什么在 DSH 中Session 是当前 Agent 交互历史的记录对象。它的重要基础是一个仅追加的事件日志。因此我们前面讲的 Session Log并不是与 Session 完全无关的另一个独立系统。Session Log 是 Session 用来记录执行事实的核心数据结构。例如turn/start step/start user/message assistant/message tool/call tool/result step/end turn/end这些都是执行期间发生的事实。10.2 System Prompt 是什么System Prompt 是发送给模型的指令内容。例如你是一个代码分析助手。 请基于实际读取到的文件进行分析。 不得虚构不存在的类、函数或配置。它是模型输入的一部分不是整个 Agent 的执行日志。在 DSH 中渲染后的 System Prompt 可以通过system/message记录为模型可见历史。10.3 为什么还需要deriveMessages()因为 Session Log 里面不只有模型消息。它还包含turn/start step/start tool/call step/end这些事件对于记录执行过程有意义但并不能全部直接作为普通聊天消息发送给模型。因此DSH 需要从 Session Log 派生真正适合模型输入的消息历史。这就是session.deriveMessages()发挥的作用。可以这样理解对象负责什么Agent Loop推进任务执行Session持有交互事件历史与相关状态Session Log记录已经发生的执行事实System Prompt向模型提供指令deriveMessages()从 Session 事件记录派生模型可见历史这五个概念不能混为一谈。11模型请求失败以后DSH 怎样判断执行状态现在回到最初的复杂任务。假设执行到了Turn 1 Step 4第一次模型请求失败了。DSH 怎样处理我们先看真实源码。11.1 可处理的模型请求失败agent/request-error在step()中模型可能返回一个带有错误结束状态的请求结果。DSH 真实源码摘录if (finish.kind error || finish.kind aborted) { live.settle( assistant/attempt, () this.session.append(assistant/attempt, { turn, step, stream: live.stream, }).seq, ) const action await this.dispatch.waterfall( agent/request-error, { turn, step, provider: request.provider, failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, signal, }, () Promise.resolveRequestErrorAction(undefined), ) signal.throwIfAborted() if (action?.kind ! retry) { throw new LlmError( finish.failure.message, finish.failure.code, finish.failure, ) } continue }源码位置agent.ts 第 504527 行这里有三个重要动作。第一记录本次模型尝试。assistant/attempt它用于保留已到达记录提交阶段、但没有形成正常模型可见消息的请求尝试信息。第二触发错误处理扩展点。agent/request-error让相应插件有机会决定是否重试。第三根据返回结果决定继续还是终止。if (action?.kind ! retry) { throw new LlmError(...) } continue如果处理器给出有效的重试决定就继续当前 Step 内的请求尝试循环。如果没有处理器接管错误就会继续向上抛出。注意这里分析的是上述源码分支。并不是所有网络异常或流式异常都会以完全相同的方式进入agent/request-error。例如流迭代过程中直接抛出的异常还有独立处理路径。11.2 如果最终失败Turn 怎样记录结果再回到turn()。DSH 真实源码摘录turnEnds { kind: error, error: error instanceof LlmError ? error.failure : { message: errorChain(error), code: UNKNOWN }, }而在 Turn 收尾阶段this.session.append(turn/end, { turn, reason: turnEnds!, })源码位置agent.ts 第 368390 行这说明系统可以将轮次结束原因作为结构化数据记录。例如Turn 1 ↓ Step 4 ↓ 模型请求失败 ↓ 没有有效重试 ↓ Step 结束 ↓ Turn 结束 ↓ turn/end reason.kind error这里最重要的是Turn 负责记录轮次最终为什么结束Step 用来定位失败发生在哪个逻辑步骤相关请求尝试和错误事件提供进一步诊断依据。step/end表示步骤边界关闭并不等于该步骤一定成功。11.3 如果失败的是工具呢再换一种情况。假设 Step 4 的模型请求成功了并提出write(architecture-summary.md)但是工具已经开始执行系统却未能确认最终结果。可能出现tool/call 已记录 ↓ 工具开始执行 ↓ 进程或执行流程发生异常 ↓ 没有可靠的 tool/result现在能否直接重试write不一定。因为工具可能已经完成写入只是最终结果没有被成功记录。DSH 对这种情况有专门的保守处理。官方 Agent Loop 实现说明提到如果已经记录tool/call但没有相应的已提交工具结果恢复处理可能产生TOOL_OUTCOME_UNKNOWN这表示工具调用的结果无法可靠确认。它并不等于工具肯定没有执行。对于可能修改文件、数据库或其他外部状态的操作这个区别尤其重要。因此Agent Loop 的可靠性不仅是“失败以后再试一次”还包括知道哪些事实已经被记录哪些结果可以确认哪些操作处于不确定状态。12最终调用链从用户输入到任务结束到这里我们已经结合真实源码解释了 Turn、Step、请求构建、工具调用、Session 和失败处理。现在把它们放进一张完整时序图。这张图是一张教学时序图主要描述正常执行与可重试模型失败的主路径。实际源码中如果agent/pre-step拒绝输入就不会打开 Step如果错误无法恢复也会退出相应的请求尝试循环并结束 Turn。同时Web 界面可能在模型流式输出期间就展示内容并不是一定要等turn/end才把所有文字一次性发送给用户。13总结Agent Loop 不是简单的 while 循环通过这一篇我们可以重新理解 Agent Loop。它不只是反复调用LLM → Tool → LLM而是需要负责一系列执行控制问题。Turn用于组织执行轮次。Step用于组织模型请求及其工具处理阶段。Model Attempt让我们进一步理解一次 Step 内可能发生多次模型请求尝试。Tool Call通过callId与相应结果建立关联。Session Log负责记录执行事实。System Prompt是模型输入的一部分而不是执行日志。LLM Runtime提供模型请求能力。Tool Runtime负责工具的受控执行。Cordis Events让其他插件可以在关键执行阶段参与处理。这些机制共同作用才构成一个可扩展、可记录、能够处理失败的 Agent 执行系统。理解 Agent Loop最重要的不是记住多少函数名而是能够根据某次执行的 Turn、Step、模型尝试及工具事件解释系统已经完成了什么、正在做什么以及失败发生在哪里。下一篇我们将深入 Tool Runtime从模型输出一个 Tool Call 开始继续分析 Tool Registry、Tool Schema、工具调度、权限检查和结果提交之间的源码调用关系。参考源码本篇核心依据源文件主要内容packages/core/agent-loop/src/agent.tskick()、turn()、preStep()、step()、prepareRequest()、buildRequest()packages/core/agent-loop/src/tool-calls.tsexecuteToolCalls()、工具调用记录和结果提交packages/core/system-prompt/src/index.tsPromptAssembly、assemble()packages/llm/llm/src/types.tsGenerateOptions、ToolSchemapackages/core/tools/src/index.tsTool Registry、模型可见 Schema 投影packages/core/session/src/types.tsSessionEventMap、Turn 与 Step 事件、失败原因官方仓库DeepSeek Harness版本说明本文根据 2026 年 10 月 19 日读取的 DSH 官方默认分支源码整理。所引用的源码片段可通过对应行号核对。示例任务与事件轨迹用于帮助理解并非实机执行记录。