从 Service 到生命周期:Agent Runtime 的插件内核

5 阅读9分钟

一套 Agent 组件真正难的,不是第一次把它们接起来,而是换掉其中一个之后,系统仍然知道该如何启动、运行和退出。

一个 Agent 程序最初往往只有一条清楚的调用链:入口创建模型,创建工具,再创建 Agent Loop,最后把 Loop 交给界面使用。此时每个对象只有一种实现,直接传递引用是最容易理解的做法。

flowchart LR
  entry[应用入口]
  model["createModel():模型对象"]
  tools["createTools():工具注册表"]
  session["createSession():会话对象"]
  loop["AgentLoop(model, tools)"]
  ui["ConsoleUi(loop, session)"]

  entry -->|创建| model
  entry -->|创建| tools
  entry -->|创建| session
  model -->|传入| loop
  tools -->|传入| loop
  loop -->|传入| ui
  session -->|传入| ui

图中的每条箭头都表示一次直接传递:入口先创建三个基础对象,再把它们作为参数传给下一个对象。AgentLoop 同时依赖模型和工具,ConsoleUi 同时依赖 Loop 和会话。依赖关系越多,入口就越需要了解每个组件的具体构造方式。

真正的困难通常在第二个模型、第二套工具或另一种 Session 实现出现之后。此时入口同时承担三件事:

  • 选择:决定本次运行使用哪个实现;
  • 排序:保证被依赖的对象先准备好;
  • 回收:失败或退出时释放已经取得的资源。

如果这三件事都散落在入口中,入口就必须了解每个组件的内部细节。

这时,入口不再只是“启动程序”,而开始承担一个组合器和生命周期管理器的职责。问题不在于对象变多,而在于对象之间的连接规则没有被明确表达:谁提供能力,谁依赖能力,能力何时可用,失效时如何收回。

为了讨论这个负责组织连接关系的角色,下面给它一个名字:PluginKernel。这是文章中的抽象名称,不指向某个预先存在的库。它可以是一个 TypeScript 类,也可以是其他语言中的对象;重要的不是名称,而是它承担的职责。

Runtime:不仅是让代码跑起来的环境,还负责管理运行中的组件、资源和错误边界。Node.js 是 JavaScript Runtime;PluginKernel 是 Agent Runtime 中负责插件组合和生命周期管理的内核。

有了这个角色,入口可以只负责提供插件配置:

const kernel = new PluginKernel()

await kernel.start([
  modelPlugin,
  toolsPlugin,
  sessionPlugin,
  agentPlugin,
  uiPlugin,
])

这些插件对象仍然由应用自己定义,内核不会凭空创建模型或工具。它只读取插件的声明,分析它们的关系,决定启动顺序,并在运行结束或中途失败时处理清理。换句话说,业务能力由 Provider 提供,组合规则由内核执行。

graph TD
  entry[应用入口] --> kernel[PluginKernel]
  kernel --> model[model Provider]
  kernel --> tools[tools Provider]
  kernel --> session[session Provider]
  model --> agent[Agent Loop Plugin]
  tools --> agent
  session --> agent
  agent --> ui[UI Plugin]
需要回答的问题内核提供的机制
谁先启动依赖声明与拓扑排序
服务放在哪里Service 注册表
插件何时可用loading / active 状态
资源如何释放Effect 与逆序清理
启动失败怎么办局部清理与回滚

下面会沿着这条关系解释:服务如何声明和注册,插件怎样被激活,资源如何释放,以及启动失败时为什么能够回滚。读者不需要预先了解某个具体框架;只要理解这些角色之间的关系,就能把同样的设计迁移到自己的运行时中。模型请求、Tool Call、Turn 等执行概念沿用前一篇文章的定义,这里不再重复。

先把依赖关系写出来

插件接口先描述“提供什么”和“需要什么”:

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>
}

providesinject 是元数据,不会自己创建对象:

provides: ["model"]
  声明:这个插件启动后应该提供 model

