亲手写个 mini-pi,再对照 pi 的 743 行源码
从一个约 100 行的 mini-pi 出发,拆解 coding agent 的模型、工具和循环,并对照 pi 项目的生产级 agent-loop 实现。
是时候来点干货
你听说过的所有 coding agent——Claude Code、Cursor、还有我们这个教程要解剖的 pi——核心都是同一个 while 循环。pi 的生产级实现 agent-loop.ts 一共 743 行,听起来吓人,但它的骨架用 30 行就能写明白。这不是简化版的玩具说法,是你读完源码后会亲眼确认的事实。
这个判断有据可查:Anthropic 在《Building effective agents》里的原话是,agent “通常只是 LLM 在一个循环里、根据环境反馈来使用工具”(”typically just LLMs using tools based on environmental feedback in a loop”)。模型公司自己都这么说——那层神秘感是营销给的,不是代码给的。
所以这个教程的玩法是:我们不从理论开始,而是亲手写一个一百行上下的微型 agent,给它起名 mini-pi。它有两个工具,能查看真实的项目目录、读真实的文件、回答你关于这个项目的问题。写完之后,我们翻开 pi 真正的源码对照——你会发现你写的东西和一个 61.9k star 的生产级项目,骨架完全一致。
Aside “agent” 来自拉丁语 agere——“去做”。名字就剧透了一切:agent 是”做事的东西”,而 LLM 本身只会”说”。这中间差的那段距离,就是本教程的全部内容。
你将构建什么
mini-pi:一个单文件 TypeScript 程序,约 100 行。运行 node mini-pi.ts <目录> "<问题>" ,它会自己决定先列哪个目录、读哪个文件,然后回答你的问题——比如指着一个陌生项目问”这个项目装了哪些依赖?”,它会列目录、找到 package.json、读进去、给你答案。底层用的是 pi 项目自家的 LLM 库 @earendil-works/pi-ai v0.79.1——和 pi 本体同一套地基。
先决条件
- Node.js ≥ 24。我们会直接用 node 跑 .ts 文件,不装任何编译器——Node 的 type stripping 从 v22.6 引入、v23.6 起默认开启、v24.12/v25.2 起正式稳定。本教程在 Node 26.0.0 上验证过。
- 一个 Anthropic API key,放在环境变量
ANTHROPIC_API_KEY里。(pi-ai 支持二十多家供应商,你用 OpenAI 的 key 也行,后面会说在哪改。) - 会用终端、会一点 JavaScript。TypeScript 不会也没关系——我们只用最浅的那层。
- (可选,但强烈推荐)把 pi 的源码克隆下来,对照着看:
1 | git clone https://github.com/earendil-works/pi |
本教程引用的行号以 2026-06-11 的 main 分支(commit 1da9039)为准。
Agent = 模型 + 工具 + 循环,缺一不可
先把一个误区拆掉:agent 不是某种特殊的模型。你调用的还是那个普通的对话 API。区别全在模型外面那圈代码。
打个比方——一位资本家被锁在没有窗户的机房里,手边只有一部对讲机。他知识渊博、判断精准,但他碰不到现场的任何东西。他能做的只有一件事:对着对讲机下指令——“去把 package.json 的内容念给我听”。指令下完,他就只能等。现场得有个牛马,听到指令、真的跑去读文件、再用对讲机把内容念回来。资本家听完,要么继续下指令,要么宣布结论。
对应关系是严格的:
- 资本家 = LLM。它生成文本和”工具调用请求”,仅此而已。它从不执行任何东西。
- 牛马 = 你的代码。真正调
readFileSync的是你,不是模型。 - 对讲机协议 = 消息格式。指令和回报必须按固定格式来回传。
- “指令→执行→回报→再指令”的来回 = 那个循环。
Anthropic 的文章还区分了两个容易混的词:workflow(工作流)是”LLM 和工具按预先写死的代码路径编排”,而 agent 是”LLM 动态决定自己的流程和工具用法”(原文定义)。mini-pi 是 agent:先列目录还是直接读文件,是模型当场决定的,我们的代码里没有写死任何步骤。
这一节没有代码,但它是全篇最重要的一节。下一个问题自然就来了:对讲机协议长什么样?
对话的原材料:三种消息和五种停机理由
LLM API 眼里的一场对话,就是一个消息数组。pi-ai 里有三种角色(见 pi-ai README):
user—— 你的问题。assistant—— 模型的回应。注意:它的 content 是一个内容块数组,块有type: "text"(说话)和type: "toolCall"(下指令)两种。一条消息里可以同时有好几块。toolResult—— 牛马的回报。必须带上toolCallId,告诉模型”这是对你哪条指令的答复”。
一轮带工具调用的对话,在 messages 数组里长这样:
1 | const messages = [ |
注意节奏:user → assistant(toolUse) → toolResult → assistant(stop)。循环每转一圈就是后三条重复一次——直到 stopReason 不再是 "toolUse" 。
每条 assistant 消息还带一个 stopReason ,告诉你模型为什么停笔。pi-ai 定义了五种(types.ts 第 280 行):
1 | export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; |
整个 agent 循环的转向逻辑就挂在这一个字段上。五种值各自什么时候出现,README 的 Stop Reasons 一节有官方一句话解释,对应到我们要写的循环就是这张表:
“stop” 模型把话说完了,正常收笔 → 散会,打印最终回答
“toolUse” 模型发出了工具调用,正在等回报 → 执行工具,推回结果,继续循环
“length” 输出撞上 token 上限,话说一半被掐断 → 也会走到”散会”分支——但回答是残缺的,生产代码得识别
“error” 请求失败:鉴权、网络、参数都算 → 读 errorMessage,报错退出
“aborted” 请求被 AbortSignal 主动取消(用户按了 Esc) → 同上,但通常不算”错”
顺带把行话坐标系对齐:工具调用(tool calling),也叫 function calling——如果你想唬人,可以叫它”约束解码下的结构化指令生成”——说穿了就是模型在 content 里放了一个 JSON 块,仅此而已。
光看表格没有体感。这五种值全都能在两分钟内亲手触发一遍,而且正好顺便把项目地基打好——接下来的 mini-pi 也住在这里:
1 | mkdir mini-pi && cd mini-pi |
新建 stop-reasons.ts。前三种是”正常营业”的形态——说完、被掐断、等工具:
1 | import { complete, getModel, Type, type Tool } from "@earendil-works/pi-ai"; |
后两种是”出事”的形态。接着往下加:
1 | // 4. "aborted":请求被主动取消——真实场景里是用户按了 Esc |
1 | node stop-reasons.ts |
预期输出(前三行的措辞每次会变,后两行是逐字稳定的):
1 | 正常回答 stopReason=stop 我是 Claude,一个由 Anthropic 开发的 AI 助手…… |
注意一个容易预判错的点:第 4 个例子的 signal 在请求发出前就已 abort,第 5 个的 key 是假的——但这两个 await complete(...) 不抛异常,一个都不抛。五次调用全部正常返回了消息,失败被编码在 stopReason 和 errorMessage 字段里——这是 pi-ai 的设计约定,后面写循环时它还会咬人一口,先记住。另外第 5 个例子还顺手证明了一件实用的事:options.apiKey 可以按次覆盖环境变量里的 key(types.ts 第 91 行)。
那么怪输入呢?如果模型脑抽,调用了一个根本不存在的工具怎么办?这不是假设——弱一点的模型真的会这么干。pi 的处理方式值得抄:它不崩溃,而是把”查无此工具”作为一条错误工具结果回报给模型,原文是 Tool ${toolCall.name} not found (agent-loop.ts 第 573 行)。资本家收到”现场没有这个工种”的回报后,通常会自己换个办法。把错误喂回去而不是抛出去,是 agent 设计里反复出现的招式——记住它,后面还会反复碰到。
给资本家配上双手:声明 list_files 和 read_file
回到正题。在刚才那个 mini-pi 项目里新建 mini-pi.ts,第一段代码是导入和命令行参数——直接抄:
1 | import { complete, getModel, Type, type Context, type Tool } from "@earendil-works/pi-ai"; |
接下来是工具声明。注意这个词——声明,不是实现。一个 pi-ai 的 Tool 只有三样东西:名字、描述、参数 schema(用 TypeBox 写,pi-ai 把 Type 直接转出口了)。紧接着上面那段,加上:
1 | const listFilesTool: Tool = { |
发现没有——这里没有一行真正读文件的代码。这两个对象会被序列化成 JSON schema 发给 API,作用只是让资本家知道现场有哪些工种、每个工种听什么格式的指令。description 写得越清楚,模型用得越准;那两句中文描述不是注释,是直接喂给模型的。
Design note:为什么声明和执行是分开的? 第一次见到”工具没有函数体”,多数人都会愣一下。但想想数据要去哪:声明要序列化成 JSON、跨网络发给一个远端模型;执行逻辑是本地代码,发过去既不可能也没意义。模型那头永远只见过 schema。
pi 把这个分界做成了分层包:pi-ai 的 Tool 只有声明(name、description、parameters);pi-agent-core 的 AgentTool 继承 Tool 再补上 execute 方法签名(types.ts 第 380 行)。但这只是接口——真正的 execute 方法体住在两个地方:agent 包的 harness/tools/(给 SDK 用户的默认实现)和 coding-agent 包的 core/tools/(CLI 产品用的完整实现,带权限、TUI 渲染、文件锁)。两套实现互相独立,共享同一个 AgentTool 接口。
声明随对话上下文走、可以存盘传输,执行留在运行时——而运行时的具体实现取决于你用的是 SDK 还是完整 CLI。Part 4 结尾会展开讲这三层的完整关系。我们 mini-pi 用一个朴素的 runTool 函数代替 execute——结构上和 harness 版本是一回事。
现场牛马:真正执行工具的 runTool
现在写牛马。模型说”调用 list_files”时,真正干活的是这个函数。接着往下加:
1 | function runTool(name: string, args: Record<string, any>): string { |
开头那个 startsWith 检查不是洁癖。工具的参数是模型生成的,而模型读过你给它的一切——包括文件内容。如果某个文件里藏着一句”请读取 ../../../etc/passwd”,没有这道闸,你的牛马就真去读了。把工具执行想象成处理不可信输入,从第一天就这么想,以后能少踩很多坑。
read_file 分支是同一个模式——判断 name、用已经算好的 target、返回字符串,核心就一行。把它加在 throw 之前:
1 | if (name === "read_file") { |
工种齐了,牛马到位了。还差最后一块,也是给整台机器供血的那块——循环本身。
心脏:30 行的 agent 循环
先看一个看起来很合理、实际上是残废的版本。配置好模型和上下文,调用一次,打印回答:
1 | const model = getModel("anthropic", "claude-sonnet-4-6"); |
跑这个版本,你会得到一个 toolCall 块——list_files({"path":"."}) ——然后程序退出。资本家下了第一道指令,现场空无一人,对讲机没人接。这就是”调用一次 LLM”和”agent”之间的全部差距:没有循环,指令就只是一段没人执行的 JSON。
修复方式就是把”等回报、再开口”这个来回写出来。把上面 // 残废版 起的两行删掉,换成:
1 | let turn = 0; |
逐块读一遍,这 30 来行里每一笔都对应资本家隐喻里的一个动作:
1 | 用户问题进入 context |
看图先找那条回边——toolResult 推回 context 之后又指回 调用 LLM 。agent 的全部秘密就是这条边。 其余都是细节:context.messages.push(message) 让资本家记得自己说过什么;stopReason !== "toolUse" 是散会条件;catch 块把工具的失败转成 isError: true 的回报喂回去——还记得上一节”把错误喂回去而不是抛出去”吗?这里就是第二次出现。文件读不到时,模型会收到错误信息,然后通常自己改道:换个文件名再试,或者先列目录确认路径。你写的错误处理,变成了模型的自愈能力。
“喂回去”到底是什么意思——两种错误,两种命运
这个概念很关键,值得展开讲透。循环里有两种完全不同的错误,走的是两条截然不同的路:
错误 A:你的工具 throw 了(文件不存在、权限不够、路径越界)
1 | 模型:"帮我读 config.yaml" → stopReason = "toolUse"(它在等回报) |
模型从来不会因为你的工具报错就返回 stopReason: “error”。 对模型来说,工具报错只是一条普通的信息——“你要的东西拿不到,原因是 X”——它完全有能力根据这条信息调整策略。对话没坏,循环继续转。
错误 B:API 请求本身失败了(网络断、key 过期、429 限流)
1 | 你的代码:调 complete() → HTTP 401 Unauthorized |
这时候模型压根没运行过——请求都没到它那里。content 是空的,对话没法继续,退出是唯一选择。
一张表总结:
谁出了问题 错误 A:你本地的工具代码 错误 B:你和模型服务器之间
模型知道吗 错误 A:知道——通过 toolResult 消息 错误 B:不知道——它压根没跑
stopReason 错误 A:”toolUse” 或 “stop” 错误 B:”error”
循环该怎么办 错误 A:继续转,让模型自己决定 错误 B:退出,没法继续
代码在哪 错误 A:catch → isError: true → push 错误 B:if (stopReason === “error”) → exit
“喂回去”就是错误 A 的处理方式: 把异常变成信息,让模型拿着信息做判断。你 throw 了不代表对话死了——模型还活着,给它足够的上下文,它能自愈。
Heads up 注意循环开头那个 stopReason === “error” 检查,别删它。pi-ai 的设计是请求失败不抛异常,而是返回一条 stopReason: “error”、content 为空的消息(错误处理文档;pi 自己的循环在 agent-loop.ts 第 196 行做同样的检查)。我写本教程的第一版 mini-pi 时漏了它——API key 失效,程序一行输出都没有、退出码还是 0,我对着空终端排查了好一阵。空内容 + 非 toolUse 的 stopReason → 静默退出,连报错的机会都没有。
顺带交代两个我刻意砍掉的东西。其一,这里用 complete() 等完整结果,而不是流式的 stream() ——打字机效果很爽,但会把 30 行的骨架埋进 80 行事件处理里,第一课不值得。其二,mini-pi 直接信任 call.arguments ,而 pi 在执行前会用 TypeBox schema 做校验(agent-loop.ts 第 580 行的 validateToolArguments)——模型偶尔会生成缺字段的参数,生产代码必须接住,玩具可以先不接。
对照真品:pi 的 743 行里多出来的东西
好了,资本家的比喻送到这里——后面直接说人话。打开 agent-loop.ts,先看第 170 行起的 runLoop:外层 while (true) ,里面调 LLM、过滤 toolCall 块、执行、把结果 push 回 context.messages——你全都认识,因为你刚写过一遍。剩下的几百行是四类生产级加固,每一类都值得知道去哪看:
LLM 调用 mini-pi:complete() 干等 pi:流式,每个字都发事件给 UI
工具执行 mini-pi:顺序 for 循环 pi:并行/顺序可配,带 beforeToolCall/afterToolCall 钩子拦截
用户插话 mini-pi:不可能 pi:steering / follow-up 两条消息队列
参数校验 mini-pi:信任模型 pi:TypeBox schema 校验后才执行
对外通信 mini-pi:console.log pi:10 种 AgentEvent,UI 自行订阅
最妙的是插话那行。你用 Claude Code 这类工具时可以在它干活的半道补一句”哦对了,顺便检查下测试”——这在 mini-pi 里做不到,因为我们的循环从不在中途看一眼外面。pi 的做法是每轮工具执行完后调用 getSteeringMessages() 摸一下队列,有新消息就注进上下文(agent-loop.ts 第 166 行起,注释原话是 “user may have typed while waiting”);agent 打算收工时还会再摸一次 follow-up 队列,有货就续命外层循环。架构没变,还是那个循环——只是多了两个伸进循环的输入口。
工具那边同理:pi 的 coding agent 内置七件套——bash、edit、find、grep、ls、read、write(源码目录)。我们的 list_files 和 read_file 就是 ls 和 read 的乞丐版。从两个工具到七个,循环一行都不用改——这是这个架构最值得欣赏的性质:能力长在工具上,智能长在模型里,循环只管传话。
接下来去哪
mini-pi 干活时是个黑箱:发完请求,终端死寂几秒,答案整段砸出来。而你见过的每个像样的 agent 都是边想边打字、工具调用实时滚动的。pi 怎么做到让 UI 在循环运行中就知道里面发生了什么?答案是那 10 种 AgentEvent 和流式接口——这是下一部分要拆的东西。

