小岛AIAI 航行日志

小岛 / 岛刊社 / 文章

拆开一个 11 万 star 的开源编程 agent,心脏只有 78 行

小岛AI

每天在终端里转圈干活的编程 agent,核心到底长什么样?开源的 pi 把它写成了 78 行,连你中途插嘴该塞在哪一刻都安排好了。附源码拆解,想自己写 agent 的可以照着这副骨架来。

AI 编程Agent 工程开源

你让 Claude Code 修一个 Bug,它会在终端里自己转起来。读文件,改代码,跑测试,测试挂了再读报错,再改,再跑。转个七八圈,停下来告诉你修好了。

这一圈一圈的,到底是什么在转?

闭源的看不到。所以我去翻了一个开源的,叫 pi。作者是 Mario Zechner,早年写过游戏框架 libGDX 的那位,项目现在 GitHub 上大概 11 万 star,MIT 协议,代码随便看。它是一个完整的编程 agent,终端里能用,也能嵌进网页和 Slack,结构干净得有点过分,特别适合拿来拆。

这是「Pi系列」的第一篇,一共 8 篇,每篇只拆一个设计决定。今天拆它的心脏。

pi 的发动机,和一堆它拒绝装上的零件

一个七十来行的循环

先交代一个词。给大模型搭运行环境的那一层工程,工具怎么接、上下文怎么管、出错了怎么办,圈里叫 harness,你可以理解成让模型真正能干活的脚手架。编程 agent 的脚手架里,最核心的就是一个循环。

pi 的这个循环叫 agent loop,在 packages/agent/src/agent-loop.ts 里,函数名 runLoop,从第 155 行到第 232 行,78 行。

好家伙,78 行。

我把注释和事件广播删掉一些,留下骨架,大概是这样的(中文注释是我加的)

// 摘自 pi v0.66.0 packages/agent/src/agent-loop.ts,有删减
async function runLoop(currentContext, newMessages, config, signal, emit) {
  let pendingMessages = (await config.getSteeringMessages?.()) || [];

  while (true) {                                   // 外圈,守着门
    let hasMoreToolCalls = true;

    while (hasMoreToolCalls || pendingMessages.length > 0) {   // 里圈,干活
      currentContext.messages.push(...pendingMessages);         // 有人插话,先塞进去
      pendingMessages = [];

      const message = await streamAssistantResponse(currentContext, config, signal, emit);
      if (message.stopReason === "error" || message.stopReason === "aborted") {
        await emit({ type: "agent_end", messages: newMessages });
        return;                                    // 出错不炸,报一声就停
      }

      const toolCalls = message.content.filter((c) => c.type === "toolCall");
      hasMoreToolCalls = toolCalls.length > 0;
      if (hasMoreToolCalls) {
        const results = await executeToolCalls(currentContext, message, config, signal, emit);
        currentContext.messages.push(...results);  // 工具结果塞回去,再问一遍
      }

      pendingMessages = (await config.getSteeringMessages?.()) || [];
    }

    const followUps = (await config.getFollowUpMessages?.()) || [];
    if (followUps.length === 0) break;             // 没人追加,真停
    pendingMessages = followUps;
  }
  await emit({ type: "agent_end", messages: newMessages });
}

先只看里圈。它干的事一句话就能讲完,把消息递给模型,拿回回答,回答里要是有工具调用,就去把工具跑了,把结果塞回对话,再问一遍。模型哪一轮不再要工具了,里圈就停。

一圈一圈转到活干完为止

你在 Claude Code 里看到的读文件、改代码、跑测试,每一步都是模型在回答里说「我要调用某某工具」,然后这个循环替它把工具跑了。模型自己一个文件都碰不到,它能做的只有说话,真正动手的是循环。

这个认知对我挺重要的。以前总觉得 agent 很神,好像模型里住着一个会操作电脑的小人。拆开一看,就是一个 while 循环在反复问同一个问题,还要工具吗,要的话我去跑,跑完了你再看看。

你插嘴的那句话,什么时候生效

外圈是干嘛的?

真实世界里人是会插嘴的。agent 正改着代码,你突然想起来,喊一句「别动那个配置文件」。pi 把这种插嘴分成了两种,代码里对应两个函数。

第一种叫 steering,转向。就是上面这种,活还没干完,你要纠正方向。

注意它读取的位置,getSteeringMessages 在里圈的最后一行,也就是这一批工具全部跑完之后、下一次问模型之前。它不会在工具跑到一半时急刹车。你喊的那句话会先排着队,等手上这批工具收尾,再被塞进对话,模型下一次开口前就能看到。