inject: ["model", "tools", "session"]
  声明:这个插件启动前必须能取得这三个服务

真正的注册和读取发生在 apply() 内:

context.provide("model", modelObject)
  把对象放进内核的服务注册表

context.get("model")
  从注册表取出对象

Service:插件对外提供的一项命名能力,例如 modeltoolssession。服务名是协作边界,具体实现可以被替换。

Provider:负责创建并注册某项 Service 的插件。

Consumer:声明并使用某项 Service 的插件。

Service Key:服务在注册表中的名字。 "model" 是 Key,不是模型对象本身。

依赖关系可以表示为:

graph TD
  modelProvider[model Provider] -->|provides model| agent[agent-loop]
  toolsProvider[tools Provider] -->|provides tools| agent
  sessionProvider[session Provider] -->|provides session| agent
  agent -->|provides agent| ui[console-ui]

内核不会把插件数组的排列当成依赖。它先建立 Service Key → Provider 的映射,再把 Consumer 的 inject 转换为依赖边,最后生成启动计划:

model Provider
session Provider
tools Provider
agent-loop
console-ui

同一层没有依赖关系时,可以按插件名排序,保证同一份配置产生相同结果。

Topological Sort:拓扑排序。它把依赖关系排列成线性顺序,使被依赖者先于依赖者启动。缺失依赖、重复 Provider 或循环依赖都会使计划无法成立。

这一步应该在任何 apply() 执行前完成。这样,组合结构本身有问题时,系统不会启动一半才发现错误。

拓扑排序的核心可以压缩成下面几行:

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

agent-loop 需要 model、tools、session
但没有任何插件提供 tools
→ 无法生成完整的启动计划

重复 Provider

model-a 提供 model
model-b 也提供 model
→ 同一个 Service 有两个来源,内核无法静默选择其中一个

循环依赖

plugin-a 需要 service-b
plugin-b 需要 service-a
→ a 等 b,b 又等 a,没有插件可以成为第一步

提前检查的价值在于,把“配置本身不成立”和“插件启动过程中出错”分开。前者不应该触发任何 apply();后者才需要进入后面的 Effect 清理和回滚流程。

Service 如何从声明变成可用对象

先看一个最小 Provider:

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())

这里有两个不同动作:

provides
  告诉内核“我应该提供 model”

context.provide
  在运行时真正注册 model

内核通常把服务保存为“所有者 + 对象”:

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 就是在这里组装出来的。它把三个公开操作转接到当前内核和当前插件的记录:

#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 的具体类:

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 的流动。

