从零构建 Coding Agent
English
本章目录

06. Agent 内核与生命周期

到目前为止,你已经有了消息协议、provider、工具和流式事件。下一步是把它们组织成 Agent 内核。内核不是产品界面,也不是某个 provider 的包装器。它的职责是维护运行状态、推进 turn、执行工具、发出事件,并把可定制点暴露给上层。

本章站在 Agent Core 内部,解决一次 run 怎样正确推进。它暂不负责资源发现、跨 turn 的配置快照、事件回调中的会话写入顺序,以及“所有 listener 都完成后才算 idle”的结算语义。那些属于 Core 之上的 harness,第十五章会把两层正式接起来。

四个职责边界

一个清晰的 Agent 内核通常分成四块:

  • State:当前消息、工具、模型、配置、队列和运行状态。
  • Runner:推进 turn 的循环,也就是第二章的 loop。
  • Tool executor:校验、调度、执行工具,形成 tool result。
  • Event bus:把生命周期事件广播给 UI、日志、扩展和测试。

不要让 UI 直接改 messages,也不要让工具直接调用 provider。每一层都通过明确契约交互。这样你才能在未来增加 JSON 模式、SDK、扩展系统或测试 harness,而不是重写核心 loop。

内核状态可以参考成熟系统的最小集合:

  • messages: 会话消息数组。
  • toolssystemPrompt: 当前暴露给模型的能力和规则。
  • model 与推理强度设置。
  • isStreaming: 是否有进行中的模型请求。
  • streamingMessage: 流式过程中的部分 assistant 消息。
  • pendingToolCalls: 正在执行的 tool call id 集合。
  • errorMessage: 最近一次失败的原因。
  • steering 和 follow-up 队列(下一章展开)。

会话名称、主题色、最近命令历史等属于产品层。把它们放进内核会让 SDK、CLI 和 TUI 互相污染。

公共 API 与运行生命周期

内核对外的方法面应该很小:

type Agent = {
  prompt(input: string | UserMessage): Promise<void>;
  steer(input: UserMessage): void;
  followUp(input: UserMessage): void;
  abort(): void;
  waitForIdle(): Promise<void>;
  subscribe(listener: (event: AgentEvent) => void | Promise<void>): () => void;
  readonly state: AgentStateSnapshot;
};

每次 prompt 启动一个 run。内核为它创建一个 AbortController 和一个完成 promise,abort() 触发前者,waitForIdle() 等待后者。关键的生命周期规则有三条:

  1. run 内任何异常(包括 transformContext、provider、订阅者抛错)都不能裸奔出去,而是被合成一条 stopReason: "error" 的 assistant 消息,走正常的事件和日志路径。
  2. run 结束时统一清理:isStreaming 复位、pendingToolCalls 清空、promise resolve。清理必须在 finally 里,否则一次异常就会让 Agent 永远“看起来在运行”。
  3. 订阅者的返回值如果是 promise,会被 await,并计入 run 的完成时机。这样“等 Agent 空闲”天然包含“日志已写完、UI 已渲染完”,测试不需要 sleep。

第三条是容易忽略的设计决策。如果事件是 fire-and-forget,日志订阅者可能在进程退出时还没写完最后一条消息;把订阅者纳入 run 的结算,就把“事件已发出”升级成“事件已被处理”。代价是慢订阅者会拖慢整个 run,所以订阅者要么快,要么自己排队。

内核状态里还值得显式保留一个 phase 字段,把“Agent 现在处于哪个阶段”变成可查询、可断言的事实,而不是散落在几个布尔量里。一个最小的阶段机通常在这几种状态之间转移:

idle -> turn -> idle
turn -> compaction -> turn
turn -> retry -> turn

idle 表示没有进行中的 run,可以接受新 prompt;turn 表示正在跑一次模型请求加它触发的工具;上下文接近窗口上限时进入 compaction,压缩完再回到 turn 继续;provider 报可重试错误时进入 retry,退避后重发同一请求,成功了再回 turn。把阶段显式化有三个好处:UI 能准确显示“正在压缩”而不是笼统的“运行中”;插话和 follow-up 知道自己该在哪个边界注入;测试可以直接断言一次任务经过了哪些阶段。run 的 AbortController 和“待写入文件”集合都挂在这台阶段机上——中止就是在任意阶段触发 abort,再走统一清理回到 idle

生命周期事件与顺序保证

内核应该发出稳定的生命周期事件,并给出明确的顺序保证:

