vbnet
title: "DeepSeek Harness 的动力引擎:Agent Loop 是怎么转起来的"
subtitle: "核心文件 496 行的引擎:不自己记状态,状态只有日志一份"
description: "Agent Loop 是 DeepSeek Harness 的动力引擎------八个核心模块里只有它在动,其余都在等它点火。本文基于 packages/core/agent-loop 源码,拆解这台引擎(核心文件仅 496 行)的运转:Inbox 双队列怎么喂燃料、kick→turn→preStep→step 怎么点火、pre-step 为什么是唯一的拦截截面、每个流式 chunk 为什么都进日志、工具调用怎么做到'调度可重叠、提交必有序'、按 Esc 之后发生了什么。贯穿全篇的一句话:这个引擎不自己记状态------状态只有一份,就是日志,每个请求都从日志派生。"
summary: "拆解 DeepSeek Harness 的动力引擎:Inbox 双队列、驱动链、pre-step 拦截、工具调度与取消收敛,以及'日志即状态'的设计主线。"
keywords:
- DeepSeek Harness
- Agent Loop
- ReAct 循环
- AI Agent 框架
- 事件溯源
- TypeScript Agent
- 工具并行调度
tags:
- 源码解析
- AI Agent
- TypeScript
categories:
- 深入理解 DeepSeek Harness
slug: "deepseek-harness-agent-loop"
series:
- "深入理解 DeepSeek Harness"
series_weight: 1
toc: true
DeepSeek Harness 的动力引擎:Agent Loop 是怎么转起来的
「深入理解 DeepSeek Harness」系列第 1 篇。全系列地图见第 0 篇《5 分钟看懂 DeepSeek Harness》。
注:Mermaid 图表在掘金等博客平台不渲染,发布版会替换为预渲染图片。
目录
-
- [① 燃料:Inbox------消息的门口](#① 燃料:Inbox——消息的门口 "#fuel")
- [② kick:loop 的入口](#② kick:loop 的入口 "#kick")
- [③ turn:轮次管理](#③ turn:轮次管理 "#turn")
- [④ preStep:拼接上下文、压缩与拦截](#④ preStep:拼接上下文、压缩与拦截 "#prestep")
- [⑤ step:调用 LLM 与执行工具](#⑤ step:调用 LLM 与执行工具 "#step")
-
[常见问题 FAQ](#常见问题 FAQ "#faq")
引言
如果你写过 Agent 框架,大概率经历过这个顺序:先写循环------调模型、看有没有 tool_calls、执行工具、把结果喂回去、再调模型。循环跑通的那一刻很有成就感。
然后需求来了。产品经理说"加个权限检查,某些命令不能执行"------你打开循环源码,在"执行工具"那一段加了一个 if。过两周:"上下文太长了会爆,加个压缩。"你又打开循环,再加一段。再后来:"换一家模型供应商。"......你发现你的循环已经没人敢动了------它不是引擎,是 if 堆。
DeepSeek Harness 把循环做成了真正的引擎:packages/core/agent-loop/src/agent.ts,核心文件 496 行(整个 agent-loop 包 6 个文件共 1643 行,其中工具调度器 tool-calls.ts 就近 300 行,后面 ⑤ 会讲到)。八个核心模块里只有它在"动"------System Prompt 等它来组装,LLM 等它来调用,Tools 等它来调度,Session 等它来写。它一转,整台机器就转。
这台引擎最特别的地方,不是它怎么转,而是它不自己记状态:
这个引擎不自己记状态------状态只有一份,就是日志,每个请求都从日志派生。
Deepseek harness用日志来实现。它的 turn 序号是从日志里恢复的,它的输入队列是从日志里重放的,它发给模型的每一个请求都是从日志重建的------后面你会看到,这条主线能解释这台引擎几乎所有的"怪"设计。
怎么转?核心是一条控制流 :kick → turn → preStep → step。它是 loop 的骨架------四个方法每层只干一件事,靠事件调用不同的插件完成不同的工作:turn 管轮次、preStep 过闸(权限、压缩、上下文)、step 调模型和工具。这条控制流本身不记状态:需要记住的只有 Phase(idle / running / maintenance)和 Inbox 队列,而它俩都能从日志重建。
阅读门槛
前置知识 :需要 TypeScript 基础(能读懂
abstract class、generics、declaration merging),了解 ReAct Agent 基本概念(LLM 交替推理与行动的循环模式)。适合:想要阅读 DeepSeek Harness 源码、做 Agent 框架二次开发的开发者。
不适合:零基础想学习大模型 Agent 入门的读者------建议先了解 ReAct 模式和 TypeScript 插件架构再回来。
一图总览

图 1:用户消息进 Inbox,Agent Loop(内部是 kick → turn → preStep → step 控制流)驱动循环,System Prompt / LLM / 工具调度各司其职,Compaction 在 pre-step 上兜底。粗虚线是全文论点:每一步都写日志,下一个请求又从日志派生------日志不是引擎的副产品,是引擎的燃料来源。
图看完了,下面按控制流的顺序拆:燃料(Inbox)→ 点火(kick)→ 轮次(turn)→ 拼接上下文与压缩(preStep)→ 调用模型与工具(step)。
引擎怎么转
引擎的运转分两步:消息先到 Inbox 排队,然后控制流 kick → turn → preStep → step 接手------五个环节各管一段具体的活:
- Inbox:收消息排队------新话题进 next-turn、插话进 next-step;引擎忙时消息先等着,不丢也不打断。
- kick:启动循环------还有排队输入就继续转;出错在这里兜底;收尾在这里收敛。
- turn:管理一次对话轮次------写边界日志、循环跑 Step、结束时和插件商量要不要续命。
- preStep:每次调模型前的准备------取输入、拼接上下文(System Prompt + 运行时快照)、压缩和权限检查。
- step:干活------调模型拿流式回复、解析 tool_calls 执行工具、判定这步是否结束。
下面逐个拆,看它们具体怎么实现。
① 燃料:Inbox------消息的门口
所有进入 Agent 的消息,第一站都是 Inbox------一个消息队列。为什么要排队?因为"消息什么时候到"和"引擎什么时候有空"是两码事。Inbox 分两个队列:
- next-turn(顺序执行队列) :按顺序排队的消息,必须等当前轮次结束才被处理。
- next-step(插队执行队列) :可以插队的消息,当前这一步一完成就处理。
围绕这两个队列,有三个使用场景:
场景一:开始聊天。 你发第一条消息"列出当前目录文件"------这是新话题,进 next-turn,引擎被唤醒,开一个新轮次处理它。
场景二:下一条要执行的消息。 对话进行中你又发了一条消息,而引擎还在忙(正在执行工具)。这条消息不打断当前工作,进 next-turn 排队,等当前轮次结束、下一轮开始再处理。
场景三:插队执行。 你在 Agent 干活干到一半补一句"顺便把测试也跑了"------这条消息进 next-step,当前这一步一完成就被取走,不用等轮次结束。就像餐厅里加单:不用重新排队,也不打乱厨房的顺序。
三个场景在源码里对应三个方法,区别只有两个参数:进哪个队列、要不要让引擎马上开工:
| 场景 | 方法 | 进哪个队列 | 引擎开工? |
|---|---|---|---|
| 开始聊天 / 下一条消息 | followup |
next-turn | 是 |
| 插队执行 | steer |
next-step | 是 |
| 顺路捎带(工具产出上下文、后台补充,不急着处理) | inject |
next-step | 否 |
消费规则(Inbox.claim):每次取走全部 next-step 的积压,加上至多一条 next-turn------所以一个轮次的第一批输入 = 新话题 + 之前所有插话。
两个值得记住的细节:
Inbox 是持久化的。 每次变更都写成一条 agent/inbox/spliced 会话事件;重启后构造函数重放这些事件重建队列(replay-once 投影------把日志事件放一遍得出当前状态,放完即弃、不保留中间过程,所以不是存快照)。配合第 0 篇说过的持久化插件,Agent 崩溃前你发的消息一条都不会丢。splice(对队列的增删操作)做了完整规范化------负索引、越界截断、去重校验------因为写进日志的数据不信任任何调用方。
wakingAfterAbort 竞态。 发给"正在被取消的活动"的开工消息,会在插入之前 被捕获并重分类到 next-turn(agent.ts 的 send())------这样可重入的 cancel 无法把它重新分类,消息不会加入一个注定中止的轮次。这个细节单看很小,但它保证了"取消"和"来新消息"同时发生时,没有消息会凭空消失。
② kick:loop 的入口
kick() 是 loop 的入口------引擎不是常驻转动的,它"被唤醒才转",kick 就是点火的开关。进入 loop 后,它在这里做了几件事:
-
驱动循环 ------启动前
wakeDriver()把引擎从 idle 推进 running,然后 kick 执行while (await this.turn()) {}:只要 Inbox 里还有排队输入,就继续开下一轮;turn 返回 false(没有输入了)就停。 -
错误兜底------循环里任何一步出错(工具抛异常、插件抛异常、日志写失败),都走同一条兜底链,用户始终知道错误原因。兜底分三层:
- 出错现场上报 :
throwError()立刻发agent/error事件,带上出错的位置(哪个 turn、哪个 step)和错误对象------UI 或 SDK 监听这个事件,用户能实时看到"哪一步失败了"。 - 轮次记录 :turn 的 catch 把失败结构化 后写进
turn/end------LlmError保留全部 facts(provider、错误码等);非 LlmError 压平成errorChain文本 +UNKNOWN码。日志里不是含糊的"出错了",而是"哪家 provider 返回了什么错误码",或一串可读的 cause 链。 - 引擎围堵:kick 的最外层 catch 是空的------错误已在前两层上报和记录,这里只负责不让异常击穿整个引擎(代码注释原话:failures are contained at the driver boundary)。
- 出错现场上报 :
-
收尾收敛 ------finally 里把 running 收敛回 idle,并处理"锁存开工":取消过程中来的新消息被
wakeDriver记到wakeRequested,等这里判断"有锁存且 Inbox 还有货"再补一次开工。whenIdle()用activityDonepromise 链(一串前后串联的 Promise,每个代表一段进行中的活动)保证外部观察者能等到引擎真正 静止,而不是"看起来停了"。时序细节:先解构 turn/wakeRequested 再 setPhase(注释原话:setPhase 之后就取不到了)。
顺带说取消的入口:cancel() 由外部调用,只做两件事------清 Inbox(可选保留)+ abort 当前 Phase 的 AbortController(标准的取消信号器,调用 abort() 后所有监听它的 signal 都会收到中止信号)。它不直接杀任何任务,协作式取消全靠各处 signal.throwIfAborted()(step 里 5 处、buildRequest 里 3 处)。
③ turn:轮次管理
turn() 是控制流的第二节:一次完整对话轮次(从用户输入到 Agent 交回控制权),内部包含 0-N 个 Step------Turn 是对话,Step 是对话里的一次心跳。什么是一次对话?看这张图:

一条消息进来,turn/start 开场写日志;然后进入迭代循环:preStep 取输入、拼上下文、压缩、过权限,step 调模型;模型说"还要调工具",就执行工具、把结果回填,回到 preStep 再来一轮------直到某一次模型不再要求工具;这时触发 turn-stopping 协商,没有插件续命就 turn/end 收场;如果 Inbox 还有排队消息,进入下一个 Turn。
turn() 在这里做了几件事:
- 轮次边界日志 ------
turn/start开场、turn/end收场,每个 Step 写step/start/step/end,claim 到的输入逐条写user/message。全部在finally里闭合:中断、异常、正常退出,日志永远配平,不存在有头无尾的 Step。 - Step 循环 ------图中的循环边:
while(true)反复 preStep → step,直到模型不再要求工具(每轮迭代的出口判定在 ⑤ step 节展开)。 - 轮次结束协商 ------
agent/turn-stopping事件,给"续命插件"最后一次机会(比如想在轮次结束时注入后续任务的黑板插件):它往 Inbox 塞了消息,轮次就继续;否则turn/end。 - 中断翻译成轮次事实 ------用户取消时(按 Esc),turn 的 catch 分支里 signal 已中止 →
turn/end的 reason 记成aborted。取消不是异常,是轮次的一种结局------日志里没有"诡异中断",只有明明白白的结局。 - 状态机推进------turn 是 Phase 的推进者:idle → running,驱动 turn/step 计数器。引擎的全部可变状态,就这一个字段:
typescript
type Phase =
| { kind: 'idle', lastTurn: number } // 空闲:等待开工
| { kind: 'maintenance', abort: AbortController, // 维护:压缩等后台任务独占
lastTurn: number, wakeRequested: boolean }
| { kind: 'running', abort: AbortController, // 运行:驱动器活跃
turn: number, step: number, wakeRequested: boolean }
三态里,maintenance 最容易被忽略但很关键:压缩这类后台任务通过 runMaintenance() 独占引擎------任务期间模型循环不可能启动,任务结束若锁存了开工请求再启动,避免"压缩到一半新请求进来"这种交错。这个独占是有代价的:如果后台任务卡死且没人中止它,引擎会一直锁在维护态,新消息进不来 。解除锁的唯一手段是任务收到 abort signal 后主动退出------而触发 abort 是调用方的责任(比如超时策略)。这是设计取舍:宁可锁住等一个后台任务,也不让压缩和新请求交错。
细节:setPhase() 只在状态变化 时发 agent/status 事件------SDK 靠它显示"思考中/空闲",状态没变不刷屏。
④ preStep:拼接上下文、压缩与拦截
preStep() 是控制流的第三节,每个 Step 开始前(也就是每次调模型前)执行。它在这里做了三件事,各解决一个问题:
-
拼接上下文------解决"模型看到的上下文从哪来" :preStep 先从 Inbox 取输入(claim------取走全部 next-step 积压 + 至多一条 next-turn,消费规则见 ①),然后把模型每次请求看到的上下文拼好。它分两部分:
- 静态部分(System Prompt) :工具列表、身份、环境、记忆文件等 Section 按优先级拼装。拼装顺序是确定的 ------Section 按
order升序、工具按名称词法排序(代码注释原话:the order is identical on every machine)。确定性不是洁癖:LLM 提供商的 prompt caching 按请求前缀匹配,前缀越稳定,缓存命中率越高,省 token 也省延迟------所以工具排序、Section 顺序都写死成确定性算法,而不是谁先注册谁在前。 - 动态部分(运行时上下文快照) :当前打开的文件、环境信息等,只在内容变化时注入新快照,被替换时打"已清除"标记------模型看到的"当前状态"是一条条带来源的快照消息,而不是隐式的系统内幕。
拼接还保证了一件容易被忽略的事:压缩不会丢系统提示词。compaction 压缩的是历史消息的范围(把一段 seq 区间换成摘要节点),System Prompt 是每次请求重新拼装的------哪怕长对话被压成一小段摘要,身份、规则、工具列表依然完整出现在模型面前。
- 静态部分(System Prompt) :工具列表、身份、环境、记忆文件等 Section 按优先级拼装。拼装顺序是确定的 ------Section 按
-
压缩------解决"长对话撑爆上下文窗口" :长对话迟早把窗口撑满。压缩挂在
agent/pre-step事件上:compaction-basic监听它,压力高了先压缩历史再放行。为什么是这里而不是 Step 之后?因为压缩必须在模型调用之前完成才有意义------挂在之后,压力检查会滞后一整轮(这个设计在系列 006 展开)。 -
权限拦截------解决"危险操作不能执行" :
agent/pre-step是 waterfall(Cordis 的事件模式:监听器排成一队接力传递,每个都能改写传下去的值,不传给下一个就等于拦截)------权限插件监听它,返回{ kind: 'reject' }就短路,整个 Turn 以 blocked 收场,一次模型调用都不花(这正是第 0 篇说的"扩展不碰核心"的落地点)。默认放行:没有任何插件时,preStep 退化成透传,上下文直接进模型请求------扩展点不影响主干,这是全文反复出现的设计基调。
两个细节:
- 空 enter 决策也拥有 Turn 边界。step 0 且消息为空时,Turn 以 completed 收场但不花模型调用------被删除的开工消息不会造成空转。
- 运行时上下文是快照,不是内幕 。动态上下文(当前打开的文件、环境信息)只在内容变化时注入新快照,被替换时打"已清除"标记------模型看到的"当前状态"是一条条带来源的快照消息,而不是隐式的系统状态。
⑤ step:调用 LLM 与执行工具
step() 是控制流的第四节,也是引擎唯一和 LLM 打交道的地方。一次调用就两件事:调 LLM 拿回复 、执行工具。两件事都可能出错,step 为它们各做了一层保障。
调 LLM:先保证请求是对的,再保证过程是稳的。
- 请求从日志派生 :读上次的
request/header恢复路由配置(事件有三种原因initial/resume/change,重启后从日志恢复上次的路由)------配置不漂移;provider/model缺失直接报错,不让脏请求发出去。插件可在agent/request事件里改写(路由切换、灰度),middleware 甚至可以服务未注册的路由(NO_ADAPTER时回退用提议配置)。 - 请求冻结 :组装完的请求对象经过
deepFreeze+ 冻结标记,下游谁也改不动------路由改写只能发生在事件里,这是"模型可见 ⟺ 已记录"的落实:模型看到的配置,永远能从日志重建。 - 流式全量落日志 :每个 chunk 一条
assistant/chunk,聚合消息通过sourceEventSeqs(来源事件序号)反向引用------回放能看到逐 token 的打字效果,token 计费、断点续传都有依据。 - 取消检查密集 :流式循环埋 5 处
signal.throwIfAborted()(组装还有 3 处)------取消以毫秒级生效,不浪费 token。 - 出错不裸奔 :error / aborted 触发
agent/request-error,插件决定是否重试(默认不重试 ,保守);context-overflow(上下文超限)由 compaction 压缩后 retry;失败结构化------LlmError保留全部 facts,其他压平成errorChain文本 +UNKNOWN码,日志里的每个 error 都能被程序消费。
执行工具:先保证不冲突,再保证不乱序。
-
按执行模式分组 :
exclusive(写文件这类有副作用的调用)形成屏障 ,屏障前后绝不重叠、屏障内逐个串行------防止并发写冲突;parallel(读文件、搜索)进有界并行池,默认上限 10------防止并发打爆资源。 -
提交必须有序 :工具们并发跑,但
tool/result严格按模型的原始顺序 提交(committed指针只在"连续段就绪"时前进)------下一轮请求的消息顺序和模型发出调用的顺序一致,对话历史不漂移。这是全篇最值得记住的规则:调度可以重叠,提交必须有序。
-
中止也补写完整日志 :取消时没跑的调用逐个补
tool/call+ 错误tool/result配对(appendSkippedToolCall)------重放时每个 tool_call 都有对应结果,日志永远自洽。 -
配置可热更新 :
maxParallelToolCalls每次调度现读(settings),改配置只影响下一组,不打断在飞的那批;每个调用启动前重读 executionMode,中途注册的独占工具即时生效。
最后判定这步的出口:completed(模型不再调工具,或某个工具结果标记了结束)、max-tokens(撞输出上限)、null(还有工具结果要喂回模型,进入下一个 Step)。其中 max-tokens 是粘性的:一旦某个 Step 撞了上限,后面正常完成的 Step 也不会把轮次结局从"超限"降级回"完成"------轮次级别的结论以最严重者为准。
总结
回到开头:你亲手写的那个循环,为什么最后变成了 if 堆?因为加需求 = 改代码。现在五个环节拆完,开头的三个需求各有各的落点:
- 加权限检查 ------不用改循环。挂一个监听器到
agent/pre-step(④ preStep),返回{ kind: 'reject' }就拦截; - 加上下文压缩 ------不用改循环。
compaction-basic监听同一个agent/pre-step事件(④ preStep),压力高了先压缩再放行; - 换模型供应商 ------不用改循环。路由在
agent/request事件里切换(⑤ step),配置一行就换。
三个需求,三处监听器,核心循环一行没改------这就是"决策和执行焊死"被拆开后的样子。它背后是三个值得带走的理念:
① 状态只有一份,就是日志。 引擎的 turn 序号、输入队列、请求配置,全部能从会话日志重建------没有快照、没有序列化层,重启即重放。这是"模型可见 ⟺ 已记录"的必然结果:既然每一步都进了日志,状态就根本不需要第二份存储。可借鉴:你的系统里如果也有"要恢复、要审计、要重放"的状态(对话、任务流、交易记录),把它做成事件日志,比维护一份随时会过期的快照省心得多------前提是你守得住写日志的纪律,见 ③。
② 决策点全部事件化,主干零 if。 拦截、请求改写、错误重试、轮次续命,四个决策点全是事件 + 默认放行。读主干不需要了解任何插件,写插件不需要改主干。可借鉴:当你发现核心代码里堆满了"如果配置 A......如果插件 B......"的判断时,把这些判断点换成事件,主干立刻瘦身。你的"加权限检查",在这里是 20 行的监听器,不是循环里的一个 if。
③ 把"日志自洽"当不变量守。 中止补写、finally 闭合、max-tokens 粘性------单看都不起眼,累积起来就是"生产可用"和"demo"的区别。可借鉴:任何"重放即真相"的系统,日志自洽不是加分项,是前提------一条有头无尾的事件,会让整条重放链报废。
代价也说清楚:这套设计把复杂度前置了------光 tool-calls.ts 的调度器就近 300 行,第一次理解它比理解一个 if 堆难。但换来的是之后每一次改动都比 if 堆简单。这是"一次性付清复杂度"和"每次改动都付利息"之间的选择,DeepSeek Harness 选了前者。
开头那句话,现在可以回收了:这个引擎不自己记状态------状态只有一份,就是日志。
系列文章导航
| 编号 | 文章 | 对应模块 |
|---|---|---|
| 000 | 5 分钟看懂 DeepSeek Harness | 全景图 |
| 001 | 动力引擎:Agent Loop 是怎么转起来的(本页) | AGENT LOOP |
| 002 | System Prompt 的动态组装 | SYSTEM PROMPT |
| 003 | LLM 适配层:从 DeepSeek 到任意模型 | LLM |
| 004 | 工具系统设计:三段 waterfall 管道 | TOOLS |
| 005 | 追加式事件日志:Session 设计 | SESSION |
| 006 | 上下文压缩:长对话管理 | COMPACTION |
| 007 | 能力接缝模式:定义/实现/使用分离 | SHELL + 全部 Seam |
| 008 | Cordis 插件体系 | CORDIS |
| 009 | Subagent:委托与隔离(规划中) | SUBAGENT |
| 010 | Workflow:模型驱动的多 Agent 协作(规划中) | WORKFLOW |
| 011 | Guard + Permission + Sandbox(规划中) | GUARD / INTERACTION / SANDBOX |
| 012 | Plan + Preset + Context + Todo(规划中) | PLAN / PRESET / CONTEXT / TODO |
| 013 | Hooks:Claude Code / Codex hook 桥(规划中) | HOOKS |
常见问题 FAQ
Q: Turn 和 Step 的边界到底是什么?
A: Turn 是一次完整对话轮次(用户输入 → Agent 交回控制权),Step 是其中一次"调模型 → 执行工具"迭代。一个 Turn 通常含多个 Step:读文件是 Step 1,改代码是 Step 2,跑测试是 Step 3,最后无工具调用的回复结束轮次。驱动条件:Step 继续 = 还有工具结果要反馈(step() 返回 null);Turn 继续 = Inbox 还有排队输入(turn() 返回 true)。
Q: 为什么压缩挂在 pre-step 而不是 Step 执行后?
A: 压缩必须在模型调用之前完成才有意义------pre-step 是组装请求上下文的最后一站,在这里压缩能保证本次请求的 token 已经瘦身。挂在 Step 后的问题是:如果本轮已经自然停止,压缩永远不会在"下一次用户输入"前发生,压力检查滞后一整轮。
Q: steer 和 inject 都进 next-step,区别是什么?
A: 唯一区别是 wakeup 参数:steer 传 true(Agent 空闲时让引擎马上开工消费它),inject 传 false(静静躺着,等下一个自然出现的步边界)。语义上 steer 是"打断式插话"------用户主动;inject 是"顺路捎带"------工具产出上下文、后台任务补充信息这类不急于消费的内容。
Q: 模型一次返回多个工具调用,会并发执行吗?结果顺序谁保证?
A: 按工具声明的执行模式分组:parallel 进有界并行池(默认上限 10,可配置可热更新),exclusive 形成屏障单独串行。无论怎么调度,tool/result 都严格按模型原始顺序提交------committed 指针只在连续段就绪时前进。下一轮请求的消息顺序必须和模型发出调用的顺序一致,乱序会让历史失去确定性。
Q: Agent 崩溃或进程重启,正在排队的用户消息会丢吗?
A: 不会------配合 Session 持久化插件。Inbox 的每次变更(插入、claim、取消)都写成 agent/inbox/spliced 会话事件,构造函数重放这些事件恢复队列。重启后引擎从日志恢复 turn 序号、Inbox 队列、运行时上下文快照,未消费的消息继续等待处理。注意前提是日志持久化了;纯内存模式重启会丢。
Q: 引擎说"不自己记状态",那 Phase 和 Inbox 不算状态吗?
A: 算------但注意,kick → turn → preStep → step 是调用链(控制流),不是状态;真正算状态的是 Phase 和 Inbox,而它们都能从日志重建------这就是"日志即状态"的含义。Phase 只有 idle/running/maintenance 三态,turn 序号从 turn/start 事件恢复;Inbox 队列从 agent/inbox/spliced 事件重放。不是不记状态,是状态只有一份(日志),其他都是它的投影。无快照、无序列化层,重启即重放。
Q: 错误兜底之后,Inbox 里排队的消息还会继续执行吗?
A: 消息保留,但不会自动继续。错误兜底后,当前驱动循环终止------正在执行的那一轮以 error 结局收场(turn/end 已记录结构化错误),引擎在 kick 的 finally 里收敛回 idle。Inbox 里的消息没有丢,还排在队列里(只有 cancel() 会清空,且可传 keepInbox 保留);但引擎不会自己重新开工------正常出错时锁存标志 wakeRequested 是 false(它只在维护任务或取消后的补跑时置位),引擎停在 idle 等下一次唤醒:用户再发一条消息(followup / steer 会唤醒),引擎从日志恢复 turn 序号和队列,继续处理剩余消息。
Q: 代码里为什么有两个 while(true) 循环?
A: 两个循环管两件完全不同的事。外层 是 turn() 的 Step 循环:一个轮次里模型要多"回合"才能完成复杂任务------读文件、改代码、跑测试,每个回合就是一个 Step,直到模型不再要求工具才退出(ReAct 模式的落地,退出条件是业务语义)。内层 是 step() 的重试循环:一个回合里的"调模型"这次请求可能失败(网络、限流、上下文溢出),失败时触发 agent/request-error 事件让插件决定是否重试,重试就重新组装请求再调一次(退出条件是错误语义:请求成功或放弃重试)。为什么不能合并?因为失败的性质不同------模型"做错了"是业务问题,重试没用;请求超时/溢出是技术问题,值得重试。分开后重试只发生在请求层面:一个回合重试 5 次,外层仍然只算一个 Step。
参考链接
- DeepSeek Harness 仓库:github.com/deepseek-ai...