sequenceDiagram
  participant K as PluginKernel
  participant P as model Provider
  participant A as agent-loop

  K->>P: 调用 apply(context)
  P->>K: provide(&#34;model&#34;, model)
  K->>K: 保存 owner 与 value
  K->>A: 依赖满足后调用 apply(context)
  A->>K: get(&#34;model&#34;)
  K-->>A: 返回 model
  A->>K: provide(&#34;agent&#34;, agent)

这条链可以浓缩为:

声明 provides
  → 内核建立服务注册表
  → Provider provide
  → Consumer get
  → Consumer 创建自己的服务
  → 再 provide 给下游

Service Locator:按照稳定 Key 查找服务的注册表。它把 Provider 的选择交给组合层;为了不隐藏依赖,插件仍需通过 inject 预先声明读取范围。

激活、清理与失败回滚

插件不是调用一次 apply() 就立即可用。内核要为它建立一份记录:

interface PluginRecord {
  plugin: Plugin
  status: PluginStatus
  effects: Disposer[]
}

const record: PluginRecord = {
  plugin,
  status: "loading",
  effects: [],
}

loading 表示启动尚未完成。内核调用 apply(),收集插件注册的 Service 和 Effect,确认声明的 Service 都真实存在后,才把状态改为 active

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
  }
}

激活流程是:

flowchart TD
  start[开始激活] --> loading[创建 loading 记录]
  loading --> apply[执行 apply]
  apply --> register[注册 Service 和 Effect]
  register --> verify{声明的 Service 都存在?}
  verify -- 是 --> active[标记 active]
  verify -- 否 --> failed[标记 failed]
  apply -->|抛出异常| failed
  failed --> cleanup[释放当前插件已取得的资源]

即使 apply() 中途抛错,记录也已经存在,内核可以清理插件在失败前取得的资源。

插件可能注册工具、监听事件、启动定时器或打开连接。每项资源都应登记对应的释放函数:

const unregister = registry.register(calculator)
context.provide(SERVICE_KEYS.tools, registry)

context.effect(() => unregister)

这里的 effect() 立即执行传入函数,并保存它返回的清理函数。卸载时,内核按登记顺序的反方向执行:

获取 resource
获取 consumer-a
获取 consumer-b

释放 consumer-b
释放 consumer-a
释放 resource

Effect:与插件生命周期绑定的副作用记录,描述资源如何释放,以及清理在卸载或启动失败时何时发生。

LIFO:Last In, First Out,后进先出。最后登记的清理函数最先执行。

如果一个插件已经完成部分注册后失败,回滚需要结算两层资源:

flowchart TD
  error[apply 抛出异常] --> mark[当前插件标记 failed]
  mark --> local[释放当前插件的 Effect]
  local --> active[逆序释放此前已激活的插件]
  active --> result[返回包含原始原因的激活错误]

Rollback:多步骤操作失败后,按已完成步骤反向撤销,使系统回到可解释状态。回滚本身也可能失败,因此实现通常要保留清理错误。

卸载 Provider 时,影响沿着“提供者 → 依赖者”方向传播:

graph TD
  provider[tool Provider] -->|provides tools| agent[agent-loop]
  agent -->|provides agent| ui[console-ui]
  provider -. 被卸载 .-> lost[tools 不再可用]
  lost --> agentOut[卸载 agent-loop]
  agentOut --> uiOut[卸载 console-ui]

内核会先找出所有直接或间接依赖被卸载 Provider 的插件,再按照激活顺序逆序释放:

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 后,toolsagentui 会消失,但没有依赖这条链的 modelsession 可以继续存在。卸载不是关闭整个进程,而是结算受影响的依赖子图。

Idempotent:同一清理操作执行一次或多次,结果相同。卸载和整体销毁都需要具备这种性质,调用方重试时不会重复释放同一资源。

边界与可观察结果

把一次运行的状态写出来,前面的代码就有了可验证的结果:

启动完成:
  model、tools、session、agent、ui 已注册
  五个插件均为 active

卸载 tool Provider:
  tools 被移除
  agent 因依赖失效而退出
  ui 因 agent 消失而退出
  model 和 session 保留

启动过程中 apply 失败:
  失败插件已经取得的资源先释放
  之前激活的插件再按逆序回滚
  最终没有残留 Service

这套内核解决的是一次批量组合中的四个基础问题:

依赖如何表达
启动顺序如何确定
资源如何随所有者释放
失败后如何回滚

它没有实现完整的动态插件运行时,仍缺少:

  • 缺少依赖时保持 PENDING,并在 Provider 出现后自动激活;
  • 运行期配置更新和热替换;
  • 隔离 Context、同名 Service 的多实例作用域;
  • 持久化 Session 与进程重启后的恢复;
  • Tool 的权限、沙箱、超时和审计;
  • 清理失败后的句柄保留与重试。

PENDING:等待依赖满足的状态。动态插件运行时可以让插件停留在这个状态;当前这套批量内核遇到缺失依赖时会在规划阶段直接失败,不会进入 PENDING。

DeepSeek Cordis 把 Service、依赖等待和生命周期作为动态插件图的一部分;资源清理则通过 Effect 与生命周期绑定。Cordis Services · Cordis Lifecycle and Effects

这套设计的重点,不是把每个文件都改成插件,而是让能力通过稳定 Service 替换,让依赖决定激活,让资源随所有者释放,并让失败得到对称回滚。