练习 26:loop——谁来触发下一轮

前面二十五章,每一轮都是你开的口。你打一行字,模型跑一轮,停下来等你。 它做不了任何需要的事:等一个文件出现、等一个任务跑完、隔十分钟再 看一眼状态——凡是要等的,要么把这一轮死死卡住,要么就得你亲自回来再问 一遍。

这一章给它一把闹钟。schedule_wakeup 让模型自己说:"多久之后再叫我, 叫醒我的时候对我说这句话。"到点了,进程自己开一轮,你不用在场。

Part 7 的头两章都在搭骨架,加的东西模型一个都看不见。这一章回到全书的 老路子:加一个工具。而且这个工具能成立,全靠前两章铺好的两样东西 ——ctx 把闹钟递到工具手里(练习 24 为了让打断穿透铺的),收件箱让到点 的那一拍能塞进正在跑的轮次(练习 25 为了插话建的)。它俩当初都不是为 这一章准备的。

敲进去

在练习 25 的代码上继续写。

先是闹钟本身。它只有一个定时器——一个会话同一时刻最多一个待命的唤醒, 再安排一次是替换,不是叠加

// maxLoopLifetime 是一个循环从第一次安排算起能活多久。到点就停,不再续。
//
// 这不是保守,是防漏:模型忘了取消、或者它安排的条件永远等不到,循环就
// 会一直空转下去烧钱。octo 里这个上限是 12 小时,每一种界面都用同一个
// 判断,本书缩短到半小时,方便你把它跑到头。
const maxLoopLifetime = 30 * time.Minute

// 唤醒间隔的下限。模型偶尔会写出"1 秒后叫我",那不是循环,那是自旋。
const minWakeupDelay = 5 * time.Second

type waker struct {
	mu    sync.Mutex
	timer *time.Timer
	start time.Time   // 第一次安排的时刻,跨 tick 保留,不随每一拍重置
	ticks chan string // 到点了,往事件循环送一条
}

// arm 安排下一次唤醒,替换掉还没到点的那个。repeat 为真是固定节奏
// (到点自己续上),为假是一次性(响一次就完,要接着来得模型自己再安排
// 一次——不安排,循环就结束了)。
func (w *waker) arm(delay time.Duration, prompt string, repeat bool) error {
	w.mu.Lock()
	defer w.mu.Unlock()
	if w.start.IsZero() {
		w.start = time.Now()
	}
	if w.loopExpired() {
		w.stopLocked()
		return fmt.Errorf("这个循环已经跑满 %s 的上限,停了,不再续;要接着跑请人来重新开一个", maxLoopLifetime)
	}
	if w.timer != nil {
		w.timer.Stop()
	}
	w.timer = time.AfterFunc(delay, func() {
		w.mu.Lock()
		w.timer = nil // 这一个定时器用掉了;start 不动,上限要跨 tick 累计
		w.mu.Unlock()
		if repeat {
			// 先续上再送,节奏就跟"被叫醒的那一轮跑多久"无关了。
			_ = w.arm(delay, prompt, repeat)
		}
		w.fire(prompt, repeat)
	})
	return nil
}

start 那一行注释值得多看一眼:定时器响完就丢掉,但开始时刻要跨 tick 留着。不留着,每一拍都把时钟归零,防漏的上限就永远到不了。

送这一拍的时候,两种模式的容错完全相反:

func (w *waker) fire(prompt string, repeat bool) {
	if repeat {
		select {
		case w.ticks <- prompt:
		default:
			// 上一拍还没被处理完,这一拍丢掉。固定节奏模式的定时器已经
			// 自己续上了,下一拍会再来——丢一拍不会把循环弄死。
		}
		return
	}
	// 一次性模式只响这一次,丢了就等于把循环悄悄杀掉。必须送到。
	w.ticks <- prompt
}

接下来是闹钟怎么递到工具手里。答案是现成的:

// ctxKeyWaker 把 waker 挂在这一轮的 ctx 上。工具拿得到 ctx(练习 24 把它
// 穿进了 tool 接口),于是它不用知道 repl 长什么样,也能安排唤醒。
type ctxKeyWaker struct{}

func withWaker(ctx context.Context, w *waker) context.Context {
	return context.WithValue(ctx, ctxKeyWaker{}, w)
}

func wakerFrom(ctx context.Context) *waker {
	w, _ := ctx.Value(ctxKeyWaker{}).(*waker)
	return w
}

到点那句话不能伪装成用户说的话:

// formatLoopTick 把到点的那句话包成一条环境提醒,而不是伪装成用户说的话。
// 两个作用:界面上不会凭空多出一句"用户"发言,模型也被明确告知这是它自己
// 安排的唤醒、该接着干活,而不是一段可看可不看的背景资料。标签沿用 octo
// 的写法。
func formatLoopTick(prompt string) string {
	return "<system-reminder>\n[定时唤醒] 你之前安排的唤醒到点了。把下面这件事当成用户刚刚说的话,接着做:\n\n" +
		prompt + "\n</system-reminder>"
}

然后是工具本身。声明里要把"怎么结束"讲清楚,因为结束方式是这个工具最容易 被用错的地方:

func (scheduleWakeupTool) definition() toolSpec {
	return toolSpec{
		Name: "schedule_wakeup",
		Description: "安排一次定时唤醒:到点后系统会自动开始新的一轮,并把你写的 prompt 交给你," +
			"就像用户刚刚说了这句话。用它来做需要等待的事——等一个文件出现、隔一会儿再检查一遍状态。" +
			"repeat=false 是只响一次,想继续就在被叫醒的那一轮里再调用一次本工具;" +
			"repeat=true 是固定节奏一直响,直到你用 cancel=true 停掉它。" +
			"不再调用本工具,循环就结束了——这是结束循环的正常方式。",
		// …… delay_seconds / prompt / reason / repeat / cancel 五个参数 ……
	}
}

func (scheduleWakeupTool) execute(ctx context.Context, args string) string {
	// …… 解析参数 ……
	w := wakerFrom(ctx)
	if w == nil {
		// 一次性跑完就退出的进程没人能被叫醒。明确报错,别假装安排上了
		// ——octo 在无头模式下同样是这么处理的。
		return "错误: 这个运行环境不会有下一轮,安排不了唤醒。"
	}
	if in.Cancel {
		w.cancel()
		return "已取消,不会再有定时唤醒了。"
	}
	if strings.TrimSpace(in.Prompt) == "" {
		return "错误: prompt 不能为空——叫醒你的时候要对你说什么?写清楚,那时候没人会替你补充。"
	}
	delay := time.Duration(in.DelaySeconds) * time.Second
	if delay < minWakeupDelay {
		delay = minWakeupDelay
	}
	if err := w.arm(delay, in.Prompt, in.Repeat); err != nil {
		return "错误: " + err.Error()
	}
	// …… 打印并返回 ……
}

注册的位置有讲究:

	// schedule_wakeup 同样排在 subAgent 之后——子 agent 的命只有一次调用,
	// 它没有"下一轮"可以被叫醒,给它这个工具只会让它安排一场永远不会来的
	// 唤醒。谁能被唤醒,谁才配拿到这把钥匙。
	toolList = append(toolList, scheduleWakeupTool{})

事件循环里加第五个 case。到点的时候如果这一轮还在跑,那一拍就当插话:

		case prompt := <-wake.ticks:
			// 轮次跑着的时候到点了:不另开一轮,当成插话塞进这一轮。
			// 收件箱是练习 25 建好的,这里一行都不用改它。
			box.enqueue(formatLoopTick(prompt), false)
			fmt.Fprintln(os.Stderr, "[定时唤醒到点,这一轮还没跑完,当插话塞进去]")

还要在 ctx 上挂闹钟,并且打断的时候把它一起停掉:

	ctx = withWaker(ctx, wake)
	// …… 信号那一档里 ……
			cancel()
			// 打断也是在说"别做了"。循环要是还留着,你按完 Ctrl+C,
			// 它过一会儿又自己醒过来接着干——那不叫打断。
			wake.cancel()

最后是空闲时的等待。这里要推翻上一章的一个决定

