Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

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

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

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

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

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

语言差异先说在最前面。 Go 版靠 context.Context 把取消信号一路 传进 bash 子进程和 HTTP 请求内部,两者执行到一半都能被真正掐断。这 一版 Python 和 JavaScript 各自的取消能力边界不一样,而且互相之间也 不对称:

  • 网络请求:JavaScript 的 fetch() 原生支持 AbortController, 能真正中断一个还没返回的请求;Python 的 urllib 没有这个机制,这 一版选择不做深度改造,取消是协作式的、只在轮次边界检查。
  • bash 子进程:Python 用线程,Ctrl+C 能被主线程及时收到,但收到 之后没有办法让正在跑的同步 subprocess.run() 提前结束;JavaScript 更极端——execFileSync 是同步阻塞调用,会独占 Node 唯一的这个线程, 命令没跑完之前连信号处理回调本身都没有机会运行。两种语言在这一 点上殊途同归:一个卡住的 bash 命令,谁都打断不了,只能等它自己的 超时。

这两条能力边界是这一章特意保留的、真实的运行时限制,不是疏漏—— 下面“你应该看到什么“和“发生了什么“里,每一条都有真机实测数据撑腰, 没有一句是凭空断言。

敲进去

在练习 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 整段搬出来,变成一个能反复调用的 函数:

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 是这一章加的全部东西:读一行、跑一轮、回到读一行。
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。信号只在轮次跑着的时候接管。
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[已打断这一轮。对话还在,接着说]")
	}
}

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

func heal(sess *session, note string) {
	sess.History = healTurn(sess.History, note)
	if err := sess.save(); err != nil {
		fmt.Fprintln(os.Stderr, "警告: 会话保存失败:", err)
	}
}

// healTurn 把一轮没正常收尾的历史补回合法状态。
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]
	}
	// ……
	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,再接着问它。

你应该看到什么

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

Python 版真机结果:

> 我叫小雷,把这句记进 notes.txt
[round 1] read_file({"path": "notes.txt"})
[round 2] write_file({"path": "notes.txt", "content": "我叫小雷\n"})
已记好:notes.txt 里现在写着「我叫小雷」。
[本轮 3 次请求 · ……]

> 我叫什么?直接回答名字,不要用工具
小雷。
[本轮 1 次请求 · ……]

> 把 notes.txt 读出来给我看
[round 1] read_file({"path": "notes.txt"})
notes.txt 的内容是:我叫小雷
[本轮 2 次请求 · ……]

第二句话没有提到任何名字,模型直接答“小雷“——这一轮和上一轮共用同一份 会话历史,进程从头到尾没有退出过。上一章要做到同样的效果,得 python3 main.py -c <id> "我叫什么" 重新启动一次。JavaScript 版跑 同一组任务,逐字对应的结果。本机 qwen3:4b-instruct 两种语言也都 跑过:第一句 write_file 写盘,第二句直接从上下文答出“小雷“。

实验二:写到一半喊停,Python 和 JavaScript 表现完全不同

让模型写一篇 1500 字的长文,不到 1 秒后发出 Ctrl+C(比人手速快,是为 了确保打断真的落在请求还没返回的时候,不是凑巧在它写完之后才按下)。

JavaScript——AbortController 真正中断了这个还在飞行中的请求:

> 不要用任何工具,直接写一篇 1500 字的说明文,题目是《进程为什么要常驻》,要写满

[已打断这一轮。对话还在,接着说]

> 上一件事你写完了吗?一句话回答,不要用工具
没写完,写到一半就被打断了。
[本轮 1 次请求 · ……]

模型一个字都没来得及吐出来,请求本身被 fetch()AbortSignal 截断——这次调用完全没有产生输出,也没有花费生成 1500 字的那部分 token。

Python——同样的时机按下 Ctrl+C,长文原样写完了,一个字都没少:

> 不要用任何工具,直接写一篇 1500 字的说明文,题目是《进程为什么要常驻》,要写满
《进程为什么要常驻》

在计算机的世界里,进程是程序运行起来之后的形态。……
(完整的约 1500 字长文原样打印)

[本轮 1 次请求 · ……]

> 上一件事你写完了吗?一句话回答,不要用工具
写完了,上一篇《进程为什么要常驻》已按要求写满约一千五百字,未使用任何工具。
[本轮 1 次请求 · ……]

[已打断这一轮] 那行提示从头到尾没有出现,原因不是信号没送到(Python 的 signal.signal 确实能在主线程阻塞于 join() 时被唤醒执行 handler, 这一点在多轮工具调用的场景下可以验证成立,见下一段):这一轮从头到 尾只有一次 send() 调用、又是直接给答案(没有 tool_calls),代码里 唯一的检查点——工具批量执行完之后——根本没被走到。取消事件确实被设置 了,但没有代码路径去读它。

同一份 Python 代码,换成一个需要多轮工具调用的任务(“第一步写文件, 等真正完成后再追加第二次写入,两步必须分开完成”),Ctrl+C 在某一轮 工具执行完之后打进来,这次真的生效了:

