这是《Agent全栈开发实战》的第 3 篇。整个系列以 catbuddy为案例,拆解一个「能干活的 AI」背后那层工程系统。上一篇我们用大约 50 行手写了一个能调工具的最小 harness------一个
while(true)套着「调 LLM → 执行工具 → 再调 LLM」。它能跑,但有个尴尬:一旦用户中途点「停止」,或者发来一条/help,那个裸while完全不知道该怎么办。这篇就看 catbuddy 的真实做法------把那一层循环拆成两层 ,再在前面挂一套三层命令路由 ,让/stop能在毫秒级把正在干活的 Agent 立刻刹住。
一个 while 为什么会变成一锅粥
先回到上一篇那 50 行。它的内核长这样,你应该还有印象:
javascript
while (true) {
const res = await llm.chat({ messages: history, tools: TOOLS });
history.push(res.message);
if (!res.toolCalls) break; // 模型说「完成了」→ 退出
for (const call of res.toolCalls) {
history.push(await runTool(call)); // 真的去读文件 / 改代码 / 跑命令
}
}
在 demo 里它完美无瑕。但你把它放进一个真实产品,三个需求会立刻把它撑破:
- 用户点「停止」要立刻响应。 用户喊停,往往是因为 Agent 正在干一件错的 事------它可能在读一个 10MB 的日志,或者陷进了一个「我再看看这个文件」的无限循环。这时候
/stop必须在毫秒级生效,而不是「等当前这一轮 LLM 调用 + 当前这个工具执行完」再说。可上面那个while,控制权一旦交给await llm.chat,你就只能干等它返回。 - 有些请求得先预处理。 你跟 Agent 聊了两百轮,上下文窗口早爆了。在调 LLM 之前,得先把旧历史压缩一下------可这段「先压缩再继续」的逻辑往哪塞?塞进
while中间? - 有些请求根本不用调 LLM 。 用户发
/help、/status,这是在跟引擎说话,不是跟模型说话。直接返回一段文本就行,凭什么花一次 LLM 调用、消耗 token、还把它写进对话历史?
你当然可以硬塞------在 while 里加 if (isCommand)、加 if (tokenOverBudget)、加一个 checkCancel()。但加到第三个,这个循环就变成了一锅粥:取消逻辑、压缩逻辑、命令逻辑、LLM-工具逻辑全搅在一起,谁也理不清谁。问题不是它跑不起来,而是每加一个生命周期节点,你都得在同一个循环里打一个新补丁。
catbuddy 的解法是分层:把「一个用户请求从进来到出去」的生命周期 ,和「LLM 与工具之间没完没了的拉扯」这个内层循环,拆成两层。

两层各管一摊,互相不知道对方的内部细节。外层状态机不关心模型调了几轮,内层 Runner 不关心自己处在哪个生命周期阶段。这个分离救了我们很多次------后面每次加新功能,基本只动其中一层,另一层纹丝不动。下面分别拆。
外层:七个状态,一条单向流水线
外层的 AgentLoop是一个状态机 ------所谓状态机,就是「把一件事切成若干个阶段,每个阶段只做一件事,做完按规则跳到下一个阶段,不绕回头」。它管理的是 一个 turn(一轮用户请求)的完整生命周期,从消息进来到结果出去,被切成这么一条单向流水线:

注意这是 单向 的:没有循环,没有回退。每个状态做完返回一个「事件名」,状态机拿「当前状态 + 事件名」去查一张转移表,跳到下一个状态。转移表在源码里就是一个普通对象,一目了然:
javascript
const TRANSITIONS: Record<string, State> = {
"RESTORE:ok": State.COMPACT,
"COMPACT:ok": State.COMMAND,
"COMMAND:dispatch": State.BUILD, // 是普通消息 → 继续走 LLM
"COMMAND:shortcut": State.DONE, // 命中命令 → 直接收工,跳过 LLM
"BUILD:ok": State.RUN,
"RUN:ok": State.SAVE,
"SAVE:ok": State.RESPOND,
"RESPOND:ok": State.DONE,
};
驱动它的主循环干净得有点不真实------查表跳转,跳到 DONE 就结束:
javascript
while (turn.state !== State.DONE) {
const event = await this._processState(turn); // 执行当前状态,拿到事件名
const next = TRANSITIONS[`${State[turn.state]}:${event}`];
if (next === undefined) throw new Error(`No transition for ${turn.state}+${event}`);
turn.state = next;
}
七个在途状态各干一件事,列张表你就懂了:
COMPACT 和 COMMAND 这俩状态,在上一篇那个 50 行玩具里根本不存在------但它们正是真实场景逼出来的刚需,也正好对应开头那两个撑破 while 的需求:
- COMPACT :状态机在每轮请求处理前先看一眼 token 预算,超了就把旧消息摘要化。(怎么压、压哪些、token 怎么精确算,是第 06 篇「眼睛」的活儿,这里点到为止------你只要知道压缩是一个独立的状态,而不是塞在别处的补丁。)
- COMMAND :用户发
/help不需要惊动 LLM,在这个状态里直接返回,然后走shortcut事件一步跳到DONE,把BUILD→RUN→SAVE→RESPOND整段全跳过。
这就是状态机相比一层 while 的核心好处:每个生命周期节点都有独立的入口和出口,加功能就是往流水线里插一个新工位,不影响上下游。 你想新增一个「请求前先做安全审计」的环节?加一个状态、在转移表里连两条边就行,前后的状态一行都不用改。
内层:AgentRunner 跟 LLM 没完没了地拉扯
RUN 状态里头才是真正干活的地方。一旦进入 RUN,AgentLoop 就把控制权整个交给 AgentRunner.run(),开始跑内层循环:

