s04: 中断与 Steering — 让循环可以被外部打断
循环转起来之后,你还得能叫停它、能给它换方向。
s01 → s02 → s03 → s04 → s05
问题
s01 的循环一旦开始,外界唯一能叫停它的方式是 kill 进程。但真实使用中有两种需求它满足不了:
- 跑偏了想立刻停:模型在错误的目录里
rm了一半,你想立刻终止,而不是等它把十轮工具调用跑完。 - 想中途改方向:模型正在重命名一批文件,你突然想起”别动 test 目录”。你不想停掉重来,只想塞一条新指令进去,让它下一轮就看到。
第一种叫 abort(终止),第二种叫 steering(转向)。一个硬停,一个软改。生产级 agent 两个都要。
另外,循环本身需要一个兜底:万一模型陷入死循环(工具调用一轮接一轮永不停止),harness 得有个熔断器。这就是 max-turns。
解决方案
三种控制,三种机制,全部叠在 s01 那个 while 循环上:
| 控制 | 机制 | 循环动作 |
|---|---|---|
| abort | AbortSignal 传进 fetch 的 RequestInit | HTTP 层抛 AbortError → 捕获 → 退出 |
| steering | getSteeringMessages 回调,每轮开始前调一次 | 返回的消息作为 user 消息注入 → 模型下一轮看到 |
| max-turns | 轮数计数器 | 超过上限 → 熔断退出 |
if (signal.aborted) returnfor (m of await steering()) messages.push(m)await callLlm(messages, signal)handlers[name](input)if (signal.aborted) returnif (++turns > maxTurns) break关键认知:AbortSignal 是 Web 标准,不是 pi 发明的。fetch(url, { signal }) 是浏览器和 Node 都内置的协议——signal 一旦 abort,正在进行的 fetch 立刻抛 AbortError。agent loop 只是把同一个 signal 顺手用作”全局停止位”。
工作原理
打开 code.ts,在 s01 基础上多了四块。
第 1 块:signal 贯穿到 fetch。 callLlm 多收一个 signal 参数,原样塞进 RequestInit:
const response = await fetch(`${CONFIG.baseUrl}/v1/messages`, {
// ...headers, body...
signal,
});
仅此一行,HTTP 层就接入了 abort。signal abort 时 fetch 抛 AbortError,调用方捕获即可。
第 2 块:循环里查 signal.aborted。 fetch 之外的窗口(工具执行后、steering 前)也得查。每轮开头先查一次,工具跑完再查一次:
while (true) {
if (signal.aborted) { console.log("\n[aborted]"); return; }
for (const msg of await steering()) { messages.push(msg); }
// ...callLlm(try/catch 捕获 AbortError)...
// ...执行工具...
if (signal.aborted) { return; }
}
两层检测互补:fetch 在等响应时被 abort → 靠抛错;其余时刻 → 靠轮询 aborted 标志。
第 3 块:steering 注入。 一个 () => Promise<ChatMessage[]> 回调,每轮开始前调一次。返回非空就作为 user 消息追加进历史,模型下一轮就看到新指令。本章用”时间预算”做示例:超过 steerAfterMs 就注入”立即收尾”。换成”用户在输入框敲了一句话”就是 pi TUI 的真实用法。
第 4 块:max-turns 熔断。 一个计数器 turns,每执行一轮工具 +1,到 CONFIG.maxTurns 就停。这是最简单的 harness 策略——循环的形状不变,行为多了一条约束。pi 用 shouldStopAfterTurn 钩子做更细的策略,本质一样。
第 5 块:AbortController 在 main 里。 每次提问 new AbortController()(注意:一个 controller 一旦 abort 就永久 aborted,所以每轮提问要新建)。rl.on("SIGINT", () => current.abort()) 把 Ctrl+C 接到 abort 上。
运行
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_API_KEY=sk-deepseek-...
export MODEL_ID=deepseek-chat # 可选,有默认值
node code.ts
试这三个场景,观察三种控制各自怎么触发:
- abort:问一个需要多轮的任务(如”递归列出当前目录所有文件并统计行数”),运行中按
Ctrl+C,应立刻看到[aborted]并回到提示符。 - max-turns:把
CONFIG.maxTurns临时改成3,再问一个会无限调用工具的任务(如”不停地列出文件”),应看到[max-turns] 已达 3 轮上限,熔断退出。 - steering:把
CONFIG.steerAfterMs改成5000,问一个慢任务,5 秒后应看到紫色的[steering] 时间预算到了...,模型随后开始收尾。
前置概念清单
本章引入五个新概念:
- AbortSignal / AbortController:Web 标准的协作式取消协议。controller 调
abort(),对应 signal 的aborted变 true;传给fetch会让请求立刻失败。不是 pi 发明。 - 两种中断模式:abort = 硬停(终止一切);steering = 软改(注入新指令,循环继续)。一个破坏性,一个非破坏性。
- getSteeringMessages 钩子:每轮开始前调用的回调,返回要注入的 user 消息。pi 用它排干”用户在 agent 工作时敲下的输入”。
- max-turns 熔断:最简单的 harness 策略。循环形状不变,只加一条”超过 N 轮就停”的约束。是所有更复杂策略(预算、token 上限)的雏形。
- 两层 abort 检测:fetch 等待响应时靠抛
AbortError;其余时刻靠轮询signal.aborted。两者互补,缺一不可。
源码锚点
mini-pi 的单层循环,在pi 里是 runLoop:packages/agent/src/agent-loop.ts 第 155-269 行。配套类型在 packages/agent/src/types.ts(搜 getSteeringMessages、getFollowUpMessages)。读完回答三个问题:
- 第 170 行和第 174 行各有一个
while,两层循环各自为什么存在?外层(170)管什么,内层(174)管什么?(提示:followUpMessagesvssteeringMessages+ 工具调用)mini-pi 只有单层,省掉的是哪一种需求? - 第 167 行在循环开始前就调了一次
getSteeringMessages,为什么不等第一轮跑完再调?(提示:用户在 agent 启动到第一次调 LLM 之间,可能已经敲了字) - 第 196 行:
stopReason === "error" || "aborted"时 pi 直接return。mini-pi 用try/catch捕获AbortError。两种做法各有什么代价?(提示:pi 的 streamFn 契约规定”不许抛错,错误编码进流”,mini-pi 的 fetch 直接抛)
动手任务(改 mini-pi):把 steering 从”时间预算”改成”队列排干”——维护一个 steeringQueue: ChatMessage[],steering 函数返回 queue.splice(0)。再想办法在循环运行中往队列里塞消息(提示:readline.emitKeypressEvents + 原始模式,或开第二个输入流)。这就是 pi TUI 的真实形态。
妥协清单
mini-pi 比pi 少做了什么,以及为什么省略是安全:
| 省略项 | pi 的做法 | 为什么本章可以省 |
|---|---|---|
| 双层循环 | 外层 follow-up + 内层 steering/工具 | 本章一问一答,没有”agent 停了又被新消息唤起”的场景,单层够用 |
| 错误编码进流 | streamFn 不抛错,错误作为 stopReason: error/aborted | mini-pi 用原生 fetch,abort 直接抛 AbortError,try/catch 更直观 |
| signal 贯穿工具 | 工具执行也收 signal,长任务可被中断 | 本章 bash 用 spawnSync 同步阻塞,signal 在执行中无法响应;s04 自身换异步工具后才有意义(s02 仍是 spawnSync 同步,signal 无法贯穿同步进程) |
| 并发输入 | TUI 输入框始终可打字,实时入 steering 队列 | 单行 REPL 阻塞,用时间预算示例等价演示”运行中注入” |
| shouldStopAfterTurn 钩子 | 每轮后调回调决定是否停 | 用硬编码 maxTurns 表达同一个思想,钩子是它的泛化 |
| prepareNextTurn | 每轮后可换 model / 改 context | 本章无动态切换需求 |
| 启动前 drain steering | pi 在循环开始前(agent-loop.ts 第 167 行)先调一次 getSteeringMessages | 教学版只在每轮开头 drain,会漏掉”agent 启动到首次调 LLM 之间”用户敲的字 |
这张表往后会逐行划掉:s05 的 session 让 follow-up 有意义,s06 的 compaction 让 prepareNextTurn 有意义。
默写验收
合上 code.ts 和本 README,打开 practice.ts,凭记忆补全四个函数体:callLlm(带 signal)、runBash、agentLoop(带 signal + steering + maxTurns)、main(带 AbortController + SIGINT)。
通过标准:node practice.ts 跑起来,能完成一轮工具调用任务;运行中按 Ctrl+C 能看到 [aborted] 并回到提示符;把 maxTurns 调成 2 能看到熔断。
写不出 agentLoop 说明三层控制的插入位置没进脑子,回到「工作原理」第 2-4 块重读,重点记”先查 abort → 再 steering → 再 LLM → 工具 → 再查 abort → 计数熔断”这个顺序。