观察 AIRI 源码:一个 Agent 系统如何处理入口、扩展与执行闭环

0 阅读6分钟

在 AI Agent 快速发展的这两年,很多项目都已经能做到“能聊、能演示、能截图。

但真正决定一个项目能走多远的,往往不是首屏效果,而是工程治理能力:请求怎么被接入、会话怎么被隔离、扩展怎么被约束、出错之后怎么继续稳定运行。

Project AIRI 值得借鉴的地方就在这里。 它不是把模型能力包一层 UI,而是在尝试把输入、推理、执行、反馈组织成一套可持续迭代的运行系统。

一、AIRI 是什么:一个“可运行”的 Agent 系统

一句话讲,AIRI 不是“给 LLM 套一层工具调用”的通用 Agent 平台,而是一个面向数字角色场景的运行系统。
它关注的不只是“任务能不能做完”,还关注“角色能不能持续存在、持续互动、跨端一致地存在”。 普通 Agent 平台通常把重点放在流程编排:输入任务、调用工具、返回结果。
AIRI 在这条链路之外,多做了三层事情:

  • 实时交互层:语音输入、语音输出、角色驱动(如 Live2D / VRM)要协同工作,体验目标不是一次性响应,而是“在场感”。
  • 多形态运行层:Web、桌面、移动端不是各做一套,而是围绕共享能力组织,确保角色能力跨端延续。
  • 长期运行层:会话、状态、能力配置、插件扩展都要可持续管理,项目目标是长期演进,不是短期 demo。 所以 AIRI 的核心不是“模型接得多”,而是“把模型、交互、执行、扩展放进同一套可运行系统里”。
    这也是它和常见 Agent 框架最容易被混淆、但本质上差异最大的地方。

从仓库结构可以看到它的职责分层:apps 承接入口,packages 沉淀复用能力,plugins 负责扩展,services 连接外部渠道。 这种分层背后有一个很现实的目标:
在保持产品迭代速度的同时,把“可复用能力”和“可扩展边界”稳定下来。否则功能一多,项目就会很快进入“每加一个能力都要改全局”的状态。

二、请求生命周期:先治理入口,再进入业务

AIRI 的服务入口不是“收到请求就直接执行业务”,而是先做基础治理:跨域策略、会话中间件、请求体限制、观测链路,然后才分发到聊天、Provider、角色等路由。
这一步的价值在于把稳定性问题前置,而不是留给业务代码兜底。

path:apps/server/src/app.ts

const app new Hono<HonoEnv>()
  .use(
    '/api/*',
    cors({
      origin: origin => getTrustedOrigin(origin),
      credentialstrue,
    }),
  )
  .use(honoLogger())
if (otel) {
  app.use('*'otelMiddleware(otel))
}
return app
  .use('*'sessionMiddleware(auth))
  .use('*'bodyLimit({ maxSize1024 * 1024 }))
  .route('/api/providers'createProviderRoutes(providerService))
  .route('/api/chats'createChatRoutes(chatService))

AIRI 把请求处理拆成了“治理层 + 业务层”,属于典型的系统化服务结构。

三、Provider 管理:不是配置项,而是系统资源(精修版)

AIRI 把模型 Provider 当成“用户可管理的资源”来做,而不是把 API Key 直接散落在前端配置里。 从路由实现可以看到两层约束:先通过 authGuard 作为权限入口;创建时用 CreateProviderConfigSchema 做结构化校验,并把 ownerId 绑定到当前用户;修改时走 patch 路由,先加载目标配置,再用 existing.ownerId !== user.id 校验归属,不属于当前用户的改动会被直接拒绝。

path:apps/server/src/routes/providers.ts

export function createProviderRoutes(providerService: ProviderService) {
  return new Hono<HonoEnv>()
    .use('*', authGuard)
    .post('/'async (c) => {
      const user = c.get('user')!
      const body = await c.req.json()
      const result = safeParse(CreateProviderConfigSchema, body)

      if (!result.success) {
        throw createBadRequestError('Invalid Request''INVALID_REQUEST', result.issues)
      }

      const provider = await providerService.createUserConfig({
        ...result.output,
        ownerId: user.id,
      })

      return c.json(provider, 201)
    })
    .patch('/:id'async (c) => {
      const user = c.get('user')!
      const id = c.req.param('id')
      const body = await c.req.json()
      const result = safeParse(UpdateProviderConfigSchema, body)

      if (!result.success) {
        throw createBadRequestError('Invalid Request''INVALID_REQUEST', result.issues)
      }

      const existing = await providerService.findUserConfigById(id)
      if (!existing) throw createNotFoundError()
      if (existing.ownerId !== user.id) throw createForbiddenError()

      const updated = await providerService.updateUserConfig(id, result.output)
      return c.json(updated)
    })
}

