练习 24:用户界面——从单次调用到常驻对话

到上一章为止,你写的这个东西一直是一句话一条命:从命令行接一个任务,跑, 退出。会话文件让你能用 -c 把上一次的对话捡回来,但捡回来的是记录, 不是进程——每一句话都要重新启动一次,重新发现 skill,重新连一遍 MCP 服务器,重新把系统提示拼一遍。

这一章把它改成常驻:读一行、跑一轮、回到读一行,中间什么都不重来。

先说清楚这一章跟前面二十三章的区别:它不是一个新工具。 从练习 5 到 练习 23,每一章的落点都是"这是一个 tool 设计决定"——注册表加一行、 sub_agent 是一个实现了同一个接口的工具、MCP 把别人的工具接进同一张表。 这一章加的东西一个都没进注册表,模型看不见它、调不到它。变的是运行 环境的形态:从"跑完就死"变成"一直醒着"。

这件事必须先做,因为它是后面几章的地基。插话(练习 25)、定时唤醒 (练习 26)、后台任务跑完了回来报信(练习 28)——这些能力全都以"有一个 还醒着的进程"为前提。进程都不在了,往哪儿报信。

代价是三件以前不存在的事,这一章要把它们一件件解决:

  1. 谁来读标准输入。以前只有权限确认在读,现在多了一个常驻循环也要读, 而标准输入只能有一个读者
  2. 跑到一半怎么喊停。以前跑一句话就是进程的全部生命,Ctrl+C 杀掉它天经 地义;现在杀掉整个进程等于把整场对话一起扔了。
  3. 喊停之后历史怎么收拾。打断会把对话停在一个协议不允许的位置,不收拾, 下一句话直接 400。

敲进去

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

第一件事,把 ctx 加进工具接口。这一行是整章能不能真的喊停的关键:

// tool 是每个工具要实现的接口:一份给模型看的声明,一个真正干活的函数。
// octo 里同名接口也是这两个方法——这不是巧合,是这件事的最小形状。
//
// execute 的第一个参数是这一轮的 ctx。它一路传到最深处:bash 交给
// exec.CommandContext,sub_agent 交给它自己那几个 HTTP 请求。ctx 一断,
// 这些地方全部立刻返回。不传这个参数,用户按下的中断就只能等一条命令
// 自己跑完——octo 的 ToolExecutor.Execute 第一个参数同样是 ctx。
type tool interface {
	definition() toolSpec
	execute(ctx context.Context, args string) string
}

八个工具的 execute 都要跟着改签名,registry.executedispatchToolCallsrunChildLoopsendsummarizecompact 也一 样,往上一路加一个 ctx context.Context 的第一参数。大部分工具拿到这个 参数根本用不上,照样得收——链子中间断一环,末端就收不到取消。

真正消费它的有两处。一处是发请求:

req, err := http.NewRequestWithContext(ctx, "POST", base+"/chat/completions", bytes.NewReader(body))

另一处是 bash。它原本自己造一个带超时的 ctx,现在改成挂在这一轮的 ctx 上:

	// 挂在这一轮的 ctx 上,不是 context.Background()。两个结束理由现在
	// 都管用:命令自己跑超时,或者用户中断了这一轮——谁先到听谁的。
	ctx, cancel := context.WithTimeout(ctx, d)
	defer cancel()
	cmd := shellCommand(ctx, in.Command)

命令被取消和被超时杀掉是两回事,返回给模型的话也该不一样:

	if ctx.Err() == context.Canceled {
		return "错误: 这一轮被用户中断,命令已终止。已产生的输出:\n" + text
	}

第二件事,标准输入收归一个读者:

// stdin 是全程序唯一的标准输入读者。这一章之前,confirm 每次调用都新建
// 一个 bufio.Reader 包住 os.Stdin,一次性跑完就退出,看不出问题;现在
// 常驻循环也要读同一个 os.Stdin,两个带缓冲的读者会互相偷字节——先读到
// 的那个把整块缓冲吃进自己肚子里,另一个再读就什么也没有了。共用一个。
var stdin = bufio.NewReader(os.Stdin)