// waitIdle 在提示符上等一件事发生:你打了一行字、闹钟到点了,或者你按了
// Ctrl+C。到点了没人打字,这一轮就由闹钟来开——"谁来触发下一轮"这个问题
// 的答案,从这一章起不只有你一个。
//
// 上一章说过"空闲时把 Ctrl+C 还给操作系统",那时候这么定是对的:空闲就是
// 真的什么都不会发生,Ctrl+C 除了退出没有第二种意思。这一章前提变了——
// 闹钟一上,空闲的进程随时会自己动起来,而"让它别再自己动了"必须有一个
// 不用杀掉整个进程的办法。所以这一档也接管:有闹钟就停闹钟,没闹钟才退出。
func waitIdle(lines <-chan string, wake *waker) (line string, quit bool) {
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)
	defer signal.Stop(sig)
	for {
		select {
		case text, ok := <-lines:
			if !ok {
				fmt.Fprintln(os.Stderr)
				return "", true
			}
			return text, false

		case prompt := <-wake.ticks:
			fmt.Fprintln(os.Stderr, "\n[定时唤醒到点,自动开始新的一轮]")
			return formatLoopTick(prompt), false

		case <-sig:
			if wake.armed() {
				wake.cancel()
				fmt.Fprintln(os.Stderr, "\n[循环已停。进程还在,接着说]")
				fmt.Fprint(os.Stderr, "\n> ")
				continue
			}
			fmt.Fprintln(os.Stderr)
			return "", true
		}
	}
}

跑起来

cd exercises/ex26
go build -o ex26 .
./ex26

给它一件必须等的事:

> 工作目录里现在还没有 ready.txt 这个文件。请用 schedule_wakeup 安排每隔 6 秒检查一次它出现了没有(用 read_file 检查),不要用 sleep 把这一轮卡住。文件一旦出现,就把内容读给我,然后取消循环。

回车之后离开这个终端,去另一个窗口,过二十秒再创建那个文件:

echo "货到了" > ready.txt

你应该看到什么

冒烟测试:防漏的闸门先在代码层面立住

上限这条线不能靠真机等半小时来验。先写纯 Go 测试,把开始时刻直接改成 半小时前,看 arm 认不认:

w.start = time.Now().Add(-maxLoopLifetime - time.Minute)
if err := w.arm(time.Second, "接着跑", true); err == nil {
	t.Error("过期之后 arm 还是安排上了——防漏的闸门没关住")
}

一共四个测试:上限到了拒绝续命(并且把时钟清零,人重开一个循环时不会 一上来就被判过期)、再安排一次是替换(旧的不许再响)、取消之后不再响、 丢拍规则(固定节奏可以丢,一次性绝不能丢)。这些都跑在 go test -race 下——闹钟是这一章唯一被定时器 goroutine 和主循环同时碰的东西。

实验一:模型自己等一个还不存在的文件

左边是从进程启动算起的秒数:

  3.33  [round 1] schedule_wakeup({"delay_seconds": 6, "repeat": true, "prompt": "请用 read_file 检查……"})
  3.33  [已安排唤醒:6s 后,每隔这么久响一次(每 6 秒检查 ready.txt 是否出现)]
  4.54  已安排好:每 6 秒唤醒一次检查 `ready.txt`。这一轮先收工。
  4.54  [本轮 2 次请求 · finish_reason=stop]

  9.33  [定时唤醒到点,自动开始新的一轮]
 10.19  [round 1] read_file({"path": "ready.txt"})
 12.55  `ready.txt` 还没出现,已安排 6 秒后再查。

 17.85  [定时唤醒到点,自动开始新的一轮]
 18.74  [round 1] read_file({"path": "ready.txt"})
 21.19  `ready.txt` 还没出现,继续每 6 秒检查。

 22.03  ===== 外面把 ready.txt 造出来了 =====

 26.31  [定时唤醒到点,自动开始新的一轮]
 27.31  [round 1] read_file({"path": "ready.txt"})
 28.74  [round 2] schedule_wakeup({"cancel": true})
 28.74  [循环已取消]
 29.80  `ready.txt` 已出现,内容读给你了("货到了"),循环也已取消。

从 4.54 秒到 29.80 秒,没有一个字是人打的。三次唤醒各自开了一轮完整 的对话,第三次拿到了想要的东西,模型自己把循环关掉。

顺带看一个真实的模型行为:它在固定节奏模式下,每一拍还是重新安排了一次 (10 秒和 18 秒那两轮里都有一次 schedule_wakeup 调用,尽管 repeat=true 本来就会自己续)。这没造成任何问题,恰恰因为 arm 的语义是替换。要是当初 写成"叠加一个新定时器",跑三拍就有三个定时器在响,再跑几拍就是一场雪崩。 "一个会话只有一个待命的唤醒"这条规矩,防的就是模型这种冗余但无害的 习惯。

实验二:一次性模式——不续,循环就结束

