跳到主要内容

qwen-code — 本课题摘录

读了哪几篇: 01-agent-loop(主循环)、03-tools(工具层)。 其余几篇本轮没读。

这一家给了本课题"循环放哪一层"最反直觉的一个答案:核心里没有循环。

它对本课题回答了什么

先把三个近义词分开 —— 这一步很多实现都省了

说法指什么边界在哪
一次往返一次请求 + 一条流式响应从开始到"这次说完了"事件
一个回合一次往返 + 它引出的工具执行工具跑完、结果回灌为止
一次交互用户敲一句话 → agent 彻底停下可能包含几十次往返

本课题读到现在,各家的分层从两级到三级不等(codex 的回合/采样、deepseek-harness 的轮/步、kimi-code 的回合/步/批次)。 qwen-code 这一份的价值在于它把"一次往返"单列了出来——那是唯一一个跟网络请求一一对应的层。

决定四(架构):核心不转圈,转圈的是入口层

很多编码 agent 把"一直转"写在核心里。这一家没有。

核心的发消息函数是一个异步生成器,跑完一轮就返回。

工具结果的回灌,一次都不在核心里。 界面版靠一个"工具跑完了"的回调,无界面版靠一个显式的循环。 (依据:Agent 库 · Qwen Code · 主循环:一次输入怎么走完「模型说话 → 跑工具 → 结果回灌」 —— core 里没有 while 循环,GeminiClient.sendMessageStream 是异步生成器跑完一轮就返回;工具结果回灌由入口层完成——TUI 靠 handleCompletedTools、headless 靠 while(true),两端共用同一台 CoreToolScheduler)

它给的理由很实在:界面需要在工具跑到一半时渲染进度、需要等用户点确认、需要允许用户在工具执行期间插话,这些都是界面世界的事。

把循环留在界面层,核心就不必知道有没有人在看屏幕。

这句话是本课题"循环放哪层"的关键判据,而且跟 kimi-code 的"循环不拥有界面"是同一枚硬币的两面:

  • kimi-code:循环在下面,界面在上面,循环不认识界面;
  • qwen-code:循环干脆就在界面里,核心只做一次往返。

两家的共同点是:"转圈"这个动作和"跑一轮"这个动作被彻底分开了。

对我们的最小原型,这个切分很有价值:先写一个"跑一轮"的纯函数,转圈那层随便谁来写。

核心内部只有两处会"再来一轮",而且都是递归调自己、都不是为了送工具结果:钩子要求续写、以及判断"模型话没说完"时补一句"请继续"。

递归深度有上限兜底,每递归一层预算减一。

决定三:一条容易踩的重试陷阱

重试时必须清空已累积的工具调用、引用、结束原因,再转发。

不清空的话,重试重放的工具调用会和上一次的叠在一起,变成重复执行。

这条很隐蔽,而且后果严重(重复执行 = 副作用做两遍)。 一般化:任何"重试"都必须定义清楚"哪些累积状态要重置"。

还有一条顺序陷阱:发送前必须先把用户内容推进历史,然后才跑孤儿修复。

顺序反了会出错——用户这次提交的可能正好是上一轮欠的那条工具结果,先推才能让它自然配对,否则修复过程会合成一条多余的错误响应。

这已经是第七家强调"调用与结果必须配对"这条不变量了,而且这次是从"修复"的角度: 修复孤儿的代码本身也可能制造问题,取决于它在什么时机跑。

一条很好的护栏:输出被截断时,拒绝执行编辑类工具

当这次生成因为输出长度而结束时,给本轮所有待执行的工具调用打上"输出被截断"的标记。

这个标记被用来拒绝执行被截断的编辑类工具——参数写了一半就落盘,比不执行更糟。 (依据:Agent 库 · Qwen Code · 主循环:一次输入怎么走完「模型说话 → 跑工具 → 结果回灌」 —— finishReason 为 MAX_TOKENS 时给本轮所有 pending 工具调用打 wasOutputTruncated 标记,调度器据此拒绝执行被截断的编辑类工具,理由是参数写了一半就落盘比不执行更糟)

这条要抄,而且它揭示了一个别家没讲的风险: "输出被截断"不只是答案不完整,还可能是工具参数不完整。

前面 kun 给截断发警告、aider 用续写接上——两家关心的都是"文本被截断"; qwen-code 关心的是"参数被截断",而这个的后果是数据损坏。

注意它只拒绝编辑类工具:只读工具参数不全无非是查错东西,不会毁数据。

决定二(结构):流式片段被翻译成语义事件

上游吐的是一串片段,里面文本、思考、函数调用、引用混在一起。上层不该关心这个格式。

做法:一台翻译机,收片段,吐一个判别联合类型。上层只要分支一下。

主循环真正在意的有七种:文本、思考、要调工具、本次收尾、报错、用户中断、判定在打转。

"把流式片段翻译成语义事件"是循环和模型接口之间的正确边界。 循环不该看见片段,只该看见事件。

决定一:工具按需披露 + 超大结果换成一个指针

注册表懒加载工具,按需把工具说明塞给模型;超大结果落盘换成一个指针。

前者跟 cherry-studio 的"折叠成元工具"是同一件事;后者跟 dexter 的"落盘 + 留预览"、hermes 的"单条结果落盘"是同一件事。 三份材料撞在同一个答案上,说明这两条是成熟做法,不是某一家的偏方。

它的直觉句:把工具层想成餐厅的传菜口。模型是坐在包间里的客人,只能写菜单条子;传菜口负责看懂条子、确认厨房真有这道菜、必要时先端出来给你看一眼再上桌,最后端上来的盘子还不能大到把桌子压塌。

它没回答什么

  • 并发的判据——提到了并发批处理,但这两篇没讲怎么判断能不能并行。
  • 历史怎么压——在另一篇。

坑与代价

  • 循环在入口层意味着有两份循环代码(界面版一份、无界面版一份)。它靠"共用同一台工具调度状态机"来控制重复,但"什么时候该再转一圈"这个判断确实写了两遍。

    判断(无锚): 对最小原型,可以只写一份(无界面版),等真要接界面时再抽。核心是纯函数这一点先做到,循环写在哪里以后再说。 如果错,会错在: 如果界面从第一天就是主要形态,那先写无界面版反而是白写一遍,应该直接照界面的需要来切。

  • 递归上限是硬编码的经验值。
  • "模型话没说完就补一句请继续"这个机制会让循环多转一圈,而且它的判据是另一次模型调用——一个用来判断的模型调用,成本不低。