confirm 里那行 bufio.NewReader(os.Stdin).ReadString('\n') 换成 stdin.ReadString('\n')。就改一个词,但这一改是有实测代价的,"你应该看到 什么"里有一组对照跑给你看。

第三件事,把 main 里那个 agent loop 整段搬出来,变成一个能反复调用的 函数。它跟练习 5 的循环结构一模一样,只多两处:ctx 一路往下传,出错时 返回 error 而不是 os.Exit

const maxRounds = 10

// runTurn 把一句话跑到底:发请求、有 tool_calls 就分发、没有就收工。
func runTurn(ctx context.Context, base, apiKey, model string, reg *registry, sess *session, window int, input string) error {
	sess.History = append(sess.History, message{Role: "user", Content: input})
	for round := 1; round <= maxRounds; round++ {
		r, err := send(ctx, base, apiKey, model, sess.History, reg.definitions())
		if err != nil {
			return err
		}
		// …… 压缩、打印、判断 finish_reason,和练习 23 一字不差 ……

		sess.History = append(sess.History, dispatchToolCalls(ctx, reg, round, msg.ToolCalls)...)
		if err := sess.save(); err != nil {
			fmt.Fprintln(os.Stderr, "警告: 会话保存失败:", err)
		}
		// 打断落在工具执行里:结果已经原样记进历史了(每条都写着"被
		// 打断"),历史是合法的,就地收工,不用等下一次请求撞上取消。
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	return fmt.Errorf("这一句话跑满 %d 次请求还没收敛,停在这里", maxRounds)
}

第四件事,常驻循环本身:

// ---- 常驻层:一个不退出的循环 ----

// repl 是这一章加的全部东西:读一行、跑一轮、回到读一行。前面二十三章
// 的进程活到 main 的最后一个 return 就结束了,一句话一条命;从这里开始
// 它不走了,一直等着你说下一句。
//
// 这不是一个工具,是运行环境的形态变了。往后几章要加的能力——插话、定时
// 唤醒、后台任务跑完了来报信——全都得先有一个"还醒着的进程"才谈得上。
func repl(base, apiKey, model string, reg *registry, sess *session, window int, firstTask string) int {
	fmt.Fprintln(os.Stderr, "[常驻模式:一行一句话。空行忽略,/exit 或 Ctrl+D 退出;轮次跑起来之后 Ctrl+C 打断这一轮,不退出进程]")
	for {
		line := firstTask
		firstTask = ""
		if line == "" {
			fmt.Fprint(os.Stderr, "\n> ")
			text, err := stdin.ReadString('\n')
			if err != nil {
				fmt.Fprintln(os.Stderr) // Ctrl+D:补个换行,别让提示符黏在下一行
				break
			}
			line = strings.TrimSpace(text)
		}
		if line == "" {
			continue
		}
		if line == "/exit" || line == "/quit" {
			break
		}
		runInterruptible(base, apiKey, model, reg, sess, window, line)
	}
	if err := sess.save(); err != nil {
		fmt.Fprintln(os.Stderr, "警告: 会话保存失败:", err)
	}
	fmt.Fprintf(os.Stderr, "[会话 ID: %s,用 -c %s 继续]\n", sess.ID, sess.ID)
	return 0
}

第五件事,喊停:

// runInterruptible 跑一轮,同时盯着 Ctrl+C。
//
// 信号只在轮次跑着的时候接管,跑完立刻还给操作系统:停在提示符上按
// Ctrl+C,就该跟任何一个命令行程序一样直接把进程干掉,那是用户的肌肉
// 记忆,别去改它。要改的只有"模型正在干活"这一小段时间里的含义——那时候
// Ctrl+C 是"这件事别做了",不是"这个程序不要了"。
func runInterruptible(base, apiKey, model string, reg *registry, sess *session, window int, input string) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)
	defer signal.Stop(sig)

	// 轮次跑在自己的 goroutine 里,主循环留在这儿守着两个 channel。
	// 不这么分,主循环就阻塞在轮次里,信号来了也没人接。
	done := make(chan error, 1)
	go func() { done <- runTurn(ctx, base, apiKey, model, reg, sess, window, input) }()

	select {
	case err := <-done:
		if err != nil {
			// 一次请求失败不该带走整个进程——这是常驻和一次性最实际的
			// 区别:报错、收拾干净、回到提示符,对话还在。
			fmt.Fprintln(os.Stderr, "错误:", err)
			heal(sess, "[这一轮没跑完:"+err.Error()+"]")
		}
	case <-sig:
		cancel()
		// 等它真的收摊再往下走。少了这一行,被打断的轮次会一边收尾一边
		// 往终端打字,和下一轮的提示符抢屏幕,历史也可能被两边同时改。
		<-done
		heal(sess, "[这一轮被用户打断]")
		fmt.Fprintln(os.Stderr, "\n[已打断这一轮。对话还在,接着说]")
	}
}

