一套 Agent 组件真正难的,不是第一次把它们接起来,而是换掉其中一个之后,系统仍然知道该如何启动、运行和退出。
一个 Agent 程序最初往往只有一条清楚的调用链:入口创建模型,创建工具,再创建 Agent Loop,最后把 Loop 交给界面使用。此时每个对象只有一种实现,直接传递引用是最容易理解的做法。
图中的每条箭头都表示一次直接传递:入口先创建三个基础对象,再把它们作为参数传给下一个对象。AgentLoop 同时依赖模型和工具,ConsoleUi 同时依赖 Loop 和会话。依赖关系越多,入口就越需要了解每个组件的具体构造方式。
真正的困难通常在第二个模型、第二套工具或另一种 Session 实现出现之后。此时入口同时承担三件事:
- 选择:决定本次运行使用哪个实现;
- 排序:保证被依赖的对象先准备好;
- 回收:失败或退出时释放已经取得的资源。
如果这三件事都散落在入口中,入口就必须了解每个组件的内部细节。
这时,入口不再只是"启动程序",而开始承担一个组合器和生命周期管理器的职责。问题不在于对象变多,而在于对象之间的连接规则没有被明确表达:谁提供能力,谁依赖能力,能力何时可用,失效时如何收回。
为了讨论这个负责组织连接关系的角色,下面给它一个名字:PluginKernel。这是文章中的抽象名称,不指向某个预先存在的库。它可以是一个 TypeScript 类,也可以是其他语言中的对象;重要的不是名称,而是它承担的职责。
Runtime :不仅是让代码跑起来的环境,还负责管理运行中的组件、资源和错误边界。Node.js 是 JavaScript Runtime;
PluginKernel是 Agent Runtime 中负责插件组合和生命周期管理的内核。
有了这个角色,入口可以只负责提供插件配置:
ts
const kernel = new PluginKernel()
await kernel.start([
modelPlugin,
toolsPlugin,
sessionPlugin,
agentPlugin,
uiPlugin,
])
这些插件对象仍然由应用自己定义,内核不会凭空创建模型或工具。它只读取插件的声明,分析它们的关系,决定启动顺序,并在运行结束或中途失败时处理清理。换句话说,业务能力由 Provider 提供,组合规则由内核执行。
| 需要回答的问题 | 内核提供的机制 |
|---|---|
| 谁先启动 | 依赖声明与拓扑排序 |
| 服务放在哪里 | Service 注册表 |
| 插件何时可用 | loading / active 状态 |
| 资源如何释放 | Effect 与逆序清理 |
| 启动失败怎么办 | 局部清理与回滚 |
下面会沿着这条关系解释:服务如何声明和注册,插件怎样被激活,资源如何释放,以及启动失败时为什么能够回滚。读者不需要预先了解某个具体框架;只要理解这些角色之间的关系,就能把同样的设计迁移到自己的运行时中。模型请求、Tool Call、Turn 等执行概念沿用前一篇文章的定义,这里不再重复。
先把依赖关系写出来
插件接口先描述"提供什么"和"需要什么":
ts
type ServiceKey = string
type Disposer = () => void | Promise<void>
type PluginStatus = "loading" | "active" | "failed" | "unloading" | "disposed"
const SERVICE_KEYS = {
model: "model",
tools: "tools",
session: "session",
agent: "agent",
ui: "ui",
} as const
interface PluginContext {
get<T>(key: ServiceKey): T
provide<T>(key: ServiceKey, value: T): void
effect(acquire: () => void | Disposer): void
}
interface Plugin {
name: string
provides?: readonly ServiceKey[]
inject?: readonly ServiceKey[]
apply(context: PluginContext): void | Promise<void>
}
provides 和 inject 是元数据,不会自己创建对象:
text
provides: ["model"]
声明:这个插件启动后应该提供 model
inject: ["model", "tools", "session"]
声明:这个插件启动前必须能取得这三个服务
真正的注册和读取发生在 apply() 内:
text
context.provide("model", modelObject)
把对象放进内核的服务注册表
context.get("model")
从注册表取出对象
Service :插件对外提供的一项命名能力,例如
model、tools或session。服务名是协作边界,具体实现可以被替换。Provider:负责创建并注册某项 Service 的插件。
Consumer:声明并使用某项 Service 的插件。
Service Key :服务在注册表中的名字。
"model"是 Key,不是模型对象本身。
依赖关系可以表示为:
内核不会把插件数组的排列当成依赖。它先建立 Service Key → Provider 的映射,再把 Consumer 的 inject 转换为依赖边,最后生成启动计划:
text
model Provider
session Provider
tools Provider
agent-loop
console-ui
同一层没有依赖关系时,可以按插件名排序,保证同一份配置产生相同结果。
Topological Sort:拓扑排序。它把依赖关系排列成线性顺序,使被依赖者先于依赖者启动。缺失依赖、重复 Provider 或循环依赖都会使计划无法成立。
这一步应该在任何 apply() 执行前完成。这样,组合结构本身有问题时,系统不会启动一半才发现错误。
拓扑排序的核心可以压缩成下面几行:
ts
const providerByService = new Map<ServiceKey, string>()
for (const plugin of plugins) {
for (const key of plugin.provides ?? []) {
if (providerByService.has(key)) {
throw new Error(`duplicate provider for ${key}`)
}
providerByService.set(key, plugin.name)
}
}
for (const plugin of plugins) {
for (const key of plugin.inject ?? []) {
const provider = providerByService.get(key)
if (!provider) throw new Error(`missing service ${key}`)
addDependency(plugin.name, provider)
}
}
return topologicalOrder(plugins)
这里的 addDependency() 和 topologicalOrder() 省略了具体实现,但数据关系已经完整:Consumer 指向提供它所需 Service 的 Provider。
这一步还会提前拦截三类组合错误。
缺失 Service:
text
agent-loop 需要 model、tools、session
但没有任何插件提供 tools
→ 无法生成完整的启动计划
重复 Provider:
text
model-a 提供 model
model-b 也提供 model
→ 同一个 Service 有两个来源,内核无法静默选择其中一个
循环依赖:
text
plugin-a 需要 service-b
plugin-b 需要 service-a
→ a 等 b,b 又等 a,没有插件可以成为第一步
提前检查的价值在于,把"配置本身不成立"和"插件启动过程中出错"分开。前者不应该触发任何 apply();后者才需要进入后面的 Effect 清理和回滚流程。
Service 如何从声明变成可用对象
先看一个最小 Provider:
ts
function createModelPlugin(model: ModelAdapter): Plugin {
return {
name: "model-provider",
provides: [SERVICE_KEYS.model],
apply(context) {
context.provide(SERVICE_KEYS.model, model)
},
}
}
const modelPlugin = createModelPlugin(createModel())
这里有两个不同动作:
text
provides
告诉内核"我应该提供 model"
context.provide
在运行时真正注册 model
内核通常把服务保存为"所有者 + 对象":
ts
interface ServiceEntry {
owner: string
value: unknown
}
class PluginKernel {
#services = new Map<ServiceKey, ServiceEntry>()
async start(plugins: readonly Plugin[]) {
const plan = planPlugins(plugins)
for (const plugin of plan) {
await this.#activate(plugin)
}
}
get<T>(key: ServiceKey): T {
const entry = this.#services.get(key)
if (!entry) throw new Error(`service unavailable: ${key}`)
return entry.value as T
}
#provide<T>(plugin: Plugin, record: { effects: Disposer[] }, key: ServiceKey, value: T) {
if (!(plugin.provides ?? []).includes(key)) {
throw new Error(`undeclared service: ${key}`)
}
if (this.#services.has(key)) {
throw new Error(`service already provided: ${key}`)
}
this.#services.set(key, {
owner: plugin.name,
value,
})
record.effects.push(() => {
if (this.#services.get(key)?.owner === plugin.name) {
this.#services.delete(key)
}
})
}
}
注册时还要检查两件事:插件是否声明过这个 Service,以及是否已经有其他 Provider 注册过同名 Service。注册动作同时登记清理函数,因此 Service 会随着 Provider 一起退出。
插件拿到的 context 就是在这里组装出来的。它把三个公开操作转接到当前内核和当前插件的记录:
ts
#createContext(plugin: Plugin, record: { effects: Disposer[] }): PluginContext {
return {
get: <T>(key) => this.get<T>(key),
provide: (key, value) => this.#provide(plugin, record, key, value),
effect: (acquire) => {
const disposer = acquire()
if (disposer) record.effects.push(disposer)
},
}
}
因此,插件调用的 context.provide() 并不是另一个独立的容器,而是最终落到 PluginKernel.#services 上。
owner 不是附加信息。Provider 被卸载时,内核需要根据它找到自己注册的 Service;依赖传播时,也需要知道某个 Service 的提供者是谁。
Consumer 只依赖稳定接口,不依赖 Provider 的具体类:
ts
const agentPlugin: Plugin = {
name: "agent-loop",
inject: [
SERVICE_KEYS.model,
SERVICE_KEYS.tools,
SERVICE_KEYS.session,
],
provides: [SERVICE_KEYS.agent],
apply(context) {
const model = context.get<ModelAdapter>(SERVICE_KEYS.model)
const tools = context.get<ToolRegistry>(SERVICE_KEYS.tools)
const session = context.get<SessionService>(SERVICE_KEYS.session)
const loop = new AgentLoop(model, tools, { maxSteps: 5 })
const agent: AgentService = {
async run(input, signal) {
const turn = await loop.run(input, signal)
session.append(turn)
return turn
},
}
context.provide(SERVICE_KEYS.agent, agent)
},
}
真实实现还会在错误路径记录失败的 Turn;这里省略那部分,只保留 Service 的流动。
这条链可以浓缩为:
text
声明 provides
→ 内核建立服务注册表
→ Provider provide
→ Consumer get
→ Consumer 创建自己的服务
→ 再 provide 给下游
Service Locator :按照稳定 Key 查找服务的注册表。它把 Provider 的选择交给组合层;为了不隐藏依赖,插件仍需通过
inject预先声明读取范围。
激活、清理与失败回滚
插件不是调用一次 apply() 就立即可用。内核要为它建立一份记录:
ts
interface PluginRecord {
plugin: Plugin
status: PluginStatus
effects: Disposer[]
}
const record: PluginRecord = {
plugin,
status: "loading",
effects: [],
}
loading 表示启动尚未完成。内核调用 apply(),收集插件注册的 Service 和 Effect,确认声明的 Service 都真实存在后,才把状态改为 active:
ts
async #activate(plugin: Plugin) {
const record: PluginRecord = {
plugin,
status: "loading",
effects: [],
}
try {
const context = this.#createContext(plugin, record)
await plugin.apply(context)
for (const key of plugin.provides ?? []) {
if (this.#services.get(key)?.owner !== plugin.name) {
throw new Error(`plugin did not provide ${key}`)
}
}
record.status = "active"
this.#activationOrder.push(record)
} catch (error) {
record.status = "failed"
await disposeRecord(record)
throw error
}
}
激活流程是:
即使 apply() 中途抛错,记录也已经存在,内核可以清理插件在失败前取得的资源。
插件可能注册工具、监听事件、启动定时器或打开连接。每项资源都应登记对应的释放函数:
ts
const unregister = registry.register(calculator)
context.provide(SERVICE_KEYS.tools, registry)
context.effect(() => unregister)
这里的 effect() 立即执行传入函数,并保存它返回的清理函数。卸载时,内核按登记顺序的反方向执行:
text
获取 resource
获取 consumer-a
获取 consumer-b
释放 consumer-b
释放 consumer-a
释放 resource
Effect:与插件生命周期绑定的副作用记录,描述资源如何释放,以及清理在卸载或启动失败时何时发生。
LIFO:Last In, First Out,后进先出。最后登记的清理函数最先执行。
如果一个插件已经完成部分注册后失败,回滚需要结算两层资源:
Rollback:多步骤操作失败后,按已完成步骤反向撤销,使系统回到可解释状态。回滚本身也可能失败,因此实现通常要保留清理错误。
卸载 Provider 时,影响沿着"提供者 → 依赖者"方向传播:
内核会先找出所有直接或间接依赖被卸载 Provider 的插件,再按照激活顺序逆序释放:
ts
const selected = new Set([pluginName])
for (const record of this.#activationOrder) {
const dependsOnSelected = (record.plugin.inject ?? []).some((key) => {
const owner = this.#services.get(key)?.owner
return owner !== undefined && selected.has(owner)
})
if (dependsOnSelected) {
selected.add(record.plugin.name)
}
}
for (const record of [...this.#activationOrder].reverse()) {
if (selected.has(record.plugin.name)) {
await disposeRecord(record)
}
}
因此卸载 tool Provider 后,tools、agent 和 ui 会消失,但没有依赖这条链的 model 和 session 可以继续存在。卸载不是关闭整个进程,而是结算受影响的依赖子图。
Idempotent:同一清理操作执行一次或多次,结果相同。卸载和整体销毁都需要具备这种性质,调用方重试时不会重复释放同一资源。
边界与可观察结果
把一次运行的状态写出来,前面的代码就有了可验证的结果:
text
启动完成:
model、tools、session、agent、ui 已注册
五个插件均为 active
卸载 tool Provider:
tools 被移除
agent 因依赖失效而退出
ui 因 agent 消失而退出
model 和 session 保留
启动过程中 apply 失败:
失败插件已经取得的资源先释放
之前激活的插件再按逆序回滚
最终没有残留 Service
这套内核解决的是一次批量组合中的四个基础问题:
text
依赖如何表达
启动顺序如何确定
资源如何随所有者释放
失败后如何回滚
它没有实现完整的动态插件运行时,仍缺少:
- 缺少依赖时保持 PENDING,并在 Provider 出现后自动激活;
- 运行期配置更新和热替换;
- 隔离 Context、同名 Service 的多实例作用域;
- 持久化 Session 与进程重启后的恢复;
- Tool 的权限、沙箱、超时和审计;
- 清理失败后的句柄保留与重试。
PENDING:等待依赖满足的状态。动态插件运行时可以让插件停留在这个状态;当前这套批量内核遇到缺失依赖时会在规划阶段直接失败,不会进入 PENDING。
DeepSeek Cordis 把 Service、依赖等待和生命周期作为动态插件图的一部分;资源清理则通过 Effect 与生命周期绑定。Cordis Services · Cordis Lifecycle and Effects
这套设计的重点,不是把每个文件都改成插件,而是让能力通过稳定 Service 替换,让依赖决定激活,让资源随所有者释放,并让失败得到对称回滚。