这个内层循环解决的核心问题是:LLM 的行为不可预测。你这一句问完,它可能一轮就答完了,也可能因为「我还得再看看那个文件」连续调八轮。Runner 不关心到底几轮结束,它只管转,直到模型不再要工具为止。
但跟 50 行玩具里那个光秃秃的 for 比,真实 Runner 在每轮调 LLM 之前先干了件玩具完全没有的事------上下文治理,我喜欢叫它「每轮开干前先打扫房间」。它处理的全是「理论上不该发生、实际上天天发生」的脏边界,不擦干净,Agent 跑不了几轮就会因为历史不一致或上下文爆炸而当场暴毙:
- 删掉孤立的 tool 结果------历史里有一条工具结果,却找不到对应的工具调用请求,这是脏数据,删。
- 补上缺失的 tool 结果 ------反过来,有工具调用请求却没结果(多半是上次崩溃中断了),补一条
{ error: "Tool result lost" }。否则模型拿到残缺的历史会懵。 - 微压缩 ------某几类「读取型」工具(
read_file、exec、grep、list_dir...)的结果攒过 10 条,就把旧的换成摘要。不是删,是压:模型仍然知道「我们读过这些」,但不再占满 token。 - token 预算截断------上面三步做完还超预算,就从最早的消息开始删,保证当前这条 user 消息绝不被截掉。
顺带提一句轮数上限 。那个 maxIterations 默认 50(允许在 1--100 之间调),是内层循环的安全带------万一模型抽风陷进死循环,转到 50 轮也会被强行刹停,吐一条「迭代次数耗尽」的提示,不会让它无限烧你的 token。这是「防御性工程」最朴素的一种体现:不指望模型永远理智,而是给它的非理智上个硬保险。
三层命令路由:让 /stop 立刻生效
回到 COMMAND 状态。斜杠命令(/stop、/new、/model ... 这种)虽然都从这儿过,但它们的「脾气」差得很远------有的能不慌不忙排队等状态机处理,有的必须立刻 生效。catbuddy 给命令做了一套三层路由 (CommandRouter),按紧急程度分了三档:
javascript
class CommandRouter {
private _priority = new Map<string, CommandHandler>(); // 第 1 层:插队
private _exact = new Map<string, CommandHandler>(); // 第 2 层:精确匹配
private _prefix: Array<[string, CommandHandler]> = []; // 第 3 层:前缀 + 参数
}
判定顺序是 priority → exact → prefix,一张图说清:

