Agent Runtime 中经常同时出现 Session、Run、Turn、Step。直接背这些定义很容易失去边界,因为不同框架使用的名称并不完全一致,有些框架甚至不会把其中某一层做成显式对象。
更稳定的理解方法是观察系统里同时存在的几种时间尺度。
一次 Provider 请求结束,并不代表当前 Agent 工作结束;一段 Tool Loop 停止,也不代表这段会话结束;浏览器连接断开,更不能直接推导出后端执行已经终止。
这些时间尺度如果全部压进 messages[] + isLoading,系统最初仍能运行,但取消、恢复、审批、并发输入和持久化都会逐渐依赖隐含约定。
多层生命周期由这些约束逐步产生。
从一次 Model Call 开始
最简单的模型调用只有一个生命周期:
Input
↓
Model Request
↓
Output
请求开始时输入确定,请求结束时结果确定。
如果系统只做这种调用,Provider Request 本身就是最主要的执行边界。超时、取消、错误都可以绑定在这次请求上。
Tool Calling 改变了这一点。
模型可能先返回一个 Tool Call:
Input
↓
Model Request #1
↓
Tool Call
↓
Tool Execution
↓
Tool Result
↓
Model Request #2
↓
Final Output
Model Request #1 已经结束,但当前工作显然还没有完成。
这意味着 Provider Request 只能描述一次模型计算,无法描述 Agent 的完整推进过程。
Step:给一次模型决策建立稳定边界
DeepSeek Harness 使用 Step 描述接近单次模型决策的边界。[1]
一个 Step 可以抽象为:
Step
│
├── Build Context
├── Model Request
├── Stream Assistant Output
├── Execute Tool Calls
├── Append Tool Results
└── Decide Next State
这里有一个细节值得明确:Step 通常不等价于单次 HTTP Provider Request。
原因在于模型产生 Tool Call 后,这些 Tool Call 仍然是本次模型决策的直接结果。如果把 Step 在模型流结束时立即关闭,Tool Execution 会变成无归属的中间状态;如果把工具结果归入后续 Step,又会削弱“一次模型决策产生了哪些效果”的可观察性。
因此把 Model Decision 与直接产生的 Tool Work 放进同一个 Step,能够形成更稳定的执行边界:
Context N
↓
Model Decision N
↓
Tool Effects N
↓
Step N ends
↓
Context N+1
这个边界对 Runtime 很有价值。
下一次 Step 开始前,可以安全地处理:
- 新的 Steering Message;
- Context Compaction;
- Hook;
- Model/Tool 配置变化;
- 取消状态;
- 运行时注入信息。
已经开始的 Model/Tool 工作不必因为这些变化被中途改写。
因此,Step 更接近 Runtime 的最小稳定推进单位。
为什么不能直接把 Assistant Message 当成 Step
UI 中最直观的单位通常是一条 Assistant Message,但它和 Runtime 的执行边界并不完全一致。
一条 Assistant Message 可能包含多个 Tool Call:
Assistant Message
├── text
├── Tool Call A
└── Tool Call B
Runtime 还需要等待 A、B 的结果,再决定是否进入下一次模型请求。
如果只观察 Message,很难判断:
这次模型决策是否已经完整结束
所有 Tool 是否都已经完成
下一次 Context 是否已经可以构造
Steering 是否可以被消费
因此 Message 更适合表达对话内容;Step 更适合表达执行生命周期。
二者可能高度相关,但承担的问题不同。
Turn:多个 Step 为什么仍属于同一段工作
有了 Step 以后,还存在第二个问题。
模型第一次返回 Tool Call,工具执行完成,再请求模型;模型第二次可能继续调用工具;第三次才产生最终文本。
这些 Step 是否应该分别看成独立任务?
通常不应该。
它们仍然由同一段连续工作驱动:
Turn
│
├── Step 1
│ ├── Model
│ └── Tool A
│
├── Step 2
│ ├── Model
│ ├── Tool B
│ └── Tool C
│
└── Step 3
└── Final Response
Turn 描述的就是这一段连续推进区间。
DeepSeek Harness 在 step/end 后检查 next-step Inbox。如果仍有输入需要在当前工作中消费,就继续进入下一 Step。自然停止前还会经过 agent/turn-stopping,允许 Hook 在 Turn 真正结束前重新插入工作;只有没有剩余工作后才写入 turn/end。[1][2]
Pi 没有把 Step 暴露成同名的一等对象,但 runLoop() 的结构体现了相同的时间层次:内层循环持续处理 Tool Calls 和 Steering Messages,当前连续工作结束后,外层循环再处理 Follow-up。[3]
Turn 的结束条件不能只依据“模型已经返回文本”。更准确的条件是:
当前连续工作已经没有必须立即推进的内容
这也是为什么 Steer 与 Follow-up 需要不同边界:
Steer
进入当前 Turn 后续最近的 Step
Follow-up
等待当前 Turn 到达停止边界
第三、四篇会从 Pi 源码继续验证这组关系。
Session:Turn 结束以后什么还需要保留
Turn 结束以后,Agent 可能已经进入 idle,但会话并没有消失。
下一次用户输入仍然需要继承已有历史、分支、Memory 或会话配置。
因此需要一个明显比 Turn 更长的生命周期:
Session
│
├── Turn A
├── Turn B
├── Turn C
└── ...
Session 更接近长期会话边界。
它通常负责保存:
历史 Message / Event
会话元数据
当前分支或 lineage
持久化状态
长期 Memory 引用
多次执行产生的结果
Pi Coding Agent 会把 Session 保存为 JSONL,并通过 id / parentId 表达树形历史,一个 Session 文件中可以存在多个分支。[4]
DeepSeek Harness 的 Session 则采用 append-only Event Log,并将 Session Log 作为 live Agent 的 durable source of truth。[2]
两者持久化结构差异很大,但生命周期位置一致:Session 位于单次 Agent Loop 之上。
为什么执行期状态不应该全部放进 Session
Session 的生命周期很长,因此它适合保存需要跨进程、跨恢复存在的事实。
当前执行中的临时对象则不同,例如:
AbortController
正在 Streaming 的半条 Assistant Message
当前网络连接
某个 Tool 的内存句柄
当前 active promise
这些对象通常没有长期持久化意义,甚至无法序列化。
如果 Session 同时承担长期事实和进程内临时状态,恢复逻辑会非常困难:系统需要判断哪些字段可以重新构造,哪些字段已经失效,哪些字段只是某个旧进程留下的引用。
更稳定的边界是:
Session
保存 durable facts
Runtime State
保存当前进程内活动状态
持久化时可以把 Runtime 中有价值的状态转化为 Event、Checkpoint 或 Run Status,但不需要把整个 Runtime 对象持久化。
为什么还需要 Run
到这里已经有 Session、Turn 和 Step。很多 Agent Core 到这一层就足够了。
Web 产品和任务型应用仍然经常引入 Run,原因来自产品层执行管理。
一次用户操作触发 Agent 后,系统通常需要一个可查询的执行实体:
QUEUED
RUNNING
WAITING_APPROVAL
PAUSED
COMPLETED
FAILED
CANCELLED
还可能需要记录:
runId
startedAt / endedAt
triggerMessageId
cost / tokens
error
approval state
parentRunId
childRunIds
resume cursor
这些状态显然不属于单个 Step。
它们也不适合直接挂在整个 Session 上,因为一个 Session 可以经历多次独立执行:
Session S1
│
├── Run R1 completed
├── Run R2 failed
└── Run R3 running
如果 Session 只有一个全局 status,就无法保留每次执行的独立生命周期。
因此 Run 主要解决:如何把一段可管理、可观察、可恢复的应用层执行从长期 Session 中分离出来。
Run 为什么不一定属于 Agent Core
这里需要避免一个常见误区:既然 Run 很有用,就把它直接固化进 Agent Loop。
这会把大量产品语义带进执行内核,例如:
审批
计费
任务队列
重试策略
父子任务
SLA
页面恢复
审计
这些能力与模型如何调用 Tool 并没有直接关系。
DeepSeek Harness 的文档对此给出了一个很有价值的边界:whenIdle() 观察的是 Agent 从当前活动直到 quiescence 的区间,只有调用方明确拥有这个区间时,才适合把它建模为一个 run。[2]
Pi 的 Agent 内部也有 ActiveRun,保存 promise、resolve 和 AbortController,但它只管理当前 active processing interval,并不承担业务层持久 Run 的完整语义。[5]
因此可以采用下面的分层:
Agent Core
├── Step
├── Turn
├── Tool
├── Input Delivery
└── Cancel
Application Runtime
├── Run ID
├── Durable Status
├── Approval
├── Retry / Resume
├── Observability
└── Parent / Child Run
Run 可以存在,但不必强迫所有 Agent Core 都理解它。
Turn 和 Run 最容易混淆在哪里
二者都可以被理解为“一次工作”,但关注点不同。
Turn 是 Agent 执行语义中的连续工作区间;Run 更偏产品和调度语义。
例如一个 Run 可能因为审批而暂停:
Run R1
│
├── Turn A
│ └── 请求执行敏感操作
│
├── WAITING_APPROVAL
│
└── Turn B
└── 审批通过后继续
这种情况下,一个 Run 可以跨越多个 Turn。
另一些简单产品也可以选择“一次 Run 对应一次 Turn”。对象模型并不要求必须复杂化;关键是概念边界能够容纳未来的暂停、恢复和调度需求。
因此本系列采用:
Turn
Agent Core 中连续推进的工作边界
Run
应用层可持久管理的一次执行边界
这两个定义比要求所有框架使用相同名称更有用。
Connection 是另一条独立生命周期
Web Agent 还存在一个更短、也更不稳定的生命周期:客户端连接。
Connection A
───────────>
Connection B
─────────────>
Run
──────────────────────────────────>
Session
────────────────────────────────────────────>
Connection 只负责实时传输。页面刷新、网络切换或 SSE 重连都会导致 Connection 更换,但 Run 和 Session 可以保持不变。
如果把它们绑定起来:
connection close
→ run cancel
→ session activity lost
系统就很难支持真正的长任务和恢复。
因此 Web Agent 至少应明确区分:
Connection
通信生命周期
Run
执行生命周期
Session
长期会话生命周期
后续 Web Runtime 单元会专门处理如何在 Connection 更换后恢复 Run 的实时视图。
Agent 与 Session 应该是一对一吗
多层生命周期确定以后,还会遇到 Agent 与 Session 的绑定问题。
DeepSeek Harness 当前倾向于一个 live Agent 驱动一个 Session。公开 Agent handle 直接持有 session、inbox、status 和 agent-scoped ctx,两者关系较强。[2]
这种设计的优势是恢复路径直接:
Session
↓
resume live Agent
Agent 的 Prompt、Tools、适配器和 Session 历史可以保持一致。
另一类产品可能允许同一个 Session 由多个 AgentDefinition 参与:
Session S1
│
├── Run R1 → CodingAgent v3
├── Run R2 → ReviewAgent v2
└── Run R3 → CodingAgent v3
这种模式更适合多角色协作或 orchestration,但每个 Run 必须记录实际使用的 Agent 配置,否则历史恢复后很难判断当时的 Prompt、Tools 和 Model Policy。
两种结构都成立。需要避免的是让 Session 在没有版本信息的情况下随意切换不兼容 Agent 配置。
取消、错误与恢复分别应该落在哪一层
生命周期模型是否合理,可以通过异常路径反向检查。正常执行时,很多对象看起来都可以合并;一旦出现取消、错误和恢复,边界会迅速暴露。
先看取消。
如果用户只想终止当前 Provider Request,取消范围可以停在 Model Call;如果当前 Tool 和后续 Step 都已经没有继续执行的意义,取消范围应覆盖当前 Turn;如果产品把这一段任务作为一个持久 Run 管理,还需要把 Run 状态写成 CANCELLED。
Abort Provider Request
↓
可能只影响当前 Model Call
Cancel current Agent work
↓
结束当前 Turn / active execution
Cancel product task
↓
Run = CANCELLED
这些动作可以由同一次用户操作触发,但内部仍然存在多层状态变化。
错误也类似。Provider 超时可能只需要重试当前 Step;Tool 失败可能作为 Tool Result 交回模型继续处理;某些不可恢复错误会直接结束 Turn;达到产品重试上限后,Run 才进入 FAILED。Session 通常仍然存在,用户可以在之后继续发起新的 Run。
Provider error
↓ retry?
Step
↓ recoverable?
Turn
↓ unrecoverable?
Run = FAILED
Session remains
如果只有一个全局 session.status,这些错误层次很难表达。一次 Tool 失败可能把整个会话标记成失败,而后续用户明明仍可继续使用同一个 Session。
恢复路径同样能验证边界。
页面刷新只需要恢复 Connection;进程重启需要重建 Runtime;Run 处于等待审批时要恢复 Run 状态和 pending work;Session 则负责提供长期历史。
Browser reconnect
→ restore connection view
Process restart
→ rebuild runtime from durable facts
Run resume
→ locate execution status / cursor
Session resume
→ load long-lived conversation state
因此生命周期对象的意义不仅体现在正常路径,更体现在每一层拥有不同的失败、取消和恢复条件。
谁创建,谁结束,谁拥有状态
还可以用三个问题检查对象边界:
谁创建它?
谁有权结束它?
它的状态由谁持久化?
Step 通常由 Agent Loop 创建并自然结束;Turn 也由 Runtime 控制其连续推进。Run 往往由应用层或调度层创建,Runtime 只负责执行并上报状态。Session 则通常由产品会话层创建并长期持久化。
如果一个对象的创建者、结束者和状态所有者完全不同,说明它很可能跨越了多个架构层,需要通过明确接口连接,而不适合简单合并到某个核心类中。
Context 为什么不在这条持久生命周期树里
Session、Run、Turn、Step 都可以讨论“什么时候开始、什么时候结束”。Context 的性质不同。
Context 更接近某个 Step 发起模型请求前构造出的计算视图。
Pi 的 Agent.prompt() 进入 runPromptMessages() 后,会先调用:
createContextSnapshot()
当前实现返回:
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
}
然后将 AgentContext 交给 runAgentLoop()。[5]
本次 Prompt 再被追加到 Loop Local Context,真正请求 Provider 前还可以经过 transformContext、convertToLlm 和其他处理。
因此更准确的关系是:
Session / Agent State
│
▼
Context Snapshot
│
▼
Current Turn / Step
│
▼
Provider Context
Context 会随着执行持续变化,它不需要像 Session 那样作为一个长期实体独立保存。
持久化系统真正需要保存的是能够重新构造 Context 的事实。
这会成为第二单元的核心问题。
为什么不能只保留 Session 和 Step
从抽象最小化的角度,可以质疑中间层是否都必要。
例如:
Session
└── Step*
这种结构在实现上可行,问题在于很多控制语义会失去稳定归属。
Follow-up 是等哪一组 Step 完成以后执行?
审批暂停的是哪一段工作?
某次用户触发的执行失败以后,错误应该挂在哪?
一次长任务的耗时、成本和状态应该如何查询?
当系统开始需要这些能力时,Turn 和 Run 就会自然出现。
所以多层生命周期并非固定教条。更合理的原则是:
当两类状态的创建、结束、恢复或控制条件不同,就应当考虑把它们分成不同生命周期对象。
如果产品没有某类需求,可以省略相应对象。
生命周期概念不一定都要直接映射成数据库表
理解 Session、Run、Turn、Step 以后,另一个常见问题是是否需要为每一层建立持久实体。
答案取决于产品需要查询和恢复到什么粒度。
Session 几乎总是需要持久化,因为它承载长期会话。Run 如果需要跨连接执行、审批、重试或独立查询状态,也通常值得拥有持久 ID。
Turn 和 Step 则未必需要独立表。
一种简单系统可以只保存 Message:
sessions
messages
Turn / Step 只是 Runtime 内存中的执行概念。
如果系统需要完整审计、Replay、成本统计和故障定位,可以把生命周期写成 Event:
session_events
├── turn/start
├── step/start
├── assistant/message
├── tool/call
├── tool/result
├── step/end
└── turn/end
这样 Turn 和 Step 仍然拥有清晰语义,但不必各自维护一份可变行状态。DeepSeek Harness 的 Session Event Log 就接近这个方向。[2]
另一类任务平台可能确实需要:
runs
run_steps
因为每个 Step 都要独立展示状态、重试或计费。此时把 Step 实体化才具有产品价值。
因此对象模型与存储模型应分开考虑。
Conceptual lifecycle
说明系统如何运行
Persistence model
说明哪些事实需要长期保存和查询
二者可以一一对应,也可以通过 Event Log、Projection 或聚合字段表达。为了让架构图“完整”而机械地为每个概念建表,通常只会增加同步成本。
生命周期越长,持久化要求通常越高
可以得到一个大致规律:
Model Call / Step
偏运行时,可由事件记录
Turn
按审计与恢复需求选择
Run
任务型产品通常需要持久状态
Session
长期会话通常必须持久化
这条规律也解释了为什么 Agent Core 更关心 Step / Turn,而 Web 产品更关心 Run / Session。它们观察的是同一执行系统的不同时间尺度。
用时间尺度统一整个模型
最终可以把这些对象放在同一张时间图上:
Session
────────────────────────────────────────────────────────>
Run A Run B
─────────────────> ────────────────>
Turn 1 Turn 2 Turn 3
───────> ───────> ─────────>
Step Step Step Step Step
───> ───> ───> ───> ───>
Model Call
─────>
Connection A
──────────>
Connection B
───────────>
它们分别回答:
- Model Call:一次 Provider 计算何时结束;
- Step:一次模型决策及其直接 Tool Effects 何时结束;
- Turn:当前连续 Agent 工作何时到达停止边界;
- Run:一次应用层执行何时具有最终状态;
- Session:这段长期会话何时结束或归档;
- Connection:某个客户端实时连接何时断开。
这套划分会直接影响后续设计。
Steer 应该进入哪个边界,Follow-up 等待哪个边界,Abort 终止哪一层,Session 恢复时要保存哪些事实,Web 重连应该恢复 Run 还是新建 Run,都依赖这里的生命周期模型。
第二篇最终留下的设计判断是:
Agent 系统中的对象边界应优先由生命周期差异决定。Session、Run、Turn、Step 分别拥有不同的开始、结束、控制和恢复条件,名称只是这些边界的表达。
下一篇进入 Pi 的真实调用链,观察 Agent.prompt() 如何建立活动区间、如何构造 Context、如何推进两层 Loop,以及 Event 如何把低层执行结果同步回外层 Agent State。
参考资料
[1] DeepSeek Harness Agent Lifecycle: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/agent-lifecycle.md
[2] DeepSeek Harness Core Subsystem: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/core.md
[3] Pi agent-loop.ts: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent-loop.ts
[4] Pi Coding Agent Sessions: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
[5] Pi Agent source: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts