DOCS v0.1.0 BUILD: STABLE GitHub ↗
章节目录
S04

Interrupt

AbortSignal + steering 消息注入 + max-turns 熔断。

前置概念 AbortSignal / AbortController两种中断模式getSteeringMessages 钩子max-turns 熔断两层 abort 检测

s04: 中断与 Steering — 让循环可以被外部打断

循环转起来之后,你还得能叫停它、能给它换方向。

s01s02s03s04s05


问题

s01 的循环一旦开始,外界唯一能叫停它的方式是 kill 进程。但真实使用中有两种需求它满足不了:

  1. 跑偏了想立刻停:模型在错误的目录里 rm 了一半,你想立刻终止,而不是等它把十轮工具调用跑完。
  2. 想中途改方向:模型正在重命名一批文件,你突然想起”别动 test 目录”。你不想停掉重来,只想塞一条新指令进去,让它下一轮就看到。

第一种叫 abort(终止),第二种叫 steering(转向)。一个硬停,一个软改。生产级 agent 两个都要。

另外,循环本身需要一个兜底:万一模型陷入死循环(工具调用一轮接一轮永不停止),harness 得有个熔断器。这就是 max-turns


解决方案

三种控制,三种机制,全部叠在 s01 那个 while 循环上:

控制机制循环动作
abortAbortSignal 传进 fetchRequestInitHTTP 层抛 AbortError → 捕获 → 退出
steeringgetSteeringMessages 回调,每轮开始前调一次返回的消息作为 user 消息注入 → 模型下一轮看到
max-turns轮数计数器超过上限 → 熔断退出
INTERRUPT & STEERING
while (true)
1
查 signal.aborted
if (signal.aborted) return
Abort
2
steering() 注入
for (m of await steering()) messages.push(m)
3
call LLM
await callLlm(messages, signal)
4
执行工具
handlers[name](input)
5
再查 signal.aborted
if (signal.aborted) return
6
turns++ / 熔断
if (++turns > maxTurns) break
↑ 回到顶部 · loop
当前模式
Abort
signal.aborted 为 true → 硬停退出(fetch 抛 AbortError 或轮询标志)
触发点
节点 1查 signal.aborted
运行状态
turn1/6
firedfalse
[aborted] 硬停退出

关键认知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

试这三个场景,观察三种控制各自怎么触发:

  1. abort:问一个需要多轮的任务(如”递归列出当前目录所有文件并统计行数”),运行中按 Ctrl+C,应立刻看到 [aborted] 并回到提示符。
  2. max-turns:把 CONFIG.maxTurns 临时改成 3,再问一个会无限调用工具的任务(如”不停地列出文件”),应看到 [max-turns] 已达 3 轮上限,熔断退出
  3. steering:把 CONFIG.steerAfterMs 改成 5000,问一个慢任务,5 秒后应看到紫色的 [steering] 时间预算到了...,模型随后开始收尾。

前置概念清单

本章引入五个新概念:

  1. AbortSignal / AbortController:Web 标准的协作式取消协议。controller 调 abort(),对应 signal 的 aborted 变 true;传给 fetch 会让请求立刻失败。不是 pi 发明。
  2. 两种中断模式:abort = 硬停(终止一切);steering = 软改(注入新指令,循环继续)。一个破坏性,一个非破坏性。
  3. getSteeringMessages 钩子:每轮开始前调用的回调,返回要注入的 user 消息。pi 用它排干”用户在 agent 工作时敲下的输入”。
  4. max-turns 熔断:最简单的 harness 策略。循环形状不变,只加一条”超过 N 轮就停”的约束。是所有更复杂策略(预算、token 上限)的雏形。
  5. 两层 abort 检测:fetch 等待响应时靠抛 AbortError;其余时刻靠轮询 signal.aborted。两者互补,缺一不可。

源码锚点

mini-pi 的单层循环,在pi 里是 runLooppackages/agent/src/agent-loop.ts 第 155-269 行。配套类型在 packages/agent/src/types.ts(搜 getSteeringMessagesgetFollowUpMessages)。读完回答三个问题:

  1. 第 170 行和第 174 行各有一个 while两层循环各自为什么存在?外层(170)管什么,内层(174)管什么?(提示:followUpMessages vs steeringMessages + 工具调用)mini-pi 只有单层,省掉的是哪一种需求?
  2. 第 167 行在循环开始前就调了一次 getSteeringMessages为什么不等第一轮跑完再调?(提示:用户在 agent 启动到第一次调 LLM 之间,可能已经敲了字)
  3. 第 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/abortedmini-pi 用原生 fetch,abort 直接抛 AbortError,try/catch 更直观
signal 贯穿工具工具执行也收 signal,长任务可被中断本章 bash 用 spawnSync 同步阻塞,signal 在执行中无法响应;s04 自身换异步工具后才有意义(s02 仍是 spawnSync 同步,signal 无法贯穿同步进程)
并发输入TUI 输入框始终可打字,实时入 steering 队列单行 REPL 阻塞,用时间预算示例等价演示”运行中注入”
shouldStopAfterTurn 钩子每轮后调回调决定是否停用硬编码 maxTurns 表达同一个思想,钩子是它的泛化
prepareNextTurn每轮后可换 model / 改 context本章无动态切换需求
启动前 drain steeringpi 在循环开始前(agent-loop.ts 第 167 行)先调一次 getSteeringMessages教学版只在每轮开头 drain,会漏掉”agent 启动到首次调 LLM 之间”用户敲的字

这张表往后会逐行划掉:s05 的 session 让 follow-up 有意义,s06 的 compaction 让 prepareNextTurn 有意义。


默写验收

合上 code.ts 和本 README,打开 practice.ts,凭记忆补全四个函数体:callLlm(带 signal)、runBashagentLoop(带 signal + steering + maxTurns)、main(带 AbortController + SIGINT)。

通过标准:node practice.ts 跑起来,能完成一轮工具调用任务;运行中按 Ctrl+C 能看到 [aborted] 并回到提示符;把 maxTurns 调成 2 能看到熔断。

写不出 agentLoop 说明三层控制的插入位置没进脑子,回到「工作原理」第 2-4 块重读,重点记”先查 abort → 再 steering → 再 LLM → 工具 → 再查 abort → 计数熔断”这个顺序。


下一章:s05 session/jsonl — append-only 持久化与 resume