第六件事,收拾被打断的历史:

// heal 收拾一轮没能正常收尾的历史,然后存盘。
func heal(sess *session, note string) {
	sess.History = healTurn(sess.History, note)
	if err := sess.save(); err != nil {
		fmt.Fprintln(os.Stderr, "警告: 会话保存失败:", err)
	}
}

// healTurn 把一轮没正常收尾的历史补回合法状态。
//
// 半途而废不是免费的:它会把 history 停在一个协议不允许的位置。一条带
// tool_calls 的 assistant 消息,后面必须跟着每个 id 对应的 tool 消息,
// 打断正好落在这两者之间,下一句话发出去就是 400——不是模型不高兴,是
// 请求本身不合法。补上"没有执行"的结果,再留一条模型看得见的说明:它得
// 知道刚才那件事是断在半路的,不是自己干完了。
//
// 请求失败走的是同一条路:那时候历史停在一条没人回应的 user 消息上,
// 下一句话再进来就是连着两条 user,同样要在这里收干净。
func healTurn(history []message, note string) []message {
	if len(history) == 0 {
		return history
	}
	last := history[len(history)-1]
	if last.Role == "assistant" && len(last.ToolCalls) == 0 {
		return history // 模型把话说完了才出的事,历史本来就是合法的
	}
	if last.Role == "assistant" && len(last.ToolCalls) > 0 {
		for _, tc := range last.ToolCalls {
			history = append(history, message{
				Role:       "tool",
				ToolCallID: tc.ID,
				Content:    "错误: 这一轮中断了,这个工具没有执行。",
			})
		}
	}
	return append(history, message{Role: "assistant", Content: note})
}

最后,main 的尾巴。命令行上的任务从必填变成选填——不给就直接进提示符, 给了就当第一句话说,说完照样留在提示符上:

	// 任务从必填变成选填:不给就直接进提示符,给了就当第一句话,说完
	// 照样留在提示符上。这是这一章唯一改变的用法。
	var firstTask string
	if len(args) >= 1 {
		firstTask = args[0]
	}

main 原本那一整段 for round := 1; round <= maxRounds; round++ 全部 删掉,换成一行:

	os.Exit(repl(base, apiKey, model, reg, sess, window, firstTask))
}

跑起来

cd exercises/ex24
go build -o ex24 .
export OPENAI_API_KEY=sk-xxxx
export MODEL=deepseek-v4-flash
export OPENAI_BASE_URL=https://api.deepseek.com/v1
./ex24

不带任务直接进提示符。连着说三句话,注意第二句故意不给任何上下文:

> 我叫小雷,把这句记进 notes.txt
> 我叫什么?直接回答名字,不要用工具
> 把 notes.txt 读出来给我看
> /exit

然后单独跑一次打断:让它写一篇长文,写到一半按 Ctrl+C,再接着问它。

你应该看到什么

实验一:一个进程,三句话

[新建会话 20260809-141911-d29bdcb3]
[常驻模式:一行一句话。空行忽略,/exit 或 Ctrl+D 退出;轮次跑起来之后 Ctrl+C 打断这一轮,不退出进程]

> 我叫小雷,把这句记进 notes.txt
[round 1 输入 1835 tokens,命中缓存 1792]
[round 1] read_file({"path": "notes.txt"})
[round 2] write_file({"path": "notes.txt", "content": "我叫小雷\n"})
已经记好了:`notes.txt` 里写入了「我叫小雷」。
[本轮 3 次请求 · 最后一次输入 2052 tokens(命中缓存 1920)· finish_reason=stop]

> 我叫什么?直接回答名字,不要用工具
小雷。
[本轮 1 次请求 · 最后一次输入 2086 tokens(命中缓存 1792)· finish_reason=stop]

> 把 notes.txt 读出来给我看
[round 1] read_file({"path": "notes.txt"})
notes.txt 的内容是:我叫小雷
[本轮 2 次请求 · 最后一次输入 2163 tokens(命中缓存 2048)· finish_reason=stop]

> /exit
[会话 ID: 20260809-141911-d29bdcb3,用 -c 20260809-141911-d29bdcb3 继续]

第二句话没有提到任何名字,模型直接答"小雷"——这一轮和上一轮共用同一份 sess.History,进程从头到尾没有退出过。上一章要做到同样的效果,得 ./ex23 -c <id> "我叫什么" 重新启动一次。

本机 Ollama(qwen3:4b-instruct)跑同一组,结论一样:第一句 write_file 写盘,第二句直接从上下文答出"小雷"。

实验二:写到一半喊停,对话还在

让模型写一篇 1500 字的长文,第 5 秒按下 Ctrl+C:

> 不要用任何工具,直接写一篇 1500 字的说明文,题目是《进程为什么要常驻》,要写满
^C
[已打断这一轮。对话还在,接着说]

> 上一件事你写完了吗?一句话回答,不要用工具
没写完——上一轮还没开始动笔就被打断了。
[本轮 1 次请求 · 最后一次输入 1877 tokens(命中缓存 1792)· finish_reason=stop]

两件事同时被证明了。第一,进程活着——打断之后提示符回来了,下一句话正常 跑完。第二,被打断的历史是合法的——如果 healTurn 不补那一条, sess.History 会停在一条没人回应的 user 消息上,下一句进来就是连着两条 user 消息。把会话文件打开看,补进去的就是那一条:

1 system   '你是一个能操作本地文件和 shell 的助手……'
2 user     '不要用任何工具,直接写一篇 1500 字的说明文……'
3 assistant '[这一轮被用户打断]'
4 user     '上一件事你写完了吗?一句话回答,不要用工具'
5 assistant '没写完——上一轮还没开始动笔就被打断了。'

第 3 条是代码补的,不是模型写的。模型答"没写完",靠的正是它读到了这一 条。本机 Ollama 同样答"没有,我还没有写完《进程为什么要常驻》这篇文章"。

实验三:喊停要能穿到最深处

上面那次打断落在等模型回话的时候,取消的是一个 HTTP 请求。工具执行到 一半呢?造一个必然卡住的场景:一个没人写入的命名管道,cat 它会一直等 下去。

A 组,第 12 秒按 Ctrl+C(左侧是从进程启动算起的秒数):

 1.29  [round 1] bash({"command": "cat block.fifo", "timeout": 30})
12.05  ===== 这一刻按下 Ctrl+C =====
12.05  [已打断这一轮。对话还在,接着说]

B 组,同一条命令,不打断,让它自己了断:

 1.80  [round 1] bash({"command": "cat block.fifo"})
