从0打造编码Agent 岔路 阅读 + 实验 ≈ 30 分钟 页内实验 ×3
A1 循环:模型只会吐文字,它怎么改你的文件?什么时候停? 岔路课

用户在它干活时插话怎么办?

干活时插话
单线程的循环只在一次运行结束后才读下一句话:放在贯穿任务的一次真实运行里,第 1 轮就插的话要等 6 次模型调用、约两分半钟之后才被看见。Pi 用两层 while 和两个队列把「干活中的纠偏」和「做完后的追加」分开;Codex、OpenCode、Hermes 各有各的插法。本课项目批次 2 加了收件箱与中断,真实录制 T1b:插话在下一次请求之前进了历史,模型却没有照做;中断时 harness 给没跑完的调用回填结果,历史保持合法。插话什么时候被看见,是 harness 的设计决策;看见之后听不听,是模型的事。
❓

本课一问:第 2 课的循环一旦转起来,messages[] 只会追加模型的回复和工具结果。用户在它干活时又说了一句话——「别改 README」——这句话什么时候、以什么形式进入对话?

这一课先不碰:多个 agent 之间互相发消息(A7)。用户按下中断(Ctrl+C)时历史怎么保持合法,放到课末的真实录制里和插话一起看。

从已有出发

第 2 课:agent 的唯一状态是 messages[],每一轮整份重发。所以「插话」的问题可以说得很精确:用户的新消息,在第几次请求之前被追加进 messages[]?

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

先自己设计:等这次运行结束再说

CoreCoder 和 learn-claude-code 都是一个读输入的 REPL:chat() / agent_loop() 返回之后才读下一句。本课项目 v0.5.0 是一次性的命令行工具,运行中根本不读输入(批次 2 加了收件箱,见课末的真实录制)。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案等这次运行结束,再把插话当成新任务
learn-claude-code s01 · REPL:agent_loop 返回之后才读下一句 learn-claude-code@0dcafa2:s01_agent_loop/code.py:121-136需联网 MIT
121if __name__ == "__main__":
122 print("s01: Agent Loop")
123 print("Enter a question, press Enter to send. Type q to quit.\n")
124
125 history = []
126 while True:
127 try:
128 # \001/\002 tell Readline the ANSI escapes have zero display width.
129 query = input("\001\033[36m\002s01 >> \001\033[0m\002")
130 except (EOFError, KeyboardInterrupt):
131 break
132 if query.strip().lower() in ("q", "exit", ""):
133 break
134 history.append({"role": "user", "content": query})
135 agent_loop(history)
136 # Print the model's final text response
👆 带下划线的行可以点:点哪行,讲哪行。

改进:Pi 的两层 while 与两个队列

Pi 把插话分成两种,放进两个队列:steering(干活期间的纠偏,尽快生效)和 follow-up(做完之后的追加,等它本来要停时再处理)。下面这个剧本是构造的(模型回复由假模型按剧本给出),但每一步的先后顺序,是 Pi 的原文件 agent-loop.ts 真实跑出来的。它不是录制:本课项目的真实录制在课末,两者分开看。

Pi 的两层 while 与两个队列:插话进哪一层 构造剧本 · 事件顺序由 Pi 原文件实跑得到
steering 队列干活期间的插话
follow-up 队列做完之后的追加

源码: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 队列,拿到追加任务,又转了一轮。