逐层看,重点在第一层。
第 1 层 · priority(插队命令)。 这一层是 /stop 的专属通道,关键在于它在会话锁(session lock)之外就被处理掉了。看外层那个消费消息的循环:
javascript
// loop.ts ------ 消息消费主循环
while (this._running) {
const msg = await this.bus.consumeInbound();
const raw = msg.content.trim();
if (this.commands.isPriority(raw)) { // /stop 走这里
const result = await this.commands.dispatchPriority(cmdCtx);
if (result) await this.bus.publishOutbound(result);
continue; // ⭐ 直接 continue,根本不进状态机
}
this._dispatch(msg).catch(/* ... */); // 普通消息才走完整状态机
}
为什么非得绕开状态机和会话锁?因为 /stop 干的事是调用 cancelSession(),而它内部会触发 AbortController.abort()------一个标准的取消信号,会直接打到正在跑的 LLM 调用和工具执行上的 AbortSignal,把它们就地中止。如果这个取消还得乖乖排队、等当前那个工具执行完,那 /stop 作为「紧急刹车」的意义就没了。它的处理器本身简单到只有几行:
javascript
export async function cmdStop(ctx: CommandContext): Promise<OutboundMessage> {
const total = await ctx.loop.cancelSession(ctx.sessionKey); // 内部 AbortController.abort()
return { content: total ? `已停止 ${total} 个任务。` : "当前没有正在运行的任务。" };
}
注意这里只讲「命令怎么把取消信号发出去」。取消信号一路传下去、怎么穿透到正在并行干活的子代理、Agent 怎么知道「自己能不能停」------那是会话「自省」的话题,留给第 11 篇。这一篇只盯住路由本身 + /stop 这个 AbortController 即时取消的机制。
第 2 层 · exact(精确匹配)。 不那么火烧眉毛的命令,在状态机的 COMMAND 阶段内部按全字符串精确匹配。注册就是一行一条:
javascript
router.exact("/new", cmdNew); // 新建会话
router.exact("/status", cmdStatus); // 查看 Agent 状态(当前模型、运行时长、活跃会话数)
router.exact("/help", cmdHelp); // 帮助
router.exact("/compact", cmdCompact); // 手动触发上下文压缩
router.exact("/dream", cmdDream); // 触发记忆提取
第 3 层 · prefix(前缀匹配 + 最长前缀优先)。 带参数的命令走这层:
javascript
router.prefix("/model ", cmdModel); // /model gpt-4o (带参,切模型)
router.prefix("/model", cmdModel); // /model (无参,显示当前模型)
router.prefix("/history ", cmdHistory); // /history 10
router.prefix("/history", cmdHistory); // /history (无参,显示全部)
这里有个不起眼但很要命的设计:前缀按长度降序排,最长的先匹配 。/model (带空格)比 /model 长,所以先比它------这样 /model gpt-4o 才会稳稳命中带参的那条,而不会被无参的 /model 抢先匹配成「显示当前模型」。参数的切分也由 Router 包办:用户输入 /model gpt-4o,命中前缀 /model 之后,ctx.args 自动就是 "gpt-4o",处理器拿来即用,不用自己解析。
命令是「对引擎的直接调用」,不是「发给模型的消息」
exact 和 prefix 这两层命令,最后都汇到状态机 COMMAND 状态里的同一句分发逻辑,命中与否决定了走 shortcut 还是 dispatch:
javascript
// loop.ts ------ COMMAND 状态
private async _state_command(ctx: TurnCtx): Promise<string> {
const result = await this.commands.dispatch(cmdCtx);
if (result !== null) { // 命中命令
ctx.outbound = result;
return "shortcut"; // → DONE,跳过 LLM
}
return "dispatch"; // 不是命令 → BUILD,老老实实交给 LLM
}
这条 shortcut 短路,引出这套命令系统真正的设计内核------命令不是一条「发给 LLM 的消息」,而是一次「对 Agent 引擎的直接调用」。落到实处是两个硬结果:
- 不消耗 token。 命令走的是
Router → Handler → OutboundMessage这条路,全程不碰 LLM。/status进来,Agent 在_state_command里查一眼当前模型、运行时长、活跃会话数,直接吐一条文本回去,一个 token 都不花。 - 不写对话历史。 你发
/stop,JSONL 里不会留下一条「用户发了 /stop」。因为命令在COMMAND状态就被拦截、短路到DONE了,根本不经过BUILD→RUN→SAVE那条消息持久化路径------它不属于你和模型的对话,凭什么污染上下文。
那命令处理器要读引擎内部状态(当前模型、会话管理、工具表)怎么办?总不能把整个 AgentLoop 实例塞给它------那一个命令就能调私有方法把状态搅乱。catbuddy 的做法是给它一个受限的只读接口 CommandLoopAPI,奉行最小权限原则:该读的读、该改的改(比如 setModelPreset 切模型、cancelSession 停任务),但内部那些 _activeTasks 的 Map、状态机的私有方法,一概不暴露。命令处理器能用到什么,就只给它什么,多一点都不给。
顺带说一个「同一份逻辑、两种触发」的小巧思:/compact 这条手动命令,和状态机里 COMPACT 状态的自动压缩,底层调的是同一个 Consolidator.compactIdleSession()。区别只在触发方式------一个是历史超阈值时自动跑,一个是用户敲命令手动跑。逻辑不重复实现一遍,这正是分层带来的复用红利。
这篇讲了什么?
- 一层
while撑不住真实需求------取消要立刻响应、长上下文要先压缩、斜杠命令压根不该惊动 LLM,全堆进一个循环就成了一锅粥。catbuddy 把它拆成两层 :外层AgentLoop状态机管「请求生命周期」(RESTORE→COMPACT→COMMAND→BUILD→RUN→SAVE→RESPOND单向流转,每个状态只做一件事,加功能只插一个工位),内层AgentRunner管「LLM↔工具」反复拉扯(默认最多 50 轮,防死循环)。 COMMAND状态里挂着三层命令路由 :priority(/stop绕开会话锁、用AbortController直接把取消信号打到 LLM 调用上,毫秒级刹车)→ exact(/new、/status精确匹配)→ prefix(/model gpt-4o带参数,最长前缀优先)。- 命令是「对引擎的直接调用」,不是「发给模型的消息」------命中就短路到
DONE,跳过BUILD→RUN→SAVE,不花 token、不污染对话历史 ,还通过只读的CommandLoopAPI守住最小权限。
下一篇预告 :心脏跳起来了、命令也能即刻响应了------可这个内层循环每轮在拉扯的「工具」到底是怎么回事?第 04 篇拆工具的注册、调度与文件安全边界:工具怎么被注册和调度,以及最危险的文件读写,如何做到不越权、不翻车。