一个能够调用工具的 Agent,最小实现并不复杂。模型接收消息,返回文本或 Tool Call;程序执行工具,把 Tool Result 追加到消息列表,再继续请求模型。Vercel AI SDK 的 ToolLoopAgent 封装的就是这一类多 Step 执行:模型、Instructions、Tools 和停止条件集中在 Agent 定义中,由 SDK 驱动后续循环。[1]
把这层抽象还原以后,核心逻辑接近下面这段代码:
while (true) {
const response = await model(messages, tools)
messages.push(response.message)
if (response.toolCalls.length === 0) {
break
}
const results = await executeTools(response.toolCalls)
messages.push(...results)
}
这已经是一个完整的 Tool Loop。模型可以根据工具结果继续决策,一次用户输入也可以触发多次模型调用。
它同时存在一个很明确的边界:整个执行生命周期仍然被当前函数调用包住。
messages 存在于内存中,正在执行哪个 Tool 可以由局部变量表达,取消可以依赖当前调用的 AbortSignal,函数返回以后,本次执行随之结束。如果调用方不要求暂停、恢复、中途输入和跨连接继续执行,这样的结构已经足够。
Agent Runtime 开始成为独立问题,通常发生在执行生命周期超出这次函数调用之后。
这个边界比 Tool Calling、Memory 等能力清单更适合判断一个系统是否真正需要 Runtime。
Tool Loop 能管理什么
先限制问题范围。Tool Loop 负责的是一条局部执行链:
User Input
↓
Model Request
↓
Assistant Message
↓
Tool Call
↓
Tool Execution
↓
Tool Result
↓
Next Model Request
它主要处理三件事:
- 什么时候请求模型;
- 模型要求调用工具时如何执行;
- Tool Result 如何回到下一次模型请求。
这三件事都发生在一次连续执行内部,因此局部变量和函数栈天然就是状态容器。
例如下面几个状态都可以直接保存在当前调用中:
let messages: Message[]
let pendingToolCalls: ToolCall[]
let aborted = false
此时还没有必要引入更复杂的对象模型。所谓 Runtime,如果只是把这些局部变量挪进一个 Class,并不会自动获得新的架构价值。
真正的变化来自状态开始拥有不同的生命周期。
第一个变化:执行状态不能再依附请求
Web 应用最容易暴露这个问题。
假设浏览器发起请求,后端开始运行 Agent:
Browser Request
│
▼
Agent Loop
│
├── Model
├── Tool
├── Model
└── Tool
如果整个执行必须依附 HTTP/SSE 连接,那么连接断开通常意味着调用链被释放。对于普通请求这是合理的:客户端离开,请求结束。
Agent 的执行经常需要另一种语义:连接只负责传输,任务本身仍由后端继续管理。客户端稍后重新连接时,应当读取当前状态并继续接收结果。
此时至少出现两个生命周期:
Connection
──────────────>
Execution
──────────────────────────────>
连接不再拥有执行。
一旦做出这个拆分,下面这些状态就必须有独立于网络请求的所有者:
当前执行是否仍在进行
执行到了哪个阶段
当前有哪些 Tool 正在运行
是否已经收到取消请求
后续输入在哪里等待
最终结果是否已经产生
这些状态已经不能可靠地保存在一次 Controller 调用或 SSE handler 中。
第二个变化:执行期间仍然会有新输入
普通函数调用有一个稳定前提:调用开始时参数已经确定。
Agent 不一定满足这个前提。当前 Tool 尚未执行完时,用户可能补充要求;插件可能注入新的环境信息;调度器可能发送后续任务;其他 Agent 也可能向当前 Agent 传递结果。
如果 Agent 正在运行时再次直接调用:
agent.prompt(newMessage)
系统必须回答一个很具体的问题:这条消息属于哪一段执行?
它可能有几种完全不同的语义:
立即终止当前工作,重新开始
等待当前 Tool 完成,影响下一次模型决策
等待当前 Turn 完整结束,再开始下一段工作
只加入下一次 Context,但不主动触发执行
这些差异已经无法通过一个 messages.push() 表达。
输入需要进入某个受控的调度结构,由 Runtime 在稳定边界消费。Pi 的 Steering / Follow-up Queue,以及 DeepSeek Harness 的 Inbox,处理的就是这一层问题。[2][3]
因此,Runtime 除了执行 Model 和 Tool,还开始承担输入交付语义。
第三个变化:长期历史与当前执行状态分离
Tool Loop 中的 messages 通常同时扮演两种角色:
历史记录
+
下一次模型请求的输入
在简单实现里这没有问题。随着会话变长,两者会逐渐分离。
长期 Session 可能保留完整历史;模型当前能够看到的内容则受到窗口大小、Compaction、Memory、分支选择和 Agent Policy 影响。
因此更准确的数据关系是:
Session History
│
├── Branch Selection
├── Compaction
├── Memory
├── Agent Policy
└── Runtime State
│
▼
Context Builder
│
▼
Model Context
Session 保存的是长期事实,Context 描述当前一次模型计算可见的信息。
这两个概念如果继续共用同一个 messages[],后续会出现几个典型问题:
- Compaction 以后是否覆盖原始历史;
- UI 是否还能展示被压缩掉的消息;
- 同一个 Session 交给不同 Agent 时是否能够使用不同 Context Policy;
- Tool 执行中的临时状态是否应该进入长期历史;
- Provider Message 与应用内部 Message 是否必须使用相同结构。
Runtime 因此还需要负责从长期状态构造当前计算视图。
Runtime 的核心是状态所有权
到这里可以重新看最开始的 Tool Loop。
原先所有状态都隐含地由当前函数拥有:
function call
├── messages
├── tool state
├── abort signal
└── control flow
生命周期拆开以后,状态需要重新分配所有权:
Session
└── 长期历史、会话元数据
Agent Runtime
├── 当前执行状态
├── Context 构造
├── Tool Runtime
├── Input Queue / Inbox
├── Cancellation
└── Lifecycle Events
Connection
└── 实时传输与重连
Runtime 的价值由此变得具体:它为跨越单次函数调用的执行状态提供稳定所有者,并负责这些状态之间的推进。
一个最小结构可以表示为:
Agent Runtime
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Model Tool Runtime Input Delivery
│ │ │
└──────────────┼──────────────┘
▼
Agent Loop
│
┌──────┴──────┐
▼ ▼
Context Lifecycle Event
这张图里,Loop 只是 Runtime 的一个组成部分。Runtime 还需要维护 Loop 外部的状态和控制关系。
Runtime 还需要区分配置、活动状态与持久事实
把执行状态从函数调用中抽出来以后,另一个问题随之出现:哪些内容应该长期存在,哪些内容只在当前活动期间有效。
可以把 Runtime 周围的数据分成三类:
Agent Definition
描述能力配置
Model / Prompt / Tools / Policy
Live Runtime State
描述当前活动
streaming / pending tool / inbox / abort
Durable Session State
描述可恢复事实
message / event / branch / metadata
三类数据经常被放在同一个 Agent 对象里,但它们的生命周期不同。
AgentDefinition 可以被多个 Session 复用,也可能通过版本号长期存在。它描述“这个 Agent 具备什么能力”。
Live Runtime State 只描述某个进程当前正在发生的事情。AbortController、active promise、网络 stream 句柄都属于这一层。进程结束以后,这些对象没有直接恢复价值。
Durable Session State 需要跨进程保存。它应当记录足够的事实,使新 Runtime 能够重新建立当前工作视图。
因此恢复 Agent 时,更稳定的路径是根据持久事实创建新的 Runtime,而非直接反序列化旧的进程内对象:
Durable Session Facts
+
Agent Definition / Version
↓
create new Runtime
↓
rebuild Context / pending work
↓
continue
这条边界会直接影响持久化设计。把所有内存字段都序列化,往往只能得到一份难以升级、也难以跨进程恢复的 Runtime Snapshot。更稳定的做法是保存能够重建运行状态的事实,再由新 Runtime 创建进程内对象。
从这个角度看,Runtime 与 Persistence 是相邻层次:Runtime 管理活动状态,Persistence 负责保存恢复所需事实。两者可以紧密协作,但不需要共用同一套对象。
Runtime 更接近状态机,而非一组工具函数
当 Runtime 拥有 idle / running / waiting / cancelled 等状态以后,很多 API 的合法性也由当前状态决定。
例如 Pi 在 active 状态下禁止再次 prompt(),允许 steer()、followUp() 和 abort();idle 状态下则可以开始新的 Prompt。
可以抽象成:
prompt
IDLE ─────────────→ ACTIVE
│ │
│ ├── steer / follow-up
│ └── abort
│
└────────────→ IDLE
finish
Runtime 因此不仅保存数据,还约束哪些状态转换是合法的。
这一点对 API 设计很重要。若外部接口只是若干无状态函数,调用方必须自己理解当前执行是否存在、某条输入应该进入哪里、什么时候可以取消。把这些约束收进 Runtime,可以让错误状态更早暴露,也让上层产品获得更稳定的行为契约。
Pi 为什么适合作为 Runtime 样本
Pi 的 Agent 与底层 agent-loop.ts 分得比较清楚。
低层 Loop 负责模型请求、Tool Call、Tool Result、Steering 和 Follow-up 的执行推进;外层 Agent 被源码描述为 low-level agent loop 的 stateful wrapper。[2]
Agent 持有的状态大致包括:
systemPrompt
model
thinkingLevel
tools
messages
isStreaming
streamingMessage
pendingToolCalls
errorMessage
这些字段形成了一个明确的分层:
messages
已经完成的稳定 transcript
streamingMessage
当前正在生成、仍会变化的 Assistant Message
pendingToolCalls
当前执行中的 Tool 状态
这些状态并没有全部塞进 messages。
Pi 还维护 Steering Queue 和 Follow-up Queue。Agent 活动期间禁止再次直接 prompt() 创建第二条主动执行链,新消息需要通过明确的输入控制 API 交给当前 Runtime。[2]
这已经符合前面推导出的 Runtime 特征:持续状态、单一活动执行链、输入调度、取消和生命周期事件具有明确所有者。
DeepSeek Harness 把 Runtime 再向外抽象一层
DeepSeek Harness 对 Agent 的抽象更偏 Harness。
公开 Agent handle 暴露:
session
inbox
status
ctx
cancel()
whenIdle()
send()
steer()
followup()
inject()
具体 Agent Loop 则由插件提供。[3]
这里出现了另一个重要变化:应用与插件依赖稳定 Agent 接口,具体 Loop 实现可以替换。
这意味着 Runtime 的“执行机制”本身也可以替换。Agent Loop、Session、Tool Registry、Model Adapter 不必全部固化在一个核心类里。这个方向最终会进入 Harness 和 Plugin Runtime,本系列后面的 Cordis / DeepSeek Harness 单元会单独处理。
在第一篇里只需要保留一个判断:
Runtime 解决执行状态的生命周期和推进问题;Harness 进一步解决 Runtime 能力如何被组合、替换和扩展。
两层关注点不同。
Vercel AI SDK 提供的是另一侧参照
Vercel AI SDK 的价值在于它把 Web 应用中几个经常混在一起的层次拆开。
ToolLoopAgent 负责多 Step Agent 执行;AI SDK UI 则提供 useChat、Transport、UI Message、消息持久化和 Stream Resume 等能力。[1][6]
因此可以把它放到下面这张图中理解:
Browser UI
│
▼
Transport
│
▼
Application / Agent Execution
│
▼
Model + Tools
AI SDK 并不要求所有产品都采用 Pi 或 DSH 那种长生命周期 Agent 对象,但它提供了一个很清楚的参照:UI 状态、网络传输、Agent 执行和模型消息可以是不同层次。
这也是后续讨论 Web Agent Runtime 时的重要基础。
Runtime 和 Agent Framework 的边界也需要控制
Runtime 独立以后,很容易继续把所有 Agent 能力都归入这一层。这样会重新形成一个不断膨胀的核心。
可以用“是否直接参与当前执行推进”作为初步边界。
下面这些能力通常与 Runtime 强相关:
Context preparation
Model invocation
Tool execution
Input delivery
Cancellation
Lifecycle state
它们会直接影响当前 Loop 下一步如何运行。
而下面这些能力更可能位于 Harness 或 Application 层:
Plugin discovery
Skill marketplace
Project management
Workflow definition
UI layout
Billing
Tenant permission
这些能力可以向 Runtime 注册 Tool、Hook 或 Policy,但没有必要成为低层执行对象的固有字段。
Pi 与 DSH 可以作为两种扩展边界的对照。Pi 倾向保留一个较小的 Agent Core,再通过 Extension 扩展行为;DSH 进一步让 Agent Loop、Session、Tool Registry 等能力本身也进入 Plugin Runtime。两种方案的扩展边界不同,但都在处理同一个问题:如何避免 Agent Core 随产品能力增长而持续膨胀。
因此 Runtime 的抽象也应保持克制。它需要足够完整地拥有执行生命周期,同时给外层 Harness 留出组合空间。
一个实用的状态归属检查方法
设计新字段或新能力时,可以连续检查四个问题:
这个状态跨不跨进程?
这个状态是否只在当前执行期间有效?
模型下一次决策是否直接依赖它?
外部系统是否需要独立查询或恢复它?
例如 pendingToolCalls 只在当前执行期间有效,又直接影响 UI 和取消控制,适合由 Runtime 持有。
Session Branch 需要跨进程恢复,对当前一次 Tool 调用没有直接控制作用,更适合归入 Session Persistence。
Run Status 需要被产品独立查询和恢复,可以放在 Application Runtime。
这种检查方法比按“Agent 相关功能都放进 Agent 类”更容易维持清晰边界。
Runtime 中为什么还会出现 Session、Run、Turn、Step
当执行生命周期被独立出来以后,仍然存在多个不同时间尺度:
Session
────────────────────────────────────────>
Run
────────────────────>
Turn
─────────────>
Step
──────>
Model Call
───>
它们分别解决不同范围的问题。
Session 管长期会话;Run 常用于应用层的一次持久执行;Turn 表示一段连续 Agent 工作;Step 接近一次模型决策和由它直接产生的 Tool 工作。
这些对象并非为了把架构画得更复杂。每增加一个生命周期,通常都是因为上一层无法稳定表达某种状态。
例如:
Model Call 结束了
但 Tool Loop 没结束
→ 需要更大的执行边界
Turn 结束了
但 Conversation 仍要继续
→ 需要 Session
页面断开了
但后端任务仍要继续
→ Connection 与 Run 分离
第二篇会沿这条推导继续,把 Session、Run、Turn、Step 的边界建立起来。
Context 为什么属于 Runtime 链路
Pi 在开始一次 Loop 前调用 createContextSnapshot():
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
}
随后将这份 AgentContext 交给 runAgentLoop()。[5]
真正进入 Provider 前,Context 还可以经过 transformContext 和 convertToLlm。
这段实现说明了一个容易被忽略的边界:
Long-lived Agent State
│
▼
Context Snapshot
│
▼
Loop Local Context
│
▼
Provider Context
模型输入是 Runtime 在某个时间点构造出的计算视图。
这也是为什么后续做 Session 持久化时,不能简单把“模型当前看到什么”与“系统长期保存什么”视为同一个问题。
如何判断一个系统是否真的需要 Agent Runtime
并非所有 Agent 应用都需要复杂 Runtime。
如果系统满足下面这些条件:
一次请求内完成
没有中途输入
没有暂停和恢复
不要求跨连接继续执行
消息列表直接作为模型上下文
Tool 状态不需要独立暴露
一个 Tool Loop 加少量状态管理通常已经足够。
当需求逐渐变成:
执行跨越网络连接
执行可以暂停、取消和恢复
用户可以在执行中追加输入
Session 跨越多次执行
Context 需要独立构造
UI 需要观察流式和 Tool 状态
Runtime 才具有明确的独立价值。
判断依据应放在状态生命周期是否已经分裂,框架选型本身不能替代这个判断。
本系列采用的边界
后续文章统一使用下面这组关系:
AgentDefinition
/ | \
Model Tools Policy
\ | /
\ | /
Runtime
│
Session ────────────────┤
│
Run
│
Turn
│
Step
│
┌─────────┴─────────┐
▼ ▼
Model Call Tool Execution
其中:
- AgentDefinition 描述 Agent 的配置和能力集合;
- Session 保存长期会话事实;
- Runtime 管理当前执行状态并推进状态变化;
- Run 提供一次应用层执行的管理边界;
- Turn 和 Step 描述 Loop 内部的连续工作与模型决策边界;
- Context 在模型请求前由当前状态构造。
这套模型用于后续比较不同框架的实现边界,并不要求它们采用相同 API。
第一篇最终只需要留下一个判断:
当 Agent 的执行生命周期开始脱离一次函数调用或一次网络请求,状态所有权就必须重新划分。Tool Loop 仍然是执行内核,但 Runtime 开始成为独立的系统层。
下一篇继续处理这些状态的时间尺度,说明 Session、Run、Turn、Step 为什么需要分别存在,以及哪些边界应当留在 Agent Core,哪些更适合由应用层承担。
参考资料
[1] Vercel AI SDK, ToolLoopAgent: https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent
[2] Pi Agent source: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts
[3] DeepSeek Harness core subsystem: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/core.md
[4] DeepSeek Harness agent lifecycle: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/agent-lifecycle.md
[5] Pi createContextSnapshot(): https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts
[6] Vercel AI SDK UI: https://ai-sdk.dev/docs/ai-sdk-ui
[7] Pi Coding Agent README: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
[8] DeepSeek Harness architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md