吃透 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 仍然会加载它和它的依赖,轻量路由就只剩表面功夫。
完整运行时负责组装,不负责包办
通过轻量入口后,完整运行时才开始准备真正需要的东西:
- 读取配置,得到当前模型和安全策略。
- 创建模型客户端。
- 加载内置工具和外部工具。
- 新建或恢复会话。
- 构建 Prompt 和动态上下文。
- 创建 Agent Loop。
- 连接终端、网页或后台任务等输入输出适配器。
这里适合使用依赖注入,而不是让 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、用量和错误会怎样在一次次消息之间流动?
用启动时间线找出不该发生的工作
把 help、version 和交互会话分别跑一次,记录模块加载、配置读取、模型创建和 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 加计数器,运行 help、version、update --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 尚未运行时也能排障。