让它用 repeat=false 做一个从 3 数到 1 的倒计时:

  4.70  [round 1] write_file({"path": "count.txt", "content": "3\n"})
  4.70  [round 1] schedule_wakeup({"delay_seconds": 6, "repeat": false, "prompt": "倒计时继续……"})

 10.70  [定时唤醒到点,自动开始新的一轮]
 13.68  [round 2] edit_file({"new_string": "3\n2", ...})
 13.68  [round 2] schedule_wakeup({"delay_seconds": 6, "repeat": false, "prompt": "倒计时最后一步……"})

 19.69  [定时唤醒到点,自动开始新的一轮]
 21.82  [round 2] edit_file({"new_string": "3\n2\n1", ...})
 22.72  结束

count.txt 里是 3 2 1 三行。22.72 秒之后进程一直待到 42 秒退出, 再没有醒过——它不是被谁关掉的,是模型没有再安排下一次。这就是一次性 模式的全部含义:循环的续命权在模型手里,什么都不做就是结束。

本机 Ollama(qwen3:4b-instruct)跑一个更简单的版本也过了:安排 6 秒后 一次性唤醒,收工;21.54 秒被叫醒,写完文件,不再安排,循环自然结束。

实验三:闹钟响的时候轮次还没跑完

先安排一个 8 秒的固定节奏,再立刻交给它一件要跑七八轮的活:

  1.90  [已安排唤醒:8s 后,每隔这么久响一次]
  7.21  [round 1] write_file(d1.txt)
  8.24  [round 2] write_file(d2.txt)
  9.43  [round 3] write_file(d3.txt)
  9.90  [定时唤醒到点,这一轮还没跑完,当插话塞进去]
 10.35  [round 4] write_file(d4.txt)
 10.35  [插话进入这一轮:1 条,模型这就看到]
 17.90  [定时唤醒到点,这一轮还没跑完,当插话塞进去]
 18.23  [round 5] write_file(d5.txt)
 18.23  [插话进入这一轮:1 条,模型这就看到]
 20.41  [本轮 7 次请求 · finish_reason=stop]
 25.90  [定时唤醒到点,自动开始新的一轮]

同一个闹钟,两种落法:轮次跑着的时候,那一拍走练习 25 的收件箱,变成 这一轮里的一条插话;轮次跑完之后,那一拍自己开一轮。这一章为此写的代码 是一个 case 加一行 box.enqueue——收件箱一个字都没改

再往后看还有一处,是练习 25 那段兜底自己接住的:

 51.50  [本轮 2 次请求 · finish_reason=stop]
 51.50  [插话来晚了:这一轮已经收工,把 1 条折成一次跟进的对话]

一拍正好卡在轮次收工的那一瞬间进来,没赶上被取用。上一章写那段兜底的 时候,想的是"用户打字打晚了";现在同一段代码接住的是闹钟。

实验四:停一个跑着的循环

固定节奏一旦转起来,就得有办法叫停。停在提示符上按 Ctrl+C:

  1.96  [已安排唤醒:30s 后,每隔这么久响一次]
  3.00  [本轮 2 次请求 · finish_reason=stop]
 18.01  ===== 此刻停在提示符上,离下一次唤醒还有十几秒,按 Ctrl+C =====
 18.01  [循环已停。进程还在,接着说]
 63.01  ===== 打断之后又过了 45 秒(本该响过一次了)=====
 63.01  > [会话 ID: ……]

按下去的那一刻循环就停了,进程还活着,会话还在,tick.txt 从此再没被 写过。要是打断落在一个正在跑的轮次上,走的是信号那一档,结果一样——那里 也调了 wake.cancel()

发生了什么

"谁来触发下一轮"这个问题,答案从一个变成了三个。 你打字、闹钟到点、 以及练习 25 那些排队的消息。三条路最后都汇进同一个入口:给 runTurn 一 句话,让它跑一轮。正因为汇进同一个入口,这一章不需要发明"自动模式"这种 东西——被闹钟叫醒的一轮,和你亲手敲出来的一轮,是同一种轮次。

这一章仍然是一个 tool 设计决定。 从练习 5 到练习 23,每一章的落点都 是"这是一个 tool 设计决定",Part 7 头两章是例外(那是运行环境的形态在变), 从这里开始又回到主线。让模型能等,本质上不需要新机制——它需要的只是一个 能表达"过一会儿再叫我"的工具,外加一个还醒着的进程去兑现这句话。

几个设计决定值得单独记住:

  • 替换而不是叠加。 一个会话只有一个待命的唤醒。实验一里模型冗余地 重复安排,正是这条规矩在替它兜底。
  • 上限跨 tick 累计。 定时器响完就丢,但开始时刻留着。不留着,防漏的 上限就永远到不了——那正是"忘了关的循环"最容易发生的形态。
  • 两种模式的容错方向相反。 固定节奏丢一拍无所谓(下一拍会补),一次性 丢一拍就等于把循环杀了。同一个 fire 函数里两条分支,理由完全不同。
  • 到点那句话是环境提醒,不是用户发言。 包成 <system-reminder> 有两 个好处:界面上不会凭空多一句"用户"说的话,模型也被明确告知这是它自己 安排的唤醒、该接着干活。
  • 子 agent 拿不到这个工具。 它的命只有一次调用,没有"下一轮"可以被 叫醒。这条判断和练习 19 的防递归、练习 21 的不许套娃是同一类:能力 发给谁,取决于谁有那个前提。

一个被推翻的决定。 上一章我写过"空闲时把 Ctrl+C 还给操作系统,因为 一个不听 Ctrl+C 的命令行程序很讨人厌"。那句话在上一章是对的,在这一章 失效了——前提变了:空闲不再意味着什么都不会发生。一个装着闹钟的空闲 进程随时会自己动起来,而"让它别再自己动了"不该以杀掉整个进程为代价。 所以这一档接管了:有闹钟就停闹钟,没闹钟才退出。设计决定会随前提失效, 这不是当初写错了。

常见问题

Q:模型会不会安排一个永远不停的循环? 会,而且它想不起来关的时候比你以为的多。三道闸拦着:间隔有下限(5 秒), 总时长有上限(半小时,到点不再续),你随时可以 Ctrl+C。真实产品里第二道 是 12 小时——够长到不打扰正常使用,够短到一个被忘掉的循环不会烧一整夜。

Q:为什么不让模型直接调用 sleep sleep 卡住的是这一轮:那段时间里进程什么都干不了,你插不了话、它也没法 被别的东西唤醒;上下文还一直占着。安排唤醒是把这一轮结束掉,让出所有 资源,到点再开一轮新的。两者的区别不是写法,是这段等待期间这个进程还能 不能干别的。

Q:被唤醒的那一轮,之前的对话还在吗? 在。sess.History 从头到尾就一份,被叫醒的一轮接在同一份历史后面。实验 一里模型第三次醒来知道自己在等什么,靠的就是这个。

Q:闹钟响的时候我正在打字怎么办? 那一拍会当插话塞进正在跑的轮次;要是那会儿没有轮次在跑,它自己开一轮。 你打到一半的那行字不受影响——它还在你的终端里,敲回车才会发出去。

Q:进程退出之后循环还在吗? 不在。闹钟活在进程的内存里,/exit 或者关掉终端,它就没了。要跨进程、 跨重启的定时任务,那是系统级的排程(cron 之类)该管的事,不是这个工具。 octo 的文档里也把这条界线划得很清楚。

加分练习

  1. 让循环能被人看见。 加一条 /loop 命令,打印当前有没有待命的唤醒、 间隔多久、下一次什么时候响、这个循环已经跑了多久。你会发现 waker 现在把这些信息都藏在私有字段里——一个用户看不见状态的后台行为,出问题 时没人能诊断。

  2. 把上限用满的那一刻讲清楚。 现在跑满半小时,arm 返回一个错误字符 串给模型,人这边什么都看不到。改成同时给人一条提示。再想一层:这条 消息该不该也进历史让模型看到?两种做法各有道理,写下你的选择和理由。

  3. 给唤醒加抖动。 固定节奏遇上一个每次都失败的检查,就是在按固定频率 撞同一堵墙。改成每次续命时把间隔乘上一个系数(比如 1.5 倍,封顶几分 钟),让它越等越久。这在真实系统里叫退避,是所有轮询的标配。

  4. 让唤醒能跨重启活下来。 把待命的唤醒写进会话文件,-c 恢复会话时 把它重新装上。先想清楚一件事:进程关了两小时再恢复,那个"两小时前就 该响"的唤醒,应该立刻补一次,还是当作过期扔掉?没有标准答案,但你的 代码必须替它做个决定。