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: 会话消息数组。tools和systemPrompt: 当前暴露给模型的能力和规则。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() 等待后者。关键的生命周期规则有三条:
- run 内任何异常(包括 transformContext、provider、订阅者抛错)都不能裸奔出去,而是被合成一条
stopReason: "error"的 assistant 消息,走正常的事件和日志路径。 - run 结束时统一清理:
isStreaming复位、pendingToolCalls清空、promise resolve。清理必须在 finally 里,否则一次异常就会让 Agent 永远“看起来在运行”。 - 订阅者的返回值如果是 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 类或工厂函数。
验收标准:
- 外部只能通过
prompt、abort、subscribe、waitForIdle和配置方法驱动内核。 - 事件订阅者不能直接修改内部 messages;订阅者返回的 promise 计入 run 完成。
- 内核任何内部异常都合成
stopReason: "error"的 assistant 消息,waitForIdle正常返回。 - 一个 faux provider 测试能断言完整生命周期事件序列,包括并行工具时 tool result 的顺序稳定。
- 未知工具、工具错误和用户 abort 都会发出明确事件。