为什么不立刻停?我自己的理解是,工具跑到一半硬停,最容易留下烂摊子。文件写了一半,命令执行到一半,这种半成品状态比跑完再纠正麻烦多了。等一批工具收尾,对话里就是一个完整的状态,模型看到你的新要求,可以接着往下走。

第二种叫 follow up,追加。活已经干完了,里圈本来要退出,你又补一句「好,现在把测试也写了」。

这个在外圈处理。里圈退出以后,外圈看一眼有没有追加的消息,有,就把它当成新的待处理消息,再进一次里圈,没有,才真正结束。

两层循环,里圈干活,外圈守门

两种话,进来的时机不一样。一个插在工作中间,一个接在工作后面。用两层 while 就把这件事表达清楚了,没有状态机,没有事件总线,读起来像伪代码。

有点子牛逼这个设计。

出门见模型那一刻,才收拾行李

循环里跑的消息,其实很杂。除了用户和模型的对话,还有系统通知、上一次压缩留下的摘要、会话分叉时插进来的标记。这些东西 pi 的其他部分要用,但模型根本看不懂,塞给它只会浪费 token,还可能把它带偏。

pi 的做法是,平时都存着,只在出门见模型的那一刻才整理。看 streamAssistantResponse 开头这几行

// 摘自同一文件的 streamAssistantResponse,有删减
let messages = context.messages;
if (config.transformContext) {
  messages = await config.transformContext(messages, signal);  // 先剪一剪
}
const llmMessages = await config.convertToLlm(messages);       // 再筛一筛

两步。第一步 transformContext,按需要裁掉太长的旧账。第二步 convertToLlm,把各种内部消息翻译成模型认得的三种,用户、助手、工具结果。

剪一剪,再筛一筛,干净了才进门

内部可以乱,出门必须干净。写过后端的朋友大概会觉得眼熟,这跟数据库实体和接口 DTO 分开是一个道理,存储格式和传输格式各管各的。

发动机自己,什么都不存

再往回看那段 runLoop,你会发现它缺了很多东西。

会话存档,没有。失败重试,没有。上下文快满了自动压缩,也没有。用哪个模型、有哪些工具、钩子函数怎么挂,全写在 config 里,由上层递进来。干了什么,只通过 emit 这一个出口往外报事件。

谁在接这些事件,循环不知道,也不关心。终端界面接过去画进度,存档模块接过去写文件,测试接过去对答案。

所以同一台发动机,可以装进终端,可以装进 Slack 机器人,也能装进网页,甚至直接塞进一个单元测试里跑。pi 的仓库里,终端版的编程 agent 和 Slack 机器人 mom 都直接依赖这个核心包,测试也是拿它原样来跑的。

同一台发动机,装进不同的壳

读到这里我想起 Unix 那句老话,一个程序只做一件事,并把它做好。这个循环把它当成了设计纪律来执行。

代价也很清楚

当然有代价。发动机不重试,网络抖一下,它就递出一张红牌停下,重试得上层来做。不存会话,关掉就没了,存档得上层来做。上下文会一直涨,涨满了它也不管,压缩得上层来做。

这些活一样都没少,只是被搬到了循环外面。

我觉得这正是 pi 最值得学的地方。把循环做薄,上层才有自由,你想换一种重试策略、换一种压缩算法,都不用碰心脏。很多自研 agent 的循环会越写越胖,重试、日志、计费、存档全往里面塞,最后没人敢改。

如果你也在写自己的 agent,可以拿这 78 行对照一下自己的主循环。三个问题就够了,它在哪一刻读取用户的插话,它出错时是抛异常还是报事件,它除了转圈还干了哪些本该上层干的事。

回到开头那个问题。Claude Code 在终端里一圈一圈转的时候,我们看不到它的源码。但至少在 pi 这里,那一圈圈的背后,就是这两层 while。

下一篇拆那个被搬到外面的难题。和 agent 聊了两百轮之后,上下文窗口塞满了,pi 是怎么压缩的,又为什么能越压越清楚。

你用的 agent,被你中途打断时,是立刻停下,还是等手上的活跑完?


这个系列的内容,我还做成了一套彩铅动画,13 集,每集两三分钟,一集讲透一个设计决定。今天这篇对应第 5 集,循环怎么转、插嘴怎么排队,动起来看会更直观。

课程在 B 站课堂,预告和前两集可以免费看。

本文基于 pi v0.66.0 源码,新版本如有变化,以源码为准。讲解脉络参考了开源书《pi 的设计艺术》,想系统深入的推荐直接读原书。