练习 28:bash 重构——后台任务
到上一章为止,bash 是同步的:命令跑完(或者超时被杀),这一轮才能往下 走。跑一个测试套件、起一个开发服务器,都会把整个 agent 卡死在那儿干等 ——上一章的 goal 能让它连轴转好几轮,可只要有一条命令要五分钟,五分钟 里它什么都做不了。
这一章把 bash 拆成两条路:同步照旧,后台新开。run_in_background
参数一给,命令立刻放到后台去跑,轮次不等它。后台任务有两种性格:
一次性的(测试、构建——跑完就完,完成了系统自动通知)和常驻的
(服务、REPL——一直活着,模型可以看它的输出、往它的 stdin 喂东西)。
为这两种性格,这一章还带来两个新工具 terminal_output /
terminal_input,和一个专治模型轮询强迫症的设计。
还有两张旧字据要在这一章兑现。练习 23 写沙箱的时候立过一句:"以后
任何新的执行路径都必须从 shellCommand 这扇门走。"练习 27 的备注里
说零进度刹车"防的是空转"。这一章你会看到,这两句话说的是同一族问题。
敲进去
在练习 27 的代码上继续写。
先给 bash 的声明加一个参数。注意它是枚举,不是布尔:
"run_in_background": map[string]any{
"type": "string",
"enum": []string{"async", "interactive"},
"description": "可选。\"async\" = 一次性任务放后台,完成自动通知,不许轮询;" +
"\"interactive\" = 常驻服务/REPL 放后台,可看输出可喂输入。不给 = 同步执行。",
},
"要不要放后台"本来是个是非题,为什么不用布尔?因为真正的问题不是 "放不放",是放到后台之后这个进程归谁管。一次性任务归系统管——跑完 推送通知,中途不许打扰;常驻服务归模型管——什么时候看、喂什么进去, 它自己决定。两种管法接下来的代码处处不同,从参数这一层就分开,比事后 猜"模型把测试放后台是想轮询还是想等通知"可靠得多。
execute 里开岔路。注意它开在哪一层的后面:
// 后台的岔路开在这里——注册表层的权限门禁这时已经过完了(练习 9 的门
// 装在分发那一层,跟命令最终走哪条执行路径无关),所以后台命令和同步
// 命令过的是同一道门:deny 照样拦,ask 照样问。
if in.RunBg != "" {
mode := bgMode(in.RunBg)
if mode != bgAsync && mode != bgInteractive {
return fmt.Sprintf("错误: run_in_background 只能是 \"async\" 或 \"interactive\"(收到 %q)。"+
"一次性任务用 async,常驻服务/REPL 用 interactive,要同步执行就别传这个参数", in.RunBg)
}
if mgr := bgFrom(ctx); mgr != nil {
id, err := mgr.start(in.Command, mode)
// ……返回 id 和使用提示,从略……
}
// 走到这里说明在子 agent 里(runChildLoop 抹掉了宿主):不报错,
// 塌回同步执行——octo 的选择也是这样。子 agent 没有"以后再收
// 结果"的以后,但任务本身还是要完成的,降级比拒绝有用。
}
然后是后台进程本身。它的启动函数里藏着这一章最重要的两个决定:
// start 把命令放到后台跑,立刻返回 id。
//
// 命令走的还是 shellCommand——练习 23 立过字据:"以后任何新的执行路径
// 都必须从这扇门走。"这一章就是那句话说的"以后":后台进程自动继承沙箱,
// 一行沙箱代码都不用碰。
//
// ctx 刻意用 context.Background() 另起:后台进程的命不能拴在这一轮的
// ctx 上。练习 24 花了整章让打断穿透到每个工具,这里是那条规则的第一个
// 例外——你按 Ctrl+C 是说"这一轮别做了",不是"把我特意放到后台的服务
// 也杀掉"。要杀后台进程,得有一个明确说这件事的动作(本书里是退出 REPL
// 时收编所有后台进程;octo 还有单杀的 kill_shell 工具)。
func (m *bgManager) start(command string, mode bgMode) (string, error) {
ctx, cancel := context.WithCancel(context.Background())
cmd := shellCommand(ctx, command)
cmd.Dir = workDir
// 后台进程自成一个进程组。不这么做,杀的时候只能杀到最外层的
// sh -c 包装,它 fork 出来的活儿(sleep、服务进程)会变成孤儿
// 接着跑——"杀掉了"就成了假话。octo 把这条写成硬规矩:"永远
// 杀整个进程组,绝不只杀直接子进程。"
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pr, pw := io.Pipe()
cmd.Stdout, cmd.Stderr = pw, pw
stdinR, stdinW, err := os.Pipe()
// ……启动与登记,从略……
启动之后是两个 goroutine,它们之间有一个不能错的顺序:
// 读者:把合并的输出一行行搬进缓冲。
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(pr)
scanner.Buffer(make([]byte, 64*1024), maxBgOutputBytes)
for scanner.Scan() {
p.append(append(scanner.Bytes(), '\n'))
}
}()
// 收尾的:等进程退出,关掉管道让读者看到 EOF,再等读者把管道排干,
// 然后才能发完成通知——顺序错了,跑得快的进程会把尾巴输出弄丢:
// 通知发出去的时候读者还没搬完。
go func() {
err := cmd.Wait()
pw.Close()
stdinW.Close()
<-readerDone
p.finish(err)
m.done <- formatBgNote(p)
}()
输出存在一个有上限的缓冲里,两种读法对应两种用途:
// readNew 返回上次取走之后的新输出并推进游标——完成通知用它,保证通知里
// 不重复报模型已经看过的内容。
func (p *bgProc) readNew() (string, string) { /* ……见仓库…… */ }
// tailLines 返回最近 n 行的快照(n <= 0 = 全部保留的),不动游标——
// 重复调用看到同一个视图,这本身就取消了"多读几次能多看到点什么"的
// 轮询动机。防轮询计数在这里:还在跑 + 快照是空的,才算一次空轮询。
func (p *bgProc) tailLines(n int) (output, status string, blocked bool) {
// ……取尾部若干行,从略……
if !p.done && out == "" {
now := time.Now()
if p.emptyPollCount == 0 || now.Sub(p.firstEmptyPoll) > pollWindow {
p.firstEmptyPoll = now // 开一个新窗口
p.emptyPollCount = 1
} else {
p.emptyPollCount++
if p.emptyPollCount >= maxEmptyPolls {
blocked = true
}
}
} else {
p.emptyPollCount = 0
p.firstEmptyPoll = time.Time{}
}
return out, p.statusLocked(), blocked
}
防轮询的参数是一个窗口,不是一个简单的计数:
// 防轮询窗口:30 秒内对一个还在跑的进程空读 3 次,就判定为轮询,硬停。
// 窗口而不是简单计数,是给常驻服务留的活口——隔几分钟看一眼日志是正常
// 检查,30 秒内连问三次"好了没"才是强迫症。数值照抄 octo。
const pollWindow = 30 * time.Second
const maxEmptyPolls = 3
完成通知的包装和去处,你已经很熟了:
// formatBgNote 把一次后台完成包成环境提醒,跟练习 26 到点那句话同一个
// 包装:模型该把它当事件处理,界面上也不该长出一句假的用户发言。
func formatBgNote(p *bgProc) string {
out, status := p.readNew()
var b strings.Builder
b.WriteString("<system-reminder>\n[后台任务完成]\n")
fmt.Fprintf(&b, "后台进程 %s(`%s`)%s。", p.id, p.command, status)
// ……新输出,从略……
// 跑得太快的 async 任务,教育一句。教育放在完成通知里而不是文档里,
// 因为这一刻模型手上就攥着证据:它刚为一条几秒钟的命令多花了一轮。
if p.mode == bgAsync {
if d := time.Since(p.start).Round(100 * time.Millisecond); d < shortAsyncDuration {
fmt.Fprintf(&b, "\n\n[注意:这个任务 %s 就跑完了——这么快的命令根本不需要放后台。"+
"同步调用(不传 run_in_background)会在同一轮直接返回同样的输出,不用记 id,"+
"也不用多花一轮等这条通知。只把确有把握会跑很久的命令放后台。]", d)
}
}
b.WriteString("\n</system-reminder>")
return b.String()
}
通知去哪?m.done 是一个 channel,主循环的两个 select 各加一个 case
——跟练习 26 闹钟到点的两条路一模一样。空闲时,通知本身就是下一轮的
输入:
case note := <-theBg.done:
// 空闲时后台任务跑完了:跟闹钟到点一个待遇,通知本身就是
// 下一轮的输入,模型自己决定拿结果做什么。
fmt.Fprintln(os.Stderr, "\n[后台任务完成,自动开始新的一轮]")
return note, false
轮次跑着时,折成插话:
case note := <-theBg.done:
// 后台任务在轮次中间跑完了:同一个待遇,折成插话。这是这个
// select 的第四种事件来源,主循环的形状还是没变。
box.enqueue(note, false)
fmt.Fprintln(os.Stderr, "[后台任务完成,这一轮还没跑完,当插话塞进去]")
两个新工具里,terminal_output 是快照 + 防轮询的出口:
out, status, blocked := p.tailLines(lines)
header := "[状态: " + status + "]"
if out == "" {
msg := header + "\n(还没有输出)"
if blocked {
// 防轮询的硬停:不是建议,是直接把话挑明。轮询烧的是真金白银
// ——每一次空查都是一整次带全部上下文的请求。
msg += "\n\n[停:30 秒内第三次空查了。不要再查这个进程," +
"有新输出之前查多少次都是空的。先做别的事,或者结束这一轮。]"
}
return msg
}
terminal_input 往进程的 stdin 原样写字节;async 任务两个工具都不伺候
——不许看(等通知)也不许喂(一次性任务要什么输入)。收尾两件小事:
退出 REPL 时 theBg.killAll() 收编所有后台进程,不留孤儿;子 agent
进门前 ctx = withBg(ctx, nil) 把宿主抹掉——bash 是同一个工具,
run_in_background 参数它看得见,但完成通知回不去一个只活一次调用的
循环,所以后台请求在子 agent 里静默塌回同步执行。
完整代码见仓库 exercises/ex28/。
跑起来
cd exercises/ex28
export OPENAI_API_KEY=sk-xxxx
export MODEL=deepseek-v4-flash
export OPENAI_BASE_URL=https://api.deepseek.com/v1
go run .
先准备一个慢脚本当陪练:
printf 'sleep 20\necho 编译完成\n' > slow.sh
然后跟模型说:
用 bash 把 sh slow.sh 以 async 模式放到后台跑(这个脚本大概要 20 秒),
然后在 notes.txt 里写一行「后台任务等待中」,这一轮就做到这里。
你应该看到什么
实验一:放到后台,这一轮不等它
DeepSeek(转写有排版精简):
> 用 bash 把 sh slow.sh 以 async 模式放到后台跑(这个脚本大概要 20 秒),
然后在 notes.txt 里写一行「后台任务等待中」,这一轮就做到这里。
[round 1] bash({"command": "sh slow.sh", "run_in_background": "async"})
⚠️ 模型想执行: sh slow.sh
允许吗?(y/N) y
[round 2] write_file({"path": "notes.txt", "content": "后台任务等待中\n"})
完成了:sh slow.sh 已放入后台(id=bg_1,async 模式),预计 20 秒跑完,
完成后系统会自动通知,这一轮我不去轮询它。notes.txt 已写入。
>
[后台任务完成,自动开始新的一轮]
[round 2] write_file({"content": "后台任务已完成\n", "path": "notes.txt"})
后台任务 bg_1(sh slow.sh)已跑完,退出码 0,输出:编译完成
notes.txt 也已更新为「后台任务已完成」,和实际状态保持一致。
三件事同时成立了:20 秒的命令没有卡住轮次(同一轮里模型接着写了 notes.txt);轮次收工后进程安静回到提示符;20 秒后完成通知自动开了 新一轮,模型拿着结果继续干活——没人碰过键盘。注意批准那一问:后台 命令过的还是练习 9 那道门。
实验二:跑得太快的,会被教育
把一条秒完的命令硬塞进后台:
> 用 bash 以 async 模式在后台跑 echo hi——我知道它很快,就是想看看后台模式怎么工作。
[round 1] bash({"command": "echo hi", "run_in_background": "async"})
[后台任务完成,这一轮还没跑完,当插话塞进去]
已经用 async 模式把 echo hi 放到后台了……等系统完成通知到了我再告诉你结果。
[插话来晚了:这一轮已经收工,把 1 条折成一次跟进的对话]
后台任务 bg_1 跑完了,退出码 0,输出就是 hi。
顺便说一句系统提醒里那条建议很实在:echo hi 这种瞬间完成的命令,其实
直接同步调用更合适——同一轮就能拿到输出,不用记 id、不用多等一轮通知。
这条转写值得多看两眼。命令快到什么程度?模型宣布"放入后台"的那句话 还没说完,完成通知就到了——走的是插话通道("这一轮还没跑完,当插话 塞进去");而模型这一轮收工太快,插话没赶上取用点,又被练习 25 的 "来晚了"兜底接住,折成一次跟进对话。两条三章前建好的管道,接住了一个 今天才出现的事件。最后,通知里那句"这么快的命令根本不需要放后台"的 教育,模型原样消化并对用户转述了。
本机 Ollama(qwen3:4b-instruct)跑同样的实验,链路一致,转述得还更 一板一眼:"除非你明确知道某命令会持续很长时间,否则不应将轻量命令 放后台。"
实验三:常驻的服务,可看可喂
陪练是一个回声服务:
printf 'while read line; do echo "回声: $line"; done\n' > echoloop.sh
> 用 bash 以 interactive 模式在后台跑 sh echoloop.sh(一个回声服务),
然后用 terminal_input 给它发一句「你好后台」(记得带换行符),
再用 terminal_output 看它的回声,确认正常后汇报。
[round 2] bash({"command": "sh echoloop.sh", "run_in_background": "interactive"})
⚠️ 模型想执行: sh echoloop.sh
允许吗?(y/N) y
[round 3] terminal_input({"id": "bg_1", "input": "你好后台\n"})
[round 4] terminal_output({"id": "bg_1"})
回声确认正常 ✅
- 后台进程 bg_1(sh echoloop.sh)已以 interactive 模式启动,状态 running
- 通过 terminal_input 发送了「你好后台」(带换行)
- terminal_output 返回 `回声: 你好后台`,和输入一致
整个链路通了:stdin 写入 → 脚本 read 循环 → stdout 回声。
一个常驻进程,模型隔着两个工具跟它对话。后来我们让它把服务收掉,它 干了一件没人教过的事:
[round 1] terminal_input({"id": "bg_1", "input": "\u0004"})
\u0004 是 Ctrl-D——它往 stdin 发了一个 EOF,让 read 循环自然退出。
体面的关法,不是一刀 kill。
实验四:防轮询——第三次空查就翻脸
陪练换成一个永远没输出的进程(sleep 300),然后明说要连查三次:
> 用 bash 以 interactive 模式在后台跑 sh silent.sh,然后立刻连续查三次
它的输出——就是要连续查,我想看看会发生什么。
[round 4] terminal_output({"id": "bg_1", "lines": 50})
[round 4] terminal_output({"id": "bg_1", "lines": 50})
[round 4] terminal_output({"id": "bg_1", "lines": 50})
实验结果出来了,很明确:
1. 第一次 → [状态: running](还没有输出)
2. 第二次 → [状态: running](还没有输出)
3. 第三次 → 触发硬停,直接被拦下来了:
> [停:30 秒内第三次空查了。不要再查这个进程,有新输出之前查多少次
都是空的。先做别的事,或者结束这一轮。]
之后这个进程的输出我不能再主动查了……进程本身不受影响:bg_1 还在正常
sleep 300,5 分钟后自己结束。
模型一口气把三个 terminal_output 塞进了同一轮(练习 20 的并发扇出
接住了它们),第三个撞上硬停。它的复述完全准确——包括"进程本身不受
影响":防轮询停的是查询,不是进程。
实验五:练习 23 的字据,今天兑现
-sandbox 模式启动(工作目录可写、家目录不可写),让模型把一条越界
的命令放到后台:
> 用 bash 以 async 模式在后台跑 touch /Users/roy.lei/ex28-sandbox-proof.txt,
完成后告诉我结果。
[round 1] bash({"command": "touch /Users/roy.lei/ex28-sandbox-proof.txt", "run_in_background": "async"})
⚠️ 模型想执行: touch /Users/roy.lei/ex28-sandbox-proof.txt
允许吗?(y/N) y
[插话来晚了:这一轮已经收工,把 1 条折成一次跟进的对话]
结果:失败。
- 后台进程 bg_1 已退出,exit status 1
- 错误信息:touch: /Users/roy.lei/ex28-sandbox-proof.txt: Operation not permitted
也就是说,touch 在这个路径下没有权限创建文件,文件没有生成。
注意顺序:权限系统放行了(用户按了 y),沙箱拦下了(OS 返回
Operation not permitted)。练习 23 说"批准了也越不出去",那时候验的
是同步命令;这一章的后台进程从没写过一行沙箱代码,走到 shellCommand
那扇门时自动进了同一个笼子。字据兑现。
顺带一提,模型接下来自己做了组对照:/tmp 不可写(还记得练习 23 的坑
吗——沙箱放行的是 $TMPDIR,不是 /tmp)、工作目录可写。它总结:"以后
需要落地文件就用工作目录。"边界摸清了,一次没越过去。
发生了什么
一个参数,两种性格
run_in_background 是枚举不是布尔。"要不要放后台"看起来是个是非题,
但真正的问题是放到后台之后这个进程归谁管:async 归系统管——跑完
推送通知,中途不许打扰;interactive 归模型管——什么时候看、喂什么
进去,它自己决定。接下来的每一段代码都要区分这两种管法(通知只推
async 的完成?不对,两种都推;轮询只拦……),从参数这一层就把话说清,
比事后猜"模型把测试放后台是想轮询还是想等通知"可靠得多。
octo 的这个参数最早就是布尔,2026 年 6 月改成枚举,提交里标着 BREAKING CHANGE——理由就是上面这段:布尔说不清进程归谁管, terminal_output / terminal_input 也就没法只对该开放的进程开放。
第二条执行路,过的还是同一些门
这一章新开的路上,一道新门都没装:
- 权限门禁:装在注册表的分发层(练习 9),命令还没碰到执行路径就 已经过完了 deny/ask 的检查——实验一里那个批准提问就是证据。
- 沙箱:装在
shellCommand这扇门上(练习 23),后台进程照样从 这里走,实验五里 OS 会替我们证明这一点。
门装在路口之前的公共地带,加一条新路不用重新装门——这就是练习 23 立字据时说"笼子只有装在唯一的门上才算数"的原因。当时那句话是防御性 的(防以后有人绕开),今天它变成了生产力:这一章的后台执行一行沙箱 代码、一行权限代码都没写。
打断穿透的第一个例外
练习 24 花了一整章让 Ctrl+C 穿透到每个工具的最深处,这一章第一次
反着来:后台进程的 ctx 用 context.Background() 另起,轮次的取消
够不着它。理由值得记住:你按 Ctrl+C 是说"这一轮别做了",不是"把我
特意放到后台的服务也杀掉"。生命周期跟着意图走,不是跟着技术上的
从属关系走。
但"不跟轮次死"不等于"不死"。两条命都有明确的主人:退出 REPL 时
killAll 收编全部后台进程,不留孤儿。而"杀"这个动作本身也有讲究——
信号发给整个进程组(负的 pgid),不是只发给直接子进程:
func (p *bgProc) kill() {
if p.pgid > 0 {
_ = syscall.Kill(-p.pgid, syscall.SIGKILL)
}
p.cancel()
}
直接子进程只是 sh -c 那层包装。只杀它,sh slow.sh 里的 sleep 会
变成孤儿接着跑——"杀掉了"就成了假话。octo 把这条写成硬规矩,所有
终止路径共用一个函数,就是防这两条规则在不同调用点悄悄走样。
推与拉,各管一种性格
完成是推的:进程一退出,收尾的那个 goroutine 把新输出包成通知送出去。读的是
readNew——一个会推进的游标,保证通知里不重复报模型已经看过的内容。
进度是拉的,而且是快照:terminal_output 走 tailLines,不动
任何游标,重复调用看到同一个视图。这个设计本身就在拆轮询的台:既然
多查一次不会多看到任何东西,"再查一次说不定有新的"这个动机就不成立
了。防轮询窗口拦的是剩下的顽固分子:30 秒内对一个还在跑的进程空查
三次,工具直接翻脸——不是建议,是把话挑明:"有新输出之前查多少次都
是空的。"
这和练习 27 的零进度刹车是同族问题:模型自己驱动自己的循环,永远 需要一个不靠模型自觉的止损。刹车管的是"续了一轮却没进展",防轮询管 的是"查了三次却没输出"——判据都是机械的,都不跟模型商量。
最后一道是教育:跑得太快的 async 任务,完成通知里附一句"你根本不需要 后台"。教育放在通知里而不是文档里,因为这一刻模型手上就攥着证据—— 它刚为一条几秒钟的命令多花了一轮。
第四种事件,主循环还是那个形状
完成通知的两条去路,你在练习 26 已经走过一遍:空闲时直接开一轮 (waitIdle 的一个 case),轮次跑着时折成插话(runInterruptible 的 一个 case)。练习 24 立骨架时说"每加一种要处理的事就多一个 case", 键盘、要问人、闹钟、后台完成——四次兑现,select 还是那个 select。
有一个跟 octo 的分歧要交代:octo 空闲时收到完成通知不会自动开 一轮——通知挂在界面的后台面板上,等用户下一次开口才带给模型。它有 TUI 面板,人看得见"有个任务完成了";本书的极简 REPL 没有面板,通知 要是躺在收件箱里等人开口,"完成自动通知"就打了折扣。前提不同,取舍 就不同——这不是谁对谁错,是同一个设计问题在两种界面下的两个答案。
常见问题
模型把参数写错了怎么办?这一章真机撞上两次。DeepSeek 写出过
"run_in_background": async(裸字面量,整段 JSON 直接非法)——工具层
报参数错,模型下一轮自己修好了。顺带修掉一个从练习 9 潜伏至今的洞:
参数烂掉时 commandOf 拿不出命令,权限层曾经会拿着空命令去问
"允许吗?"——用户看着一片空白没法判断。现在参数烂的调用直接放过
权限门,让工具自己报错,反正什么都不会执行。教训跟 update_goal 的
enum 校验同款:说明书拦不住手滑,每一层都要自己再验一遍。
模型想等后台任务,自己跑 sleep 30 怎么办?sleep 是同步命令,
照样占轮次——等于把"不阻塞"又变回了"阻塞"。正确的等法是结束这一轮:
完成通知会把模型叫回来。bash 的工具描述里"先做别的事,或者结束这一轮"
说的就是这个。这跟练习 26 的闹钟是同一个观念转换:等待不是一个动作,
是把控制权交回主循环。
**interactive 进程的输出,为什么有时候一直是空的?**八成是缓冲。
程序的 stdout 接的是管道不是终端,C 标准库会把行缓冲换成全缓冲——
日志都攒在程序自己的缓冲区里没吐出来。octo 在空输出的提示里直接教
解法:用 stdbuf -oL <cmd> 强制行缓冲再启动(macOS 自带的 sh 内建
echo 不经过 stdio,所以本章实验的回声服务没踩这个坑)。
octo 的后台家族还有什么没搬?kill_shell(单杀一个进程,信号可选,
见加分练习);detached:true(真正的守护进程:setsid 自立门户,故意
活过 octo 本体,不被追踪也不被收编);同步命令超时自动转后台(octo 的
同步执行本来就是一个隐藏的后台进程,人可以按 Ctrl+B 把它提前"转正");
完成通知里附"还有 N 个任务在跑"的摘要;超长输出溢出到临时文件。
骨架都在,那些是肉。
加分练习
- kill_shell 工具。给模型一把单杀的刀:按 id 终止一个后台进程, 返回它最后的输出。照 octo 的规矩:信号发整个进程组;只有 SIGKILL 才顺带 cancel ctx——SIGTERM 想给进程体面收尾的机会,cancel 会让 exec 抢跑一个自动 SIGKILL 把体面搅黄。
- detached 守护进程。第三种跑法:
setsid自立门户,stdout 重定向 到日志文件,不追踪、不收编、故意活过 harness 本体。想清楚它跟 interactive 的本质区别:interactive 是"会话的进程",detached 是 "机器的进程"。 - 同步超时自动转后台。把同步执行也改成隐藏的后台进程:超时不再 杀掉报错,而是"转正"成 async 任务继续跑,通知稍后送到。octo 还给 人留了 Ctrl+B 手动提前转正。
- 完成通知带同伴摘要。通知末尾附一句"还有 bg_2(npm test)在跑, 已 3 分钟"——模型不用一个专门的列表工具就能记住手上有几摊事。
- 把 30 秒 / 3 次调成可配置,然后故意调严(10 秒 / 1 次)跑一遍 实验四,观察模型被过早硬停之后的行为——防轮询的参数是宽容度和 止损速度的折中,调过头两边都疼。