agent_start
turn_start
message_start (assistant)
message_update ...
message_end (assistant)
tool_execution_start ...
tool_execution_end ...
message_start / message_end (每条 tool result)
turn_end
agent_end

顺序保证至少包括:每条消息的事件严格按 start、update、end 排列;turn_end 之前,本 turn 所有工具执行事件和 tool result 消息事件都已发出;agent_end 是 run 的最后一个事件。这些保证不是为了好看,它们支撑几类能力:

  • UI 显示当前状态,不需要猜测。
  • 会话日志准确记录发生顺序。
  • 扩展在 tool call 前做权限检查。
  • 测试断言一次任务的执行轨迹。
  • 统计系统计算耗时和 token 成本。

如果你没有统一事件流,这些能力会各自发明一套状态,系统很快失去一致性。

钩子面,而不是继承树

Agent 很容易被需求推向复杂继承:安全 Agent、测试 Agent、带压缩 Agent、带扩展 Agent。更稳妥的方式是暴露钩子。成熟系统的内核钩子面大致是:

type AgentHooks = {
  transformContext?: (messages: AgentMessage[], signal: AbortSignal) => Promise<AgentMessage[]>;
  convertToLlm?: (messages: AgentMessage[]) => Message[];
  beforeToolCall?: (call: ToolCallBlock) => Promise<{ block?: string }>;
  afterToolResult?: (result: ToolResultMessage) => Promise<void>;
  shouldStopAfterTurn?: (state: AgentStateSnapshot) => Promise<boolean>;
};

前两个钩子值得多说。transformContext 在每次模型请求前对消息数组做投影:压缩、裁剪、注入外部上下文都发生在这里。convertToLlm 把内核存储的消息类型转换成协议层消息——它的存在允许上层定义自定义消息类型(比如“用户执行了一条本地命令”这样的应用事件),存进会话但在发给模型前被过滤或改写。这两个钩子有一条共同契约:不允许抛错,失败时返回原始输入。上下文投影失败不应该毁掉整个 run。

beforeToolCall 抛错或返回 block 时,内核把它转成错误 tool result,而不是终止任务——权限拒绝对模型来说也是一种可观察的结果。每个钩子的时机和错误语义都要在内核层定义清楚,否则扩展作者只能靠试。

并行工具与顺序事实

很多模型会在一个 assistant turn 里请求多个工具。成熟系统的调度策略值得借鉴:准备阶段(校验、权限检查)永远串行,按 tool call 在消息里的顺序进行;执行阶段并行(除非某个工具声明必须串行);tool_execution_end 事件按完成顺序发出,让 UI 实时更新;但 tool result 消息按 tool call 的原始顺序追加进历史。这样 UI 看到的是真实的完成时序,模型和日志看到的是稳定的、可复现的顺序。

还有一个并发细节:工具执行结束后,可能还有迟到的进度回调(定时器、残余的 stdout)。内核要在结束时翻转一个“不再接受更新”的开关,把迟到更新丢弃,否则 UI 会在工具已显示完成后又闪出一条进度。

并行执行带来的真正问题是文件写冲突。两个工具同时读取旧文件,各自计算修改,最后一个写入覆盖前一个。这不是模型问题,而是工具运行时缺少同一文件的写队列。后面的 coding 工具章会专门处理。

运行观察

一个用户请求从开始到空闲,事件可能是:

agent_start
turn_start
message_start(assistant) ... message_end(assistant) stopReason=toolUse
tool_execution_start read
tool_execution_end read
message_start(toolResult) message_end(toolResult)
turn_end
turn_start
message_start(assistant) ... message_end(assistant) stopReason=stop
turn_end
agent_end

这条序列能同时驱动终端 spinner、日志写入、扩展权限门和测试断言。稳定事件序列是 Agent 产品化的地基。

练习

把当前 loop 包成 Agent 类或工厂函数。

验收标准:

  • 外部只能通过 promptabortsubscribewaitForIdle 和配置方法驱动内核。
  • 事件订阅者不能直接修改内部 messages;订阅者返回的 promise 计入 run 完成。
  • 内核任何内部异常都合成 stopReason: "error" 的 assistant 消息,waitForIdle 正常返回。
  • 一个 faux provider 测试能断言完整生命周期事件序列,包括并行工具时 tool result 的顺序稳定。
  • 未知工具、工具错误和用户 abort 都会发出明确事件。