33.35  命令原样执行了 `cat block.fifo`。结果:**超时被终止**(30 秒内没有输出)。
33.35  [本轮 2 次请求 · 最后一次输入 2010 tokens(命中缓存 1920)· finish_reason=stop]

按下 Ctrl+C 和命令死掉落在同一个百分之一秒的刻度里;不打断的话它要卡满 30 秒的超时。差别只来自 context.WithTimeout(ctx, d) 里的第一个参数: 上一章那里写的是 context.Background(),取消传不进去,这条命令会把你 晾满 30 秒。打断能不能生效,取决于 ctx 有没有真的穿到最深的那一层, 不取决于你在最外面写了多漂亮的 select

实验四:标准输入只能有一个读者

这一组对照证明"共用一个 bufio.Reader"不是洁癖。让模型跑一条 ask 档 命令(sudo -n true),然后一次性把批准和下一句话一起送进去——真人 提前敲好两行、或者从脚本喂输入,都是这个形状:

y
接着说:中国的首都是哪里?一句话,不要用工具

老写法(confirm 里每次新建一个 bufio.Reader):

⚠️  模型想执行: sudo -n true
命令已原样执行:`sudo -n true`
结果:`sudo: a password is required`,退出码 1。
[本轮 2 次请求 · …… · finish_reason=stop]

> [会话 ID: 20260809-142957-6603881e,用 -c …… 继续]

批准生效了,但第二句话消失了——它跟 y 一起被那个临时读者吸进缓冲 区,函数一返回,缓冲区连同里面没读完的字节一起被丢掉。用户看到的是自己 明明打了字,程序装作没看见。

共用一个读者:

⚠️  模型想执行: sudo -n true
命令已原样执行,没有改写或添加任何符号。结果:`sudo: a password is required`,退出码 1……
[本轮 2 次请求 · …… · finish_reason=stop]

中国的首都是北京。
[本轮 1 次请求 · …… · finish_reason=stop]

同一份输入,同一个模型,差别只在一个变量声明放在哪里。

发生了什么

这一章总共加了四个东西:一个不退出的循环、一个跑在 goroutine 里的轮次、 一个只在轮次期间生效的信号处理、一个把历史补回合法状态的函数。它们合起来 干的是同一件事——把"一次调用"换成"一个持续的运行环境"

为什么轮次非得跑在 goroutine 里。 你可能想直接写 runTurn(ctx, ...),然后指望信号处理函数去 cancel()。这不是不能写, 但主循环会整个阻塞在 runTurn 里,"轮次结束了没有"这个状态没人拿得到, 于是"打断之后等它收摊再打提示符"这件事就没地方做。换成 go func() { done <- runTurn(...) }() 加一个 select,主循环就重新 变成一个同时守着多个来源的调度者。这个形状不是为了这一章好看:往后 每加一章,select 里就多一个 case——插进来的新消息、定时唤醒、后台任务 的完成通知。octo 的常驻循环里,同时往里送消息的来源有十来个:轮次事件、 后台进程退出、子 agent 完成、workflow 进度、MCP 连好了、定时器到点。 形状和这里一模一样,只是 case 更多。

为什么信号只在轮次期间接管。 signal.Notify 一挂上,Go 就不再让 默认行为杀进程了。要是从头到尾挂着,用户停在提示符上按 Ctrl+C 会发现 毫无反应——一个不听 Ctrl+C 的命令行程序是很讨人厌的。所以挂在轮次开始, defer signal.Stop(sig) 在轮次结束时还回去:模型干活的时候 Ctrl+C 是 "这件事别做了",其余任何时候还是"这个程序不要了"。

为什么打断之后必须收拾历史。 这是最容易漏、也最容易在几天后变成 灵异事件的一处。协议要求一条带 tool_calls 的 assistant 消息后面必须跟 着每个 id 对应的 tool 消息,打断可以正好落在这两者之间。你按下 Ctrl+C, 看起来一切正常,下一句话发出去却收到 400——报错来自请求本身不合法,跟 模型没关系。顺手补的那条 [这一轮被用户打断] 还有第二个作用:模型下一 轮读得到它,于是它知道刚才那件事是被人喊停的,不是自己干完了。实验二里 它答"没写完",就是读到了这一条。

