用户在它干活时插话怎么办?
本课一问:第 2 课的循环一旦转起来,messages[] 只会追加模型的回复和工具结果。用户在它干活时又说了一句话——「别改 README」——这句话什么时候、以什么形式进入对话?
这一课先不碰:多个 agent 之间互相发消息(A7)。用户按下中断(Ctrl+C)时历史怎么保持合法,放到课末的真实录制里和插话一起看。
从已有出发
第 2 课:agent 的唯一状态是 messages[],每一轮整份重发。所以「插话」的问题可以说得很精确:用户的新消息,在第几次请求之前被追加进 messages[]?
先自己设计:等这次运行结束再说
CoreCoder 和 learn-claude-code 都是一个读输入的 REPL:chat() / agent_loop() 返回之后才读下一句。本课项目 v0.5.0 是一次性的命令行工具,运行中根本不读输入(批次 2 加了收件箱,见课末的真实录制)。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
插话要有个地方先放着,循环在每一轮之间看一眼。这就需要把「用户输入」和「循环」分开:输入随时进队列,循环在固定的检查点取。检查点放在哪,决定了插话是「这一轮之后马上生效」还是「等它做完再说」。
改进:Pi 的两层 while 与两个队列
Pi 把插话分成两种,放进两个队列:steering(干活期间的纠偏,尽快生效)和 follow-up(做完之后的追加,等它本来要停时再处理)。下面这个剧本是构造的(模型回复由假模型按剧本给出),但每一步的先后顺序,是 Pi 的原文件 agent-loop.ts 真实跑出来的。它不是录制:本课项目的真实录制在课末,两者分开看。
源码:earendil-works/pi@31b513e3:packages/agent/src/agent-loop.ts(MIT)。剧本:第 1 轮调 bash,执行期间用户插话「别改 README」;第 2 轮调 edit_file;第 3 轮给出总结,这时用户已经排了一条追加任务「再加一个测试」;第 4 轮给出总结。模型回复由假模型按剧本给出。
试三件事:① 走到「harness 执行 bash」:插话进了 steering 队列,没有立刻出现在对话里;② 再走两步到「取 steering 队列(:259)」:队列非空,内层 while 继续,插话在第 2 轮请求之前写进上下文;③ 走到第 3 轮的文字回复:本该停了,外层 while 先看 follow-up 队列,拿到追加任务,又转了一轮。
其他三家怎么插
| 循环 | 插话什么时候被模型看到 | 以什么形式 | 出处(pin) |
|---|---|---|---|
| Pi | 这一轮结束后的检查点(:259) | 一条新的 user 消息,在下一次请求之前 | agent-loop.ts:167,:174,:182-190,:259 |
| Codex | turn 循环每一圈开头 drain 待处理输入;本轮采样后若有待处理输入,即使模型没调工具也继续 | 写进历史的用户输入;task 层循环发现 turn 结束后仍有输入,会再起一个 turn | turn.rs:282-293,:395;tasks/regular.rs:73-90 |
| OpenCode | 下一次循环开头:最后一条 assistant 的 parentID 对不上最后一条 user,退出条件不成立 | 会话里一条新的 user 消息 | prompt.ts:1115 |
| Hermes | 下一次 API 调用之前(/steer) | 不新增消息:把插话接到最后一条工具结果的末尾;插话来得太晚(最后的回复已生成)就交还调用方,作为下一轮输入 | conversation_loop.py:1710-1755;agent_runtime_helpers.py:3940-3956;turn_finalizer.py:714-719 |
同一条事件序列交给 8 个循环:
真实序列取自本课项目 miniagent 的 trace(Qwen3-8B-MLX-4bit,录制于 2026-09-26);构造序列只用来检验谓词,标「构造事件序列 · 真谓词」。每格的判定由浏览器里的 JS 移植现算,并与生成脚本在 pin 源码上跑出的期望值比对(a01-termination.json,2026-09-26)。源码链接需联网。
「工具执行时插话」这一列,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环排队等这次运行结束 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:给定事件序列,各循环的判定唯一确定;表中判定与 Pi 原文件实跑、OpenCode 原条件求值、Codex 与 Hermes 的源码复刻逐格一致。 出处待补 。切到「最后一条回复生成时插话」:Pi、Codex、OpenCode 因为插话还在队列里而不停,再转一轮;Hermes 照常结束,把插话交还给调用方。
表里「本课项目」那一行按 v0.5.0 的代码判定。批次 2 给循环加了收件箱:每一圈调模型之前取一次(loop.py:169-170),工具执行时的插话因此在下一次请求之前送进去,课末是它的真实录制。「最后一条回复生成时插话」那一列不变:模型不调工具时循环直接停(:222-236),走不到下一圈的取收件箱,剩下的话不会再送。
模型生成到一半确实插不进去(一次请求发出去就是整份),但「下一次请求之前」是 harness 自己定的检查点。检查点放在每一轮之间(Pi、Codex、OpenCode、Hermes),插话一轮之内就生效;只在整次运行之间检查(CoreCoder、learn-claude-code、本课项目 v0.5.0),就要等全部做完。还有一个细节决定成本:Hermes 把插话接在工具结果末尾而不是新开一条消息,不打乱 user / assistant 的交替(注释写明的理由),也不改动前面已经发过的内容。
反方向也有一个误会:「插进去了,模型就会照做。」课末的真实录制里,插话准时进了历史,模型没有照做。
🔍 steering 和中断(interrupt)有什么区别?
steering 是「继续干,但把这句话考虑进去」;中断是「停下」。Hermes 两种都有:循环每一圈开头检查中断请求,有就以 interrupted_by_user 退出(conversation_loop.py:1648-1654);/redirect 则取消当前这一次请求、带着纠正重发(:3818-3832)。中断时还要处理一个合法性问题:assistant 的 tool_calls 已经写进历史、结果还没回填,下一次请求会被 API 拒绝——CoreCoder 的做法是给每个没回复的调用补一条 [interrupted](agent.py:90-94,:132-146)。
真实录制 T1b:一次插话、一次中断
项目批次 2 给 miniagent 加了两样东西(先写失败测试再实现):收件箱 queue_user_message(),每一圈调模型之前把收件箱里的话作为一条 user 消息写进历史(loop.py:169-170,trace 里这条消息带 interjection: true);中断 request_interrupt() 与工具执行中的 KeyboardInterrupt(:241-252)。录制脚本 scripts/record_t1b.py 在贯穿任务上各触发一次(Qwen/Qwen3-8B-MLX-4bit,seed 1,2026-09-26)。下面两段是真实运行,和上面 Pi 的构造剧本不是一回事。
插话:准时送到,模型没有照做
录制脚本在第 2 次模型调用返回时,往收件箱放了一句「Before editing anything, run the tests first and show me the exact failure.」。那时 harness 正在执行这一轮的 bash(跑测试)。
数据:本课项目 miniagent 的真实运行(src/data/project/agent-t1b-steer.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。
- 在哪一轮进入:第 3 次请求之前写进历史,成为第 6 条消息,紧跟在第 2 轮的测试结果后面;第 3 次请求因此发出 7 条消息(上一次是 4 条)。从放进收件箱到写进历史,等的是这一轮的 bash 跑完,约 76 ms。
- 朴素设计会怎样:同一次运行,要是等运行结束再读,这句话要到第 9 轮结束才被看见:约 117.9 秒之后,中间又调了 7 次模型,第 4、7 轮的两处修改都已完成。
- 模型之后怎么做:第 3 轮的思考共 2,049 字(原始 trace 里的全文;回放只截取约 600 字),一处也没提到这句插话;它接着读源文件,第 4 轮就改了代码。插话要的两件事:「先跑测试」在插话之前(第 2 轮)已经做过;「把确切的报错给我看」没有做,整次运行模型对用户说的话只有第 9 轮的总结,里面没有报错原文。
harness 能保证的只是「下一次请求之前送到」;送到之后听不听,由模型决定 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:Qwen3-8B、一次运行;这句插话要的第一件事,模型恰好刚做完。只说明「送到不等于照做」会发生,不说明频率。 出处待补 。录制设计本身也有局限:这句插话和模型刚做完的一步重叠了,换一句和它当前计划冲突的纠偏(比如「别改 README」),才测得出它会不会改变行为。
中断:没跑完的调用也要有结果
数据:本课项目 miniagent 的真实运行(src/data/project/agent-t1b-steer.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。
第 2 轮模型调 bash 跑测试,子进程运行 51 ms 时,录制脚本杀掉进程组、抛出 KeyboardInterrupt(和终端里按 Ctrl-C 走同一条路径)。loop 接住它(loop.py:247-252):给这个调用回填一条结果「Tool call interrupted by the user while running.」,trace 记 executed: false 与 meta.interrupted,以 interrupted 停,2 轮、3,062 token,验收没通过(还没开始修)。停下时的历史(第 0 条 system 导出时省略):
| # | role | 内容 |
|---|---|---|
| 1 | user | 任务:The test `test_parse_date` in test_dateparse.py … |
| 2 | assistant | 调用 list_dir |
| 3 | tool | 回应 413c5ed3…:README.md dateparse.py test_dateparse.py |
| 4 | assistant | 调用 bash |
| 5 | tool | 回应 56a52caf…:Tool call interrupted by the user while running. |
每个 tool_call 后面都紧跟着回应它的结果(导出脚本逐条校验:history_valid = true) 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条消息结构直接校验:每条 assistant 的每个 tool_call id,后面都有一条 tool_call_id 相同的 tool 消息。 出处待补 。为什么非这样不可:OpenAI 兼容接口要求 assistant 消息里的每个 tool_call id 都有一条对应的 tool 消息,缺一条,下一次请求(比如用户中断后接着说「换个办法」)就会被拒——CoreCoder 在 _answer_pending_tool_calls 的 docstring 里写的就是这个理由。
回扣上面的深挖:CoreCoder 给每个没回复的调用补一条 [interrupted],再把 KeyboardInterrupt 往外抛(agent.py:90-94,:132-146)。本课项目多分了一层:正在跑的那个回填「执行中被中断」,同一批里还没轮到的回填「未执行」(loop.py:241-252)。两者对模型和读 trace 的人意思不同:前一个可能做了一半(bash 被杀之前也许已经写了文件),后一个肯定没碰过。中断落在两轮之间时,本课项目和 Hermes 一样,在下一次调模型之前检查、直接退出(loop.py:158-160;Hermes conversation_loop.py:1648-1654 以 interrupted_by_user 退出),这时没有发出去的调用,也就没有要回填的。这次录制只有一个调用在跑,「未执行」那种回填没有出现在真实录制里,由项目单元测试 test_interrupt_during_tool_calls_backfills_the_rest 覆盖。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 用户不必等整次运行结束才能纠偏;纠偏与追加走不同的路 |
| ② 为什么是这样? | messages[] 只能在两次请求之间追加,所以插话的时机只能是某个检查点;检查点放在哪一层循环,决定了多久生效 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条由循环结构直接推出:请求一旦发出就是整份,插话只能进入之后的请求。 出处待补 |
| ③ 实验上呢? | 开头的时间差是把一次真实运行(pd-v2-unlimited-s3)的时间戳套进两种设计算出来的;批次 2 的真实录制 T1b 里,插话在第 3 次请求之前进了历史,模型没有照做;中断后回填了结果,历史仍然合法 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目 trace 的时间戳与消息顺序:插话与中断各一次运行;本机有其他任务同时占用,秒数只能粗看,次数与顺序是准的。 出处待补 |
演进地图:你在这里
- 解决了不需要任何协议:模型本来就会写命令。卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法
sed -i在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。 - 解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以
role: tool回填,模型能看见后果。卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。 - 解决了能连续走几步了,而且一定会停。卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
- 解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
- 解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以
dropped_tool_call停;而且它不回答「要不要强制停」。 - 解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
- 解决了用户不必等整次运行结束才能纠偏。卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
- 解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
- 解决了「模型说做完了」交给另一个没有工具的模型核对。卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
一句话
回主线
那它什么时候停?「没有工具调用就停」够不够? → 第 3 课
注意上面「最后一条回复生成时插话」那一列:有三台循环在模型已经「写完了」的时候仍然没停——停不停,不只取决于模型的回复。
你可能会问
- 「本课项目要不要支持插话?」 批次 2 已经加了:一个收件箱,每一圈调模型之前取一次,相当于 Pi 的 steering 队列,没有 follow-up 队列。它还缺第 3 课那一条:有待送的插话时,「没有工具调用」也不能停——现在模型一不调工具,循环就直接停了,收件箱里剩下的话不会再送。
- 「插话会不会破坏前缀缓存?」 插话追加在末尾,不改前面已发的内容,缓存照样命中;Hermes 把它接在工具结果末尾而不是新开一条消息,也是出于不打乱消息结构的考虑。缓存怎么计费见 A8。