Pi · agent-loop.ts:两层 while pi@31b513e3:packages/agent/src/agent-loop.ts:165-190需联网 MIT 最新版已变化 · 勘误表 P3
165 let firstTurn = true;
166 // Check for steering messages at start (user may have typed while waiting)
167 let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
168
169 // Outer loop: continues when queued follow-up messages arrive after agent would stop
170 while (true) {
171 let hasMoreToolCalls = true;
172
173 // Inner loop: process tool calls and steering messages
174 while (hasMoreToolCalls || pendingMessages.length > 0) {
175 if (!firstTurn) {
176 await emit({ type: "turn_start" });
177 } else {
178 firstTurn = false;
179 }
180
181 // Process pending messages (inject before next assistant response)
182 if (pendingMessages.length > 0) {
183 for (const message of pendingMessages) {
184 await emit({ type: "message_start", message });
185 await emit({ type: "message_end", message });
186 currentContext.messages.push(message);
187 newMessages.push(message);
188 }
189 pendingMessages = [];
190 }
👆 带下划线的行可以点:点哪行,讲哪行。

其他三家怎么插

循环插话什么时候被模型看到以什么形式出处(pin)
Pi这一轮结束后的检查点(:259)一条新的 user 消息,在下一次请求之前agent-loop.ts:167,:174,:182-190,:259
Codexturn 循环每一圈开头 drain 待处理输入;本轮采样后若有待处理输入,即使模型没调工具也继续写进历史的用户输入;task 层循环发现 turn 结束后仍有输入,会再起一个 turnturn.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 个循环:

插话的两个时机:工具执行时 vs 最后一条回复生成时 正在用浏览器移植重算…

真实序列取自本课项目 miniagent 的 trace(Qwen3-8B-MLX-4bit,录制于 2026-09-26);构造序列只用来检验谓词,标「构造事件序列 · 真谓词」。每格的判定由浏览器里的 JS 移植现算,并与生成脚本在 pin 源码上跑出的期望值比对(a01-termination.json,2026-09-26)。源码链接需联网。

「工具执行时插话」这一列,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环排队等这次运行结束 。切到「最后一条回复生成时插话」: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(跑测试)。

真实运行 t1b-steer-s1:插话在下一次请求之前写进历史 请求模型回复工具调用工具结果harness 注入

数据:本课项目 miniagent 的真实运行(src/data/project/agent-t1b-steer.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。

harness 能保证的只是「下一次请求之前送到」;送到之后听不听,由模型决定 。录制设计本身也有局限:这句插话和模型刚做完的一步重叠了,换一句和它当前计划冲突的纠偏(比如「别改 README」),才测得出它会不会改变行为。

中断:没跑完的调用也要有结果

真实运行 t1b-interrupt-s1:bash 跑到 51 ms 被中断,harness 回填结果后停下 请求模型回复工具调用工具结果harness 注入

数据:本课项目 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内容
1user任务:The test `test_parse_date` in test_dateparse.py …
2assistant调用 list_dir
3tool回应 413c5ed3…:README.md dateparse.py test_dateparse.py
4assistant调用 bash
5tool回应 56a52caf…:Tool call interrupted by the user while running.

每个 tool_call 后面都紧跟着回应它的结果(导出脚本逐条校验:history_valid = true) 。为什么非这样不可: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 次请求之前进了历史,模型没有照做;中断后回填了结果,历史仍然合法

演进地图:你在这里

模型只会吐文字,怎么动手、什么时候停:每一代都被上一代的某个真数字逼出来
  1. 解决了不需要任何协议:模型本来就会写命令。
    卡在真实基线 T1a:同一个模型、同一个任务跑 8 次,执行抠出来的命令后测试通过 0 / 8;其中 6 次抠出的 GNU 写法 sed -i 在 macOS 上直接报错,改成 BSD 写法再跑也 0 次通过——一次回复只看得见第一处 bug。
  2. 第一代 工具调用协议:模型写结构化的调用,harness 执行后回填 tools 参数 + tool_calls + role: tool
    解决了格式由模型训练与服务端解析器负责,harness 拿到的是 name + JSON 参数;执行结果以 role: tool 回填,模型能看见后果。
    卡在一次调用只能走一步:贯穿任务的真实成功运行,最少也用了 6 轮(读 → 改 → 测出第二处 → 改 → 再测)。协议之下仍然是文字:24 次原生调用的运行里,服务端解析器 7 次把 JSON 不合法的调用整条丢掉。
  3. 朴素 固定调用 3 轮 for _ in range(3)
    解决了能连续走几步了,而且一定会停。
    卡在贯穿任务的 20 次真实运行里,3 轮以内收工的 0 次;只调 3 轮,6 次成功里有 6 次变成失败(重放前 3 轮的 edit 后实跑验收)。
  4. 第二代 循环到模型不再调工具为止 CoreCoder、learn-claude-code s01
    解决了轮数由任务决定,不由写死的数字决定;状态只有一个 messages[],每轮整份重发。
    卡在「没有工具调用」不等于「做完了」:harness v1 有 3 次运行因服务端丢了调用而误停;26 次里模型自己停下 21 次,其中 13 次验收没通过。
  5. 第三代 双重校验 + 截断防护 + 可枚举的退出原因 OpenCode、Pi、Hermes;本课项目 v2
    解决了finish_reason 和消息体都看:说要调工具却没给调用、被截断、报了不认识的结束原因,都不当成「做完了」。
    卡在补救也会失败:pd-v2-nothink-s1 连续 3 次发出被丢弃的调用,提醒 2 次无效后以 dropped_tool_call 停;而且它不回答「要不要强制停」。
  6. 第四代 上限与预算:硬上限 / 收尾提示 / 不设上限靠压缩兜底 / 预算加总结调用 CoreCoder 50 · OpenCode steps · Codex 不设 · Hermes 预算
    解决了死循环有了兜底;到上限时可以返回显式原因,或让模型先收尾。
    卡在上限 4:2 次运行都在第 4 轮刚改完第一处时被截断,模型毫不知情;不设上限的 6 次全部自己停下(最长 8 轮)。重放:上限 6 碰巧救回 2 次(截在重复 edit 之前),而最长那次成功运行要到第 19 轮才修好。
  7. 岔路 双层循环:干活时插话、做完后追加 Pi steering / follow-up;Codex 任务层 你在这里
    解决了用户不必等整次运行结束才能纠偏。
    卡在插话时机本身是设计决策:同一条事件序列里,4 个生产循环把插话送进下一次请求,3 个教学 / 项目循环要等这次运行结束。
  8. 岔路 不用框架的自动多步,自己驱动循环 OpenCode 调 streamText 不传 stopWhen
    解决了压缩检查、提醒注入、权限中断恢复都能插在两次调用之间。
    卡在循环要自己维护:Codex 把它拆成四层(submission / task / turn / stream),教学蓝本只写了其中一层。
  9. 岔路 独立评估器决定能不能停 learn-claude-code s17 · Hermes goals · Codex Stop hook
    解决了「模型说做完了」交给另一个没有工具的模型核对。
    卡在评估器也会错:Hermes 给目标循环设了 20 轮上限(pin 56dc01d9 config_defaults.py:1801),注释写明是防评估器「假阴性」;每轮多一次模型调用。
数字口径:本课项目 miniagent 的真实运行(Qwen3-8B-MLX-4bit,2026-09-26;单一模型、单一任务,只说明现象会发生,不估计频率);「截在第 N 轮」的验收结果是重放那次运行前 N 轮的 edit 后实跑测试得到的;插话一行来自 8 个终止谓词在同一条构造事件序列上的判定(a01-termination.json)。

一句话

📌
插话什么时候被模型看见,是 harness 定的:纠偏在下一次请求前插进去,追加等它做完再说。

回主线

那它什么时候停?「没有工具调用就停」够不够? → 第 3 课

注意上面「最后一条回复生成时插话」那一列:有三台循环在模型已经「写完了」的时候仍然没停——停不停,不只取决于模型的回复。

你可能会问