加载中...
  • Agent 应用如何启动:入口层与运行层为什么要分开 loading

    吃透 AI Agent 开发 · 第 02 篇 · 第一章 · 认知校准

    用户只想看帮助时,不应该先加载模型、MCP 和终端 UI。启动边界决定了速度、可测试性和故障半径。

    你在终端里输入 agent --help,只是想看看有哪些命令。结果光标转了三秒,然后报错:“缺少模型 API Key”。

    这就很奇怪了。查看帮助既不需要模型,也不需要 API Key,为什么会被模型配置拦住?原因通常不是某个判断写错,而是应用启动时把所有东西一次性初始化了。

    Agent 应用比普通 CLI 更容易掉进这个坑。它可能同时依赖模型 SDK、工具、MCP 服务、会话数据库、终端 UI 和观测平台。启动边界没有分好,任何一个环节出问题,连 --version 都跑不起来。

    先分清两种请求

    可以把 Agent 应用收到的请求分成两类。

    第一类是轻量请求,例如:

    • 显示帮助和版本号。
    • 检查配置文件格式。
    • 查看本地审计日志。
    • 启动静态报告页。

    第二类才是完整 Agent 请求,例如:

    • 进入交互式聊天。
    • 恢复一段历史会话继续工作。
    • 执行需要模型和工具的自动任务。

    两类请求需要的依赖完全不同。合理的启动流程应该先做一次便宜的路由判断,再决定是否加载完整运行时。

    命令行参数
      -> 轻量路由
         -> 能直接处理:立即返回
         -> 需要 Agent:加载完整运行时
    

    Bootstrap 只回答“要去哪里”

    Bootstrap 可以理解成商场门口的导览台。顾客只是问洗手间在哪,导览员直接回答;顾客要去电影院,导览员才把他引到电影院。导览台不需要提前打开每一家店的灯。

    一个简单的 TypeScript 入口可以这样写:

    type EarlyCommand =
      | { kind: 'help' }
      | { kind: 'version' }
      | { kind: 'doctor' }
    
    function getEarlyCommand(args: string[]): EarlyCommand | null {
      if (args.includes('--help')) return { kind: 'help' }
      if (args.includes('--version')) return { kind: 'version' }
      if (args[0] === 'doctor') return { kind: 'doctor' }
      return null
    }
    
    const early = getEarlyCommand(process.argv.slice(2))
    
    if (early) {
      await runEarlyCommand(early)
    } else {
      const { runAgentApp } = await import('./runtime.js')
      await runAgentApp()
    }
    

    这里最重要的不是 if,而是动态 import。如果文件顶部已经静态导入了 runtime.js,Node 仍然会加载它和它的依赖,轻量路由就只剩表面功夫。

    完整运行时负责组装,不负责包办

    通过轻量入口后,完整运行时才开始准备真正需要的东西:

    1. 读取配置,得到当前模型和安全策略。
    2. 创建模型客户端。
    3. 加载内置工具和外部工具。
    4. 新建或恢复会话。
    5. 构建 Prompt 和动态上下文。
    6. 创建 Agent Loop。
    7. 连接终端、网页或后台任务等输入输出适配器。

    这里适合使用依赖注入,而不是让 Agent Loop 自己到处读取全局变量。

    interface AgentRuntime {
      model: LanguageModel
      tools: ToolRegistry
      sessions: SessionStore
      events: EventSink
    }
    
    async function createRuntime(config: Config): Promise<AgentRuntime> {
      return {
        model: createModel(config.model),
        tools: await createTools(config),
        sessions: createSessionStore(config),
        events: createEventSink(config)
      }
    }
    

    这样做有两个直接好处。测试时可以传入假模型和内存存储;将来把终端换成 Web 服务时,也不用修改 Agent Loop。

    Ready 不等于所有事情都完成

    有些初始化是运行前必须完成的,例如模型配置是否合法、核心工具能否注册。有些事情可以后台慢慢做,例如刷新文件索引、连接一个可选的 MCP 服务、预热搜索缓存。

    可以把启动任务分成三类:

    类型 例子 失败后怎么办
    阻塞型 配置解析、核心模型创建 停止启动并给出清晰错误
    可降级 可选 MCP、外部观测 记录警告,继续运行
    后台型 文件索引刷新、缓存预热 使用旧数据,完成后再切换

    如果把所有初始化都塞进一个 Promise.all,一个天气插件连不上就可能让整个 Agent 无法启动。并发并不等于边界清晰。

    三种常见失败写法

    顶层创建全局单例

    export const model = createModel(process.env.API_KEY!)
    

    任何导入这个模块的命令都会立刻读取 API Key。测试难以替换,帮助命令也可能失败。更好的方式是把创建动作放进显式函数,由完整运行时调用。

    入口层直接消费模型流

    入口一边解析参数,一边处理模型文本和工具进度。将来增加 Web UI 时,只能复制一遍逻辑。入口应该选择适配器,事件流的语义应由核心运行时定义。

    恢复会话时恢复一切

    历史会话记录了旧模型,不代表今天还要继续使用旧模型。会话负责上下文连续性,当前进程配置负责这次运行选择。两个概念混在一起,会让用户明明切了模型,下一条请求却又悄悄切回去。

    用 Agent Loop 做个小练习

    给上面的最小入口增加一个 config check 命令,要求:

    • 只解析本地配置,不创建模型。
    • 配置不存在时返回非零退出码。
    • 在测试中统计 createModel 被调用的次数,预期为 0。

    这个练习看似简单,却能很快暴露项目有没有把副作用藏在模块顶层。

    命令行参数 到 Agent Loop 的小结

    启动分层的核心不是追求几十毫秒,而是减少无关依赖。Bootstrap 只负责识别请求和选择路径,完整运行时负责组装依赖,Agent Loop 只负责执行闭环。

    下一篇进入心脏部分:模型返回的不只有文字。工具调用、reasoning、用量和错误会怎样在一次次消息之间流动?

    用启动时间线找出不该发生的工作

    helpversion 和交互会话分别跑一次,记录模块加载、配置读取、模型创建和 TUI 初始化的时刻。理想情况下,前两个命令在轻量路由就结束;只有真正进入交互会话,才启动模型、MCP、会话存储和终端界面。

    gantt
      title 三种启动路径
      dateFormat X
      axisFormat %L ms
      section help
      解析参数           :0, 8
      输出帮助           :8, 14
      section version
      解析参数           :0, 8
      读取包版本         :8, 16
      section interactive
      解析参数           :0, 8
      加载配置           :8, 28
      装配运行时         :28, 80
      准备终端           :80, 120
    

    时间线能暴露顶层 import 的副作用。若 version 阶段出现网络连接或 API Key 校验,说明某个模块在“被导入”时就偷偷执行了完整运行时职责。

    四个文件足够验证启动边界

    读取次序 文件 只回答这个问题
    1. Bootstrap src/cli/bootstrap.ts early command 是否在动态加载重模块前返回
    2. 命令识别 src/runtime/cli-info.ts 哪些参数可以 short-circuit
    3. 主运行时 src/cli/main.ts 模型、会话和工具在哪里组装
    4. 启动追踪 src/runtime/startup-trace.ts 每个阶段的耗时怎样被记录

    一个只在生产出现的失败

    顶层 import 创建模型单例,导致缺少 API Key 时连 version 都无法执行。开发机上环境变量齐全,这个问题很难出现;安装到新机器、CI 或离线环境后,最简单的诊断命令也失效了。

    修复的判断标准不是“加了 try/catch”,而是轻量命令根本不触达模型依赖。测试里给 createModel 加计数器,运行 helpversionupdate --dry-run,调用次数都应为零。

    练习:设计一个 doctor 命令

    新增一个只读配置检查命令,先输出本地配置是否存在,再按需检测网络。要求它在模型 SDK 无法加载时仍能给出诊断;网络检查失败时也不能阻止读取本地版本。用启动 trace 比较改造前后的加载模块数,而不只比较总毫秒数。

    别凭体感谈启动速度,先采一组冷启动

    优化 CLI 时很容易被热缓存骗到。连续运行第二次,Node 模块和磁盘页都已进入缓存,200 毫秒的差异可能完全消失。比较启动边界至少要区分冷启动、热启动和首次配置三种场景,并记录阶段,而不是只看命令总耗时。

    下面是一组示意采样,重点在数据形状,不在具体数字:

    command,scenario,route_ms,config_ms,model_ms,tui_ms,total_ms
    help,cold,9,0,0,0,18
    version,cold,8,0,0,0,21
    chat,cold,10,31,86,72,241
    chat,warm,7,12,44,39,128
    doctor,no-network,8,15,0,0,1240
    

    doctor 总耗时最高,但本地结果在 23 毫秒时已经可用,剩余时间只是可选网络探测。于是正确改造不是把所有步骤并行后等 Promise.all,而是先输出本地诊断,再给网络检查单独的超时和状态。

    Ready Gate 只等待“不能缺”的东西

    交互界面启动后,用户希望立刻输入;模型执行前,又必须确保配置和核心工具已经就绪。可以用一个 ready gate 表达这条边界:界面渲染不等于 Agent 可执行,后台预热完成也不等于必须阻塞输入。

    interface RuntimeReadiness {
      core: Promise<{ model: LanguageModel; tools: ToolRegistry }>
      optional: PromiseSettledResult<unknown>[]
    }
    
    const readiness = createReadiness(config)
    renderTerminal({
      submit: async (input) => {
        const core = await readiness.core
        return runRequest(core, input)
      }
    })
    

    这里不能让 core 静默失败后永久 pending。配置错误应在 gate 上留下可展示的终态;用户提交时得到同一条确定错误,而不是每按一次回车就重新初始化一遍。可选任务则应该能失败、重试或稍后启用,不改变核心 gate。

    启动失败后也要按相反顺序收尾

    完整运行时装配到一半时可能失败:MCP 子进程已经启动,会话文件已经打开,模型客户端创建又报错。如果只有“成功后统一注册退出回调”,失败路径会泄漏资源。

    可以维护一个很小的清理栈。每创建一项资源,立刻登记对应 disposer;后续阶段失败时,按相反顺序执行。之所以反向,是因为后创建的对象往往依赖先创建的对象。

    const disposers: Array<() => Promise<void> | void> = []
    
    try {
      const sessions = await openSessionStore()
      disposers.push(() => sessions.close())
    
      const mcp = await connectOptionalMcp()
      if (mcp) disposers.push(() => mcp.close())
    
      return createRuntime({ sessions, mcp, disposers })
    } catch (error) {
      for (const dispose of disposers.reverse()) await dispose()
      throw error
    }
    

    这段逻辑适合放在装配层,不应塞进 Loop。Loop 负责一次请求的取消和收尾,Bootstrap/Runtime 负责进程级资源的生命周期,两种 teardown 不要混成一个巨大的 finally

    用导入图做比时间更稳定的回归

    毫秒数会受机器影响,模块边界却更稳定。测试 help 时,可以在加载钩子里记录模块名,断言没有出现 Ink、React、模型 SDK、MCP SDK 和观测客户端。这样即使某次运行碰巧很快,只要有人在入口顶部加入重依赖,测试仍会立刻失败。

    测试矩阵至少覆盖四条路径:

    启动请求 允许读取配置 允许创建模型 允许加载 TUI 网络失败的结果
    help 无关
    version 无关
    doctor 输出降级诊断
    交互会话 TTY 下允许 可选连接降级

    最后还要测非 TTY 和管道场景。用户执行 echo "hello" | agent 时,加载全屏 Ink 界面往往会产生控制字符或挂住 CI。入口应根据调用形态选择 classic/stream 适配器,模型循环仍使用同一套事件协议。

    启动分层的最终收益不是排行榜上的几十毫秒,而是故障半径变小。模型供应商宕机时仍能看帮助,MCP 配错时仍能查版本,UI 依赖损坏时管道模式仍能输出诊断。一个 CLI 越容易自救,用户越敢把它放进真正的工作流。

    可选依赖要在真正使用时才暴露故障

    Langfuse SDK 安装损坏,不应让未开启观测的用户启动失败;Ink 只影响 TUI,不应拖垮 --classic;Eval 报告库出错,也不影响普通对话。代码上的关键是动态 import 位于能力分支内部,且错误被映射为该能力的 degraded,而不是 Runtime 全局 fatal。

    async function createOptionalTelemetry(config: Config): Promise<Telemetry> {
      if (!config.telemetry.enabled) return noopTelemetry
      try {
        const { createTelemetry } = await import('./telemetry-runtime.js')
        return await createTelemetry(config.telemetry)
      } catch (error) {
        return degradedTelemetry(sanitizeStartupError(error))
      }
    }
    

    并非所有 import 失败都可降级。模型核心 adapter 缺失时,交互请求无法执行,应在 ready gate 给清楚错误;但 help、version、config doctor 仍可运行。边界由当前命令真正需要什么决定,而不是由依赖是否“重要”决定。

    用兼容矩阵覆盖启动形态

    至少组合 Windows/Linux、TTY/非 TTY、有无配置、模型可用/不可用、可选 MCP 可用/不可用。无需每个组合跑真实网络,模块 fixture 和注入失败足以验证路由。

    help + no API key              -> 0,打印帮助
    version + broken Ink           -> 0,不加载 TUI
    pipe + valid model             -> classic 输出,无 ANSI
    interactive + broken model     -> ready error,可退出/看 doctor
    interactive + broken MCP       -> 核心可用,MCP 标 degraded
    audit tail + model outage      -> 本地审计仍可读
    

    这张矩阵也是发布安装包的冒烟测试。开发态源码能运行,不代表打包后动态 chunk、changelog、静态 TUI 资源都在正确位置;从 dist 启动 early command 和一轮 fake-model 交互,才能验证交付物。

    启动错误应告诉用户在哪一阶段停了

    “Cannot find module”或“初始化失败”无法行动。错误信息包含 stage、能力、来源和脱敏建议:配置解析指出文件与字段,模型错误显示 provider/脱敏 endpoint,MCP 错误显示 server alias,TUI 错误建议 --classic。任何分支都不打印 token。

    启动 trace 与 crash report 使用同一阶段名称,用户给出 reportId 后,维护者能知道是在 route、config、runtime import、ready 还是 first request 停止。把启动本身当作一条可观测状态机,Agent 尚未运行时也能排障。

    本文目录
    本文目录