[round 1] write_file({"path": "notes2.txt", "content": "第一步"})
[round 2] read_file({"path": "notes2.txt"})
[round 3] write_file({"content": "第一步\n第二步", "path": "notes2.txt"})

[已打断这一轮。对话还在,接着说]

打断没有等任何请求的响应,落在了 round 3 的工具结果刚刚存盘、还没发出 round 4 请求的那个窄窗口里——检查点确实工作,只是这一版 Python 的检查点 只存在于“两个工具调用批次之间“,一个不含工具调用的单轮直接生成,从头 到尾没有这样的窗口。

实验三:喊停要能穿到最深处——三种语言,三种答案

造一个必然卡住的场景:一个没人写入的命名管道,cat 它会一直等下去。

Go:按下 Ctrl+C 和命令死掉落在同一个百分之一秒的刻度里, context.WithTimeout(ctx, d) 的第一个参数真的把取消传了进去。

Python 和 JavaScript:真机测了同一个场景(bash({"command": "cat block.fifo", "timeout": 30}),进程启动后第 5 秒左右按 Ctrl+C),两边 的 [已打断这一轮] 提示都在第 32-33 秒才出现——比 Ctrl+C 按下的 时刻晚了将近 30 秒,跟 bash 自己的超时时长完全对上:

elapsed: 32.87s(Python)
elapsed: 32.65s(JavaScript)
[round 1] bash({"command": "cat block.fifo", "timeout": 30})

[已打断这一轮。对话还在,接着说]

两边其实都有反应——取消事件/信号确实被设置了,只是设置的时刻早,兑现 的时刻晚:subprocess.run()(Python)和 execFileSync(JavaScript) 一旦发出,谁都没有办法让它提前结束,只能等它自己的 30 秒超时打上门, 这时候检查点才第一次有机会读到“其实用户十几秒前就想喊停了“。问题不 出在这两个实现偷懒,而是两种语言处理“同步阻塞子进程“的方式里,压根不存在 “从外部提前结束它“的钩子,除非改用更底层的进程句柄主动 kill(留给 加分练习)。

实验四:标准输入只能有一个读者——这个坑在 py/js 里不成立

Go 版这里有一组对照:老写法(confirm 每次新建一个 bufio.Reader) 会在批准 + 下一句话一起送进去时把第二句话吞掉。这个坑在 Python 和 JavaScript 里从一开始就不存在:Python 的 sys.stdin.readline() 全程 序共用同一个对象,没有“新建一个读者“这个动作;JavaScript 这一版让 repl 复用 confirm() 已有的 readLineSync(),同样没有第二套机制去 竞争标准输入。这算不上“提前修好了“,只是这两种语言的标准库设计 原本就不支持“每次调用临时包一层缓冲“这种会产生冲突的写法。

真机测试反而在 JavaScript 这边测出了一个不一样的真实 bugreadLineSync() 原本没有区分“流已经结束“和“用户敲了个空行“——管道 输入耗尽(比如脚本化调用时 < /dev/null)之后,函数会不停返回空 字符串,repl 把每一次都当成“空行,继续等下一句“,导致进程在一个 每次都立即返回的循环里空转,永远不会打印“会话已结束“退出——这条 这条结论来自真机跑 node main.mjs < /dev/null 复现出来的结果,不是理论推演,退出码 正常但进程占着,需要外部 kill。修法就是“敲进去“里那段区分逻辑:一个 字符都没攒到就碰上流结束才是真 EOF,攒了几个字符后才碰上,说明这是 最后一行没写换行符,先把内容交回去。Python 的 sys.stdin.readline() 天生不会有这个问题——它在真正的 EOF 时返回空字符串,在用户只按了 回车的空行时返回 "\n",两者从协议层面就是两个不同的返回值。

发生了什么

这一章总共加了四个部件:一个不退出的循环、一个可以被打断的轮次、一套 只在轮次期间生效的信号/取消处理、一个把历史补回合法状态的函数。它们 合起来做的是一件事——把“一次调用“换成“一个持续的运行环境“。 三种 语言在“循环“和“补历史“这两块上做法几乎一致(repl/heal_turn 这类 函数三语言逐段对应),真正分道扬镳的是“轮次怎么跑、怎么被打断“这一层 ——这恰好是三种语言并发/取消原语差异最大的地方,前面几章(练习 20 的 并发扇出、这一章)反复印证一条规律:并发和取消从来靠的不是“抄一份代码 换个语法“,而是每种运行时真实能力的直接投影。

为什么 Go 能做到“深度穿透“,py/js 选择不做。 context.Context 是 Go 标准库里专门为“取消信号沿调用链传播“设计的一等公民,net/httpos/exec 都原生认它——这不是巧合:Go 语言设计层面就把“这个操作可能 需要被取消“当成了值得单独建模的一等公民。Python 的 urllibsubprocess 和 JavaScript 的 execFileSync 都没有对应的抽象; JavaScript 的 fetch 算是半个例外(AbortController 补上了网络请求 这一块,但没有覆盖同步子进程)。给 py/js 的每个工具签名都加一个用不上 的取消令牌参数,换来的能力和 Go 版不对等,这本书选择诚实地把“能做到 多深“画出来,而不是做一个名义上统一、实际上大部分参数从不生效的接口。