它为什么不是一个工具。 全书到这里,每一章的落点都是"这是一个 tool 设计决定"。这一章是唯一的例外,而且是有意为之:replrunInterruptiblehealTurn 一个都没进注册表,模型看不见它们。它们是 装东西的骨架,不是装进去的东西。骨架搭好之后,接下来几章要加的能力 ——定时唤醒是 schedule_wakeup 这个工具,目标追踪是三个工具,后台任务 是两个工具——立刻又回到"加一个工具"的老路上。主线没有断,只是这一章在 铺路。

常见问题

Q:真实产品也是这么写的吗? 形状是,规模不是。octo 的常驻循环用了一套全屏终端界面框架,有输入框、 滚动区、弹窗、面板。我们不搬它,因为学一整套界面框架会占掉不成比例的 篇幅,而且它跟"agent 是什么"没有关系。这一章要的是常驻循环这个结构 ——一个 goroutine 跑活、一个 select 收各方消息、取消靠 ctx——这个结构 你已经有了,换不换全屏界面只是长相问题。

Q:轮次跑着的时候我打字,字去哪了? 留在终端的行缓冲里,等这一轮结束、stdin.ReadString 再被调用时一次读走, 当成下一句话执行。也就是说你现在已经有"排队"了,只不过队是终端替你排的, 不是你的代码排的。真要做到"插进正在跑的那一轮里去",得自己接管这件事, 那是下一章。

Q:打断之后,工具已经改掉的东西会回滚吗? 不会。write_file 已经落盘的内容就是落了,bash 已经执行的副作用就是 发生了。ctx 取消只保证"还没做的不做了",不保证"已经做的当没做过"。历史 里那条"这个工具没有执行"说的是被取消的那几个调用,不是这一轮的全部。

Q:为什么请求失败不再退出进程了? 因为退出的代价变了。以前一次调用就是进程的全部生命,报错退出没损失什么; 现在退出等于把整场对话连同已经连好的 MCP 服务器一起扔掉,只因为一次网络 抖动。所以 runTurn 返回 error,runInterruptible 打印出来、收拾历史、 回到提示符。

Q:/exit 之外要不要更多命令? 按需要加。这一章只给了 /exit/quit,因为它们是"没有就出不去"的 那种必需品。命令一多就得考虑补全、帮助、参数解析,那些跟 agent 没关系。

加分练习

  1. 让空闲时的 Ctrl+C 也走你的代码。 现在空闲时按 Ctrl+C 走的是操作 系统默认行为,进程死得很突然,最后那次 sess.save() 都没跑。改成在 提示符上也接管信号:第一次按提示"再按一次退出",第二次才真的走存盘 退出的路。注意别把常驻循环卡在读输入上——想想为什么这需要再开一个 goroutine,这个问题下一章还会再遇到一次。

  2. 给权限确认加超时。 confirm 现在会一直等下去。轮次被打断了, 它还在等——因为它读的是 stdin,不认识 ctx。把 ctx 传进去,等待的时候 同时盯着取消信号,取消了就按拒绝处理。

  3. 加一条 /compact 命令。 压缩现在只在预算超标时自动触发。做一条 手动命令,让用户在开始一个新话题前主动折叠掉前面的对话。已有的 compact 函数直接就能用,你要写的只是命令解析和把结果装回 sess.History

  4. 量一量常驻省了多少。 用上一章的 ./ex23 -c <id> 连问三句话,和 这一章连问三句话,对比每次请求的 命中缓存 数字和端到端耗时。省下来 的不只是启动时间——想想每次重启都要重新做一遍的那些事:发现 skill、 连 MCP 服务器、拼系统提示。