Turn / Step / Loop 状态机
从输入认领到模型、工具和 turn 停止点
结论:Loop 很小,但它守住了事件顺序和可重建性
默认 loop 只拥有一条语义主干:认领输入 → 打开 Turn/Step → 从日志重建请求 → 调模型 → 持久化流与完成锚点 → 执行工具 → 决定继续或停止。压缩、权限、重试策略、计划和多 Agent 都通过插件 seam 参与,而不改写这条主干。
“小”不代表简单。它的价值集中在精确的 commit 顺序:任意一次真正发出的模型请求,都能由发送前的 Session log 独立重建。
1. 四个层级不要混用
| 层级 | 开始条件 | 拥有的语义 | 结束条件 |
|---|---|---|---|
| Driver activity | waking input 从 idle 启动 | 一个 AbortController、initiator scope、连续 queued turns 的 drain | 无可继续工作,或失败/取消收敛 |
| Turn | driver 调用 turn() | 一批工作的 durable accounting;从 turn/start 到唯一 turn/end | completed / max-tokens / blocked / aborted / error |
| Step | pre-step 接受非空首批输入,或已有工具续跑需要下一请求 | 一次模型调用及其工具批次;从 step/start 到 step/end | 模型完成、工具要求续跑、工具结束 turn、或异常 |
| Request attempt | Step 内 buildRequest() | 冻结的 route/system/tools/messages;provider 失败可在同一 Step 内重试 | 成功 finish,或未被 recovery 接管的失败 |
官方时序把 turn、step、prompt assembly、LLM stream、tool settlement 与停止点明确分层;turn/step boundary 是 durable SessionEvent,而不是另一套 agent/* 镜像。ARCH-TURNTURN-SEQUENCE
2. 最外层 driver 只做循环、包含和收敛
wakeDriver() 先同步把 phase 设为 running,再用 ctx.agents.withInitiator(agent, () => kick()) 启动。kick() 只执行 while (await turn());所有已经报告的失败与取消在这里被吞并,最后切回 idle,并重放取消收敛期间 latch 的 wake。LOOP-DRIVER-PRESTEP
同一个 running interval 可以连续执行多个 queued turns。一个 turn 结束后若 Inbox 仍有 pending work,loop 换一个新的 AbortController、把 step 计数归零并继续;没有 pending work 才退出 driver。
异常不会摧毁 Agent 对象:失败归属于当前 turn,driver 收敛回 idle,下一次 wake 仍可启动新 turn。这使“loop 存活”与“某次工作成功”严格分离。
3. Turn 先打开,再决定有没有 Step
Append turn/start
turn number 取上一个 turn + 1,并在认领任何输入之前成为 durable boundary。
Propose first pre-step
target 为 next-turn:claim 所有 next-step 加一条 next-turn,然后组装 prompt/context 并进入 waterfall。
Possibly open step
只有 decision 允许且分支需要模型调用,才 append step/start、提交 accepted user messages 并调用 step()。
Close every opened step
step/end 在 finally 中写入,所以已打开 Step 必有终点,即使模型、工具或插件失败。
Offer turn-stopping
Step 已得出终局且当前没有 next-step input 时,serial listener 最后一次决定是否 steer 新 Step。
Append turn/end
除 turn/start 自身无法写入这类更早失败外,已打开 Turn 在 finally 中写唯一结束原因。
首次 pre-step 被 reject 时,Turn 以 blocked 结束,没有 step/start、没有 user/message、也没有模型请求;首次 decision 允许但 messages 为空时,Turn 以 completed 结束,同样不打开 Step。LOOP-TURN-STATE
测试验证:blocked prompt 只产生 turn/start/end;已完成 Turn 的后续 pre-step 若被改写为空,不会凭空打开 Step;claim 之后才到达的 inject/steer 不会混入当前 request,而会留给下一 Step 或下一次 wake。LOOP-INTERCEPTION-TEST
4. Pre-step 的顺序决定上下文归属
preStep() 依次执行:从 Inbox 原子 claim → 为该 Agent 组装 system prompt 与 tool schema → 渲染动态 context sections → 由 RuntimeContextProjection 判断是否需要新的 snapshot message → 调用 agent/pre-step waterfall。默认 decision 把 claimed messages 与变化后的 runtime-context message 合并。LOOP-DRIVER-PRESTEPLOOP-RUNTIME-CONTEXT
这产生三条重要语义:
- Claim 是排他边界。Waterfall 执行期间新来的消息仍在 Inbox,不属于已冻结的 proposed batch。
- Prompt assembly 发生在
agent/pre-step之前;listener 可以改变进入历史的 messages,但拿到的是已经确定的 proposed position 与 signal。 - 动态 runtime context 不是偷偷塞入 request 的字符串。内容变化时,它先成为带 plugin source 的候选 UserMessage,随后随 accepted batch 写入日志。
插件能拒绝、包装或扩充一次 Step,但必须面对 claim 已发生的事实。Reject 不会把被 claim 的 prompt 自动放回 Inbox;durable blocked Turn 是对这批被消费但未进模型的工作结算。
5. Step 是“重建请求—流式完成—工具结算”的一轮
Step 先渲染该次 assembly 的 system text,并在每个 request attempt 现场调用 session.deriveMessages()。模型流的每个 chunk 都立即 append 为 assistant/chunk 并交给 BlockAssembler;成功 finish 无论内容是否为空,都写一条 assistant/message completion anchor,附 usage、provenance 与精确 sourceEventSeqs。LOOP-STEP-STATE
| 模型完成形态 | Step 返回 | 工具行为 | Turn 倾向 |
|---|---|---|---|
| normal,且无 tool-call | completed | 无 | 进入 turn-stopping 后结束 |
max-tokens | max-tokens | 即使内容里已有 tool-call,也不 dispatch | 结束原因在本 Turn 内 sticky |
| normal,有 tool-call,未 concluded | null | 执行并持久化结果 | 必开下一 Step,让模型读结果 |
| normal,有 tool-call,concluded | completed | 执行并持久化结果 | 可直接结束,除非已有 steering |
简单 turn 测试证明边界顺序严格为 turn/start → step/start → step/end → turn/end,Inbox receipt 更早;工具测试证明 call/result 进入 Session log,并出现在同一 Turn 下一 Step 的派生 request 中。LOOP-ORDER-TEST
6. Request 不是内存拼装物,而是日志可折叠的快照
Seed route proposal
首步来自 AgentOptions;后续步从最新 request header 去除标记为 adapter-derived 的字段,让新 route 重新求默认值。
agent/request waterfall
插件可以返回新的 provider/model/temperature/maxTokens/stop 等 config;两项 route 都必须最终存在。
Prepare exact adapter call
llm.prepareCall() 验证 adapter-owned 字段、物化 exact-model defaults,并冻结该 adapter registration 与 retry policy。
Commit request/header
canonical header 包含 effective config、adapter-default markers、system 与 tools;首个实例写 initial/resume,之后只在变化时写 change。
Commit request/context
provider、model 或 contextWindow 变化才追加容量记录,供压缩与观测读取。
Freeze dispatch object
使用 durable-derived messages、header system/tools、sessionId 与 turn signal 构造并标记 loop request。
prepared call 持有解析默认值时的 exact adapter registration,避免 HMR 在异步间隙把“旧 adapter 的能力判断”和“新 adapter 的 dispatch”混用。没有注册 adapter 的 route 暂时保留 proposal,让 llm/stream middleware 有机会完全接管;无人接管时终端仍报 NO_ADAPTER。LOOP-REQUEST-BUILD
Invariant companion 只检查 loop-marked request:对象和 messages 必须 frozen、Session 必须 live、日志必须已有 step/start 与 request/header;随后独立比较 session.deriveMessages() 和 folded header,任何偏差都报告为 reconstruction desync。LOOP-REQUEST-INVARIANT
测试跨越工具 Step、后续 Turn、system prompt 变化和 request config 变化,在每个 dispatch 前用一份全新 Session 重放事件前缀,逐字段证明 messages 与 header 和真实 request 相等。LOOP-RECONSTRUCTION-TEST
7. 结束原因是 durable accounting,不只是 UI 状态
| TurnEndReason | 触发 | 是否可能有 Step | 对被认领工作的解释 |
|---|---|---|---|
completed | 正常完成、工具 concluded,或空首批 | 可能有,也可能没有 | 工作按正常边界结算 |
max-tokens | 任一 Step 被输出上限截断 | 有 | 本 Turn 即使后来强制续跑成功,仍保留截断事实 |
blocked | pre-step reject | 无 | claimed input 已消费但未进入模型 |
aborted | turn signal 被 user/parent/disposed 等 cause abort | 可能有 | 记录取消来源;已开始副作用仍需 drain |
error | 未恢复的模型或插件/工具错误 | 可能有 | LlmError 保留结构化 failure,其余归一为 UNKNOWN + errorChain |
中途 cancel 写入带 cause 的 aborted;max-tokens 在一个 Turn 内是 sticky,即使 turn-stopping 强制下一 Step 成功也不降级,但不会污染下一个 Turn;max-tokens finish 后不执行可能被截断的 tool call。LOOP-OUTCOME-TEST
8. Recovery 只拥有模型请求失败,不吞掉所有异常
BlockAssembler 产出 error/aborted finish 时,Loop 调用 agent/request-error waterfall,携带 turn/step、provider、结构化 failure、prepared registration 的 immutable retryPolicy 与 signal。Listener 返回 {kind:'retry'} 才在同一 Step 内重建并重发;否则抛 LlmError。LOOP-STEP-STATE
agent/request middleware 自己抛错不会进入 request recovery;provider failure 可被同一 listener 连续接管;若 listener 一边 cancel 一边返回 retry,取消优先;recovery listener 自己失败则 Turn 以 error 结束。LOOP-REQUEST-ERROR-TEST
其他异常——prompt render、pre-step plugin、工具/结果处理、request middleware——直接进入 Turn error 边界。throwError() 先发 live agent/error,Turn finally 再写 durable error reason;driver 吞并已报告异常并回到 idle。LOOP-ERROR-CONTAINMENT-TEST
9. Extension seams 决定“能力在哪一层实现”
| 扩展需求 | 所属 seam | 为什么不进 loop |
|---|---|---|
| 压缩触发 / overflow repair | agent/pre-step / agent/request-error | 是 context policy,不是 turn 机械结构 |
| 模型 route 与请求参数 | agent/request | provider selection 可替换且需要日志 header |
| 重试等待与策略 | agent/request-error | Loop 只识别 retry action,不拥有 backoff policy |
| 工具权限、审批、沙箱 | tools/pre-execute / guards / post / result | 必须按每个 call 管理,且多个插件可组合 |
| 强制继续或终止 | agent/turn-stopping | Loop 给出自然停止点,策略可 steer 或 cancel |
| 持久化、UI、telemetry | session/event | durable fact 已经是共同事实源,无需 loop 主动调用每个消费者 |
默认 Loop 没有内建 turn budget。工具或 steering 可以持续制造下一 Step;要限制 runaway turn,策略插件必须在现有 seam 上计数并 cancel。这是刻意的“mechanism in core, policy in plugins”,也是部署方必须主动补齐的风险控制。
10. 设计收益、代价与语义边界
| 选择 | 收益 | 代价 |
|---|---|---|
| Turn/Step durable boundaries | 失败、取消、工具续跑都能精确重放和归属 | 即使 no-step/blocked 也会增加 lifecycle events |
| 每 Step 从日志 deriveMessages | 没有隐藏的 conversation buffer,resume/replay 同构 | event vocabulary 与 projection 兼容性成为核心负担 |
| Request header snapshots | prompt、tools、route defaults 与模型容量均可解释 | header 变化需要稳定 canonicalization,错误标记会破坏 cache/replay |
| Raw chunks + completion anchor | UI fidelity 与规范历史同时存在 | 日志更大,必须维护 chunk-to-message 来源关系 |
| 窄 request-error recovery | 重试只处理它真正能安全重做的 provider request | 插件作者必须理解异常属于 request 还是整个 Step/Turn |
| 策略全部插件化 | Loop 稳定,压缩/权限/计划可独立组合 | 系统行为分散到 waterfall 顺序,组合验证不可缺失 |
DeepSeek Harness 的默认 loop 并不试图成为“Agent 大脑”,而是充当一台事件提交机器。其最强原则是把模型实际所见和每次工作结局固化到可重建日志;其最大风险则是扩展行为高度分散,正确性不再只由 agent.ts 决定,而依赖所有 waterfalls、工具管线与 session projections 共同遵守顺序合同。
本章复核清单
- 已区分 driver、turn、step 与 request attempt。
- 已追踪 turn/start、claim、pre-step、step boundaries 和 turn/end 的所有主要分支。
- 已验证 reject、空首步、空 continuation 和 claim 后并发输入。
- 已追踪流式 chunk、assistant completion anchor、工具续跑与 concluded。
- 已核对 request proposal、adapter defaults、header/context logging 与 frozen dispatch。
- 已用 invariant 与重建测试核对“请求可由日志独立重建”。
- 已核对 completed、max-tokens、blocked、aborted、error 五类结局。
- 已区分 request-error recovery 与其他 Step/Turn 异常。
我的学习体会
内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。