为什么信号/取消只在轮次期间接管。 跟 Go 版一句话:模型干活的时候 Ctrl+C 是“这一步别做了“,其余任何时候还是“这个程序不要了“——这条原则 三语言完全一致,只是接管和归还的机制不同:Go 用 signal.Notify/ signal.Stop,Python 用 signal.signal 换回旧的 handler,JavaScript 用 process.on/process.off

为什么打断之后必须收拾历史,这一点没有任何语言分叉。 协议要求一条 带 tool_calls 的 assistant 消息后面必须跟着每个 id 对应的 tool 消息, 这是 API 协议层面的硬规矩,跟实现语言无关。三语言的 healTurn/ heal_turn 逻辑逐字对应:模型话说完才被打断——历史本来就合法,不用管; 打断落在工具调用和结果之间——补齐“没有执行“的结果,再留一条模型看得见 的说明。这条说明在实验二里被模型自己读到过:Python 版模型答“没写完, 写到一半就被打断了“,靠的正是补进去的那一条。

这次真机测试意外验证了 JavaScript 版一个真实的实现 bug,而不是纸上 谈兵的边界情况。 readLineSync() 分不清“流结束“和“空行“,在 < /dev/null 这种非交互场景下会陷入死循环——这原本是给 confirm() 用的函数,从练习 18 沿用到现在从没出过问题,因为 confirm() 从不关心 “到底是空答案还是读不到”,两者都按拒绝处理。这一章第一次让同一个函数 承担“要不要退出整个循环“的判断,这层区别立刻从潜伏状态变成了真实故障 ——这正是为什么“敲进去“里除了抄一遍 Go 版的敲进去顺序,还多花了几行 去处理一个 Go 版根本不会遇到的问题:Go 的 bufio.Reader.ReadString 在 EOF 时返回的错误本身就自带这个区分,Python 的 readline() 同理, 只有这个手写的按字节读取器需要自己把它补上。

常见问题

  • 真实产品也是这么写的吗:结构是,规模不是。这一章要的是常驻循环 这个结构——一个后台执行单元跑活、一套机制收各方消息、取消靠某种 形式的令牌——这个结构你已经有了,换不换全屏界面只是长相问题。
  • 轮次跑着的时候我打字,字去哪了:留在终端的行缓冲里,等这一轮 结束、下一次读取标准输入时一次读走,当成下一句话执行。三语言行为 一致,因为这是终端本身的行缓冲机制,不是任何一层应用代码的选择。
  • 打断之后,工具已经改动的内容会回滚吗:不会,三语言都一样。 write_file 已经落盘的内容就是落了,bash 已经执行的副作用就是 发生了。取消只保证“还没做的不做了“,不保证“已经做的当没做过“。
  • 为什么请求失败不再退出进程了:因为退出的代价变了。以前一次调用 就是进程的全部生命,报错退出没损失什么;现在退出等于把整场对话连同 已经连好的 MCP 服务器一起扔掉,只因为一次网络抖动。三语言都是异常/ 错误往上抛、heal 收拾、回到提示符这一套。
  • Python 版的取消能力能不能加强到接近 Go:能,但要多花代码。给 subprocess.Popen 换成手动管理(不用 subprocess.run 的一站式 阻塞封装),把进程句柄存在一个共享位置,signal handler 里直接 proc.kill(),就能做到“bash 也能被立刻打断“——这是加分练习 2 的 内容,这一版没做是因为这本书的取舍是“先诚实展示边界在哪,需要更强 能力时读者知道往哪个方向使劲“。

加分练习

  1. 让空闲时的 Ctrl+C 也走你的代码。 现在空闲时按 Ctrl+C 走的是 语言/操作系统默认行为,进程死得很突然,最后那次存盘都没跑。改成 在提示符上也接管信号:第一次按提示“再按一次退出“,第二次才真的走 存盘退出的路。
  2. 把 bash 的取消做深。 Python 换成手动管理 subprocess.Popen (不用 .run() 的阻塞封装),JavaScript 换成异步的 execFile (不用同步的 execFileSync),配合各自的取消令牌,在 signal 到达 时主动杀掉正在跑的子进程——跑一遍实验三,确认“卡住的 fifo“这次能在 按下 Ctrl+C 的同一秒被打断,而不是等 30 秒超时。
  3. 给权限确认加超时。 confirm/ask_approval 现在会一直等下去。 轮次被打断了,它还在等——因为它读的是标准输入,不认识取消信号。
  4. 加一条 /compact 命令。 压缩现在只在预算超标时自动触发。做一条 手动命令,让用户在开始一个新话题前主动折叠掉前面的对话。
  5. 量一量常驻省了多少。 用上一章的 -c <id> 方式连问三句话,和 这一章连问三句话,对比每次请求的“命中缓存“数字和端到端耗时。