这类实现的工程意义在于:Provider 的修改边界由后端路由用 ownerId 校验强制固定下来,而不是依赖前端或约定维持。

四、插件机制:能扩展,也要可控

AIRI 的插件扩展不是“把能力塞进系统就算完成”,而是把插件当成需要长期协作的模块来管理。
插件宿主用状态机约束生命周期阶段:状态机覆盖这些阶段,再到需要配置、完成配置,最终进入就绪状态;失败会进入统一的失败阶段。
这样系统在任何时刻都能回答一个工程问题:插件现在处于什么阶段、下一步应该做什么、失败该怎么被观测与处理。 同时,宿主在插件调用能力前还会做权限断言:扩展能力可以被接入,但不会默认获得越权操作的能力边界。

path:packages/plugin-sdk/src/plugin-host/core.ts

const pluginLifecycleMachine createMachine({
  id'plugin-lifecycle',
  initial'loading',
  states: {
    loading: { on: { SESSION_LOADED'loaded', SESSION_FAILED'failed' } },
    loaded: { on: { START_AUTHENTICATION'authenticating', SESSION_FAILED'failed', STOP'stopped' } },
    authenticating: { on: { AUTHENTICATED'authenticated', SESSION_FAILED'failed' } },
    authenticated: { on: { ANNOUNCED'announced', SESSION_FAILED'failed' } },
    announced: { on: { START_PREPARING'preparing', CONFIGURATION_NEEDED'configuration-needed', STOP'stopped', SESSION_FAILED'failed' } },
    preparing: { on: { WAITING_DEPENDENCIES'waiting-deps', PREPARED'prepared', SESSION_FAILED'failed' } },
    prepared: { on: { CONFIGURATION_NEEDED'configuration-needed', CONFIGURED'configured', SESSION_FAILED'failed' } },
    configuration-needed: { on: { CONFIGURED'configured', SESSION_FAILED'failed' } },
    configured: { on: { READY'ready', SESSION_FAILED'failed' } },
    ready: { on: { REANNOUNCE'announced', CONFIGURATION_NEEDED'configuration-needed', STOP'stopped', SESSION_FAILED'failed' } },
    failed: { on: { STOP'stopped' } },
  },
})


private assertPermission(session: PluginHostSession, input: { area'apis'|'resources'|'capabilities'|'processors'|'pipelines'; actionstring; keystring; reason?: string }) {
  const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key)
  if (allowed) return
  const error new PermissionDeniedError({ area: input.area, action: input.action, key: input.key })
  session.channels.host.emit(errorPermission, { identity: session.identity, error: { area: input.area, action: input.action, key: input.key, recoverabletrue } })
  throw error
}

扩展增长可以持续,但增长要留在契约和权限边界内。

五、执行闭环:不止“会回复”,还要“会把事做完”

AIRI 在渠道服务(如 Telegram)里的实现,已经体现了典型 agent loop: 先根据上下文推断动作,再执行动作,必要时进入下一轮循环。

path:services/telegram-bot/src/bots/telegram/index.ts

const action = await imagineAnAction(
  ctx.bot.botInfo.id.toString(),
  currentController,
  chatCtx?.messages || [],
  chatCtx?.actions || [],
  { unreadMessages: ctx.unreadMessages, incomingMessages: [incomingMessage] },
)
return await dispatchAction(ctx, action, currentController, chatCtx)

while (typeof result === 'function') {
  result = await result()
}

六、如何理解这个仓库:一条更顺的阅读路径

如果要快速看懂 AIRI 的系统设计,可以按这个顺序:

apps/server:先看请求如何进入系统、入口如何治理 apps/:再看多端入口如何复用核心能力 packages/:看 Provider、Plugin、UI/runtime 的边界抽象 plugins 与 services:看扩展和外部渠道如何接入主链路 按这条路径阅读,会先建立系统主干,再进入扩展细节,不容易陷入局部代码。

结语

当一个 Agent 项目同时具备可治理的入口、可约束的扩展、可追踪的执行闭环,它才真正从“可演示”走向“可运行”,这也是 AIRI 最值得参考的地方,它把 AI 能力从功能展示,推进到了系统工程。

参考链接

Project AIRI:github.com/moeru-ai/ai…