练习 24:用户界面——从单次调用到常驻对话
到上一章为止,你写的这套程序一直是一句话一条命:从命令行接一个任务,跑,
退出。会话文件让你能用 -c 把上一次的对话捡回来,但捡回来的是记录,
不是进程——每一句话都要重新启动一次,重新发现 skill,重新连一遍 MCP
服务器,重新把系统提示拼一遍。
这一章把它改成常驻:读一行、跑一轮、回到读一行,中间什么都不重来。
先说清楚这一章跟前面二十三章的区别:没有新工具进注册表。 从练习 5 到 练习 23,每一章的落点都是“这是一个 tool 设计决定“——注册表加一行、 sub_agent 是一个实现了同一个接口的工具、MCP 把别人的工具接进同一张表。 这一章加的内容一个都没进注册表,模型看不见它、调不到它。变的是运行 环境的形态:从“跑完就死“变成“一直醒着“。
这一步必须先做,因为它是后面几章的地基。插话(练习 25)、定时唤醒 (练习 26)、后台任务跑完了回来报信(练习 28)——这些能力全都以“有一个 还醒着的进程“为前提。进程都不在了,往哪儿报信。
代价是三件以前不存在的事,这一章要把它们一件件解决:
- 谁来读标准输入。以前只有权限确认在读,现在多了一个常驻循环也要读, 而标准输入只能有一个读者。
- 跑到一半怎么喊停。以前跑一句话就是进程的全部生命,Ctrl+C 杀掉它天经 地义;现在杀掉整个进程等于把整场对话一起扔了。
- 喊停之后历史怎么收拾。打断会把对话停在一个协议不允许的位置,不收拾, 下一句话直接 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.execute、
dispatchToolCalls、runChildLoop、send、summarize、compact 也一
样,往上一路加一个 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 这边测出了一个不一样的真实 bug:
readLineSync() 原本没有区分“流已经结束“和“用户敲了个空行“——管道
输入耗尽(比如脚本化调用时 < /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/http 和
os/exec 都原生认它——这不是巧合:Go 语言设计层面就把“这个操作可能
需要被取消“当成了值得单独建模的一等公民。Python 的 urllib、
subprocess 和 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 的 内容,这一版没做是因为这本书的取舍是“先诚实展示边界在哪,需要更强 能力时读者知道往哪个方向使劲“。
加分练习
- 让空闲时的 Ctrl+C 也走你的代码。 现在空闲时按 Ctrl+C 走的是 语言/操作系统默认行为,进程死得很突然,最后那次存盘都没跑。改成 在提示符上也接管信号:第一次按提示“再按一次退出“,第二次才真的走 存盘退出的路。
- 把 bash 的取消做深。 Python 换成手动管理
subprocess.Popen(不用.run()的阻塞封装),JavaScript 换成异步的execFile(不用同步的execFileSync),配合各自的取消令牌,在 signal 到达 时主动杀掉正在跑的子进程——跑一遍实验三,确认“卡住的 fifo“这次能在 按下 Ctrl+C 的同一秒被打断,而不是等 30 秒超时。 - 给权限确认加超时。
confirm/ask_approval现在会一直等下去。 轮次被打断了,它还在等——因为它读的是标准输入,不认识取消信号。 - 加一条
/compact命令。 压缩现在只在预算超标时自动触发。做一条 手动命令,让用户在开始一个新话题前主动折叠掉前面的对话。 - 量一量常驻省了多少。 用上一章的
-c <id>方式连问三句话,和 这一章连问三句话,对比每次请求的“命中缓存“数字和端到端耗时。